From 843f99649ede6cc029e865682cfcf5d34a745c8b Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 31 Mar 2025 18:32:22 +0200 Subject: [PATCH 001/336] Scenarios: Prepare DataModel (#213) * Update core.py to work with another dimension * Add scenarios to TimeSeries * Update TimeSeriesCollection * Update get_numeric_stats() to return values per scenario * Update repr and str * Improve stats * Add utility methods to analyze data * Move test insto class * Improve DataConverter * Improve DataConverter * Improve conversion and copying * Improve conversion and copying * Update tests * Update test * Bugfix stats * Bugfix stored_data.setter * Improve __str__ of TimeSeries * Bugfixes * Add tests * Temp * Simplify the TImeSeriesCollection * Simplify the TImeSeriesCollection * Add test script * Improve TImeSeriesAllocator * Update TimeSeries * Update TimeSeries * Update selection * Renaming * Update TimeSeriesAllocator * Update TimeSeriesAllocator * Update TimeSeriesAllocator * Update TimeSeriesAllocator * Update selection * Improve selection * Improve validation of Timesteps * Improve TimeSeries * Improve TimeSeriesAllocator * Update calculation and FlowSystem * rename active_data to selected_data * Add property * Improve type hints * Improve type hints * Add options to get data without extra timestep * Rename * Update tests * Bugfix for TImeSeriesData to work * Update calculation.py * Bugfix * Improve as_dataset to improve aggregation * Bugfix * Update test * Remove test script * ruff check * Revert some renaming * Bugfix in test --- flixopt/calculation.py | 24 +- flixopt/components.py | 24 +- flixopt/core.py | 1354 ++++++++++++++++++++++++----------- flixopt/effects.py | 6 +- flixopt/elements.py | 8 +- flixopt/features.py | 4 +- flixopt/flow_system.py | 24 +- flixopt/io.py | 2 +- flixopt/structure.py | 2 +- tests/test_dataconverter.py | 871 ++++++++++++++++++++-- tests/test_timeseries.py | 716 ++++++++++-------- 11 files changed, 2244 insertions(+), 791 deletions(-) diff --git a/flixopt/calculation.py b/flixopt/calculation.py index c7367cad2..03cf8b9a6 100644 --- a/flixopt/calculation.py +++ b/flixopt/calculation.py @@ -183,8 +183,8 @@ def solve(self, solver: _Solver, log_file: Optional[pathlib.Path] = None, log_ma def _activate_time_series(self): self.flow_system.transform_data() - self.flow_system.time_series_collection.activate_timesteps( - active_timesteps=self.active_timesteps, + self.flow_system.time_series_collection.set_selection( + timesteps=self.active_timesteps ) @@ -217,6 +217,8 @@ def __init__( list with indices, which should be used for calculation. If None, then all timesteps are used. folder: folder where results should be saved. If None, then the current working directory is used. """ + if flow_system.time_series_collection.scenarios is not None: + raise ValueError('Aggregation is not supported for scenarios yet. Please use FullCalculation instead.') super().__init__(name, flow_system, active_timesteps, folder=folder) self.aggregation_parameters = aggregation_parameters self.components_to_clusterize = components_to_clusterize @@ -272,9 +274,9 @@ def _perform_aggregation(self): # Aggregation - creation of aggregated timeseries: self.aggregation = Aggregation( - original_data=self.flow_system.time_series_collection.to_dataframe( - include_extra_timestep=False - ), # Exclude last row (NaN) + original_data=self.flow_system.time_series_collection.as_dataset( + with_extra_timestep=False, with_constants=False + ).to_dataframe(), hours_per_time_step=float(dt_min), hours_per_period=self.aggregation_parameters.hours_per_period, nr_of_periods=self.aggregation_parameters.nr_of_periods, @@ -286,9 +288,11 @@ def _perform_aggregation(self): self.aggregation.cluster() self.aggregation.plot(show=True, save=self.folder / 'aggregation.html') if self.aggregation_parameters.aggregate_data_and_fix_non_binary_vars: - self.flow_system.time_series_collection.insert_new_data( - self.aggregation.aggregated_data, include_extra_timestep=False - ) + for col in self.aggregation.aggregated_data.columns: + data = self.aggregation.aggregated_data[col].values + if col in self.flow_system.time_series_collection._has_extra_timestep: + data = np.append(data, data[-1]) + self.flow_system.time_series_collection.update_time_series(col, data) self.durations['aggregation'] = round(timeit.default_timer() - t_start_agg, 2) @@ -327,8 +331,8 @@ def __init__( self.nr_of_previous_values = nr_of_previous_values self.sub_calculations: List[FullCalculation] = [] - self.all_timesteps = self.flow_system.time_series_collection.all_timesteps - self.all_timesteps_extra = self.flow_system.time_series_collection.all_timesteps_extra + self.all_timesteps = self.flow_system.time_series_collection._full_timesteps + self.all_timesteps_extra = self.flow_system.time_series_collection._full_timesteps_extra self.segment_names = [ f'Segment_{i + 1}' for i in range(math.ceil(len(self.all_timesteps) / self.timesteps_per_segment)) diff --git a/flixopt/components.py b/flixopt/components.py index d5d1df12d..2a69c6165 100644 --- a/flixopt/components.py +++ b/flixopt/components.py @@ -194,12 +194,12 @@ def transform_data(self, flow_system: 'FlowSystem') -> None: self.relative_minimum_charge_state = flow_system.create_time_series( f'{self.label_full}|relative_minimum_charge_state', self.relative_minimum_charge_state, - needs_extra_timestep=True, + has_extra_timestep=True, ) self.relative_maximum_charge_state = flow_system.create_time_series( f'{self.label_full}|relative_maximum_charge_state', self.relative_maximum_charge_state, - needs_extra_timestep=True, + has_extra_timestep=True, ) self.eta_charge = flow_system.create_time_series(f'{self.label_full}|eta_charge', self.eta_charge) self.eta_discharge = flow_system.create_time_series(f'{self.label_full}|eta_discharge', self.eta_discharge) @@ -342,7 +342,7 @@ def __init__(self, model: SystemModel, element: Transmission): def do_modeling(self): """Initiates all FlowModels""" # Force On Variable if absolute losses are present - if (self.element.absolute_losses is not None) and np.any(self.element.absolute_losses.active_data != 0): + if (self.element.absolute_losses is not None) and np.any(self.element.absolute_losses.selected_data != 0): for flow in self.element.inputs + self.element.outputs: if flow.on_off_parameters is None: flow.on_off_parameters = OnOffParameters() @@ -379,14 +379,14 @@ def create_transmission_equation(self, name: str, in_flow: Flow, out_flow: Flow) # eq: out(t) + on(t)*loss_abs(t) = in(t)*(1 - loss_rel(t)) con_transmission = self.add( self._model.add_constraints( - out_flow.model.flow_rate == -in_flow.model.flow_rate * (self.element.relative_losses.active_data - 1), + out_flow.model.flow_rate == -in_flow.model.flow_rate * (self.element.relative_losses.selected_data - 1), name=f'{self.label_full}|{name}', ), name, ) if self.element.absolute_losses is not None: - con_transmission.lhs += in_flow.model.on_off.on * self.element.absolute_losses.active_data + con_transmission.lhs += in_flow.model.on_off.on * self.element.absolute_losses.selected_data return con_transmission @@ -413,8 +413,8 @@ def do_modeling(self): self.add( self._model.add_constraints( - sum([flow.model.flow_rate * conv_factors[flow.label].active_data for flow in used_inputs]) - == sum([flow.model.flow_rate * conv_factors[flow.label].active_data for flow in used_outputs]), + sum([flow.model.flow_rate * conv_factors[flow.label].selected_data for flow in used_inputs]) + == sum([flow.model.flow_rate * conv_factors[flow.label].selected_data for flow in used_outputs]), name=f'{self.label_full}|conversion_{i}', ) ) @@ -474,12 +474,12 @@ def do_modeling(self): ) charge_state = self.charge_state - rel_loss = self.element.relative_loss_per_hour.active_data + rel_loss = self.element.relative_loss_per_hour.selected_data hours_per_step = self._model.hours_per_step charge_rate = self.element.charging.model.flow_rate discharge_rate = self.element.discharging.model.flow_rate - eff_charge = self.element.eta_charge.active_data - eff_discharge = self.element.eta_discharge.active_data + eff_charge = self.element.eta_charge.selected_data + eff_discharge = self.element.eta_discharge.selected_data self.add( self._model.add_constraints( @@ -565,8 +565,8 @@ def absolute_charge_state_bounds(self) -> Tuple[NumericData, NumericData]: @property def relative_charge_state_bounds(self) -> Tuple[NumericData, NumericData]: return ( - self.element.relative_minimum_charge_state.active_data, - self.element.relative_maximum_charge_state.active_data, + self.element.relative_minimum_charge_state.selected_data, + self.element.relative_maximum_charge_state.selected_data, ) diff --git a/flixopt/core.py b/flixopt/core.py index 379828554..d2a8edd59 100644 --- a/flixopt/core.py +++ b/flixopt/core.py @@ -7,6 +7,7 @@ import json import logging import pathlib +import textwrap from collections import Counter from typing import Any, Dict, Iterator, List, Literal, Optional, Tuple, Union @@ -40,56 +41,563 @@ class ConversionError(Exception): class DataConverter: """ - Converts various data types into xarray.DataArray with a timesteps index. - - Supports: scalars, arrays, Series, DataFrames, and DataArrays. + Converts various data types into xarray.DataArray with timesteps and optional scenarios dimensions. + + Supports: + - Scalar values (broadcast to all timesteps/scenarios) + - 1D arrays (mapped to timesteps, broadcast to scenarios if provided) + - 2D arrays (mapped to scenarios × timesteps if dimensions match) + - Series with time index (broadcast to scenarios if provided) + - DataFrames with time index and a single column (broadcast to scenarios if provided) + - Series/DataFrames with MultiIndex (scenario, time) + - Existing DataArrays """ + #TODO: Allow DataFrame with scenarios as columns + @staticmethod - def as_dataarray(data: NumericData, timesteps: pd.DatetimeIndex) -> xr.DataArray: - """Convert data to xarray.DataArray with specified timesteps index.""" - if not isinstance(timesteps, pd.DatetimeIndex) or len(timesteps) == 0: - raise ValueError(f'Timesteps must be a non-empty DatetimeIndex, got {type(timesteps).__name__}') - if not timesteps.name == 'time': - raise ConversionError(f'DatetimeIndex is not named correctly. Must be named "time", got {timesteps.name=}') + def as_dataarray( + data: NumericData, timesteps: pd.DatetimeIndex, scenarios: Optional[pd.Index] = None + ) -> xr.DataArray: + """ + Convert data to xarray.DataArray with specified timesteps and optional scenarios dimensions. + + Args: + data: The data to convert (scalar, array, Series, DataFrame, or DataArray) + timesteps: DatetimeIndex representing the time dimension (must be named 'time') + scenarios: Optional Index representing scenarios (must be named 'scenario') + + Returns: + DataArray with the converted data + + Raises: + ValueError: If timesteps or scenarios are invalid + ConversionError: If the data cannot be converted to the expected dimensions + """ + # Validate inputs + DataConverter._validate_timesteps(timesteps) + if scenarios is not None: + DataConverter._validate_scenarios(scenarios) - coords = [timesteps] - dims = ['time'] - expected_shape = (len(timesteps),) + # Determine dimensions and coordinates + coords, dims, expected_shape = DataConverter._get_dimensions(timesteps, scenarios) try: + # Convert different data types using specialized methods if isinstance(data, (int, float, np.integer, np.floating)): - return xr.DataArray(data, coords=coords, dims=dims) + return DataConverter._convert_scalar(data, coords, dims) + elif isinstance(data, pd.DataFrame): - if not data.index.equals(timesteps): - raise ConversionError("DataFrame index doesn't match timesteps index") - if not len(data.columns) == 1: - raise ConversionError('DataFrame must have exactly one column') - return xr.DataArray(data.values.flatten(), coords=coords, dims=dims) + return DataConverter._convert_dataframe(data, timesteps, scenarios, coords, dims) + elif isinstance(data, pd.Series): - if not data.index.equals(timesteps): - raise ConversionError("Series index doesn't match timesteps index") - return xr.DataArray(data.values, coords=coords, dims=dims) + return DataConverter._convert_series(data, timesteps, scenarios, coords, dims) + elif isinstance(data, np.ndarray): - if data.ndim != 1: - raise ConversionError(f'Array must be 1-dimensional, got {data.ndim}') - elif data.shape[0] != expected_shape[0]: - raise ConversionError(f"Array shape {data.shape} doesn't match expected {expected_shape}") - return xr.DataArray(data, coords=coords, dims=dims) + return DataConverter._convert_ndarray(data, timesteps, scenarios, coords, dims, expected_shape) + elif isinstance(data, xr.DataArray): - if data.dims != tuple(dims): - raise ConversionError(f"DataArray dimensions {data.dims} don't match expected {dims}") - if data.sizes[dims[0]] != len(coords[0]): - raise ConversionError( - f"DataArray length {data.sizes[dims[0]]} doesn't match expected {len(coords[0])}" - ) - return data.copy(deep=True) + return DataConverter._convert_dataarray(data, timesteps, scenarios, coords, dims) + else: raise ConversionError(f'Unsupported type: {type(data).__name__}') + except Exception as e: if isinstance(e, ConversionError): raise - raise ConversionError(f'Converting data {type(data)} to xarray.Dataset raised an error: {str(e)}') from e + raise ConversionError(f'Converting {type(data)} to DataArray raised an error: {str(e)}') from e + + @staticmethod + def _validate_timesteps(timesteps: pd.DatetimeIndex) -> None: + """ + Validate that timesteps is a properly named non-empty DatetimeIndex. + + Args: + timesteps: The DatetimeIndex to validate + + Raises: + ValueError: If timesteps is not a non-empty DatetimeIndex + ConversionError: If timesteps is not named 'time' + """ + if not isinstance(timesteps, pd.DatetimeIndex) or len(timesteps) == 0: + raise ValueError(f'Timesteps must be a non-empty DatetimeIndex, got {type(timesteps).__name__}') + if timesteps.name != 'time': + raise ConversionError(f'DatetimeIndex must be named "time", got {timesteps.name=}') + + @staticmethod + def _validate_scenarios(scenarios: pd.Index) -> None: + """ + Validate that scenarios is a properly named non-empty Index. + + Args: + scenarios: The Index to validate + + Raises: + ValueError: If scenarios is not a non-empty Index + ConversionError: If scenarios is not named 'scenario' + """ + if not isinstance(scenarios, pd.Index) or len(scenarios) == 0: + raise ValueError(f'Scenarios must be a non-empty Index, got {type(scenarios).__name__}') + if scenarios.name != 'scenario': + raise ConversionError(f'Scenarios Index must be named "scenario", got {scenarios.name=}') + + @staticmethod + def _get_dimensions( + timesteps: pd.DatetimeIndex, scenarios: Optional[pd.Index] = None + ) -> Tuple[Dict[str, pd.Index], Tuple[str, ...], Tuple[int, ...]]: + """ + Create the coordinates, dimensions, and expected shape for the output DataArray. + + Args: + timesteps: The time index + scenarios: Optional scenario index + + Returns: + Tuple containing: + - Dict mapping dimension names to coordinate indexes + - Tuple of dimension names + - Tuple of expected shape + """ + if scenarios is not None: + coords = {'scenario': scenarios, 'time': timesteps} + dims = ('scenario', 'time') + expected_shape = (len(scenarios), len(timesteps)) + else: + coords = {'time': timesteps} + dims = ('time',) + expected_shape = (len(timesteps),) + + return coords, dims, expected_shape + + @staticmethod + def _convert_scalar( + data: Union[int, float, np.integer, np.floating], coords: Dict[str, pd.Index], dims: Tuple[str, ...] + ) -> xr.DataArray: + """ + Convert a scalar value to a DataArray. + + Args: + data: The scalar value to convert + coords: Dictionary mapping dimension names to coordinate indexes + dims: Tuple of dimension names + + Returns: + DataArray with the scalar value broadcast to all coordinates + """ + return xr.DataArray(data, coords=coords, dims=dims) + + @staticmethod + def _convert_dataframe( + df: pd.DataFrame, + timesteps: pd.DatetimeIndex, + scenarios: Optional[pd.Index], + coords: Dict[str, pd.Index], + dims: Tuple[str, ...], + ) -> xr.DataArray: + """ + Convert a DataFrame to a DataArray. + + Args: + df: The DataFrame to convert + timesteps: The time index + scenarios: Optional scenario index + coords: Dictionary mapping dimension names to coordinate indexes + dims: Tuple of dimension names + + Returns: + DataArray created from the DataFrame + + Raises: + ConversionError: If the DataFrame cannot be converted to the expected dimensions + """ + # Case 1: DataFrame with MultiIndex (scenario, time) + if ( + isinstance(df.index, pd.MultiIndex) + and len(df.index.names) == 2 + and 'scenario' in df.index.names + and 'time' in df.index.names + and scenarios is not None + ): + return DataConverter._convert_multi_index_dataframe(df, timesteps, scenarios, coords, dims) + + # Case 2: Standard DataFrame with time index + elif not isinstance(df.index, pd.MultiIndex): + return DataConverter._convert_standard_dataframe(df, timesteps, scenarios, coords, dims) + + else: + raise ConversionError(f'Unsupported DataFrame index structure: {df}') + + @staticmethod + def _convert_multi_index_dataframe( + df: pd.DataFrame, + timesteps: pd.DatetimeIndex, + scenarios: pd.Index, + coords: Dict[str, pd.Index], + dims: Tuple[str, ...], + ) -> xr.DataArray: + """ + Convert a DataFrame with MultiIndex (scenario, time) to a DataArray. + + Args: + df: The DataFrame with MultiIndex to convert + timesteps: The time index + scenarios: The scenario index + coords: Dictionary mapping dimension names to coordinate indexes + dims: Tuple of dimension names + + Returns: + DataArray created from the MultiIndex DataFrame + + Raises: + ConversionError: If the DataFrame's index doesn't match expected or has multiple columns + """ + # Validate that the index contains the expected values + if not set(df.index.get_level_values('time')).issubset(set(timesteps)): + raise ConversionError("DataFrame time index doesn't match or isn't a subset of timesteps") + if not set(df.index.get_level_values('scenario')).issubset(set(scenarios)): + raise ConversionError("DataFrame scenario index doesn't match or isn't a subset of scenarios") + + # Ensure single column + if len(df.columns) != 1: + raise ConversionError('DataFrame must have exactly one column') + + # Reindex to ensure complete coverage and correct order + multi_idx = pd.MultiIndex.from_product([scenarios, timesteps], names=['scenario', 'time']) + reindexed = df.reindex(multi_idx).iloc[:, 0] + + # Reshape to 2D array + reshaped = reindexed.values.reshape(len(scenarios), len(timesteps)) + return xr.DataArray(reshaped, coords=coords, dims=dims) + + @staticmethod + def _convert_standard_dataframe( + df: pd.DataFrame, + timesteps: pd.DatetimeIndex, + scenarios: Optional[pd.Index], + coords: Dict[str, pd.Index], + dims: Tuple[str, ...], + ) -> xr.DataArray: + """ + Convert a standard DataFrame with time index to a DataArray. + + Args: + df: The DataFrame to convert + timesteps: The time index + scenarios: Optional scenario index + coords: Dictionary mapping dimension names to coordinate indexes + dims: Tuple of dimension names + + Returns: + DataArray created from the DataFrame + + Raises: + ConversionError: If the DataFrame's index doesn't match timesteps or has multiple columns + """ + if not df.index.equals(timesteps): + raise ConversionError("DataFrame index doesn't match timesteps index") + if len(df.columns) != 1: + raise ConversionError('DataFrame must have exactly one column') + + # Get values + values = df.values.flatten() + + if scenarios is not None: + # Broadcast to scenarios dimension + values = np.tile(values, (len(scenarios), 1)) + + return xr.DataArray(values, coords=coords, dims=dims) + + @staticmethod + def _convert_series( + series: pd.Series, + timesteps: pd.DatetimeIndex, + scenarios: Optional[pd.Index], + coords: Dict[str, pd.Index], + dims: Tuple[str, ...], + ) -> xr.DataArray: + """ + Convert a Series to a DataArray. + + Args: + series: The Series to convert + timesteps: The time index + scenarios: Optional scenario index + coords: Dictionary mapping dimension names to coordinate indexes + dims: Tuple of dimension names + + Returns: + DataArray created from the Series + + Raises: + ConversionError: If the Series cannot be converted to the expected dimensions + """ + # Case 1: Series with MultiIndex (scenario, time) + if ( + isinstance(series.index, pd.MultiIndex) + and len(series.index.names) == 2 + and 'scenario' in series.index.names + and 'time' in series.index.names + and scenarios is not None + ): + return DataConverter._convert_multi_index_series(series, timesteps, scenarios, coords, dims) + + # Case 2: Standard Series with time index + elif not isinstance(series.index, pd.MultiIndex): + return DataConverter._convert_standard_series(series, timesteps, scenarios, coords, dims) + + else: + raise ConversionError('Unsupported Series index structure') + + @staticmethod + def _convert_multi_index_series( + series: pd.Series, + timesteps: pd.DatetimeIndex, + scenarios: pd.Index, + coords: Dict[str, pd.Index], + dims: Tuple[str, ...], + ) -> xr.DataArray: + """ + Convert a Series with MultiIndex (scenario, time) to a DataArray. + + Args: + series: The Series with MultiIndex to convert + timesteps: The time index + scenarios: The scenario index + coords: Dictionary mapping dimension names to coordinate indexes + dims: Tuple of dimension names + + Returns: + DataArray created from the MultiIndex Series + + Raises: + ConversionError: If the Series' index doesn't match expected + """ + # Validate that the index contains the expected values + if not set(series.index.get_level_values('time')).issubset(set(timesteps)): + raise ConversionError("Series time index doesn't match or isn't a subset of timesteps") + if not set(series.index.get_level_values('scenario')).issubset(set(scenarios)): + raise ConversionError("Series scenario index doesn't match or isn't a subset of scenarios") + + # Reindex to ensure complete coverage and correct order + multi_idx = pd.MultiIndex.from_product([scenarios, timesteps], names=['scenario', 'time']) + reindexed = series.reindex(multi_idx) + + # Reshape to 2D array + reshaped = reindexed.values.reshape(len(scenarios), len(timesteps)) + return xr.DataArray(reshaped, coords=coords, dims=dims) + + @staticmethod + def _convert_standard_series( + series: pd.Series, + timesteps: pd.DatetimeIndex, + scenarios: Optional[pd.Index], + coords: Dict[str, pd.Index], + dims: Tuple[str, ...], + ) -> xr.DataArray: + """ + Convert a standard Series with time index to a DataArray. + + Args: + series: The Series to convert + timesteps: The time index + scenarios: Optional scenario index + coords: Dictionary mapping dimension names to coordinate indexes + dims: Tuple of dimension names + + Returns: + DataArray created from the Series + + Raises: + ConversionError: If the Series' index doesn't match timesteps + """ + if not series.index.equals(timesteps): + raise ConversionError("Series index doesn't match timesteps index") + + # Get values + values = series.values + + if scenarios is not None: + # Broadcast to scenarios dimension + values = np.tile(values, (len(scenarios), 1)) + + return xr.DataArray(values, coords=coords, dims=dims) + + @staticmethod + def _convert_ndarray( + arr: np.ndarray, + timesteps: pd.DatetimeIndex, + scenarios: Optional[pd.Index], + coords: Dict[str, pd.Index], + dims: Tuple[str, ...], + expected_shape: Tuple[int, ...], + ) -> xr.DataArray: + """ + Convert a numpy array to a DataArray. + + Args: + arr: The numpy array to convert + timesteps: The time index + scenarios: Optional scenario index + coords: Dictionary mapping dimension names to coordinate indexes + dims: Tuple of dimension names + expected_shape: Expected shape of the resulting array + + Returns: + DataArray created from the numpy array + + Raises: + ConversionError: If the array cannot be converted to the expected dimensions + """ + # Case 1: With scenarios - array can be 1D or 2D + if scenarios is not None: + return DataConverter._convert_ndarray_with_scenarios( + arr, timesteps, scenarios, coords, dims, expected_shape + ) + + # Case 2: Without scenarios - array must be 1D + else: + return DataConverter._convert_ndarray_without_scenarios(arr, timesteps, coords, dims) + + @staticmethod + def _convert_ndarray_with_scenarios( + arr: np.ndarray, + timesteps: pd.DatetimeIndex, + scenarios: pd.Index, + coords: Dict[str, pd.Index], + dims: Tuple[str, ...], + expected_shape: Tuple[int, ...], + ) -> xr.DataArray: + """ + Convert a numpy array to a DataArray with scenarios dimension. + + Args: + arr: The numpy array to convert + timesteps: The time index + scenarios: The scenario index + coords: Dictionary mapping dimension names to coordinate indexes + dims: Tuple of dimension names + expected_shape: Expected shape (scenarios, timesteps) + + Returns: + DataArray created from the numpy array + + Raises: + ConversionError: If the array dimensions don't match expected + """ + if arr.ndim == 1: + # 1D array should match timesteps and be broadcast to scenarios + if arr.shape[0] != len(timesteps): + raise ConversionError(f"1D array length {arr.shape[0]} doesn't match timesteps length {len(timesteps)}") + # Broadcast to scenarios + values = np.tile(arr, (len(scenarios), 1)) + return xr.DataArray(values, coords=coords, dims=dims) + + elif arr.ndim == 2: + # 2D array should match (scenarios, timesteps) + if arr.shape != expected_shape: + raise ConversionError(f"2D array shape {arr.shape} doesn't match expected shape {expected_shape}") + return xr.DataArray(arr, coords=coords, dims=dims) + + else: + raise ConversionError(f'Array must be 1D or 2D, got {arr.ndim}D') + + @staticmethod + def _convert_ndarray_without_scenarios( + arr: np.ndarray, timesteps: pd.DatetimeIndex, coords: Dict[str, pd.Index], dims: Tuple[str, ...] + ) -> xr.DataArray: + """ + Convert a numpy array to a DataArray without scenarios dimension. + + Args: + arr: The numpy array to convert + timesteps: The time index + coords: Dictionary mapping dimension names to coordinate indexes + dims: Tuple of dimension names + + Returns: + DataArray created from the numpy array + + Raises: + ConversionError: If the array isn't 1D or doesn't match timesteps length + """ + if arr.ndim != 1: + raise ConversionError(f'Without scenarios, array must be 1D, got {arr.ndim}D') + if arr.shape[0] != len(timesteps): + raise ConversionError(f"Array shape {arr.shape} doesn't match expected length {len(timesteps)}") + return xr.DataArray(arr, coords=coords, dims=dims) + + @staticmethod + def _convert_dataarray( + da: xr.DataArray, + timesteps: pd.DatetimeIndex, + scenarios: Optional[pd.Index], + coords: Dict[str, pd.Index], + dims: Tuple[str, ...], + ) -> xr.DataArray: + """ + Convert an existing DataArray to a new DataArray with the desired dimensions. + + Args: + da: The DataArray to convert + timesteps: The time index + scenarios: Optional scenario index + coords: Dictionary mapping dimension names to coordinate indexes + dims: Tuple of dimension names + + Returns: + New DataArray with the specified coordinates and dimensions + + Raises: + ConversionError: If the DataArray dimensions don't match expected + """ + # Case 1: DataArray with only time dimension when scenarios are provided + if scenarios is not None and set(da.dims) == {'time'}: + return DataConverter._broadcast_time_only_dataarray(da, timesteps, scenarios, coords, dims) + + # Case 2: DataArray dimensions should match expected + elif set(da.dims) != set(dims): + raise ConversionError(f"DataArray dimensions {da.dims} don't match expected {dims}") + + # Validate dimensions sizes + for dim in dims: + if not np.array_equal(da.coords[dim].values, coords[dim].values): + raise ConversionError(f"DataArray dimension '{dim}' doesn't match expected {coords[dim]}") + + # Create a new DataArray with our coordinates to ensure consistency + result = xr.DataArray(da.values.copy(), coords=coords, dims=dims) + return result + + @staticmethod + def _broadcast_time_only_dataarray( + da: xr.DataArray, + timesteps: pd.DatetimeIndex, + scenarios: pd.Index, + coords: Dict[str, pd.Index], + dims: Tuple[str, ...], + ) -> xr.DataArray: + """ + Broadcast a time-only DataArray to include the scenarios dimension. + + Args: + da: The DataArray with only time dimension + timesteps: The time index + scenarios: The scenario index + coords: Dictionary mapping dimension names to coordinate indexes + dims: Tuple of dimension names + + Returns: + DataArray with the data broadcast to include scenarios dimension + + Raises: + ConversionError: If the DataArray time coordinates aren't compatible with timesteps + """ + # Ensure the time dimension is compatible + if not np.array_equal(da.coords['time'].values, timesteps.values): + raise ConversionError("DataArray time coordinates aren't compatible with timesteps") + + # Broadcast to scenarios + values = np.tile(da.values.copy(), (len(scenarios), 1)) + return xr.DataArray(values, coords=coords, dims=dims) class TimeSeriesData: @@ -146,18 +654,19 @@ class TimeSeries: name (str): The name of the time series aggregation_weight (Optional[float]): Weight used for aggregation aggregation_group (Optional[str]): Group name for shared aggregation weighting - needs_extra_timestep (bool): Whether this series needs an extra timestep + has_extra_timestep (bool): Whether this series needs an extra timestep """ @classmethod def from_datasource( cls, - data: NumericData, + data: NumericDataTS, name: str, timesteps: pd.DatetimeIndex, + scenarios: Optional[pd.Index] = None, aggregation_weight: Optional[float] = None, aggregation_group: Optional[str] = None, - needs_extra_timestep: bool = False, + has_extra_timestep: bool = False, ) -> 'TimeSeries': """ Initialize the TimeSeries from multiple data sources. @@ -166,19 +675,20 @@ def from_datasource( data: The time series data name: The name of the TimeSeries timesteps: The timesteps of the TimeSeries + scenarios: The scenarios of the TimeSeries aggregation_weight: The weight in aggregation calculations aggregation_group: Group this TimeSeries belongs to for aggregation weight sharing - needs_extra_timestep: Whether this series requires an extra timestep + has_extra_timestep: Whether this series requires an extra timestep Returns: A new TimeSeries instance """ return cls( - DataConverter.as_dataarray(data, timesteps), + DataConverter.as_dataarray(data, timesteps, scenarios), name, aggregation_weight, aggregation_group, - needs_extra_timestep, + has_extra_timestep, ) @classmethod @@ -212,7 +722,7 @@ def from_json(cls, data: Optional[Dict[str, Any]] = None, path: Optional[str] = name=data['name'], aggregation_weight=data['aggregation_weight'], aggregation_group=data['aggregation_group'], - needs_extra_timestep=data['needs_extra_timestep'], + has_extra_timestep=data['has_extra_timestep'], ) def __init__( @@ -221,7 +731,7 @@ def __init__( name: str, aggregation_weight: Optional[float] = None, aggregation_group: Optional[str] = None, - needs_extra_timestep: bool = False, + has_extra_timestep: bool = False, ): """ Initialize a TimeSeries with a DataArray. @@ -231,35 +741,42 @@ def __init__( name: The name of the TimeSeries aggregation_weight: The weight in aggregation calculations aggregation_group: Group this TimeSeries belongs to for weight sharing - needs_extra_timestep: Whether this series requires an extra timestep + has_extra_timestep: Whether this series requires an extra timestep Raises: - ValueError: If data doesn't have a 'time' index or has more than 1 dimension + ValueError: If data doesn't have a 'time' index or has unsupported dimensions """ if 'time' not in data.indexes: raise ValueError(f'DataArray must have a "time" index. Got {data.indexes}') - if data.ndim > 1: - raise ValueError(f'Number of dimensions of DataArray must be 1. Got {data.ndim}') + + allowed_dims = {'time', 'scenario'} + if not set(data.dims).issubset(allowed_dims): + raise ValueError(f'DataArray dimensions must be subset of {allowed_dims}. Got {data.dims}') self.name = name self.aggregation_weight = aggregation_weight self.aggregation_group = aggregation_group - self.needs_extra_timestep = needs_extra_timestep + self.has_extra_timestep = has_extra_timestep # Data management self._stored_data = data.copy(deep=True) self._backup = self._stored_data.copy(deep=True) - self._active_timesteps = self._stored_data.indexes['time'] - self._active_data = None - self._update_active_data() - def reset(self): + # Selection state + self._selected_timesteps: Optional[pd.DatetimeIndex] = None + self._selected_scenarios: Optional[pd.Index] = None + + # Flag for whether this series has scenarios + self._has_scenarios = 'scenario' in data.dims + + def reset(self) -> None: """ - Reset active timesteps to the full set of stored timesteps. + Reset selections to include all timesteps and scenarios. + This is equivalent to clearing all selections. """ - self.active_timesteps = None + self.clear_selection() - def restore_data(self): + def restore_data(self) -> None: """ Restore stored_data from the backup and reset active timesteps. """ @@ -280,8 +797,8 @@ def to_json(self, path: Optional[pathlib.Path] = None) -> Dict[str, Any]: 'name': self.name, 'aggregation_weight': self.aggregation_weight, 'aggregation_group': self.aggregation_group, - 'needs_extra_timestep': self.needs_extra_timestep, - 'data': self.active_data.to_dict(), + 'has_extra_timestep': self.has_extra_timestep, + 'data': self.selected_data.to_dict(), } # Convert datetime objects to ISO strings @@ -303,84 +820,100 @@ def stats(self) -> str: Returns: String representation of data statistics """ - return get_numeric_stats(self.active_data, padd=0) - - def _update_active_data(self): - """ - Update the active data based on active_timesteps. - """ - self._active_data = self._stored_data.sel(time=self.active_timesteps) + return get_numeric_stats(self.selected_data, padd=0, by_scenario=True) @property def all_equal(self) -> bool: """Check if all values in the series are equal.""" - return np.unique(self.active_data.values).size == 1 + return np.unique(self.selected_data.values).size == 1 @property - def active_timesteps(self) -> pd.DatetimeIndex: - """Get the current active timesteps.""" - return self._active_timesteps - - @active_timesteps.setter - def active_timesteps(self, timesteps: Optional[pd.DatetimeIndex]): + def selected_data(self) -> xr.DataArray: """ - Set active_timesteps and refresh active_data. - - Args: - timesteps: New timesteps to activate, or None to use all stored timesteps - - Raises: - TypeError: If timesteps is not a pandas DatetimeIndex or None + Get a view of stored_data based on current selections. + This computes the view dynamically based on the current selection state. """ - if timesteps is None: - self._active_timesteps = self.stored_data.indexes['time'] - elif isinstance(timesteps, pd.DatetimeIndex): - self._active_timesteps = timesteps - else: - raise TypeError('active_timesteps must be a pandas DatetimeIndex or None') + return self._stored_data.sel(**self._valid_selector) - self._update_active_data() + @property + def active_timesteps(self) -> pd.DatetimeIndex: + """Get the current active timesteps.""" + if self._selected_timesteps is None: + return self._stored_data.indexes['time'] + return self._selected_timesteps @property - def active_data(self) -> xr.DataArray: - """Get a view of stored_data based on active_timesteps.""" - return self._active_data + def active_scenarios(self) -> Optional[pd.Index]: + """Get the current active scenarios.""" + if not self._has_scenarios: + return None + if self._selected_scenarios is None: + return self._stored_data.indexes['scenario'] + return self._selected_scenarios @property def stored_data(self) -> xr.DataArray: """Get a copy of the full stored data.""" return self._stored_data.copy() - @stored_data.setter - def stored_data(self, value: NumericData): + def update_stored_data(self, value: xr.DataArray) -> None: """ - Update stored_data and refresh active_data. + Update stored_data and refresh selected_data. Args: value: New data to store """ - new_data = DataConverter.as_dataarray(value, timesteps=self.active_timesteps) + new_data = DataConverter.as_dataarray( + value, + timesteps=self.active_timesteps, + scenarios=self.active_scenarios if self._has_scenarios else None + ) # Skip if data is unchanged to avoid overwriting backup if new_data.equals(self._stored_data): return self._stored_data = new_data - self.active_timesteps = None # Reset to full timeline + self.clear_selection() # Reset selections to full dataset + + def clear_selection(self, timesteps: bool = True, scenarios: bool = True) -> None: + if timesteps: + self._selected_timesteps = None + if scenarios: + self._selected_scenarios = None + + def set_selection(self, timesteps: Optional[pd.DatetimeIndex] = None, scenarios: Optional[pd.Index] = None) -> None: + if timesteps is None: + self.clear_selection(timesteps=True, scenarios=False) + else: + self._selected_timesteps = timesteps + + if scenarios is None: + self.clear_selection(timesteps=False, scenarios=True) + else: + self._selected_scenarios = scenarios @property def sel(self): - return self.active_data.sel + """Direct access to the selected_data's sel method for convenience.""" + return self.selected_data.sel @property def isel(self): - return self.active_data.isel + """Direct access to the selected_data's isel method for convenience.""" + return self.selected_data.isel + + @property + def _valid_selector(self) -> Dict[str, pd.Index]: + """Get the current selection as a dictionary.""" + full_selection = {'time': self._selected_timesteps, 'scenario': self._selected_scenarios} + return {dim: sel for dim, sel in full_selection.items() if dim in self._stored_data.dims and sel is not None} def _apply_operation(self, other, op): """Apply an operation between this TimeSeries and another object.""" if isinstance(other, TimeSeries): - other = other.active_data - return op(self.active_data, other) + other = other.selected_data + return op(self.selected_data, other) def __add__(self, other): return self._apply_operation(other, lambda x, y: x + y) @@ -395,25 +928,25 @@ def __truediv__(self, other): return self._apply_operation(other, lambda x, y: x / y) def __radd__(self, other): - return other + self.active_data + return other + self.selected_data def __rsub__(self, other): - return other - self.active_data + return other - self.selected_data def __rmul__(self, other): - return other * self.active_data + return other * self.selected_data def __rtruediv__(self, other): - return other / self.active_data + return other / self.selected_data def __neg__(self) -> xr.DataArray: - return -self.active_data + return -self.selected_data def __pos__(self) -> xr.DataArray: - return +self.active_data + return +self.selected_data def __abs__(self) -> xr.DataArray: - return abs(self.active_data) + return abs(self.selected_data) def __gt__(self, other): """ @@ -426,7 +959,7 @@ def __gt__(self, other): True if all values in this TimeSeries are greater than other """ if isinstance(other, TimeSeries): - return (self.active_data > other.active_data).all().item() + return (self.selected_data > other.selected_data).all().item() return NotImplemented def __array_ufunc__(self, ufunc, method, *inputs, **kwargs): @@ -435,8 +968,8 @@ def __array_ufunc__(self, ufunc, method, *inputs, **kwargs): This allows NumPy functions to work with TimeSeries objects. """ - # Convert any TimeSeries inputs to their active_data - inputs = [x.active_data if isinstance(x, TimeSeries) else x for x in inputs] + # Convert any TimeSeries inputs to their selected_data + inputs = [x.selected_data if isinstance(x, TimeSeries) else x for x in inputs] return getattr(ufunc, method)(*inputs, **kwargs) def __repr__(self): @@ -450,10 +983,10 @@ def __repr__(self): 'name': self.name, 'aggregation_weight': self.aggregation_weight, 'aggregation_group': self.aggregation_group, - 'needs_extra_timestep': self.needs_extra_timestep, - 'shape': self.active_data.shape, - 'time_range': f'{self.active_timesteps[0]} to {self.active_timesteps[-1]}', + 'has_extra_timestep': self.has_extra_timestep, + 'shape': self.selected_data.shape, } + attr_str = ', '.join(f'{k}={repr(v)}' for k, v in attrs.items()) return f'TimeSeries({attr_str})' @@ -464,281 +997,329 @@ def __str__(self): Returns: Descriptive string with statistics """ - return f"TimeSeries '{self.name}': {self.stats}" + return f'TimeSeries "{self.name}":\n{textwrap.indent(self.stats, " ")}' class TimeSeriesCollection: """ - Collection of TimeSeries objects with shared timestep management. + Simplified central manager for time series data with reference tracking. - TimeSeriesCollection handles multiple TimeSeries objects with synchronized - timesteps, provides operations on collections, and manages extra timesteps. + Provides a way to store time series data and work with subsets of dimensions + that automatically update all references when changed. """ - def __init__( self, timesteps: pd.DatetimeIndex, + scenarios: Optional[pd.Index] = None, hours_of_last_timestep: Optional[float] = None, hours_of_previous_timesteps: Optional[Union[float, np.ndarray]] = None, ): - """ - Args: - timesteps: The timesteps of the Collection. - hours_of_last_timestep: The duration of the last time step. Uses the last time interval if not specified - hours_of_previous_timesteps: The duration of previous timesteps. - If None, the first time increment of time_series is used. - This is needed to calculate previous durations (for example consecutive_on_hours). - If you use an array, take care that its long enough to cover all previous values! - """ - # Prepare and validate timesteps + """Initialize a TimeSeriesCollection.""" self._validate_timesteps(timesteps) self.hours_of_previous_timesteps = self._calculate_hours_of_previous_timesteps( timesteps, hours_of_previous_timesteps - ) + ) #TODO: Make dynamic - # Set up timesteps and hours - self.all_timesteps = timesteps - self.all_timesteps_extra = self._create_timesteps_with_extra(timesteps, hours_of_last_timestep) - self.all_hours_per_timestep = self.calculate_hours_per_timestep(self.all_timesteps_extra) + self._full_timesteps = timesteps + self._full_timesteps_extra = self._create_timesteps_with_extra(timesteps, hours_of_last_timestep) + self._full_hours_per_timestep = self.calculate_hours_per_timestep(self._full_timesteps_extra) - # Active timestep tracking - self._active_timesteps = None - self._active_timesteps_extra = None - self._active_hours_per_timestep = None + self._full_scenarios = scenarios - # Dictionary of time series by name - self.time_series_data: Dict[str, TimeSeries] = {} + # Series that need extra timestep + self._has_extra_timestep: set = set() - # Aggregation - self.group_weights: Dict[str, float] = {} - self.weights: Dict[str, float] = {} + # Storage for TimeSeries objects + self._time_series: Dict[str, TimeSeries] = {} - @classmethod - def with_uniform_timesteps( - cls, start_time: pd.Timestamp, periods: int, freq: str, hours_per_step: Optional[float] = None - ) -> 'TimeSeriesCollection': - """Create a collection with uniform timesteps.""" - timesteps = pd.date_range(start_time, periods=periods, freq=freq, name='time') - return cls(timesteps, hours_of_previous_timesteps=hours_per_step) - - def create_time_series( - self, data: Union[NumericData, TimeSeriesData], name: str, needs_extra_timestep: bool = False + # Active subset selectors + self._selected_timesteps: Optional[pd.DatetimeIndex] = None + self._selected_scenarios: Optional[pd.Index] = None + self._selected_timesteps_extra: Optional[pd.DatetimeIndex] = None + self._selected_hours_per_timestep: Optional[xr.DataArray] = None + + def add_time_series( + self, + name: str, + data: Union[NumericDataTS, TimeSeries], + aggregation_weight: Optional[float] = None, + aggregation_group: Optional[str] = None, + has_extra_timestep: bool = False, ) -> TimeSeries: """ - Creates a TimeSeries from the given data and adds it to the collection. + Add a new TimeSeries to the allocator. Args: - data: The data to create the TimeSeries from. - name: The name of the TimeSeries. - needs_extra_timestep: Whether to create an additional timestep at the end of the timesteps. - The data to create the TimeSeries from. + name: Name of the time series + data: Data for the time series (can be raw data or an existing TimeSeries) + aggregation_weight: Weight used for aggregation + aggregation_group: Group name for shared aggregation weighting + has_extra_timestep: Whether this series needs an extra timestep Returns: - The created TimeSeries. - + The created TimeSeries object """ - # Check for duplicate name - if name in self.time_series_data: - raise ValueError(f"TimeSeries '{name}' already exists in this collection") - - # Determine which timesteps to use - timesteps_to_use = self.timesteps_extra if needs_extra_timestep else self.timesteps - - # Create the time series - if isinstance(data, TimeSeriesData): - time_series = TimeSeries.from_datasource( + if name in self._time_series: + raise KeyError(f"TimeSeries '{name}' already exists in allocator") + + # Choose which timesteps to use + target_timesteps = self.timesteps_extra if has_extra_timestep else self.timesteps + + # Create or adapt the TimeSeries object + if isinstance(data, TimeSeries): + # Use the existing TimeSeries but update its parameters + time_series = data + # Update the stored data to use our timesteps and scenarios + data_array = DataConverter.as_dataarray( + time_series.stored_data, timesteps=target_timesteps, scenarios=self.scenarios + ) + time_series = TimeSeries( + data=data_array, name=name, - data=data.data, - timesteps=timesteps_to_use, - aggregation_weight=data.agg_weight, - aggregation_group=data.agg_group, - needs_extra_timestep=needs_extra_timestep, + aggregation_weight=aggregation_weight or time_series.aggregation_weight, + aggregation_group=aggregation_group or time_series.aggregation_group, + has_extra_timestep=has_extra_timestep or time_series.has_extra_timestep, ) - # Connect the user time series to the created TimeSeries - data.label = name else: + # Create a new TimeSeries from raw data time_series = TimeSeries.from_datasource( - name=name, data=data, timesteps=timesteps_to_use, needs_extra_timestep=needs_extra_timestep + data=data, + name=name, + timesteps=target_timesteps, + scenarios=self.scenarios, + aggregation_weight=aggregation_weight, + aggregation_group=aggregation_group, + has_extra_timestep=has_extra_timestep, ) - # Add to the collection - self.add_time_series(time_series) + # Add to storage + self._time_series[name] = time_series - return time_series + # Track if it needs extra timestep + if has_extra_timestep: + self._has_extra_timestep.add(name) - def calculate_aggregation_weights(self) -> Dict[str, float]: - """Calculate and return aggregation weights for all time series.""" - self.group_weights = self._calculate_group_weights() - self.weights = self._calculate_weights() - - if np.all(np.isclose(list(self.weights.values()), 1, atol=1e-6)): - logger.info('All Aggregation weights were set to 1') - - return self.weights + # Return the TimeSeries object + return time_series - def activate_timesteps(self, active_timesteps: Optional[pd.DatetimeIndex] = None): + def clear_selection(self, timesteps: bool = True, scenarios: bool = True) -> None: """ - Update active timesteps for the collection and all time series. - If no arguments are provided, the active timesteps are reset. + Clear selection for timesteps and/or scenarios. Args: - active_timesteps: The active timesteps of the model. - If None, the all timesteps of the TimeSeriesCollection are taken. + timesteps: Whether to clear timesteps selection + scenarios: Whether to clear scenarios selection """ - if active_timesteps is None: - return self.reset() + if timesteps: + self._update_selected_timesteps(timesteps=None) + if scenarios: + self._selected_scenarios = None - if not np.all(np.isin(active_timesteps, self.all_timesteps)): - raise ValueError('active_timesteps must be a subset of the timesteps of the TimeSeriesCollection') + # Apply the selection to all TimeSeries objects + self._propagate_selection_to_time_series() - # Calculate derived timesteps - self._active_timesteps = active_timesteps - first_ts_index = np.where(self.all_timesteps == active_timesteps[0])[0][0] - last_ts_idx = np.where(self.all_timesteps == active_timesteps[-1])[0][0] - self._active_timesteps_extra = self.all_timesteps_extra[first_ts_index : last_ts_idx + 2] - self._active_hours_per_timestep = self.all_hours_per_timestep.isel(time=slice(first_ts_index, last_ts_idx + 1)) + def set_selection(self, timesteps: Optional[pd.DatetimeIndex] = None, scenarios: Optional[pd.Index] = None) -> None: + """ + Set active subset for timesteps and scenarios. - # Update all time series - self._update_time_series_timesteps() + Args: + timesteps: Timesteps to activate, or None to clear + scenarios: Scenarios to activate, or None to clear + """ + if timesteps is None: + self.clear_selection(timesteps=True, scenarios=False) + else: + self._update_selected_timesteps(timesteps) - def reset(self): - """Reset active timesteps to defaults for all time series.""" - self._active_timesteps = None - self._active_timesteps_extra = None - self._active_hours_per_timestep = None + if scenarios is None: + self.clear_selection(timesteps=False, scenarios=True) + else: + self._selected_scenarios = scenarios - for time_series in self.time_series_data.values(): - time_series.reset() + # Apply the selection to all TimeSeries objects + self._propagate_selection_to_time_series() - def restore_data(self): - """Restore original data for all time series.""" - for time_series in self.time_series_data.values(): - time_series.restore_data() + def _update_selected_timesteps(self, timesteps: Optional[pd.DatetimeIndex]) -> None: + """ + Updates the timestep and related metrics (timesteps_extra, hours_per_timestep) based on the current selection. + """ + if timesteps is None: + self._selected_timesteps = None + self._selected_timesteps_extra = None + self._selected_hours_per_timestep = None + return - def add_time_series(self, time_series: TimeSeries): - """Add an existing TimeSeries to the collection.""" - if time_series.name in self.time_series_data: - raise ValueError(f"TimeSeries '{time_series.name}' already exists in this collection") + self._validate_timesteps(timesteps, self._full_timesteps) - self.time_series_data[time_series.name] = time_series + self._selected_timesteps = timesteps + self._selected_hours_per_timestep = self._full_hours_per_timestep.sel(time=timesteps) + self._selected_timesteps_extra = self._create_timesteps_with_extra( + timesteps, self._selected_hours_per_timestep.isel(time=-1).max().item() + ) - def insert_new_data(self, data: pd.DataFrame, include_extra_timestep: bool = False): + def as_dataset(self, with_extra_timestep: bool = True, with_constants: bool = True) -> xr.Dataset: """ - Update time series with new data from a DataFrame. + Convert the TimeSeriesCollection to a xarray Dataset, containing the data of each TimeSeries. Args: - data: DataFrame containing new data with timestamps as index - include_extra_timestep: Whether the provided data already includes the extra timestep, by default False + with_extra_timestep: Whether to exclude the extra timesteps. + Effectively, this removes the last timestep for certain TImeSeries, but mitigates the presence of NANs in others. + with_constants: Whether to exclude TimeSeries with a constant value from the dataset. """ - if not isinstance(data, pd.DataFrame): - raise TypeError(f'data must be a pandas DataFrame, got {type(data).__name__}') + if self.scenarios is None: + ds = xr.Dataset(coords={'time': self.timesteps_extra}) + else: + ds = xr.Dataset(coords={'scenario': self.scenarios, 'time': self.timesteps_extra}) + + for ts in self._time_series.values(): + if not with_constants and ts.all_equal: + continue + ds[ts.name] = ts.selected_data - # Check if the DataFrame index matches the expected timesteps - expected_timesteps = self.timesteps_extra if include_extra_timestep else self.timesteps - if not data.index.equals(expected_timesteps): - raise ValueError( - f'DataFrame index must match {"collection timesteps with extra timestep" if include_extra_timestep else "collection timesteps"}' + if not with_extra_timestep: + return ds.sel(time=self.timesteps) + + return ds + + @property + def timesteps(self) -> pd.DatetimeIndex: + """Get the current active timesteps.""" + if self._selected_timesteps is None: + return self._full_timesteps + return self._selected_timesteps + + @property + def timesteps_extra(self) -> pd.DatetimeIndex: + """Get the current active timesteps with extra timestep.""" + if self._selected_timesteps_extra is None: + return self._full_timesteps_extra + return self._selected_timesteps_extra + + @property + def hours_per_timestep(self) -> xr.DataArray: + """Get the current active hours per timestep.""" + if self._selected_hours_per_timestep is None: + return self._full_hours_per_timestep + return self._selected_hours_per_timestep + + @property + def scenarios(self) -> Optional[pd.Index]: + """Get the current active scenarios.""" + if self._selected_scenarios is None: + return self._full_scenarios + return self._selected_scenarios + + def _propagate_selection_to_time_series(self) -> None: + """Apply the current selection to all TimeSeries objects.""" + for ts_name, ts in self._time_series.items(): + timesteps = self._selected_timesteps_extra if ts_name in self._has_extra_timestep else self._selected_timesteps + ts.set_selection( + timesteps=timesteps, + scenarios=self._selected_scenarios ) - for name, ts in self.time_series_data.items(): - if name in data.columns: - if not ts.needs_extra_timestep: - # For time series without extra timestep - if include_extra_timestep: - # If data includes extra timestep but series doesn't need it, exclude the last point - ts.stored_data = data[name].iloc[:-1] - else: - # Use data as is - ts.stored_data = data[name] - else: - # For time series with extra timestep - if include_extra_timestep: - # Data already includes extra timestep - ts.stored_data = data[name] - else: - # Need to add extra timestep - extrapolate from the last value - extra_step_value = data[name].iloc[-1] - extra_step_index = pd.DatetimeIndex([self.timesteps_extra[-1]], name='time') - extra_step_series = pd.Series([extra_step_value], index=extra_step_index) - - # Combine the regular data with the extra timestep - ts.stored_data = pd.concat([data[name], extra_step_series]) - - logger.debug(f'Updated data for {name}') - - def to_dataframe( - self, filtered: Literal['all', 'constant', 'non_constant'] = 'non_constant', include_extra_timestep: bool = True - ) -> pd.DataFrame: - """ - Convert collection to DataFrame with optional filtering and timestep control. + def __getitem__(self, name: str) -> TimeSeries: + """ + Get a reference to a time series or data array. Args: - filtered: Filter time series by variability, by default 'non_constant' - include_extra_timestep: Whether to include the extra timestep in the result, by default True + name: Name of the data array or time series Returns: - DataFrame representation of the collection + TimeSeries object if it exists, otherwise DataArray with current selection applied """ - include_constants = filtered != 'non_constant' - ds = self.to_dataset(include_constants=include_constants) - - if not include_extra_timestep: - ds = ds.isel(time=slice(None, -1)) + # First check if this is a TimeSeries + if name in self._time_series: + # Return the TimeSeries object (it will handle selection internally) + return self._time_series[name] + raise ValueError(f'No TimeSeries named "{name}" found') + + def __contains__(self, value) -> bool: + if isinstance(value, str): + return value in self._time_series + elif isinstance(value, TimeSeries): + return value.name in self._time_series + raise TypeError(f'Invalid type for __contains__ of {self.__class__.__name__}: {type(value)}') - df = ds.to_dataframe() - - # Apply filtering - if filtered == 'all': - return df - elif filtered == 'constant': - return df.loc[:, df.nunique() == 1] - elif filtered == 'non_constant': - return df.loc[:, df.nunique() > 1] - else: - raise ValueError("filtered must be one of: 'all', 'constant', 'non_constant'") + def __iter__(self) -> Iterator[TimeSeries]: + """Iterate over TimeSeries objects.""" + return iter(self._time_series.values()) - def to_dataset(self, include_constants: bool = True) -> xr.Dataset: + def update_time_series(self, name: str, data: NumericData) -> TimeSeries: """ - Combine all time series into a single Dataset with all timesteps. + Update an existing TimeSeries with new data. Args: - include_constants: Whether to include time series with constant values, by default True + name: Name of the TimeSeries to update + data: New data to assign Returns: - Dataset containing all selected time series with all timesteps - """ - # Determine which series to include - if include_constants: - series_to_include = self.time_series_data.values() - else: - series_to_include = self.non_constants + The updated TimeSeries - # Create individual datasets and merge them - ds = xr.merge([ts.active_data.to_dataset(name=ts.name) for ts in series_to_include]) + Raises: + KeyError: If no TimeSeries with the given name exists + """ + if name not in self._time_series: + raise KeyError(f"No TimeSeries named '{name}' found") - # Ensure the correct time coordinates - ds = ds.reindex(time=self.timesteps_extra) + # Get the TimeSeries + ts = self._time_series[name] - ds.attrs.update( - { - 'timesteps_extra': f'{self.timesteps_extra[0]} ... {self.timesteps_extra[-1]} | len={len(self.timesteps_extra)}', - 'hours_per_timestep': self._format_stats(self.hours_per_timestep), - } + # Convert data to proper format + data_array = DataConverter.as_dataarray( + data, + self.timesteps_extra if name in self._has_extra_timestep else self.timesteps, + self.scenarios ) - return ds + # Update the TimeSeries + ts.update_stored_data(data_array) + + return ts + + def calculate_aggregation_weights(self) -> Dict[str, float]: + """Calculate and return aggregation weights for all time series.""" + group_weights = self._calculate_group_weights() - def _update_time_series_timesteps(self): - """Update active timesteps for all time series.""" - for ts in self.time_series_data.values(): - if ts.needs_extra_timestep: - ts.active_timesteps = self.timesteps_extra + weights = {} + for name, ts in self._time_series.items(): + if ts.aggregation_group is not None: + # Use group weight + weights[name] = group_weights.get(ts.aggregation_group, 1) else: - ts.active_timesteps = self.timesteps + # Use individual weight or default to 1 + weights[name] = ts.aggregation_weight or 1 + + if np.all(np.isclose(list(weights.values()), 1, atol=1e-6)): + logger.info('All Aggregation weights were set to 1') + + return weights + + def _calculate_group_weights(self) -> Dict[str, float]: + """Calculate weights for aggregation groups.""" + # Count series in each group + groups = [ts.aggregation_group for ts in self._time_series.values() if ts.aggregation_group is not None] + group_counts = Counter(groups) + + # Calculate weight for each group (1/count) + return {group: 1 / count for group, count in group_counts.items()} @staticmethod - def _validate_timesteps(timesteps: pd.DatetimeIndex): - """Validate timesteps format and rename if needed.""" + def _validate_timesteps(timesteps: pd.DatetimeIndex, present_timesteps: Optional[pd.DatetimeIndex] = None): + """ + Validate timesteps format and rename if needed. + Args: + timesteps: The timesteps to validate + present_timesteps: The timesteps that are present in the dataset + + Raises: + ValueError: If timesteps is not a pandas DatetimeIndex + ValueError: If timesteps is not at least 2 timestamps + ValueError: If timesteps has a different name than 'time' + ValueError: If timesteps is not sorted + ValueError: If timesteps contains duplicates + ValueError: If timesteps is not a subset of present_timesteps + """ if not isinstance(timesteps, pd.DatetimeIndex): raise TypeError('timesteps must be a pandas DatetimeIndex') @@ -750,6 +1331,18 @@ def _validate_timesteps(timesteps: pd.DatetimeIndex): logger.warning('Renamed timesteps to "time" (was "%s")', timesteps.name) timesteps.name = 'time' + # Ensure timesteps is sorted + if not timesteps.is_monotonic_increasing: + raise ValueError('timesteps must be sorted') + + # Ensure timesteps has no duplicates + if len(timesteps) != len(timesteps.drop_duplicates()): + raise ValueError('timesteps must not contain duplicates') + + # Ensure timesteps is a subset of present_timesteps + if present_timesteps is not None and not set(timesteps).issubset(set(present_timesteps)): + raise ValueError('timesteps must be a subset of present_timesteps') + @staticmethod def _create_timesteps_with_extra( timesteps: pd.DatetimeIndex, hours_of_last_timestep: Optional[float] @@ -787,128 +1380,49 @@ def calculate_hours_per_timestep(timesteps_extra: pd.DatetimeIndex) -> xr.DataAr data=hours_per_step, coords={'time': timesteps_extra[:-1]}, dims=('time',), name='hours_per_step' ) - def _calculate_group_weights(self) -> Dict[str, float]: - """Calculate weights for aggregation groups.""" - # Count series in each group - groups = [ts.aggregation_group for ts in self.time_series_data.values() if ts.aggregation_group is not None] - group_counts = Counter(groups) - # Calculate weight for each group (1/count) - return {group: 1 / count for group, count in group_counts.items()} - - def _calculate_weights(self) -> Dict[str, float]: - """Calculate weights for all time series.""" - # Calculate weight for each time series - weights = {} - for name, ts in self.time_series_data.items(): - if ts.aggregation_group is not None: - # Use group weight - weights[name] = self.group_weights.get(ts.aggregation_group, 1) - else: - # Use individual weight or default to 1 - weights[name] = ts.aggregation_weight or 1 - - return weights - - def _format_stats(self, data) -> str: - """Format statistics for a data array.""" - if hasattr(data, 'values'): - values = data.values - else: - values = np.asarray(data) - - mean_val = np.mean(values) - min_val = np.min(values) - max_val = np.max(values) - - return f'mean: {mean_val:.2f}, min: {min_val:.2f}, max: {max_val:.2f}' - - def __getitem__(self, name: str) -> TimeSeries: - """Get a TimeSeries by name.""" - try: - return self.time_series_data[name] - except KeyError as e: - raise KeyError(f'TimeSeries "{name}" not found in the TimeSeriesCollection') from e - - def __iter__(self) -> Iterator[TimeSeries]: - """Iterate through all TimeSeries in the collection.""" - return iter(self.time_series_data.values()) - - def __len__(self) -> int: - """Get the number of TimeSeries in the collection.""" - return len(self.time_series_data) - - def __contains__(self, item: Union[str, TimeSeries]) -> bool: - """Check if a TimeSeries exists in the collection.""" - if isinstance(item, str): - return item in self.time_series_data - elif isinstance(item, TimeSeries): - return item in self.time_series_data.values() - return False - - @property - def non_constants(self) -> List[TimeSeries]: - """Get time series with varying values.""" - return [ts for ts in self.time_series_data.values() if not ts.all_equal] - - @property - def constants(self) -> List[TimeSeries]: - """Get time series with constant values.""" - return [ts for ts in self.time_series_data.values() if ts.all_equal] - - @property - def timesteps(self) -> pd.DatetimeIndex: - """Get the active timesteps.""" - return self.all_timesteps if self._active_timesteps is None else self._active_timesteps - - @property - def timesteps_extra(self) -> pd.DatetimeIndex: - """Get the active timesteps with extra step.""" - return self.all_timesteps_extra if self._active_timesteps_extra is None else self._active_timesteps_extra - - @property - def hours_per_timestep(self) -> xr.DataArray: - """Get the duration of each active timestep.""" - return ( - self.all_hours_per_timestep if self._active_hours_per_timestep is None else self._active_hours_per_timestep - ) - - @property - def hours_of_last_timestep(self) -> float: - """Get the duration of the last timestep.""" - return float(self.hours_per_timestep[-1].item()) - - def __repr__(self): - return f'TimeSeriesCollection:\n{self.to_dataset()}' - - def __str__(self): - longest_name = max([time_series.name for time_series in self.time_series_data], key=len) - - stats_summary = '\n'.join( - [ - f' - {time_series.name:<{len(longest_name)}}: {get_numeric_stats(time_series.active_data)}' - for time_series in self.time_series_data - ] - ) - - return ( - f'TimeSeriesCollection with {len(self.time_series_data)} series\n' - f' Time Range: {self.timesteps[0]} → {self.timesteps[-1]}\n' - f' No. of timesteps: {len(self.timesteps)} + 1 extra\n' - f' Hours per timestep: {get_numeric_stats(self.hours_per_timestep)}\n' - f' Time Series Data:\n' - f'{stats_summary}' - ) +def get_numeric_stats(data: xr.DataArray, decimals: int = 2, padd: int = 10, by_scenario: bool = False) -> str: + """ + Calculates the mean, median, min, max, and standard deviation of a numeric DataArray. + Args: + data: The DataArray to analyze + decimals: Number of decimal places to show + padd: Padding for alignment + by_scenario: Whether to break down stats by scenario -def get_numeric_stats(data: xr.DataArray, decimals: int = 2, padd: int = 10) -> str: - """Calculates the mean, median, min, max, and standard deviation of a numeric DataArray.""" + Returns: + String representation of data statistics + """ format_spec = f'>{padd}.{decimals}f' if padd else f'.{decimals}f' + + # If by_scenario is True and there's a scenario dimension with multiple values + if by_scenario and 'scenario' in data.dims and data.sizes['scenario'] > 1: + results = [] + for scenario in data.coords['scenario'].values: + scenario_data = data.sel(scenario=scenario) + if np.unique(scenario_data).size == 1: + results.append(f' {scenario}: {scenario_data.max().item():{format_spec}} (constant)') + else: + mean = scenario_data.mean().item() + median = scenario_data.median().item() + min_val = scenario_data.min().item() + max_val = scenario_data.max().item() + std = scenario_data.std().item() + results.append( + f' {scenario}: {mean:{format_spec}} (mean), {median:{format_spec}} (median), ' + f'{min_val:{format_spec}} (min), {max_val:{format_spec}} (max), {std:{format_spec}} (std)' + ) + return '\n'.join(['By scenario:'] + results) + + # Standard logic for non-scenario data or aggregated stats if np.unique(data).size == 1: return f'{data.max().item():{format_spec}} (constant)' + mean = data.mean().item() median = data.median().item() min_val = data.min().item() max_val = data.max().item() std = data.std().item() + return f'{mean:{format_spec}} (mean), {median:{format_spec}} (median), {min_val:{format_spec}} (min), {max_val:{format_spec}} (max), {std:{format_spec}} (std)' diff --git a/flixopt/effects.py b/flixopt/effects.py index 82aa63a43..9b5ea41d6 100644 --- a/flixopt/effects.py +++ b/flixopt/effects.py @@ -137,10 +137,10 @@ def __init__(self, model: SystemModel, element: Effect): label_full=f'{self.label_full}(operation)', total_max=self.element.maximum_operation, total_min=self.element.minimum_operation, - min_per_hour=self.element.minimum_operation_per_hour.active_data + min_per_hour=self.element.minimum_operation_per_hour.selected_data if self.element.minimum_operation_per_hour is not None else None, - max_per_hour=self.element.maximum_operation_per_hour.active_data + max_per_hour=self.element.maximum_operation_per_hour.selected_data if self.element.maximum_operation_per_hour is not None else None, ) @@ -376,7 +376,7 @@ def _add_share_between_effects(self): for target_effect, time_series in origin_effect.specific_share_to_other_effects_operation.items(): self.effects[target_effect].model.operation.add_share( origin_effect.model.operation.label_full, - origin_effect.model.operation.total_per_timestep * time_series.active_data, + origin_effect.model.operation.total_per_timestep * time_series.selected_data, ) # 2. invest: -> hier ist es Scalar (share) for target_effect, factor in origin_effect.specific_share_to_other_effects_invest.items(): diff --git a/flixopt/elements.py b/flixopt/elements.py index 05898d4e5..95536b910 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -374,7 +374,7 @@ def _create_shares(self): self._model.effects.add_share_to_effects( name=self.label_full, # Use the full label of the element expressions={ - effect: self.flow_rate * self._model.hours_per_step * factor.active_data + effect: self.flow_rate * self._model.hours_per_step * factor.selected_data for effect, factor in self.element.effects_per_flow_hour.items() }, target='operation', @@ -429,8 +429,8 @@ def relative_flow_rate_bounds(self) -> Tuple[NumericData, NumericData]: """Returns relative flow rate bounds.""" fixed_profile = self.element.fixed_relative_profile if fixed_profile is None: - return self.element.relative_minimum.active_data, self.element.relative_maximum.active_data - return fixed_profile.active_data, fixed_profile.active_data + return self.element.relative_minimum.selected_data, self.element.relative_maximum.selected_data + return fixed_profile.selected_data, fixed_profile.selected_data class BusModel(ElementModel): @@ -451,7 +451,7 @@ def do_modeling(self) -> None: # Fehlerplus/-minus: if self.element.with_excess: excess_penalty = np.multiply( - self._model.hours_per_step, self.element.excess_penalty_per_flow_hour.active_data + self._model.hours_per_step, self.element.excess_penalty_per_flow_hour.selected_data ) self.excess_input = self.add( self._model.add_variables(lower=0, coords=self._model.coords, name=f'{self.label_full}|excess_input'), diff --git a/flixopt/features.py b/flixopt/features.py index 92caf9dc2..32c382486 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -441,7 +441,7 @@ def _get_duration_in_hours( if previous_duration + self._model.hours_per_step[0] > first_step_max: logger.warning( - f'The maximum duration of "{variable_name}" is set to {maximum_duration.active_data}h, ' + f'The maximum duration of "{variable_name}" is set to {maximum_duration.selected_data}h, ' f'but the consecutive_duration previous to this model is {previous_duration}h. ' f'This forces "{binary_variable.name} = 0" in the first time step ' f'(dt={self._model.hours_per_step[0]}h)!' @@ -450,7 +450,7 @@ def _get_duration_in_hours( duration_in_hours = self.add( self._model.add_variables( lower=0, - upper=maximum_duration.active_data if maximum_duration is not None else mega, + upper=maximum_duration.selected_data if maximum_duration is not None else mega, coords=self._model.coords, name=f'{self.label_full}|{variable_name}', ), diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index 93720de60..e39d71e94 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -35,12 +35,14 @@ class FlowSystem: def __init__( self, timesteps: pd.DatetimeIndex, + scenarios: Optional[pd.Index] = None, hours_of_last_timestep: Optional[float] = None, hours_of_previous_timesteps: Optional[Union[int, float, np.ndarray]] = None, ): """ Args: timesteps: The timesteps of the model. + scenarios: The scenarios of the model. hours_of_last_timestep: The duration of the last time step. Uses the last time interval if not specified hours_of_previous_timesteps: The duration of previous timesteps. If None, the first time increment of time_series is used. @@ -49,6 +51,7 @@ def __init__( """ self.time_series_collection = TimeSeriesCollection( timesteps=timesteps, + scenarios=scenarios, hours_of_last_timestep=hours_of_last_timestep, hours_of_previous_timesteps=hours_of_previous_timesteps, ) @@ -184,7 +187,7 @@ def as_dataset(self, constants_in_dataset: bool = False) -> xr.Dataset: Args: constants_in_dataset: If True, constants are included as Dataset variables. """ - ds = self.time_series_collection.to_dataset(include_constants=constants_in_dataset) + ds = self.time_series_collection.as_dataset() ds.attrs = self.as_dict(data_mode='name') return ds @@ -275,7 +278,7 @@ def create_time_series( self, name: str, data: Optional[Union[NumericData, TimeSeriesData, TimeSeries]], - needs_extra_timestep: bool = False, + has_extra_timestep: bool = False, ) -> Optional[TimeSeries]: """ Tries to create a TimeSeries from NumericData Data and adds it to the time_series_collection @@ -290,11 +293,20 @@ def create_time_series( data.restore_data() if data in self.time_series_collection: return data - return self.time_series_collection.create_time_series( - data=data.active_data, name=name, needs_extra_timestep=needs_extra_timestep + return self.time_series_collection.add_time_series( + data=data.selected_data, name=name, has_extra_timestep=has_extra_timestep ) - return self.time_series_collection.create_time_series( - data=data, name=name, needs_extra_timestep=needs_extra_timestep + elif isinstance(data, TimeSeriesData): + data.label = name + return self.time_series_collection.add_time_series( + data=data.data, + name=name, + has_extra_timestep=has_extra_timestep, + aggregation_weight=data.agg_weight, + aggregation_group=data.agg_group + ) + return self.time_series_collection.add_time_series( + data=data, name=name, has_extra_timestep=has_extra_timestep ) def create_effect_time_series( diff --git a/flixopt/io.py b/flixopt/io.py index 35d927136..5cc353836 100644 --- a/flixopt/io.py +++ b/flixopt/io.py @@ -23,7 +23,7 @@ def replace_timeseries(obj, mode: Literal['name', 'stats', 'data'] = 'name'): return [replace_timeseries(v, mode) for v in obj] elif isinstance(obj, TimeSeries): # Adjust this based on the actual class if obj.all_equal: - return obj.active_data.values[0].item() + return obj.selected_data.values[0].item() elif mode == 'name': return f'::::{obj.name}' elif mode == 'stats': diff --git a/flixopt/structure.py b/flixopt/structure.py index e7f1c62a4..2e136c652 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -534,7 +534,7 @@ def copy_and_convert_datatypes(data: Any, use_numpy: bool = True, use_element_la return copy_and_convert_datatypes(data.tolist(), use_numpy, use_element_label) elif isinstance(data, TimeSeries): - return copy_and_convert_datatypes(data.active_data, use_numpy, use_element_label) + return copy_and_convert_datatypes(data.selected_data, use_numpy, use_element_label) elif isinstance(data, TimeSeriesData): return copy_and_convert_datatypes(data.data, use_numpy, use_element_label) diff --git a/tests/test_dataconverter.py b/tests/test_dataconverter.py index 49f1438e7..579de9c00 100644 --- a/tests/test_dataconverter.py +++ b/tests/test_dataconverter.py @@ -3,69 +3,848 @@ import pytest import xarray as xr -from flixopt.core import ConversionError, DataConverter # Adjust this import to match your project structure +from flixopt.core import ( # Adjust this import to match your project structure + ConversionError, + DataConverter, + TimeSeries, +) @pytest.fixture -def sample_time_index(request): - index = pd.date_range('2024-01-01', periods=5, freq='D', name='time') - return index +def sample_time_index(): + return pd.date_range('2024-01-01', periods=5, freq='D', name='time') -def test_scalar_conversion(sample_time_index): - # Test scalar conversion - result = DataConverter.as_dataarray(42, sample_time_index) - assert isinstance(result, xr.DataArray) - assert result.shape == (len(sample_time_index),) - assert result.dims == ('time',) - assert np.all(result.values == 42) +@pytest.fixture +def sample_scenario_index(): + return pd.Index(['baseline', 'high_demand', 'low_price'], name='scenario') + + +@pytest.fixture +def multi_index(sample_time_index, sample_scenario_index): + """Create a sample MultiIndex combining scenarios and times.""" + return pd.MultiIndex.from_product([sample_scenario_index, sample_time_index], names=['scenario', 'time']) + + +class TestSingleDimensionConversion: + """Tests for converting data without scenarios (1D: time only).""" + + def test_scalar_conversion(self, sample_time_index): + """Test converting a scalar value.""" + # Test with integer + result = DataConverter.as_dataarray(42, sample_time_index) + assert isinstance(result, xr.DataArray) + assert result.shape == (len(sample_time_index),) + assert result.dims == ('time',) + assert np.all(result.values == 42) + + # Test with float + result = DataConverter.as_dataarray(42.5, sample_time_index) + assert np.all(result.values == 42.5) + + # Test with numpy scalar types + result = DataConverter.as_dataarray(np.int64(42), sample_time_index) + assert np.all(result.values == 42) + result = DataConverter.as_dataarray(np.float32(42.5), sample_time_index) + assert np.all(result.values == 42.5) + + def test_series_conversion(self, sample_time_index): + """Test converting a pandas Series.""" + # Test with integer values + series = pd.Series([1, 2, 3, 4, 5], index=sample_time_index) + result = DataConverter.as_dataarray(series, sample_time_index) + assert isinstance(result, xr.DataArray) + assert result.shape == (5,) + assert result.dims == ('time',) + assert np.array_equal(result.values, series.values) + + # Test with float values + series = pd.Series([1.1, 2.2, 3.3, 4.4, 5.5], index=sample_time_index) + result = DataConverter.as_dataarray(series, sample_time_index) + assert np.array_equal(result.values, series.values) + + # Test with mixed NA values + series = pd.Series([1, np.nan, 3, None, 5], index=sample_time_index) + result = DataConverter.as_dataarray(series, sample_time_index) + assert np.array_equal(np.isnan(result.values), np.isnan(series.values)) + assert np.array_equal(result.values[~np.isnan(result.values)], series.values[~np.isnan(series.values)]) + + def test_dataframe_conversion(self, sample_time_index): + """Test converting a pandas DataFrame.""" + # Test with a single-column DataFrame + df = pd.DataFrame({'A': [1, 2, 3, 4, 5]}, index=sample_time_index) + result = DataConverter.as_dataarray(df, sample_time_index) + assert isinstance(result, xr.DataArray) + assert result.shape == (5,) + assert result.dims == ('time',) + assert np.array_equal(result.values.flatten(), df['A'].values) + + # Test with float values + df = pd.DataFrame({'A': [1.1, 2.2, 3.3, 4.4, 5.5]}, index=sample_time_index) + result = DataConverter.as_dataarray(df, sample_time_index) + assert np.array_equal(result.values.flatten(), df['A'].values) + + # Test with NA values + df = pd.DataFrame({'A': [1, np.nan, 3, None, 5]}, index=sample_time_index) + result = DataConverter.as_dataarray(df, sample_time_index) + assert np.array_equal(np.isnan(result.values), np.isnan(df['A'].values)) + assert np.array_equal(result.values[~np.isnan(result.values)], df['A'].values[~np.isnan(df['A'].values)]) + + def test_ndarray_conversion(self, sample_time_index): + """Test converting a numpy ndarray.""" + # Test with integer 1D array + arr_1d = np.array([1, 2, 3, 4, 5]) + result = DataConverter.as_dataarray(arr_1d, sample_time_index) + assert result.shape == (5,) + assert result.dims == ('time',) + assert np.array_equal(result.values, arr_1d) + + # Test with float 1D array + arr_1d = np.array([1.1, 2.2, 3.3, 4.4, 5.5]) + result = DataConverter.as_dataarray(arr_1d, sample_time_index) + assert np.array_equal(result.values, arr_1d) + + # Test with array containing NaN + arr_1d = np.array([1, np.nan, 3, np.nan, 5]) + result = DataConverter.as_dataarray(arr_1d, sample_time_index) + assert np.array_equal(np.isnan(result.values), np.isnan(arr_1d)) + assert np.array_equal(result.values[~np.isnan(result.values)], arr_1d[~np.isnan(arr_1d)]) + + def test_dataarray_conversion(self, sample_time_index): + """Test converting an existing xarray DataArray.""" + # Create original DataArray + original = xr.DataArray(data=np.array([1, 2, 3, 4, 5]), coords={'time': sample_time_index}, dims=['time']) + + # Convert and check + result = DataConverter.as_dataarray(original, sample_time_index) + assert result.shape == (5,) + assert result.dims == ('time',) + assert np.array_equal(result.values, original.values) + + # Ensure it's a copy + result[0] = 999 + assert original[0].item() == 1 # Original should be unchanged + + # Test with different time coordinates but same length + different_times = pd.date_range('2025-01-01', periods=5, freq='D', name='time') + original = xr.DataArray(data=np.array([1, 2, 3, 4, 5]), coords={'time': different_times}, dims=['time']) + + # Should raise an error for mismatched time coordinates + with pytest.raises(ConversionError): + DataConverter.as_dataarray(original, sample_time_index) + + +class TestMultiDimensionConversion: + """Tests for converting data with scenarios (2D: scenario × time).""" + + def test_scalar_with_scenarios(self, sample_time_index, sample_scenario_index): + """Test converting scalar values with scenario dimension.""" + # Test with integer + result = DataConverter.as_dataarray(42, sample_time_index, sample_scenario_index) + + assert isinstance(result, xr.DataArray) + assert result.shape == (len(sample_scenario_index), len(sample_time_index)) + assert result.dims == ('scenario', 'time') + assert np.all(result.values == 42) + assert set(result.coords['scenario'].values) == set(sample_scenario_index.values) + assert set(result.coords['time'].values) == set(sample_time_index.values) + + # Test with float + result = DataConverter.as_dataarray(42.5, sample_time_index, sample_scenario_index) + assert np.all(result.values == 42.5) + + def test_series_with_scenarios(self, sample_time_index, sample_scenario_index): + """Test converting Series with scenario dimension.""" + # Create time series data + series = pd.Series([1, 2, 3, 4, 5], index=sample_time_index) + + # Convert with scenario dimension + result = DataConverter.as_dataarray(series, sample_time_index, sample_scenario_index) + + assert result.shape == (len(sample_scenario_index), len(sample_time_index)) + assert result.dims == ('scenario', 'time') + + # Values should be broadcast to all scenarios + for scenario in sample_scenario_index: + scenario_slice = result.sel(scenario=scenario) + assert np.array_equal(scenario_slice.values, series.values) + + # Test with series containing NaN + series = pd.Series([1, np.nan, 3, np.nan, 5], index=sample_time_index) + result = DataConverter.as_dataarray(series, sample_time_index, sample_scenario_index) + + # Each scenario should have the same pattern of NaNs + for scenario in sample_scenario_index: + scenario_slice = result.sel(scenario=scenario) + assert np.array_equal(np.isnan(scenario_slice.values), np.isnan(series.values)) + assert np.array_equal( + scenario_slice.values[~np.isnan(scenario_slice.values)], series.values[~np.isnan(series.values)] + ) + + def test_multi_index_series(self, sample_time_index, sample_scenario_index, multi_index): + """Test converting a Series with MultiIndex (scenario, time).""" + # Create a MultiIndex Series with scenario-specific values + values = [ + # baseline scenario + 10, + 20, + 30, + 40, + 50, + # high_demand scenario + 15, + 25, + 35, + 45, + 55, + # low_price scenario + 5, + 15, + 25, + 35, + 45, + ] + series_multi = pd.Series(values, index=multi_index) + + # Convert the MultiIndex Series + result = DataConverter.as_dataarray(series_multi, sample_time_index, sample_scenario_index) + + assert result.shape == (len(sample_scenario_index), len(sample_time_index)) + assert result.dims == ('scenario', 'time') + + # Check values for each scenario + baseline_values = result.sel(scenario='baseline').values + assert np.array_equal(baseline_values, [10, 20, 30, 40, 50]) + + high_demand_values = result.sel(scenario='high_demand').values + assert np.array_equal(high_demand_values, [15, 25, 35, 45, 55]) + + low_price_values = result.sel(scenario='low_price').values + assert np.array_equal(low_price_values, [5, 15, 25, 35, 45]) + + # Test with some missing values in the MultiIndex + incomplete_index = multi_index[:-2] # Remove last two entries + incomplete_values = values[:-2] # Remove corresponding values + incomplete_series = pd.Series(incomplete_values, index=incomplete_index) + + result = DataConverter.as_dataarray(incomplete_series, sample_time_index, sample_scenario_index) + + # The last value of low_price scenario should be NaN + assert np.isnan(result.sel(scenario='low_price').values[-1]) + + def test_dataframe_with_scenarios(self, sample_time_index, sample_scenario_index): + """Test converting DataFrame with scenario dimension.""" + # Create a single-column DataFrame + df = pd.DataFrame({'A': [1, 2, 3, 4, 5]}, index=sample_time_index) + + # Convert with scenario dimension + result = DataConverter.as_dataarray(df, sample_time_index, sample_scenario_index) + + assert result.shape == (len(sample_scenario_index), len(sample_time_index)) + assert result.dims == ('scenario', 'time') + + # Values should be broadcast to all scenarios + for scenario in sample_scenario_index: + scenario_slice = result.sel(scenario=scenario) + assert np.array_equal(scenario_slice.values, df['A'].values) + + def test_multi_index_dataframe(self, sample_time_index, sample_scenario_index, multi_index): + """Test converting a DataFrame with MultiIndex (scenario, time).""" + # Create a MultiIndex DataFrame with scenario-specific values + values = [ + # baseline scenario + 10, + 20, + 30, + 40, + 50, + # high_demand scenario + 15, + 25, + 35, + 45, + 55, + # low_price scenario + 5, + 15, + 25, + 35, + 45, + ] + df_multi = pd.DataFrame({'A': values}, index=multi_index) + + # Convert the MultiIndex DataFrame + result = DataConverter.as_dataarray(df_multi, sample_time_index, sample_scenario_index) + + assert result.shape == (len(sample_scenario_index), len(sample_time_index)) + assert result.dims == ('scenario', 'time') + + # Check values for each scenario + baseline_values = result.sel(scenario='baseline').values + assert np.array_equal(baseline_values, [10, 20, 30, 40, 50]) + + high_demand_values = result.sel(scenario='high_demand').values + assert np.array_equal(high_demand_values, [15, 25, 35, 45, 55]) + + low_price_values = result.sel(scenario='low_price').values + assert np.array_equal(low_price_values, [5, 15, 25, 35, 45]) + + # Test with missing values + incomplete_index = multi_index[:-2] # Remove last two entries + incomplete_values = values[:-2] # Remove corresponding values + incomplete_df = pd.DataFrame({'A': incomplete_values}, index=incomplete_index) + + result = DataConverter.as_dataarray(incomplete_df, sample_time_index, sample_scenario_index) + + # The last value of low_price scenario should be NaN + assert np.isnan(result.sel(scenario='low_price').values[-1]) + + # Test with multiple columns (should raise error) + df_multi_col = pd.DataFrame({'A': values, 'B': [v * 2 for v in values]}, index=multi_index) + + with pytest.raises(ConversionError): + DataConverter.as_dataarray(df_multi_col, sample_time_index, sample_scenario_index) + + def test_1d_array_with_scenarios(self, sample_time_index, sample_scenario_index): + """Test converting 1D array with scenario dimension (broadcasting).""" + # Create 1D array matching timesteps length + arr_1d = np.array([1, 2, 3, 4, 5]) + + # Convert with scenarios + result = DataConverter.as_dataarray(arr_1d, sample_time_index, sample_scenario_index) + + assert result.shape == (len(sample_scenario_index), len(sample_time_index)) + assert result.dims == ('scenario', 'time') + + # Each scenario should have the same values (broadcasting) + for scenario in sample_scenario_index: + scenario_slice = result.sel(scenario=scenario) + assert np.array_equal(scenario_slice.values, arr_1d) + + def test_2d_array_with_scenarios(self, sample_time_index, sample_scenario_index): + """Test converting 2D array with scenario dimension.""" + # Create 2D array with different values per scenario + arr_2d = np.array( + [ + [1, 2, 3, 4, 5], # baseline scenario + [6, 7, 8, 9, 10], # high_demand scenario + [11, 12, 13, 14, 15], # low_price scenario + ] + ) + + # Convert to DataArray + result = DataConverter.as_dataarray(arr_2d, sample_time_index, sample_scenario_index) + + assert result.shape == (3, 5) + assert result.dims == ('scenario', 'time') + + # Check that each scenario has correct values + assert np.array_equal(result.sel(scenario='baseline').values, arr_2d[0]) + assert np.array_equal(result.sel(scenario='high_demand').values, arr_2d[1]) + assert np.array_equal(result.sel(scenario='low_price').values, arr_2d[2]) + + def test_dataarray_with_scenarios(self, sample_time_index, sample_scenario_index): + """Test converting an existing DataArray with scenarios.""" + # Create a multi-scenario DataArray + original = xr.DataArray( + data=np.array([[1, 2, 3, 4, 5], [6, 7, 8, 9, 10], [11, 12, 13, 14, 15]]), + coords={'scenario': sample_scenario_index, 'time': sample_time_index}, + dims=['scenario', 'time'], + ) + + # Test conversion + result = DataConverter.as_dataarray(original, sample_time_index, sample_scenario_index) + + assert result.shape == (3, 5) + assert result.dims == ('scenario', 'time') + assert np.array_equal(result.values, original.values) + + # Ensure it's a copy + result.loc['baseline'] = 999 + assert original.sel(scenario='baseline')[0].item() == 1 # Original should be unchanged + + def test_time_only_dataarray_with_scenarios(self, sample_time_index, sample_scenario_index): + """Test broadcasting a time-only DataArray to scenarios.""" + # Create a DataArray with only time dimension + time_only = xr.DataArray(data=np.array([1, 2, 3, 4, 5]), coords={'time': sample_time_index}, dims=['time']) + + # Convert with scenarios - should broadcast to all scenarios + result = DataConverter.as_dataarray(time_only, sample_time_index, sample_scenario_index) + + assert result.shape == (3, 5) + assert result.dims == ('scenario', 'time') + + # Each scenario should have same values + for scenario in sample_scenario_index: + assert np.array_equal(result.sel(scenario=scenario).values, time_only.values) + + +class TestInvalidInputs: + """Tests for invalid inputs and error handling.""" + + def test_time_index_validation(self): + """Test validation of time index.""" + # Test with unnamed index + unnamed_index = pd.date_range('2024-01-01', periods=5, freq='D') + with pytest.raises(ConversionError): + DataConverter.as_dataarray(42, unnamed_index) + + # Test with empty index + empty_index = pd.DatetimeIndex([], name='time') + with pytest.raises(ValueError): + DataConverter.as_dataarray(42, empty_index) + + # Test with non-DatetimeIndex + wrong_type_index = pd.Index([1, 2, 3, 4, 5], name='time') + with pytest.raises(ValueError): + DataConverter.as_dataarray(42, wrong_type_index) + + def test_scenario_index_validation(self, sample_time_index): + """Test validation of scenario index.""" + # Test with unnamed scenario index + unnamed_index = pd.Index(['baseline', 'high_demand']) + with pytest.raises(ConversionError): + DataConverter.as_dataarray(42, sample_time_index, unnamed_index) + # Test with empty scenario index + empty_index = pd.Index([], name='scenario') + with pytest.raises(ValueError): + DataConverter.as_dataarray(42, sample_time_index, empty_index) -def test_series_conversion(sample_time_index): - series = pd.Series([1, 2, 3, 4, 5], index=sample_time_index) + # Test with non-Index scenario + with pytest.raises(ValueError): + DataConverter.as_dataarray(42, sample_time_index, ['baseline', 'high_demand']) - # Test Series conversion - result = DataConverter.as_dataarray(series, sample_time_index) - assert isinstance(result, xr.DataArray) - assert result.shape == (5,) - assert result.dims == ('time',) - assert np.array_equal(result.values, series.values) + def test_invalid_data_types(self, sample_time_index, sample_scenario_index): + """Test handling of invalid data types.""" + # Test invalid input type (string) + with pytest.raises(ConversionError): + DataConverter.as_dataarray('invalid_string', sample_time_index) + # Test invalid input type with scenarios + with pytest.raises(ConversionError): + DataConverter.as_dataarray('invalid_string', sample_time_index, sample_scenario_index) -def test_dataframe_conversion(sample_time_index): - # Create a single-column DataFrame - df = pd.DataFrame({'A': [1, 2, 3, 4, 5]}, index=sample_time_index) + # Test unsupported complex object + with pytest.raises(ConversionError): + DataConverter.as_dataarray(object(), sample_time_index) - # Test DataFrame conversion - result = DataConverter.as_dataarray(df, sample_time_index) - assert isinstance(result, xr.DataArray) - assert result.shape == (5,) - assert result.dims == ('time',) - assert np.array_equal(result.values.flatten(), df['A'].values) + # Test None value + with pytest.raises(ConversionError): + DataConverter.as_dataarray(None, sample_time_index) + def test_mismatched_input_dimensions(self, sample_time_index, sample_scenario_index): + """Test handling of mismatched input dimensions.""" + # Test mismatched Series index + mismatched_series = pd.Series( + [1, 2, 3, 4, 5, 6], index=pd.date_range('2025-01-01', periods=6, freq='D', name='time') + ) + with pytest.raises(ConversionError): + DataConverter.as_dataarray(mismatched_series, sample_time_index) -def test_ndarray_conversion(sample_time_index): - # Test 1D array conversion - arr_1d = np.array([1, 2, 3, 4, 5]) - result = DataConverter.as_dataarray(arr_1d, sample_time_index) - assert result.shape == (5,) - assert result.dims == ('time',) - assert np.array_equal(result.values, arr_1d) + # Test DataFrame with multiple columns + df_multi_col = pd.DataFrame({'A': [1, 2, 3, 4, 5], 'B': [6, 7, 8, 9, 10]}, index=sample_time_index) + with pytest.raises(ConversionError): + DataConverter.as_dataarray(df_multi_col, sample_time_index) + # Test mismatched array shape for time-only + with pytest.raises(ConversionError): + DataConverter.as_dataarray(np.array([1, 2, 3]), sample_time_index) # Wrong length -def test_dataarray_conversion(sample_time_index): - # Create a DataArray - original = xr.DataArray(data=np.array([1, 2, 3, 4, 5]), coords={'time': sample_time_index}, dims=['time']) + # Test mismatched array shape for scenario × time + # Array shape should be (n_scenarios, n_timesteps) + wrong_shape_array = np.array( + [ + [1, 2, 3, 4], # Missing a timestep + [5, 6, 7, 8], + [9, 10, 11, 12], + ] + ) + with pytest.raises(ConversionError): + DataConverter.as_dataarray(wrong_shape_array, sample_time_index, sample_scenario_index) - # Test DataArray conversion - result = DataConverter.as_dataarray(original, sample_time_index) - assert result.shape == (5,) - assert result.dims == ('time',) - assert np.array_equal(result.values, original.values) + # Test array with too many dimensions + with pytest.raises(ConversionError): + # 3D array not allowed + DataConverter.as_dataarray(np.ones((3, 5, 2)), sample_time_index, sample_scenario_index) - # Ensure it's a copy - result[0] = 999 - assert original[0].item() == 1 # Original should be unchanged + def test_dataarray_dimension_mismatch(self, sample_time_index, sample_scenario_index): + """Test handling of mismatched DataArray dimensions.""" + # Create DataArray with wrong dimensions + wrong_dims = xr.DataArray(data=np.array([1, 2, 3, 4, 5]), coords={'wrong_dim': range(5)}, dims=['wrong_dim']) + with pytest.raises(ConversionError): + DataConverter.as_dataarray(wrong_dims, sample_time_index) + + # Create DataArray with scenario but no time + wrong_dims_2 = xr.DataArray(data=np.array([1, 2, 3]), coords={'scenario': ['a', 'b', 'c']}, dims=['scenario']) + with pytest.raises(ConversionError): + DataConverter.as_dataarray(wrong_dims_2, sample_time_index, sample_scenario_index) + + # Create DataArray with right dims but wrong length + wrong_length = xr.DataArray( + data=np.array([[1, 2, 3], [4, 5, 6], [7, 8, 9]]), + coords={ + 'scenario': sample_scenario_index, + 'time': pd.date_range('2024-01-01', periods=3, freq='D', name='time'), + }, + dims=['scenario', 'time'], + ) + with pytest.raises(ConversionError): + DataConverter.as_dataarray(wrong_length, sample_time_index, sample_scenario_index) + + +class TestEdgeCases: + """Tests for edge cases and special scenarios.""" + + def test_single_timestep(self, sample_scenario_index): + """Test with a single timestep.""" + # Test with only one timestep + single_timestep = pd.DatetimeIndex(['2024-01-01'], name='time') + + # Scalar conversion + result = DataConverter.as_dataarray(42, single_timestep) + assert result.shape == (1,) + assert result.dims == ('time',) + + # With scenarios + result_with_scenarios = DataConverter.as_dataarray(42, single_timestep, sample_scenario_index) + assert result_with_scenarios.shape == (len(sample_scenario_index), 1) + assert result_with_scenarios.dims == ('scenario', 'time') + + def test_single_scenario(self, sample_time_index): + """Test with a single scenario.""" + # Test with only one scenario + single_scenario = pd.Index(['baseline'], name='scenario') + + # Scalar conversion with single scenario + result = DataConverter.as_dataarray(42, sample_time_index, single_scenario) + assert result.shape == (1, len(sample_time_index)) + assert result.dims == ('scenario', 'time') + + # Array conversion with single scenario + arr = np.array([1, 2, 3, 4, 5]) + result_arr = DataConverter.as_dataarray(arr, sample_time_index, single_scenario) + assert result_arr.shape == (1, 5) + assert np.array_equal(result_arr.sel(scenario='baseline').values, arr) + + # 2D array with single scenario + arr_2d = np.array([[1, 2, 3, 4, 5]]) # Note the extra dimension + result_arr_2d = DataConverter.as_dataarray(arr_2d, sample_time_index, single_scenario) + assert result_arr_2d.shape == (1, 5) + assert np.array_equal(result_arr_2d.sel(scenario='baseline').values, arr_2d[0]) + + def test_different_scenario_order(self, sample_time_index): + """Test that scenario order is preserved.""" + # Test with different scenario orders + scenarios1 = pd.Index(['a', 'b', 'c'], name='scenario') + scenarios2 = pd.Index(['c', 'b', 'a'], name='scenario') + + # Create DataArray with first order + data = np.array( + [ + [1, 2, 3, 4, 5], # a + [6, 7, 8, 9, 10], # b + [11, 12, 13, 14, 15], # c + ] + ) + + result1 = DataConverter.as_dataarray(data, sample_time_index, scenarios1) + assert np.array_equal(result1.sel(scenario='a').values, [1, 2, 3, 4, 5]) + assert np.array_equal(result1.sel(scenario='c').values, [11, 12, 13, 14, 15]) + + # Create DataArray with second order + result2 = DataConverter.as_dataarray(data, sample_time_index, scenarios2) + # First row should match 'c' now + assert np.array_equal(result2.sel(scenario='c').values, [1, 2, 3, 4, 5]) + # Last row should match 'a' now + assert np.array_equal(result2.sel(scenario='a').values, [11, 12, 13, 14, 15]) + + def test_all_nan_data(self, sample_time_index, sample_scenario_index): + """Test handling of all-NaN data.""" + # Create array of all NaNs + all_nan_array = np.full(5, np.nan) + result = DataConverter.as_dataarray(all_nan_array, sample_time_index) + assert np.all(np.isnan(result.values)) + + # With scenarios + result = DataConverter.as_dataarray(all_nan_array, sample_time_index, sample_scenario_index) + assert result.shape == (len(sample_scenario_index), len(sample_time_index)) + assert np.all(np.isnan(result.values)) + + # Series of all NaNs + all_nan_series = pd.Series([np.nan, np.nan, np.nan, np.nan, np.nan], index=sample_time_index) + result = DataConverter.as_dataarray(all_nan_series, sample_time_index, sample_scenario_index) + assert np.all(np.isnan(result.values)) + + def test_subset_index_multiindex(self, sample_time_index, sample_scenario_index): + """Test handling of MultiIndex Series/DataFrames with subset of expected indices.""" + # Create a subset of the expected indexes + subset_time = sample_time_index[1:4] # Middle subset + subset_scenarios = sample_scenario_index[0:2] # First two scenarios + + # Create MultiIndex with subset + subset_multi_index = pd.MultiIndex.from_product([subset_scenarios, subset_time], names=['scenario', 'time']) + + # Create Series with subset of data + values = [ + # baseline (3 values) + 20, + 30, + 40, + # high_demand (3 values) + 25, + 35, + 45, + ] + subset_series = pd.Series(values, index=subset_multi_index) + + # Convert and test + result = DataConverter.as_dataarray(subset_series, sample_time_index, sample_scenario_index) + + # Shape should be full size + assert result.shape == (len(sample_scenario_index), len(sample_time_index)) + + # Check values - present values should match + assert result.sel(scenario='baseline', time=subset_time[0]).item() == 20 + assert result.sel(scenario='high_demand', time=subset_time[1]).item() == 35 + + # Missing values should be NaN + assert np.isnan(result.sel(scenario='baseline', time=sample_time_index[0]).item()) + assert np.isnan(result.sel(scenario='low_price', time=sample_time_index[2]).item()) + + def test_mixed_data_types(self, sample_time_index, sample_scenario_index): + """Test conversion of mixed integer and float data.""" + # Create array with mixed types + mixed_array = np.array([1, 2.5, 3, 4.5, 5]) + result = DataConverter.as_dataarray(mixed_array, sample_time_index) + + # Result should be float dtype + assert np.issubdtype(result.dtype, np.floating) + assert np.array_equal(result.values, mixed_array) + + # With scenarios + result = DataConverter.as_dataarray(mixed_array, sample_time_index, sample_scenario_index) + assert np.issubdtype(result.dtype, np.floating) + for scenario in sample_scenario_index: + assert np.array_equal(result.sel(scenario=scenario).values, mixed_array) + + +class TestFunctionalUseCase: + """Tests for realistic use cases combining multiple features.""" + + def test_multiindex_with_nans_and_partial_data(self, sample_time_index, sample_scenario_index): + """Test MultiIndex Series with partial data and NaN values.""" + # Create a MultiIndex Series with missing values and partial coverage + time_subset = sample_time_index[1:4] # Middle 3 timestamps only + + # Build index with holes + idx_tuples = [] + for scenario in sample_scenario_index: + for time in time_subset: + # Skip some combinations to create holes + if scenario == 'baseline' and time == time_subset[0]: + continue + if scenario == 'high_demand' and time == time_subset[2]: + continue + idx_tuples.append((scenario, time)) + + partial_idx = pd.MultiIndex.from_tuples(idx_tuples, names=['scenario', 'time']) + + # Create values with some NaNs + values = [ + # baseline (2 values, skipping first) + 30, + 40, + # high_demand (2 values, skipping last) + 25, + 35, + # low_price (3 values) + 15, + np.nan, + 35, + ] + + # Create Series + partial_series = pd.Series(values, index=partial_idx) + + # Convert and test + result = DataConverter.as_dataarray(partial_series, sample_time_index, sample_scenario_index) + + # Shape should be full size + assert result.shape == (len(sample_scenario_index), len(sample_time_index)) + + # Check specific values + assert result.sel(scenario='baseline', time=time_subset[1]).item() == 30 + assert result.sel(scenario='high_demand', time=time_subset[0]).item() == 25 + assert np.isnan(result.sel(scenario='low_price', time=time_subset[1]).item()) + + # All skipped combinations should be NaN + assert np.isnan(result.sel(scenario='baseline', time=time_subset[0]).item()) + assert np.isnan(result.sel(scenario='high_demand', time=time_subset[2]).item()) + + # First and last timestamps should all be NaN (not in original subset) + assert np.all(np.isnan(result.sel(time=sample_time_index[0]).values)) + assert np.all(np.isnan(result.sel(time=sample_time_index[-1]).values)) + + def test_scenario_broadcast_with_nan_values(self, sample_time_index, sample_scenario_index): + """Test broadcasting a Series with NaN values to scenarios.""" + # Create Series with some NaN values + series = pd.Series([1, np.nan, 3, np.nan, 5], index=sample_time_index) + + # Convert with scenario broadcasting + result = DataConverter.as_dataarray(series, sample_time_index, sample_scenario_index) + + # All scenarios should have the same pattern of NaN values + for scenario in sample_scenario_index: + scenario_data = result.sel(scenario=scenario) + assert np.isnan(scenario_data[1].item()) + assert np.isnan(scenario_data[3].item()) + assert scenario_data[0].item() == 1 + assert scenario_data[2].item() == 3 + assert scenario_data[4].item() == 5 + + def test_large_dataset(self, sample_scenario_index): + """Test with a larger dataset to ensure performance.""" + # Create a larger timestep array (e.g., hourly for a year) + large_timesteps = pd.date_range( + '2024-01-01', + periods=8760, # Hours in a year + freq='H', + name='time', + ) + + # Create large 2D array (3 scenarios × 8760 hours) + large_data = np.random.rand(len(sample_scenario_index), len(large_timesteps)) + + # Convert and check + result = DataConverter.as_dataarray(large_data, large_timesteps, sample_scenario_index) + + assert result.shape == (len(sample_scenario_index), len(large_timesteps)) + assert result.dims == ('scenario', 'time') + assert np.array_equal(result.values, large_data) + + +class TestMultiScenarioArrayConversion: + """Tests specifically focused on array conversion with scenarios.""" + + def test_1d_array_broadcasting(self, sample_time_index, sample_scenario_index): + """Test that 1D arrays are properly broadcast to all scenarios.""" + arr_1d = np.array([1, 2, 3, 4, 5]) + result = DataConverter.as_dataarray(arr_1d, sample_time_index, sample_scenario_index) + + assert result.shape == (len(sample_scenario_index), len(sample_time_index)) + + # Each scenario should have identical values + for i, scenario in enumerate(sample_scenario_index): + assert np.array_equal(result.sel(scenario=scenario).values, arr_1d) + + # Modify one scenario's values + result.loc[dict(scenario=scenario)] = np.ones(len(sample_time_index)) * i + + # Ensure modifications are isolated to each scenario + for i, scenario in enumerate(sample_scenario_index): + assert np.all(result.sel(scenario=scenario).values == i) + + def test_2d_array_different_shapes(self, sample_time_index): + """Test different scenario shapes with 2D arrays.""" + # Test with 1 scenario + single_scenario = pd.Index(['baseline'], name='scenario') + arr_1_scenario = np.array([[1, 2, 3, 4, 5]]) + + result = DataConverter.as_dataarray(arr_1_scenario, sample_time_index, single_scenario) + assert result.shape == (1, len(sample_time_index)) + + # Test with 2 scenarios + two_scenarios = pd.Index(['baseline', 'high_demand'], name='scenario') + arr_2_scenarios = np.array([[1, 2, 3, 4, 5], [6, 7, 8, 9, 10]]) + + result = DataConverter.as_dataarray(arr_2_scenarios, sample_time_index, two_scenarios) + assert result.shape == (2, len(sample_time_index)) + assert np.array_equal(result.sel(scenario='baseline').values, arr_2_scenarios[0]) + assert np.array_equal(result.sel(scenario='high_demand').values, arr_2_scenarios[1]) + + # Test mismatched scenarios count + three_scenarios = pd.Index(['a', 'b', 'c'], name='scenario') + with pytest.raises(ConversionError): + DataConverter.as_dataarray(arr_2_scenarios, sample_time_index, three_scenarios) + + def test_array_handling_edge_cases(self, sample_time_index, sample_scenario_index): + """Test array edge cases.""" + # Test with boolean array + bool_array = np.array([True, False, True, False, True]) + result = DataConverter.as_dataarray(bool_array, sample_time_index, sample_scenario_index) + assert result.dtype == bool + assert result.shape == (len(sample_scenario_index), len(sample_time_index)) + + # Test with array containing infinite values + inf_array = np.array([1, np.inf, 3, -np.inf, 5]) + result = DataConverter.as_dataarray(inf_array, sample_time_index, sample_scenario_index) + for scenario in sample_scenario_index: + scenario_data = result.sel(scenario=scenario) + assert np.isinf(scenario_data[1].item()) + assert np.isinf(scenario_data[3].item()) + assert scenario_data[3].item() < 0 # Negative infinity + + +class TestScenarioReindexing: + """Tests for reindexing and coordinate preservation in DataConverter.""" + + def test_preserving_scenario_order(self, sample_time_index): + """Test that scenario order is preserved in converted DataArrays.""" + # Define scenarios in a specific order + scenarios = pd.Index(['scenario3', 'scenario1', 'scenario2'], name='scenario') + + # Create 2D array + data = np.array( + [ + [1, 2, 3, 4, 5], # scenario3 + [6, 7, 8, 9, 10], # scenario1 + [11, 12, 13, 14, 15], # scenario2 + ] + ) + + # Convert to DataArray + result = DataConverter.as_dataarray(data, sample_time_index, scenarios) + + # Verify order of scenarios is preserved + assert list(result.coords['scenario'].values) == list(scenarios) + + # Verify data for each scenario + assert np.array_equal(result.sel(scenario='scenario3').values, data[0]) + assert np.array_equal(result.sel(scenario='scenario1').values, data[1]) + assert np.array_equal(result.sel(scenario='scenario2').values, data[2]) + + def test_multiindex_reindexing(self, sample_time_index): + """Test reindexing of MultiIndex Series.""" + # Create scenarios with intentional different order + scenarios = pd.Index(['z_scenario', 'a_scenario', 'm_scenario'], name='scenario') + + # Create MultiIndex with different order than the target + source_scenarios = pd.Index(['a_scenario', 'm_scenario', 'z_scenario'], name='scenario') + multi_idx = pd.MultiIndex.from_product([source_scenarios, sample_time_index], names=['scenario', 'time']) + + # Create values - order should match the source index + values = [] + for i, _ in enumerate(source_scenarios): + values.extend([i * 10 + j for j in range(1, len(sample_time_index) + 1)]) + + # Create Series + series = pd.Series(values, index=multi_idx) + + # Convert using the target scenario order + result = DataConverter.as_dataarray(series, sample_time_index, scenarios) + + # Verify scenario order matches the target + assert list(result.coords['scenario'].values) == list(scenarios) + + # Verify values are correctly indexed + assert np.array_equal(result.sel(scenario='a_scenario').values, [1, 2, 3, 4, 5]) + assert np.array_equal(result.sel(scenario='m_scenario').values, [11, 12, 13, 14, 15]) + assert np.array_equal(result.sel(scenario='z_scenario').values, [21, 22, 23, 24, 25]) + + +if __name__ == '__main__': + pytest.main() def test_invalid_inputs(sample_time_index): diff --git a/tests/test_timeseries.py b/tests/test_timeseries.py index 48c7ab7b2..50136536b 100644 --- a/tests/test_timeseries.py +++ b/tests/test_timeseries.py @@ -8,7 +8,7 @@ import pytest import xarray as xr -from flixopt.core import ConversionError, DataConverter, TimeSeries, TimeSeriesCollection, TimeSeriesData +from flixopt.core import ConversionError, DataConverter, TimeSeries, TimeSeriesCollection @pytest.fixture @@ -44,13 +44,13 @@ def test_initialization(self, simple_dataarray): # Check data initialization assert isinstance(ts.stored_data, xr.DataArray) assert ts.stored_data.equals(simple_dataarray) - assert ts.active_data.equals(simple_dataarray) + assert ts.selected_data.equals(simple_dataarray) # Check backup was created assert ts._backup.equals(simple_dataarray) # Check active timesteps - assert ts.active_timesteps.equals(simple_dataarray.indexes['time']) + assert ts._valid_selector == {} # No selections initially def test_initialization_with_aggregation_params(self, simple_dataarray): """Test initialization with aggregation parameters.""" @@ -73,53 +73,51 @@ def test_initialization_validation(self, sample_timesteps): multi_dim_data = xr.DataArray( [[1, 2, 3], [4, 5, 6]], coords={'dim1': [0, 1], 'time': sample_timesteps[:3]}, dims=['dim1', 'time'] ) - with pytest.raises(ValueError, match='dimensions of DataArray must be 1'): + with pytest.raises(ValueError, match='DataArray dimensions must be subset of'): TimeSeries(multi_dim_data, name='Multi-dim Series') - def test_active_timesteps_getter_setter(self, sample_timeseries, sample_timesteps): - """Test active_timesteps getter and setter.""" - # Initial state should use all timesteps - assert sample_timeseries.active_timesteps.equals(sample_timesteps) + def test_selection_methods(self, sample_timeseries, sample_timesteps): + """Test selection methods.""" + # Initial state should have no selections + assert sample_timeseries._selected_timesteps is None + assert sample_timeseries._selected_scenarios is None # Set to a subset subset_index = sample_timesteps[1:3] - sample_timeseries.active_timesteps = subset_index - assert sample_timeseries.active_timesteps.equals(subset_index) + sample_timeseries.set_selection(timesteps=subset_index) + assert sample_timeseries._selected_timesteps.equals(subset_index) # Active data should reflect the subset - assert sample_timeseries.active_data.equals(sample_timeseries.stored_data.sel(time=subset_index)) + assert sample_timeseries.selected_data.equals(sample_timeseries.stored_data.sel(time=subset_index)) - # Reset to full index - sample_timeseries.active_timesteps = None - assert sample_timeseries.active_timesteps.equals(sample_timesteps) - - # Test invalid type - with pytest.raises(TypeError, match='must be a pandas DatetimeIndex'): - sample_timeseries.active_timesteps = 'invalid' + # Clear selection + sample_timeseries.clear_selection() + assert sample_timeseries._selected_timesteps is None + assert sample_timeseries.selected_data.equals(sample_timeseries.stored_data) def test_reset(self, sample_timeseries, sample_timesteps): """Test reset method.""" # Set to subset first subset_index = sample_timesteps[1:3] - sample_timeseries.active_timesteps = subset_index + sample_timeseries.set_selection(timesteps=subset_index) # Reset sample_timeseries.reset() - # Should be back to full index - assert sample_timeseries.active_timesteps.equals(sample_timesteps) - assert sample_timeseries.active_data.equals(sample_timeseries.stored_data) + # Should be back to full index (all selections cleared) + assert sample_timeseries._selected_timesteps is None + assert sample_timeseries.selected_data.equals(sample_timeseries.stored_data) def test_restore_data(self, sample_timeseries, simple_dataarray): """Test restore_data method.""" # Modify the stored data - new_data = xr.DataArray([1, 2, 3, 4, 5], coords={'time': sample_timeseries.active_timesteps}, dims=['time']) + new_data = xr.DataArray([1, 2, 3, 4, 5], coords={'time': sample_timeseries.stored_data.coords['time']}, dims=['time']) # Store original data for comparison original_data = sample_timeseries.stored_data - # Set new data - sample_timeseries.stored_data = new_data + # Update data + sample_timeseries.update_stored_data(new_data) assert sample_timeseries.stored_data.equals(new_data) # Restore from backup @@ -127,42 +125,42 @@ def test_restore_data(self, sample_timeseries, simple_dataarray): # Should be back to original data assert sample_timeseries.stored_data.equals(original_data) - assert sample_timeseries.active_data.equals(original_data) + assert sample_timeseries.selected_data.equals(original_data) - def test_stored_data_setter(self, sample_timeseries, sample_timesteps): - """Test stored_data setter with different data types.""" + def test_update_stored_data(self, sample_timeseries, sample_timesteps): + """Test update_stored_data method with different data types.""" # Test with a Series series_data = pd.Series([5, 6, 7, 8, 9], index=sample_timesteps) - sample_timeseries.stored_data = series_data + sample_timeseries.update_stored_data(series_data) assert np.array_equal(sample_timeseries.stored_data.values, series_data.values) # Test with a single-column DataFrame df_data = pd.DataFrame({'col1': [15, 16, 17, 18, 19]}, index=sample_timesteps) - sample_timeseries.stored_data = df_data + sample_timeseries.update_stored_data(df_data) assert np.array_equal(sample_timeseries.stored_data.values, df_data['col1'].values) # Test with a NumPy array array_data = np.array([25, 26, 27, 28, 29]) - sample_timeseries.stored_data = array_data + sample_timeseries.update_stored_data(array_data) assert np.array_equal(sample_timeseries.stored_data.values, array_data) # Test with a scalar - sample_timeseries.stored_data = 42 + sample_timeseries.update_stored_data(42) assert np.all(sample_timeseries.stored_data.values == 42) # Test with another DataArray another_dataarray = xr.DataArray([30, 31, 32, 33, 34], coords={'time': sample_timesteps}, dims=['time']) - sample_timeseries.stored_data = another_dataarray + sample_timeseries.update_stored_data(another_dataarray) assert sample_timeseries.stored_data.equals(another_dataarray) def test_stored_data_setter_no_change(self, sample_timeseries): - """Test stored_data setter when data doesn't change.""" + """Test update_stored_data method when data doesn't change.""" # Get current data current_data = sample_timeseries.stored_data current_backup = sample_timeseries._backup # Set the same data - sample_timeseries.stored_data = current_data + sample_timeseries.update_stored_data(current_data) # Backup shouldn't change assert sample_timeseries._backup is current_backup # Should be the same object @@ -229,35 +227,35 @@ def test_all_equal(self, sample_timesteps): def test_arithmetic_operations(self, sample_timeseries): """Test arithmetic operations.""" # Create a second TimeSeries for testing - data2 = xr.DataArray([1, 2, 3, 4, 5], coords={'time': sample_timeseries.active_timesteps}, dims=['time']) + data2 = xr.DataArray([1, 2, 3, 4, 5], coords={'time': sample_timeseries.stored_data.coords['time']}, dims=['time']) ts2 = TimeSeries(data2, 'Second Series') # Test operations between two TimeSeries objects assert np.array_equal( - (sample_timeseries + ts2).values, sample_timeseries.active_data.values + ts2.active_data.values + (sample_timeseries + ts2).values, sample_timeseries.selected_data.values + ts2.selected_data.values ) assert np.array_equal( - (sample_timeseries - ts2).values, sample_timeseries.active_data.values - ts2.active_data.values + (sample_timeseries - ts2).values, sample_timeseries.selected_data.values - ts2.selected_data.values ) assert np.array_equal( - (sample_timeseries * ts2).values, sample_timeseries.active_data.values * ts2.active_data.values + (sample_timeseries * ts2).values, sample_timeseries.selected_data.values * ts2.selected_data.values ) assert np.array_equal( - (sample_timeseries / ts2).values, sample_timeseries.active_data.values / ts2.active_data.values + (sample_timeseries / ts2).values, sample_timeseries.selected_data.values / ts2.selected_data.values ) # Test operations with DataArrays - assert np.array_equal((sample_timeseries + data2).values, sample_timeseries.active_data.values + data2.values) - assert np.array_equal((data2 + sample_timeseries).values, data2.values + sample_timeseries.active_data.values) + assert np.array_equal((sample_timeseries + data2).values, sample_timeseries.selected_data.values + data2.values) + assert np.array_equal((data2 + sample_timeseries).values, data2.values + sample_timeseries.selected_data.values) # Test operations with scalars - assert np.array_equal((sample_timeseries + 5).values, sample_timeseries.active_data.values + 5) - assert np.array_equal((5 + sample_timeseries).values, 5 + sample_timeseries.active_data.values) + assert np.array_equal((sample_timeseries + 5).values, sample_timeseries.selected_data.values + 5) + assert np.array_equal((5 + sample_timeseries).values, 5 + sample_timeseries.selected_data.values) # Test unary operations - assert np.array_equal((-sample_timeseries).values, -sample_timeseries.active_data.values) - assert np.array_equal((+sample_timeseries).values, +sample_timeseries.active_data.values) - assert np.array_equal((abs(sample_timeseries)).values, abs(sample_timeseries.active_data.values)) + assert np.array_equal((-sample_timeseries).values, -sample_timeseries.selected_data.values) + assert np.array_equal((+sample_timeseries).values, +sample_timeseries.selected_data.values) + assert np.array_equal((abs(sample_timeseries)).values, abs(sample_timeseries.selected_data.values)) def test_comparison_operations(self, sample_timesteps): """Test comparison operations.""" @@ -279,327 +277,473 @@ def test_comparison_operations(self, sample_timesteps): def test_numpy_ufunc(self, sample_timeseries): """Test numpy ufunc compatibility.""" # Test basic numpy functions - assert np.array_equal(np.add(sample_timeseries, 5).values, np.add(sample_timeseries.active_data, 5).values) + assert np.array_equal(np.add(sample_timeseries, 5).values, np.add(sample_timeseries.selected_data, 5).values) assert np.array_equal( - np.multiply(sample_timeseries, 2).values, np.multiply(sample_timeseries.active_data, 2).values + np.multiply(sample_timeseries, 2).values, np.multiply(sample_timeseries.selected_data, 2).values ) # Test with two TimeSeries objects - data2 = xr.DataArray([1, 2, 3, 4, 5], coords={'time': sample_timeseries.active_timesteps}, dims=['time']) + data2 = xr.DataArray([1, 2, 3, 4, 5], coords={'time': sample_timeseries.stored_data.coords['time']}, dims=['time']) ts2 = TimeSeries(data2, 'Second Series') assert np.array_equal( - np.add(sample_timeseries, ts2).values, np.add(sample_timeseries.active_data, ts2.active_data).values + np.add(sample_timeseries, ts2).values, np.add(sample_timeseries.selected_data, ts2.selected_data).values ) def test_sel_and_isel_properties(self, sample_timeseries): """Test sel and isel properties.""" # Test that sel property works - selected = sample_timeseries.sel(time=sample_timeseries.active_timesteps[0]) - assert selected.item() == sample_timeseries.active_data.values[0] + selected = sample_timeseries.sel(time=sample_timeseries.stored_data.coords['time'][0]) + assert selected.item() == sample_timeseries.selected_data.values[0] # Test that isel property works indexed = sample_timeseries.isel(time=0) - assert indexed.item() == sample_timeseries.active_data.values[0] + assert indexed.item() == sample_timeseries.selected_data.values[0] + + +@pytest.fixture +def sample_scenario_index(): + """Create a sample scenario index with the required 'scenario' name.""" + return pd.Index(['baseline', 'high_demand', 'low_price'], name='scenario') + + +@pytest.fixture +def simple_scenario_dataarray(sample_timesteps, sample_scenario_index): + """Create a DataArray with both scenario and time dimensions.""" + data = np.array([ + [10, 20, 30, 40, 50], # baseline + [15, 25, 35, 45, 55], # high_demand + [5, 15, 25, 35, 45] # low_price + ]) + return xr.DataArray( + data=data, + coords={'scenario': sample_scenario_index, 'time': sample_timesteps}, + dims=['scenario', 'time'] + ) @pytest.fixture -def sample_collection(sample_timesteps): +def sample_scenario_timeseries(simple_scenario_dataarray): + """Create a sample TimeSeries object with scenario dimension.""" + return TimeSeries(simple_scenario_dataarray, name='Test Scenario Series') + + +@pytest.fixture +def sample_allocator(sample_timesteps): """Create a sample TimeSeriesCollection.""" return TimeSeriesCollection(sample_timesteps) @pytest.fixture -def populated_collection(sample_collection): - """Create a TimeSeriesCollection with test data.""" - # Add a constant time series - sample_collection.create_time_series(42, 'constant_series') - - # Add a varying time series - varying_data = np.array([10, 20, 30, 40, 50]) - sample_collection.create_time_series(varying_data, 'varying_series') - - # Add a time series with extra timestep - sample_collection.create_time_series( - np.array([1, 2, 3, 4, 5, 6]), 'extra_timestep_series', needs_extra_timestep=True - ) +def sample_scenario_allocator(sample_timesteps, sample_scenario_index): + """Create a sample TimeSeriesCollection with scenarios.""" + return TimeSeriesCollection(sample_timesteps, scenarios=sample_scenario_index) - # Add series with aggregation settings - sample_collection.create_time_series( - TimeSeriesData(np.array([5, 5, 5, 5, 5]), agg_group='group1'), 'group1_series1' - ) - sample_collection.create_time_series( - TimeSeriesData(np.array([6, 6, 6, 6, 6]), agg_group='group1'), 'group1_series2' - ) - sample_collection.create_time_series( - TimeSeriesData(np.array([10, 10, 10, 10, 10]), agg_weight=0.5), 'weighted_series' - ) - return sample_collection +class TestTimeSeriesWithScenarios: + """Test suite for TimeSeries class with scenarios.""" + + def test_initialization_with_scenarios(self, simple_scenario_dataarray): + """Test initialization of TimeSeries with scenario dimension.""" + ts = TimeSeries(simple_scenario_dataarray, name='Scenario Series') + # Check basic properties + assert ts.name == 'Scenario Series' + assert ts._has_scenarios is True + assert ts._selected_scenarios is None # No selection initially + + # Check data initialization + assert isinstance(ts.stored_data, xr.DataArray) + assert ts.stored_data.equals(simple_scenario_dataarray) + assert ts.selected_data.equals(simple_scenario_dataarray) + + # Check backup was created + assert ts._backup.equals(simple_scenario_dataarray) + + def test_reset_with_scenarios(self, sample_scenario_timeseries, simple_scenario_dataarray): + """Test reset method with scenarios.""" + # Get original full indexes + full_timesteps = simple_scenario_dataarray.coords['time'] + full_scenarios = simple_scenario_dataarray.coords['scenario'] + + # Set to subset timesteps and scenarios + subset_timesteps = full_timesteps[1:3] + subset_scenarios = full_scenarios[:2] -class TestTimeSeriesCollection: - """Test suite for TimeSeriesCollection.""" + sample_scenario_timeseries.set_selection(timesteps=subset_timesteps, scenarios=subset_scenarios) + + # Verify subsets were set + assert sample_scenario_timeseries._selected_timesteps.equals(subset_timesteps) + assert sample_scenario_timeseries._selected_scenarios.equals(subset_scenarios) + assert sample_scenario_timeseries.selected_data.shape == (len(subset_scenarios), len(subset_timesteps)) + + # Reset + sample_scenario_timeseries.reset() + + # Should be back to full indexes + assert sample_scenario_timeseries._selected_timesteps is None + assert sample_scenario_timeseries._selected_scenarios is None + assert sample_scenario_timeseries.selected_data.shape == (len(full_scenarios), len(full_timesteps)) + + def test_scenario_selection(self, sample_scenario_timeseries, sample_scenario_index): + """Test scenario selection.""" + # Initial state should use all scenarios + assert sample_scenario_timeseries._selected_scenarios is None + + # Set to a subset + subset_index = sample_scenario_index[:2] # First two scenarios + sample_scenario_timeseries.set_selection(scenarios=subset_index) + assert sample_scenario_timeseries._selected_scenarios.equals(subset_index) + + # Active data should reflect the subset + assert sample_scenario_timeseries.selected_data.equals( + sample_scenario_timeseries.stored_data.sel(scenario=subset_index) + ) + + # Clear selection + sample_scenario_timeseries.clear_selection(timesteps=False, scenarios=True) + assert sample_scenario_timeseries._selected_scenarios is None + + def test_all_equal_with_scenarios(self, sample_timesteps, sample_scenario_index): + """Test all_equal property with scenarios.""" + # All values equal across all scenarios + equal_data = np.full((3, 5), 5) # All values are 5 + equal_dataarray = xr.DataArray( + data=equal_data, + coords={'scenario': sample_scenario_index, 'time': sample_timesteps}, + dims=['scenario', 'time'] + ) + ts_equal = TimeSeries(equal_dataarray, 'Equal Scenario Series') + assert ts_equal.all_equal is True + + # Equal within each scenario but different between scenarios + per_scenario_equal = np.array([ + [5, 5, 5, 5, 5], # baseline - all 5 + [10, 10, 10, 10, 10], # high_demand - all 10 + [15, 15, 15, 15, 15] # low_price - all 15 + ]) + per_scenario_dataarray = xr.DataArray( + data=per_scenario_equal, + coords={'scenario': sample_scenario_index, 'time': sample_timesteps}, + dims=['scenario', 'time'] + ) + ts_per_scenario = TimeSeries(per_scenario_dataarray, 'Per-Scenario Equal Series') + assert ts_per_scenario.all_equal is False + + def test_arithmetic_with_scenarios(self, sample_scenario_timeseries, sample_timesteps, sample_scenario_index): + """Test arithmetic operations with scenarios.""" + # Create a second TimeSeries with scenarios + data2 = np.ones((3, 5)) # All ones + second_dataarray = xr.DataArray( + data=data2, + coords={'scenario': sample_scenario_index, 'time': sample_timesteps}, + dims=['scenario', 'time'] + ) + ts2 = TimeSeries(second_dataarray, 'Second Series') + + # Test operations between two scenario TimeSeries objects + result = sample_scenario_timeseries + ts2 + assert result.shape == (3, 5) + assert result.dims == ('scenario', 'time') + + # First scenario values should be increased by 1 + baseline_original = sample_scenario_timeseries.sel(scenario='baseline').values + baseline_result = result.sel(scenario='baseline').values + assert np.array_equal(baseline_result, baseline_original + 1) + + +class TestTimeSeriesAllocator: + """Test suite for TimeSeriesCollection class.""" def test_initialization(self, sample_timesteps): """Test basic initialization.""" - collection = TimeSeriesCollection(sample_timesteps) + allocator = TimeSeriesCollection(sample_timesteps) - assert collection.all_timesteps.equals(sample_timesteps) - assert len(collection.all_timesteps_extra) == len(sample_timesteps) + 1 - assert isinstance(collection.all_hours_per_timestep, xr.DataArray) - assert len(collection) == 0 + assert allocator.timesteps.equals(sample_timesteps) + assert len(allocator.timesteps_extra) == len(sample_timesteps) + 1 + assert isinstance(allocator.hours_per_timestep, xr.DataArray) + assert len(allocator._time_series) == 0 def test_initialization_with_custom_hours(self, sample_timesteps): """Test initialization with custom hour settings.""" # Test with last timestep duration last_timestep_hours = 12 - collection = TimeSeriesCollection(sample_timesteps, hours_of_last_timestep=last_timestep_hours) + allocator = TimeSeriesCollection(sample_timesteps, hours_of_last_timestep=last_timestep_hours) # Verify the last timestep duration - extra_step_delta = collection.all_timesteps_extra[-1] - collection.all_timesteps_extra[-2] + extra_step_delta = allocator.timesteps_extra[-1] - allocator.timesteps_extra[-2] assert extra_step_delta == pd.Timedelta(hours=last_timestep_hours) # Test with previous timestep duration hours_per_step = 8 - collection2 = TimeSeriesCollection(sample_timesteps, hours_of_previous_timesteps=hours_per_step) + allocator2 = TimeSeriesCollection(sample_timesteps, hours_of_previous_timesteps=hours_per_step) - assert collection2.hours_of_previous_timesteps == hours_per_step + assert allocator2.hours_of_previous_timesteps == hours_per_step - def test_create_time_series(self, sample_collection): - """Test creating time series.""" + def test_add_time_series(self, sample_allocator, sample_timesteps): + """Test adding time series.""" # Test scalar - ts1 = sample_collection.create_time_series(42, 'scalar_series') + ts1 = sample_allocator.add_time_series('scalar_series', 42) assert ts1.name == 'scalar_series' - assert np.all(ts1.active_data.values == 42) + assert np.all(ts1.selected_data.values == 42) # Test numpy array data = np.array([1, 2, 3, 4, 5]) - ts2 = sample_collection.create_time_series(data, 'array_series') - assert np.array_equal(ts2.active_data.values, data) + ts2 = sample_allocator.add_time_series('array_series', data) + assert np.array_equal(ts2.selected_data.values, data) - # Test with TimeSeriesData - ts3 = sample_collection.create_time_series(TimeSeriesData(10, agg_weight=0.7), 'weighted_series') - assert ts3.aggregation_weight == 0.7 + # Test with existing TimeSeries + existing_ts = TimeSeries.from_datasource(10, 'original_name', sample_timesteps, aggregation_weight=0.7) + ts3 = sample_allocator.add_time_series('weighted_series', existing_ts) + assert ts3.name == 'weighted_series' # Name changed + assert ts3.aggregation_weight == 0.7 # Weight preserved # Test with extra timestep - ts4 = sample_collection.create_time_series(5, 'extra_series', needs_extra_timestep=True) - assert ts4.needs_extra_timestep - assert len(ts4.active_data) == len(sample_collection.timesteps_extra) + ts4 = sample_allocator.add_time_series('extra_series', 5, has_extra_timestep=True) + assert ts4.name == 'extra_series' + assert ts4.has_extra_timestep + assert len(ts4.selected_data) == len(sample_allocator.timesteps_extra) # Test duplicate name - with pytest.raises(ValueError, match='already exists'): - sample_collection.create_time_series(1, 'scalar_series') + with pytest.raises(KeyError, match='already exists'): + sample_allocator.add_time_series('scalar_series', 1) - def test_access_time_series(self, populated_collection): + def test_access_time_series(self, sample_allocator): """Test accessing time series.""" + # Add a few time series + sample_allocator.add_time_series('series1', 42) + sample_allocator.add_time_series('series2', np.array([1, 2, 3, 4, 5])) + # Test __getitem__ - ts = populated_collection['varying_series'] - assert ts.name == 'varying_series' + ts = sample_allocator['series1'] + assert ts.name == 'series1' # Test __contains__ with string - assert 'constant_series' in populated_collection - assert 'nonexistent_series' not in populated_collection + assert 'series1' in sample_allocator + assert 'nonexistent_series' not in sample_allocator # Test __contains__ with TimeSeries object - assert populated_collection['varying_series'] in populated_collection - - # Test __iter__ - names = [ts.name for ts in populated_collection] - assert len(names) == 6 - assert 'varying_series' in names + assert sample_allocator['series2'] in sample_allocator # Test access to non-existent series - with pytest.raises(KeyError): - populated_collection['nonexistent_series'] - - def test_constants_and_non_constants(self, populated_collection): - """Test constants and non_constants properties.""" - # Test constants - constants = populated_collection.constants - assert len(constants) == 4 # constant_series, group1_series1, group1_series2, weighted_series - assert all(ts.all_equal for ts in constants) - - # Test non_constants - non_constants = populated_collection.non_constants - assert len(non_constants) == 2 # varying_series, extra_timestep_series - assert all(not ts.all_equal for ts in non_constants) - - # Test modifying a series changes the results - populated_collection['constant_series'].stored_data = np.array([1, 2, 3, 4, 5]) - updated_constants = populated_collection.constants - assert len(updated_constants) == 3 # One less constant - assert 'constant_series' not in [ts.name for ts in updated_constants] - - def test_timesteps_properties(self, populated_collection, sample_timesteps): - """Test timestep-related properties.""" - # Test default (all) timesteps - assert populated_collection.timesteps.equals(sample_timesteps) - assert len(populated_collection.timesteps_extra) == len(sample_timesteps) + 1 - - # Test activating a subset - subset = sample_timesteps[1:3] - populated_collection.activate_timesteps(subset) - - assert populated_collection.timesteps.equals(subset) - assert len(populated_collection.timesteps_extra) == len(subset) + 1 - - # Check that time series were updated - assert populated_collection['varying_series'].active_timesteps.equals(subset) - assert populated_collection['extra_timestep_series'].active_timesteps.equals( - populated_collection.timesteps_extra - ) - - # Test reset - populated_collection.reset() - assert populated_collection.timesteps.equals(sample_timesteps) + with pytest.raises(ValueError): + sample_allocator['nonexistent_series'] - def test_to_dataframe_and_dataset(self, populated_collection): - """Test conversion to DataFrame and Dataset.""" - # Test to_dataset - ds = populated_collection.to_dataset() - assert isinstance(ds, xr.Dataset) - assert len(ds.data_vars) == 6 + def test_selection_propagation(self, sample_allocator, sample_timesteps): + """Test that selections propagate to TimeSeries.""" + # Add a few time series + ts1 = sample_allocator.add_time_series('series1', 42) + ts2 = sample_allocator.add_time_series('series2', np.array([1, 2, 3, 4, 5])) + ts3 = sample_allocator.add_time_series('series3', 5, has_extra_timestep=True) - # Test to_dataframe with different filters - df_all = populated_collection.to_dataframe(filtered='all') - assert len(df_all.columns) == 6 + # Initially no selections + assert ts1._selected_timesteps is None + assert ts2._selected_timesteps is None + assert ts3._selected_timesteps is None - df_constant = populated_collection.to_dataframe(filtered='constant') - assert len(df_constant.columns) == 4 + # Apply selection + subset_timesteps = sample_timesteps[1:3] + sample_allocator.set_selection(timesteps=subset_timesteps) - df_non_constant = populated_collection.to_dataframe(filtered='non_constant') - assert len(df_non_constant.columns) == 2 + # Check selection propagated to regular time series + assert ts1._selected_timesteps.equals(subset_timesteps) + assert ts2._selected_timesteps.equals(subset_timesteps) - # Test invalid filter - with pytest.raises(ValueError): - populated_collection.to_dataframe(filtered='invalid') - - def test_calculate_aggregation_weights(self, populated_collection): - """Test aggregation weight calculation.""" - weights = populated_collection.calculate_aggregation_weights() - - # Group weights should be 0.5 each (1/2) - assert populated_collection.group_weights['group1'] == 0.5 - - # Series in group1 should have weight 0.5 - assert weights['group1_series1'] == 0.5 - assert weights['group1_series2'] == 0.5 - - # Series with explicit weight should have that weight - assert weights['weighted_series'] == 0.5 - - # Series without group or weight should have weight 1 - assert weights['constant_series'] == 1 - - def test_insert_new_data(self, populated_collection, sample_timesteps): - """Test inserting new data.""" - # Create new data - new_data = pd.DataFrame( - { - 'constant_series': [100, 100, 100, 100, 100], - 'varying_series': [5, 10, 15, 20, 25], - # extra_timestep_series is omitted to test partial updates - }, - index=sample_timesteps, - ) + # Check selection with extra timestep + assert ts3._selected_timesteps is not None + assert len(ts3._selected_timesteps) == len(subset_timesteps) + 1 - # Insert data - populated_collection.insert_new_data(new_data) + # Clear selection + sample_allocator.clear_selection() - # Verify updates - assert np.all(populated_collection['constant_series'].active_data.values == 100) - assert np.array_equal(populated_collection['varying_series'].active_data.values, np.array([5, 10, 15, 20, 25])) + # Check selection cleared + assert ts1._selected_timesteps is None + assert ts2._selected_timesteps is None + assert ts3._selected_timesteps is None - # Series not in the DataFrame should be unchanged - assert np.array_equal( - populated_collection['extra_timestep_series'].active_data.values[:-1], np.array([1, 2, 3, 4, 5]) - ) + def test_update_time_series(self, sample_allocator): + """Test updating a time series.""" + # Add a time series + ts = sample_allocator.add_time_series('series', 42) - # Test with mismatched index - bad_index = pd.date_range('2023-02-01', periods=5, freq='D', name='time') - bad_data = pd.DataFrame({'constant_series': [1, 1, 1, 1, 1]}, index=bad_index) - - with pytest.raises(ValueError, match='must match collection timesteps'): - populated_collection.insert_new_data(bad_data) - - def test_restore_data(self, populated_collection): - """Test restoring original data.""" - # Capture original data - original_values = {name: ts.stored_data.copy() for name, ts in populated_collection.time_series_data.items()} - - # Modify data - new_data = pd.DataFrame( - { - name: np.ones(len(populated_collection.timesteps)) * 999 - for name in populated_collection.time_series_data - if not populated_collection[name].needs_extra_timestep - }, - index=populated_collection.timesteps, - ) + # Update it + sample_allocator.update_time_series('series', np.array([1, 2, 3, 4, 5])) - populated_collection.insert_new_data(new_data) + # Check update was applied + assert np.array_equal(ts.selected_data.values, np.array([1, 2, 3, 4, 5])) - # Verify data was changed - assert np.all(populated_collection['constant_series'].active_data.values == 999) + # Test updating non-existent series + with pytest.raises(KeyError): + sample_allocator.update_time_series('nonexistent', 42) - # Restore data - populated_collection.restore_data() + def test_as_dataset(self, sample_allocator): + """Test as_dataset method.""" + # Add some time series + sample_allocator.add_time_series('series1', 42) + sample_allocator.add_time_series('series2', np.array([1, 2, 3, 4, 5])) - # Verify data was restored - for name, original in original_values.items(): - restored = populated_collection[name].stored_data - assert np.array_equal(restored.values, original.values) + # Get dataset + ds = sample_allocator.as_dataset(with_extra_timestep=False) - def test_class_method_with_uniform_timesteps(self): - """Test the with_uniform_timesteps class method.""" - collection = TimeSeriesCollection.with_uniform_timesteps( - start_time=pd.Timestamp('2023-01-01'), periods=24, freq='H', hours_per_step=1 - ) + # Check dataset contents + assert isinstance(ds, xr.Dataset) + assert 'series1' in ds + assert 'series2' in ds + assert np.all(ds['series1'].values == 42) + assert np.array_equal(ds['series2'].values, np.array([1, 2, 3, 4, 5])) - assert len(collection.timesteps) == 24 - assert collection.hours_of_previous_timesteps == 1 - assert (collection.timesteps[1] - collection.timesteps[0]) == pd.Timedelta(hours=1) - - def test_hours_per_timestep(self, populated_collection): - """Test hours_per_timestep calculation.""" - # Standard case - uniform timesteps - hours = populated_collection.hours_per_timestep.values - assert np.allclose(hours, 24) # Default is daily timesteps - - # Create non-uniform timesteps - non_uniform_times = pd.DatetimeIndex( - [ - pd.Timestamp('2023-01-01'), - pd.Timestamp('2023-01-02'), - pd.Timestamp('2023-01-03 12:00:00'), # 1.5 days from previous - pd.Timestamp('2023-01-04'), # 0.5 days from previous - pd.Timestamp('2023-01-06'), # 2 days from previous - ], - name='time', - ) - collection = TimeSeriesCollection(non_uniform_times) - hours = collection.hours_per_timestep.values +class TestTimeSeriesAllocatorWithScenarios: + """Test suite for TimeSeriesCollection with scenarios.""" - # Expected hours between timestamps - expected = np.array([24, 36, 12, 48, 48]) - assert np.allclose(hours, expected) + def test_initialization_with_scenarios(self, sample_timesteps, sample_scenario_index): + """Test initialization with scenarios.""" + allocator = TimeSeriesCollection(sample_timesteps, scenarios=sample_scenario_index) - def test_validation_and_errors(self, sample_timesteps): - """Test validation and error handling.""" - # Test non-DatetimeIndex - with pytest.raises(TypeError, match='must be a pandas DatetimeIndex'): - TimeSeriesCollection(pd.Index([1, 2, 3, 4, 5])) + assert allocator.timesteps.equals(sample_timesteps) + assert allocator.scenarios.equals(sample_scenario_index) + assert len(allocator._time_series) == 0 - # Test too few timesteps - with pytest.raises(ValueError, match='must contain at least 2 timestamps'): - TimeSeriesCollection(pd.DatetimeIndex([pd.Timestamp('2023-01-01')], name='time')) + def test_add_time_series_with_scenarios(self, sample_scenario_allocator): + """Test creating time series with scenarios.""" + # Test scalar (broadcasts to all scenarios) + ts1 = sample_scenario_allocator.add_time_series('scalar_series', 42) + assert ts1._has_scenarios + assert ts1.name == 'scalar_series' + assert ts1.selected_data.shape == (3, 5) # 3 scenarios, 5 timesteps + assert np.all(ts1.selected_data.values == 42) - # Test invalid active_timesteps - collection = TimeSeriesCollection(sample_timesteps) - invalid_timesteps = pd.date_range('2024-01-01', periods=3, freq='D', name='time') + # Test 1D array (broadcasts to all scenarios) + data = np.array([1, 2, 3, 4, 5]) + ts2 = sample_scenario_allocator.add_time_series('array_series', data) + assert ts2._has_scenarios + assert ts2.selected_data.shape == (3, 5) + # Each scenario should have the same values + for scenario in sample_scenario_allocator.scenarios: + assert np.array_equal(ts2.sel(scenario=scenario).values, data) + + # Test 2D array (one row per scenario) + data_2d = np.array([ + [10, 20, 30, 40, 50], + [15, 25, 35, 45, 55], + [5, 15, 25, 35, 45] + ]) + ts3 = sample_scenario_allocator.add_time_series('scenario_specific_series', data_2d) + assert ts3._has_scenarios + assert ts3.selected_data.shape == (3, 5) + # Each scenario should have its own values + assert np.array_equal(ts3.sel(scenario='baseline').values, data_2d[0]) + assert np.array_equal(ts3.sel(scenario='high_demand').values, data_2d[1]) + assert np.array_equal(ts3.sel(scenario='low_price').values, data_2d[2]) + + def test_selection_propagation_with_scenarios(self, sample_scenario_allocator, sample_timesteps, sample_scenario_index): + """Test scenario selection propagation.""" + # Add some time series + ts1 = sample_scenario_allocator.add_time_series('series1', 42) + ts2 = sample_scenario_allocator.add_time_series('series2', np.array([1, 2, 3, 4, 5])) + + # Initial state - no selections + assert ts1._selected_scenarios is None + assert ts2._selected_scenarios is None + + # Select scenarios + subset_scenarios = sample_scenario_index[:2] + sample_scenario_allocator.set_selection(scenarios=subset_scenarios) + + # Check selections propagated + assert ts1._selected_scenarios.equals(subset_scenarios) + assert ts2._selected_scenarios.equals(subset_scenarios) + + # Check data is filtered + assert ts1.selected_data.shape == (2, 5) # 2 scenarios, 5 timesteps + assert ts2.selected_data.shape == (2, 5) + + # Apply combined selection + subset_timesteps = sample_timesteps[1:3] + sample_scenario_allocator.set_selection(timesteps=subset_timesteps, scenarios=subset_scenarios) + + # Check combined selection applied + assert ts1._selected_timesteps.equals(subset_timesteps) + assert ts1._selected_scenarios.equals(subset_scenarios) + assert ts1.selected_data.shape == (2, 2) # 2 scenarios, 2 timesteps + + # Clear selections + sample_scenario_allocator.clear_selection() + assert ts1._selected_timesteps is None + assert ts1._selected_scenarios is None + assert ts1.selected_data.shape == (3, 5) # Back to full shape + + def test_as_dataset_with_scenarios(self, sample_scenario_allocator): + """Test as_dataset method with scenarios.""" + # Add some time series + sample_scenario_allocator.add_time_series('scalar_series', 42) + sample_scenario_allocator.add_time_series( + 'varying_series', + np.array([ + [10, 20, 30, 40, 50], + [15, 25, 35, 45, 55], + [5, 15, 25, 35, 45] + ]) + ) - with pytest.raises(ValueError, match='must be a subset'): - collection.activate_timesteps(invalid_timesteps) + # Get dataset + ds = sample_scenario_allocator.as_dataset(with_extra_timestep=False) + + # Check dataset dimensions + assert 'scenario' in ds.dims + assert 'time' in ds.dims + assert ds.dims['scenario'] == 3 + assert ds.dims['time'] == 5 + + # Check dataset variables + assert 'scalar_series' in ds + assert 'varying_series' in ds + + # Check values + assert np.all(ds['scalar_series'].values == 42) + baseline_values = ds['varying_series'].sel(scenario='baseline').values + assert np.array_equal(baseline_values, np.array([10, 20, 30, 40, 50])) + + def test_contains_and_iteration(self, sample_scenario_allocator): + """Test __contains__ and __iter__ methods.""" + # Add some time series + ts1 = sample_scenario_allocator.add_time_series('series1', 42) + sample_scenario_allocator.add_time_series('series2', 10) + + # Test __contains__ + assert 'series1' in sample_scenario_allocator + assert ts1 in sample_scenario_allocator + assert 'nonexistent' not in sample_scenario_allocator + + # Test behavior with invalid type + with pytest.raises(TypeError): + assert 42 in sample_scenario_allocator + + def test_update_time_series_with_scenarios(self, sample_scenario_allocator, sample_scenario_index): + """Test updating a time series with scenarios.""" + # Add a time series + ts = sample_scenario_allocator.add_time_series('series', 42) + assert ts._has_scenarios + assert np.all(ts.selected_data.values == 42) + + # Update with scenario-specific data + new_data = np.array([ + [1, 2, 3, 4, 5], + [6, 7, 8, 9, 10], + [11, 12, 13, 14, 15] + ]) + sample_scenario_allocator.update_time_series('series', new_data) + + # Check update was applied + assert np.array_equal(ts.selected_data.values, new_data) + assert ts._has_scenarios + + # Check scenario-specific values + assert np.array_equal(ts.sel(scenario='baseline').values, new_data[0]) + assert np.array_equal(ts.sel(scenario='high_demand').values, new_data[1]) + assert np.array_equal(ts.sel(scenario='low_price').values, new_data[2]) + + +if __name__ == '__main__': + pytest.main() From 8c4a45bf59773e72858ff4a01058d0619ba97a6c Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 1 Apr 2025 18:42:26 +0200 Subject: [PATCH 002/336] Feature/scenarios Transform data and update type hints (#215) * Update TImeSeries to work with only scenario data * Get TImeSeriesCollection for Scenario data without time index * Simpliefy dataconverter * Drop support for pandas dataframe and Series for now * Remove test for pandas * ruff check * remove weird file * Update methods to create timeseries in FLowSystem * Bugfix * Add new Datatypes * Rename NumericData to TimestepData * Update Datatypes * Update Datatypes * Update create_time_series() * Add dimension data to Piece interfaces * Update transform_data() * Modify how time dimension is determined in Piecewise * Update OnOffParameters * Update typehints * Update Flow * Update Storage * Update typehints * Update Storage * Bugfix * Bugfix * Make sure TImeSeries are only created if needed * Bugfix * Bugfix * Bugfix and improve * Use function to get the coords of the linopy model * Updae method to determine what coords to use * Bugfix --- flixopt/components.py | 81 ++-- flixopt/core.py | 749 ++++++++++++------------------- flixopt/effects.py | 36 +- flixopt/elements.py | 47 +- flixopt/features.py | 38 +- flixopt/flow_system.py | 67 ++- flixopt/interface.py | 142 ++++-- flixopt/io.py | 2 +- flixopt/structure.py | 33 +- flixopt/utils.py | 8 - site/release-notes/_template.txt | 32 -- tests/run_all_tests.py | 2 +- tests/test_dataconverter.py | 344 +------------- 13 files changed, 590 insertions(+), 991 deletions(-) delete mode 100644 site/release-notes/_template.txt diff --git a/flixopt/components.py b/flixopt/components.py index 2a69c6165..4726ca0f4 100644 --- a/flixopt/components.py +++ b/flixopt/components.py @@ -9,7 +9,7 @@ import numpy as np from . import utils -from .core import NumericData, NumericDataTS, PlausibilityError, Scalar, TimeSeries +from .core import TimestepData, PlausibilityError, Scalar, TimeSeries, ScenarioData from .elements import Component, ComponentModel, Flow from .features import InvestmentModel, OnOffModel, PiecewiseModel from .interface import InvestParameters, OnOffParameters, PiecewiseConversion @@ -34,7 +34,7 @@ def __init__( inputs: List[Flow], outputs: List[Flow], on_off_parameters: OnOffParameters = None, - conversion_factors: List[Dict[str, NumericDataTS]] = None, + conversion_factors: List[Dict[str, TimestepData]] = None, piecewise_conversion: Optional[PiecewiseConversion] = None, meta_data: Optional[Dict] = None, ): @@ -92,6 +92,7 @@ def transform_data(self, flow_system: 'FlowSystem'): if self.conversion_factors: self.conversion_factors = self._transform_conversion_factors(flow_system) if self.piecewise_conversion: + self.piecewise_conversion.has_time_dim = True self.piecewise_conversion.transform_data(flow_system, f'{self.label_full}|PiecewiseConversion') def _transform_conversion_factors(self, flow_system: 'FlowSystem') -> List[Dict[str, TimeSeries]]: @@ -124,14 +125,14 @@ def __init__( charging: Flow, discharging: Flow, capacity_in_flow_hours: Union[Scalar, InvestParameters], - relative_minimum_charge_state: NumericData = 0, - relative_maximum_charge_state: NumericData = 1, - initial_charge_state: Union[Scalar, Literal['lastValueOfSim']] = 0, - minimal_final_charge_state: Optional[Scalar] = None, - maximal_final_charge_state: Optional[Scalar] = None, - eta_charge: NumericData = 1, - eta_discharge: NumericData = 1, - relative_loss_per_hour: NumericData = 0, + relative_minimum_charge_state: TimestepData = 0, + relative_maximum_charge_state: TimestepData = 1, + initial_charge_state: Union[ScenarioData, Literal['lastValueOfSim']] = 0, + minimal_final_charge_state: Optional[ScenarioData] = None, + maximal_final_charge_state: Optional[ScenarioData] = None, + eta_charge: TimestepData = 1, + eta_discharge: TimestepData = 1, + relative_loss_per_hour: TimestepData = 0, prevent_simultaneous_charge_and_discharge: bool = True, meta_data: Optional[Dict] = None, ): @@ -172,16 +173,16 @@ def __init__( self.charging = charging self.discharging = discharging self.capacity_in_flow_hours = capacity_in_flow_hours - self.relative_minimum_charge_state: NumericDataTS = relative_minimum_charge_state - self.relative_maximum_charge_state: NumericDataTS = relative_maximum_charge_state + self.relative_minimum_charge_state: TimestepData = relative_minimum_charge_state + self.relative_maximum_charge_state: TimestepData = relative_maximum_charge_state self.initial_charge_state = initial_charge_state self.minimal_final_charge_state = minimal_final_charge_state self.maximal_final_charge_state = maximal_final_charge_state - self.eta_charge: NumericDataTS = eta_charge - self.eta_discharge: NumericDataTS = eta_discharge - self.relative_loss_per_hour: NumericDataTS = relative_loss_per_hour + self.eta_charge: TimestepData = eta_charge + self.eta_discharge: TimestepData = eta_discharge + self.relative_loss_per_hour: TimestepData = relative_loss_per_hour self.prevent_simultaneous_charge_and_discharge = prevent_simultaneous_charge_and_discharge def create_model(self, model: SystemModel) -> 'StorageModel': @@ -206,14 +207,28 @@ def transform_data(self, flow_system: 'FlowSystem') -> None: self.relative_loss_per_hour = flow_system.create_time_series( f'{self.label_full}|relative_loss_per_hour', self.relative_loss_per_hour ) + if self.initial_charge_state != 'lastValueOfSim': + self.initial_charge_state = flow_system.create_time_series( + f'{self.label_full}|initial_charge_state', self.initial_charge_state, has_time_dim=False + ) + self.minimal_final_charge_state = flow_system.create_time_series( + f'{self.label_full}|minimal_final_charge_state', self.minimal_final_charge_state, has_time_dim=False + ) + self.maximal_final_charge_state = flow_system.create_time_series( + f'{self.label_full}|maximal_final_charge_state', self.maximal_final_charge_state, has_time_dim=False + ) if isinstance(self.capacity_in_flow_hours, InvestParameters): - self.capacity_in_flow_hours.transform_data(flow_system) + self.capacity_in_flow_hours.transform_data(flow_system, f'{self.label_full}|InvestParameters') def _plausibility_checks(self) -> None: """ Check for infeasible or uncommon combinations of parameters """ - if utils.is_number(self.initial_charge_state): + if isinstance(self.initial_charge_state, str) and not self.initial_charge_state == 'lastValueOfSim': + raise PlausibilityError( + f'initial_charge_state has undefined value: {self.initial_charge_state}' + ) + else: if isinstance(self.capacity_in_flow_hours, InvestParameters): if self.capacity_in_flow_hours.fixed_size is None: maximum_capacity = self.capacity_in_flow_hours.maximum_size @@ -229,20 +244,18 @@ def _plausibility_checks(self) -> None: minimum_inital_capacity = maximum_capacity * self.relative_minimum_charge_state.isel(time=1) # initial capacity <= allowed max for minimum_size: maximum_inital_capacity = minimum_capacity * self.relative_maximum_charge_state.isel(time=1) + #TODO: index=1 ??? I think index 0 - if self.initial_charge_state > maximum_inital_capacity: + if (self.initial_charge_state > maximum_inital_capacity).any(): raise ValueError( f'{self.label_full}: {self.initial_charge_state=} ' f'is above allowed maximum charge_state {maximum_inital_capacity}' ) - if self.initial_charge_state < minimum_inital_capacity: + if (self.initial_charge_state < minimum_inital_capacity).any(): raise ValueError( f'{self.label_full}: {self.initial_charge_state=} ' f'is below allowed minimum charge_state {minimum_inital_capacity}' ) - elif self.initial_charge_state != 'lastValueOfSim': - raise ValueError(f'{self.label_full}: {self.initial_charge_state=} has an invalid value') - @register_class_for_io class Transmission(Component): @@ -259,8 +272,8 @@ def __init__( out1: Flow, in2: Optional[Flow] = None, out2: Optional[Flow] = None, - relative_losses: Optional[NumericDataTS] = None, - absolute_losses: Optional[NumericDataTS] = None, + relative_losses: Optional[TimestepData] = None, + absolute_losses: Optional[TimestepData] = None, on_off_parameters: OnOffParameters = None, prevent_simultaneous_flows_in_both_directions: bool = True, meta_data: Optional[Dict] = None, @@ -454,12 +467,12 @@ def do_modeling(self): lb, ub = self.absolute_charge_state_bounds self.charge_state = self.add( self._model.add_variables( - lower=lb, upper=ub, coords=self._model.coords_extra, name=f'{self.label_full}|charge_state' + lower=lb, upper=ub, coords=self._model.get_coords(extra_timestep=True), name=f'{self.label_full}|charge_state' ), 'charge_state', ) self.netto_discharge = self.add( - self._model.add_variables(coords=self._model.coords, name=f'{self.label_full}|netto_discharge'), + self._model.add_variables(coords=self._model.get_coords(), name=f'{self.label_full}|netto_discharge'), 'netto_discharge', ) # netto_discharge: @@ -511,24 +524,20 @@ def _initial_and_final_charge_state(self): name_short = 'initial_charge_state' name = f'{self.label_full}|{name_short}' - if utils.is_number(self.element.initial_charge_state): + if self.element.initial_charge_state == 'lastValueOfSim': self.add( self._model.add_constraints( - self.charge_state.isel(time=0) == self.element.initial_charge_state, name=name + self.charge_state.isel(time=0) == self.charge_state.isel(time=-1), name=name ), name_short, ) - elif self.element.initial_charge_state == 'lastValueOfSim': + else: self.add( self._model.add_constraints( - self.charge_state.isel(time=0) == self.charge_state.isel(time=-1), name=name + self.charge_state.isel(time=0) == self.element.initial_charge_state, name=name ), name_short, ) - else: # TODO: Validation in Storage Class, not in Model - raise PlausibilityError( - f'initial_charge_state has undefined value: {self.element.initial_charge_state}' - ) if self.element.maximal_final_charge_state is not None: self.add( @@ -549,7 +558,7 @@ def _initial_and_final_charge_state(self): ) @property - def absolute_charge_state_bounds(self) -> Tuple[NumericData, NumericData]: + def absolute_charge_state_bounds(self) -> Tuple[TimestepData, TimestepData]: relative_lower_bound, relative_upper_bound = self.relative_charge_state_bounds if not isinstance(self.element.capacity_in_flow_hours, InvestParameters): return ( @@ -563,7 +572,7 @@ def absolute_charge_state_bounds(self) -> Tuple[NumericData, NumericData]: ) @property - def relative_charge_state_bounds(self) -> Tuple[NumericData, NumericData]: + def relative_charge_state_bounds(self) -> Tuple[TimestepData, TimestepData]: return ( self.element.relative_minimum_charge_state.selected_data, self.element.relative_maximum_charge_state.selected_data, diff --git a/flixopt/core.py b/flixopt/core.py index d2a8edd59..185236b3a 100644 --- a/flixopt/core.py +++ b/flixopt/core.py @@ -26,6 +26,12 @@ NumericDataTS = Union[NumericData, 'TimeSeriesData'] """Represents either standard numeric data or TimeSeriesData.""" +TimestepData = NumericData +"""Represents any form of numeric data that corresponds to timesteps.""" + +ScenarioData = NumericData +"""Represents any form of numeric data that corresponds to scenarios.""" + class PlausibilityError(Exception): """Error for a failing Plausibility check.""" @@ -41,568 +47,322 @@ class ConversionError(Exception): class DataConverter: """ - Converts various data types into xarray.DataArray with timesteps and optional scenarios dimensions. - - Supports: - - Scalar values (broadcast to all timesteps/scenarios) - - 1D arrays (mapped to timesteps, broadcast to scenarios if provided) - - 2D arrays (mapped to scenarios × timesteps if dimensions match) - - Series with time index (broadcast to scenarios if provided) - - DataFrames with time index and a single column (broadcast to scenarios if provided) - - Series/DataFrames with MultiIndex (scenario, time) - - Existing DataArrays - """ + Converts various data types into xarray.DataArray with optional time and scenario dimension. - #TODO: Allow DataFrame with scenarios as columns + Current implementation handles: + - Scalar values + - NumPy arrays + - xarray.DataArray + """ @staticmethod def as_dataarray( - data: NumericData, timesteps: pd.DatetimeIndex, scenarios: Optional[pd.Index] = None + data: TimestepData, timesteps: Optional[pd.DatetimeIndex] = None, scenarios: Optional[pd.Index] = None ) -> xr.DataArray: """ - Convert data to xarray.DataArray with specified timesteps and optional scenarios dimensions. + Convert data to xarray.DataArray with specified dimensions. Args: - data: The data to convert (scalar, array, Series, DataFrame, or DataArray) - timesteps: DatetimeIndex representing the time dimension (must be named 'time') - scenarios: Optional Index representing scenarios (must be named 'scenario') + data: The data to convert (scalar, array, or DataArray) + timesteps: Optional DatetimeIndex for time dimension + scenarios: Optional Index for scenario dimension Returns: DataArray with the converted data - - Raises: - ValueError: If timesteps or scenarios are invalid - ConversionError: If the data cannot be converted to the expected dimensions """ - # Validate inputs - DataConverter._validate_timesteps(timesteps) - if scenarios is not None: - DataConverter._validate_scenarios(scenarios) - - # Determine dimensions and coordinates - coords, dims, expected_shape = DataConverter._get_dimensions(timesteps, scenarios) - - try: - # Convert different data types using specialized methods - if isinstance(data, (int, float, np.integer, np.floating)): - return DataConverter._convert_scalar(data, coords, dims) + # Prepare dimensions and coordinates + coords, dims = DataConverter._prepare_dimensions(timesteps, scenarios) - elif isinstance(data, pd.DataFrame): - return DataConverter._convert_dataframe(data, timesteps, scenarios, coords, dims) + # Select appropriate converter based on data type + if isinstance(data, (int, float, np.integer, np.floating)): + return DataConverter._convert_scalar(data, coords, dims) - elif isinstance(data, pd.Series): - return DataConverter._convert_series(data, timesteps, scenarios, coords, dims) + elif isinstance(data, xr.DataArray): + return DataConverter._convert_dataarray(data, coords, dims) - elif isinstance(data, np.ndarray): - return DataConverter._convert_ndarray(data, timesteps, scenarios, coords, dims, expected_shape) + elif isinstance(data, np.ndarray): + return DataConverter._convert_ndarray(data, coords, dims) - elif isinstance(data, xr.DataArray): - return DataConverter._convert_dataarray(data, timesteps, scenarios, coords, dims) - - else: - raise ConversionError(f'Unsupported type: {type(data).__name__}') - - except Exception as e: - if isinstance(e, ConversionError): - raise - raise ConversionError(f'Converting {type(data)} to DataArray raised an error: {str(e)}') from e + else: + raise ConversionError(f'Unsupported data type: {type(data).__name__}') @staticmethod - def _validate_timesteps(timesteps: pd.DatetimeIndex) -> None: + def _validate_timesteps(timesteps: pd.DatetimeIndex) -> pd.DatetimeIndex: """ - Validate that timesteps is a properly named non-empty DatetimeIndex. + Validate and prepare time index. Args: - timesteps: The DatetimeIndex to validate + timesteps: The time index to validate - Raises: - ValueError: If timesteps is not a non-empty DatetimeIndex - ConversionError: If timesteps is not named 'time' + Returns: + Validated time index """ if not isinstance(timesteps, pd.DatetimeIndex) or len(timesteps) == 0: - raise ValueError(f'Timesteps must be a non-empty DatetimeIndex, got {type(timesteps).__name__}') - if timesteps.name != 'time': - raise ConversionError(f'DatetimeIndex must be named "time", got {timesteps.name=}') + raise ConversionError('Timesteps must be a non-empty DatetimeIndex') - @staticmethod - def _validate_scenarios(scenarios: pd.Index) -> None: - """ - Validate that scenarios is a properly named non-empty Index. + if not timesteps.name == 'time': + raise ConversionError(f'Scenarios must be named "time", got "{timesteps.name}"') - Args: - scenarios: The Index to validate - - Raises: - ValueError: If scenarios is not a non-empty Index - ConversionError: If scenarios is not named 'scenario' - """ - if not isinstance(scenarios, pd.Index) or len(scenarios) == 0: - raise ValueError(f'Scenarios must be a non-empty Index, got {type(scenarios).__name__}') - if scenarios.name != 'scenario': - raise ConversionError(f'Scenarios Index must be named "scenario", got {scenarios.name=}') + return timesteps @staticmethod - def _get_dimensions( - timesteps: pd.DatetimeIndex, scenarios: Optional[pd.Index] = None - ) -> Tuple[Dict[str, pd.Index], Tuple[str, ...], Tuple[int, ...]]: + def _validate_scenarios(scenarios: pd.Index) -> pd.Index: """ - Create the coordinates, dimensions, and expected shape for the output DataArray. + Validate and prepare scenario index. Args: - timesteps: The time index - scenarios: Optional scenario index - - Returns: - Tuple containing: - - Dict mapping dimension names to coordinate indexes - - Tuple of dimension names - - Tuple of expected shape - """ - if scenarios is not None: - coords = {'scenario': scenarios, 'time': timesteps} - dims = ('scenario', 'time') - expected_shape = (len(scenarios), len(timesteps)) - else: - coords = {'time': timesteps} - dims = ('time',) - expected_shape = (len(timesteps),) - - return coords, dims, expected_shape - - @staticmethod - def _convert_scalar( - data: Union[int, float, np.integer, np.floating], coords: Dict[str, pd.Index], dims: Tuple[str, ...] - ) -> xr.DataArray: + scenarios: The scenario index to validate """ - Convert a scalar value to a DataArray. + if not isinstance(scenarios, pd.Index) or len(scenarios) == 0: + raise ConversionError('Scenarios must be a non-empty Index') - Args: - data: The scalar value to convert - coords: Dictionary mapping dimension names to coordinate indexes - dims: Tuple of dimension names + if not scenarios.name == 'scenario': + raise ConversionError(f'Scenarios must be named "scenario", got "{scenarios.name}"') - Returns: - DataArray with the scalar value broadcast to all coordinates - """ - return xr.DataArray(data, coords=coords, dims=dims) + return scenarios @staticmethod - def _convert_dataframe( - df: pd.DataFrame, - timesteps: pd.DatetimeIndex, - scenarios: Optional[pd.Index], - coords: Dict[str, pd.Index], - dims: Tuple[str, ...], - ) -> xr.DataArray: + def _prepare_dimensions( + timesteps: Optional[pd.DatetimeIndex], scenarios: Optional[pd.Index] + ) -> Tuple[Dict[str, pd.Index], Tuple[str, ...]]: """ - Convert a DataFrame to a DataArray. + Prepare coordinates and dimensions for the DataArray. Args: - df: The DataFrame to convert - timesteps: The time index + timesteps: Optional time index scenarios: Optional scenario index - coords: Dictionary mapping dimension names to coordinate indexes - dims: Tuple of dimension names Returns: - DataArray created from the DataFrame - - Raises: - ConversionError: If the DataFrame cannot be converted to the expected dimensions - """ - # Case 1: DataFrame with MultiIndex (scenario, time) - if ( - isinstance(df.index, pd.MultiIndex) - and len(df.index.names) == 2 - and 'scenario' in df.index.names - and 'time' in df.index.names - and scenarios is not None - ): - return DataConverter._convert_multi_index_dataframe(df, timesteps, scenarios, coords, dims) - - # Case 2: Standard DataFrame with time index - elif not isinstance(df.index, pd.MultiIndex): - return DataConverter._convert_standard_dataframe(df, timesteps, scenarios, coords, dims) - - else: - raise ConversionError(f'Unsupported DataFrame index structure: {df}') - - @staticmethod - def _convert_multi_index_dataframe( - df: pd.DataFrame, - timesteps: pd.DatetimeIndex, - scenarios: pd.Index, - coords: Dict[str, pd.Index], - dims: Tuple[str, ...], - ) -> xr.DataArray: + Tuple of (coordinates dict, dimensions tuple) """ - Convert a DataFrame with MultiIndex (scenario, time) to a DataArray. + # Validate inputs if provided + if timesteps is not None: + timesteps = DataConverter._validate_timesteps(timesteps) - Args: - df: The DataFrame with MultiIndex to convert - timesteps: The time index - scenarios: The scenario index - coords: Dictionary mapping dimension names to coordinate indexes - dims: Tuple of dimension names - - Returns: - DataArray created from the MultiIndex DataFrame + if scenarios is not None: + scenarios = DataConverter._validate_scenarios(scenarios) - Raises: - ConversionError: If the DataFrame's index doesn't match expected or has multiple columns - """ - # Validate that the index contains the expected values - if not set(df.index.get_level_values('time')).issubset(set(timesteps)): - raise ConversionError("DataFrame time index doesn't match or isn't a subset of timesteps") - if not set(df.index.get_level_values('scenario')).issubset(set(scenarios)): - raise ConversionError("DataFrame scenario index doesn't match or isn't a subset of scenarios") + # Build coordinates and dimensions + coords = {} + dims = [] - # Ensure single column - if len(df.columns) != 1: - raise ConversionError('DataFrame must have exactly one column') + if scenarios is not None: + coords['scenario'] = scenarios + dims.append('scenario') - # Reindex to ensure complete coverage and correct order - multi_idx = pd.MultiIndex.from_product([scenarios, timesteps], names=['scenario', 'time']) - reindexed = df.reindex(multi_idx).iloc[:, 0] + if timesteps is not None: + coords['time'] = timesteps + dims.append('time') - # Reshape to 2D array - reshaped = reindexed.values.reshape(len(scenarios), len(timesteps)) - return xr.DataArray(reshaped, coords=coords, dims=dims) + return coords, tuple(dims) @staticmethod - def _convert_standard_dataframe( - df: pd.DataFrame, - timesteps: pd.DatetimeIndex, - scenarios: Optional[pd.Index], - coords: Dict[str, pd.Index], - dims: Tuple[str, ...], + def _convert_scalar( + data: Union[int, float, np.integer, np.floating], coords: Dict[str, pd.Index], dims: Tuple[str, ...] ) -> xr.DataArray: """ - Convert a standard DataFrame with time index to a DataArray. + Convert a scalar value to a DataArray. Args: - df: The DataFrame to convert - timesteps: The time index - scenarios: Optional scenario index - coords: Dictionary mapping dimension names to coordinate indexes - dims: Tuple of dimension names + data: The scalar value + coords: Coordinate dictionary + dims: Dimension names Returns: - DataArray created from the DataFrame - - Raises: - ConversionError: If the DataFrame's index doesn't match timesteps or has multiple columns + DataArray with the scalar value """ - if not df.index.equals(timesteps): - raise ConversionError("DataFrame index doesn't match timesteps index") - if len(df.columns) != 1: - raise ConversionError('DataFrame must have exactly one column') - - # Get values - values = df.values.flatten() - - if scenarios is not None: - # Broadcast to scenarios dimension - values = np.tile(values, (len(scenarios), 1)) - - return xr.DataArray(values, coords=coords, dims=dims) + return xr.DataArray(data, coords=coords, dims=dims) @staticmethod - def _convert_series( - series: pd.Series, - timesteps: pd.DatetimeIndex, - scenarios: Optional[pd.Index], - coords: Dict[str, pd.Index], - dims: Tuple[str, ...], - ) -> xr.DataArray: + def _convert_dataarray(data: xr.DataArray, coords: Dict[str, pd.Index], dims: Tuple[str, ...]) -> xr.DataArray: """ - Convert a Series to a DataArray. + Convert an existing DataArray to desired dimensions. Args: - series: The Series to convert - timesteps: The time index - scenarios: Optional scenario index - coords: Dictionary mapping dimension names to coordinate indexes - dims: Tuple of dimension names + data: The source DataArray + coords: Target coordinates + dims: Target dimensions Returns: - DataArray created from the Series - - Raises: - ConversionError: If the Series cannot be converted to the expected dimensions - """ - # Case 1: Series with MultiIndex (scenario, time) - if ( - isinstance(series.index, pd.MultiIndex) - and len(series.index.names) == 2 - and 'scenario' in series.index.names - and 'time' in series.index.names - and scenarios is not None - ): - return DataConverter._convert_multi_index_series(series, timesteps, scenarios, coords, dims) - - # Case 2: Standard Series with time index - elif not isinstance(series.index, pd.MultiIndex): - return DataConverter._convert_standard_series(series, timesteps, scenarios, coords, dims) - - else: - raise ConversionError('Unsupported Series index structure') + DataArray with the target dimensions + """ + # No dimensions case + if len(dims) == 0: + if data.size != 1: + raise ConversionError('When converting to dimensionless DataArray, source must be scalar') + return xr.DataArray(data.values.item()) + + # Check if data already has matching dimensions + if set(data.dims) == set(dims): + # Check if coordinates match + is_compatible = True + for dim in dims: + if dim in data.dims and not np.array_equal(data.coords[dim].values, coords[dim].values): + is_compatible = False + break + + if is_compatible: + # Return existing DataArray if compatible + return data.copy(deep=True) + + # Handle dimension broadcasting + if len(data.dims) == 1 and len(dims) == 2: + # Single dimension to two dimensions + if data.dims[0] == 'time' and 'scenario' in dims: + # Broadcast time dimension to include scenarios + return DataConverter._broadcast_time_to_scenarios(data, coords, dims) + + elif data.dims[0] == 'scenario' and 'time' in dims: + # Broadcast scenario dimension to include time + return DataConverter._broadcast_scenario_to_time(data, coords, dims) + + raise ConversionError(f'Cannot convert {data.dims} to {dims}') @staticmethod - def _convert_multi_index_series( - series: pd.Series, - timesteps: pd.DatetimeIndex, - scenarios: pd.Index, - coords: Dict[str, pd.Index], - dims: Tuple[str, ...], + def _broadcast_time_to_scenarios( + data: xr.DataArray, coords: Dict[str, pd.Index], dims: Tuple[str, ...] ) -> xr.DataArray: """ - Convert a Series with MultiIndex (scenario, time) to a DataArray. + Broadcast a time-only DataArray to include scenarios. Args: - series: The Series with MultiIndex to convert - timesteps: The time index - scenarios: The scenario index - coords: Dictionary mapping dimension names to coordinate indexes - dims: Tuple of dimension names + data: The time-indexed DataArray + coords: Target coordinates + dims: Target dimensions Returns: - DataArray created from the MultiIndex Series - - Raises: - ConversionError: If the Series' index doesn't match expected + DataArray with time and scenario dimensions """ - # Validate that the index contains the expected values - if not set(series.index.get_level_values('time')).issubset(set(timesteps)): - raise ConversionError("Series time index doesn't match or isn't a subset of timesteps") - if not set(series.index.get_level_values('scenario')).issubset(set(scenarios)): - raise ConversionError("Series scenario index doesn't match or isn't a subset of scenarios") + # Check compatibility + if not np.array_equal(data.coords['time'].values, coords['time'].values): + raise ConversionError("Source time coordinates don't match target time coordinates") - # Reindex to ensure complete coverage and correct order - multi_idx = pd.MultiIndex.from_product([scenarios, timesteps], names=['scenario', 'time']) - reindexed = series.reindex(multi_idx) - - # Reshape to 2D array - reshaped = reindexed.values.reshape(len(scenarios), len(timesteps)) - return xr.DataArray(reshaped, coords=coords, dims=dims) + # Broadcast values + values = np.tile(data.values, (len(coords['scenario']), 1)) + return xr.DataArray(values, coords=coords, dims=dims) @staticmethod - def _convert_standard_series( - series: pd.Series, - timesteps: pd.DatetimeIndex, - scenarios: Optional[pd.Index], - coords: Dict[str, pd.Index], - dims: Tuple[str, ...], + def _broadcast_scenario_to_time( + data: xr.DataArray, coords: Dict[str, pd.Index], dims: Tuple[str, ...] ) -> xr.DataArray: """ - Convert a standard Series with time index to a DataArray. + Broadcast a scenario-only DataArray to include time. Args: - series: The Series to convert - timesteps: The time index - scenarios: Optional scenario index - coords: Dictionary mapping dimension names to coordinate indexes - dims: Tuple of dimension names + data: The scenario-indexed DataArray + coords: Target coordinates + dims: Target dimensions Returns: - DataArray created from the Series - - Raises: - ConversionError: If the Series' index doesn't match timesteps + DataArray with time and scenario dimensions """ - if not series.index.equals(timesteps): - raise ConversionError("Series index doesn't match timesteps index") - - # Get values - values = series.values - - if scenarios is not None: - # Broadcast to scenarios dimension - values = np.tile(values, (len(scenarios), 1)) + # Check compatibility + if not np.array_equal(data.coords['scenario'].values, coords['scenario'].values): + raise ConversionError("Source scenario coordinates don't match target scenario coordinates") + # Broadcast values + values = np.repeat(data.values[:, np.newaxis], len(coords['time']), axis=1) return xr.DataArray(values, coords=coords, dims=dims) @staticmethod - def _convert_ndarray( - arr: np.ndarray, - timesteps: pd.DatetimeIndex, - scenarios: Optional[pd.Index], - coords: Dict[str, pd.Index], - dims: Tuple[str, ...], - expected_shape: Tuple[int, ...], - ) -> xr.DataArray: + def _convert_ndarray(data: np.ndarray, coords: Dict[str, pd.Index], dims: Tuple[str, ...]) -> xr.DataArray: """ - Convert a numpy array to a DataArray. + Convert a NumPy array to a DataArray. Args: - arr: The numpy array to convert - timesteps: The time index - scenarios: Optional scenario index - coords: Dictionary mapping dimension names to coordinate indexes - dims: Tuple of dimension names - expected_shape: Expected shape of the resulting array + data: The NumPy array + coords: Target coordinates + dims: Target dimensions Returns: - DataArray created from the numpy array - - Raises: - ConversionError: If the array cannot be converted to the expected dimensions - """ - # Case 1: With scenarios - array can be 1D or 2D - if scenarios is not None: - return DataConverter._convert_ndarray_with_scenarios( - arr, timesteps, scenarios, coords, dims, expected_shape - ) - - # Case 2: Without scenarios - array must be 1D - else: - return DataConverter._convert_ndarray_without_scenarios(arr, timesteps, coords, dims) - - @staticmethod - def _convert_ndarray_with_scenarios( - arr: np.ndarray, - timesteps: pd.DatetimeIndex, - scenarios: pd.Index, - coords: Dict[str, pd.Index], - dims: Tuple[str, ...], - expected_shape: Tuple[int, ...], - ) -> xr.DataArray: + DataArray from the NumPy array """ - Convert a numpy array to a DataArray with scenarios dimension. - - Args: - arr: The numpy array to convert - timesteps: The time index - scenarios: The scenario index - coords: Dictionary mapping dimension names to coordinate indexes - dims: Tuple of dimension names - expected_shape: Expected shape (scenarios, timesteps) + # Handle dimensionless case + if len(dims) == 0: + if data.size != 1: + raise ConversionError('Without dimensions, can only convert scalar arrays') + return xr.DataArray(data.item()) - Returns: - DataArray created from the numpy array + # Handle single dimension + elif len(dims) == 1: + return DataConverter._convert_ndarray_single_dim(data, coords, dims) - Raises: - ConversionError: If the array dimensions don't match expected - """ - if arr.ndim == 1: - # 1D array should match timesteps and be broadcast to scenarios - if arr.shape[0] != len(timesteps): - raise ConversionError(f"1D array length {arr.shape[0]} doesn't match timesteps length {len(timesteps)}") - # Broadcast to scenarios - values = np.tile(arr, (len(scenarios), 1)) - return xr.DataArray(values, coords=coords, dims=dims) - - elif arr.ndim == 2: - # 2D array should match (scenarios, timesteps) - if arr.shape != expected_shape: - raise ConversionError(f"2D array shape {arr.shape} doesn't match expected shape {expected_shape}") - return xr.DataArray(arr, coords=coords, dims=dims) + # Handle two dimensions + elif len(dims) == 2: + return DataConverter._convert_ndarray_two_dims(data, coords, dims) else: - raise ConversionError(f'Array must be 1D or 2D, got {arr.ndim}D') - - @staticmethod - def _convert_ndarray_without_scenarios( - arr: np.ndarray, timesteps: pd.DatetimeIndex, coords: Dict[str, pd.Index], dims: Tuple[str, ...] - ) -> xr.DataArray: - """ - Convert a numpy array to a DataArray without scenarios dimension. - - Args: - arr: The numpy array to convert - timesteps: The time index - coords: Dictionary mapping dimension names to coordinate indexes - dims: Tuple of dimension names - - Returns: - DataArray created from the numpy array - - Raises: - ConversionError: If the array isn't 1D or doesn't match timesteps length - """ - if arr.ndim != 1: - raise ConversionError(f'Without scenarios, array must be 1D, got {arr.ndim}D') - if arr.shape[0] != len(timesteps): - raise ConversionError(f"Array shape {arr.shape} doesn't match expected length {len(timesteps)}") - return xr.DataArray(arr, coords=coords, dims=dims) + raise ConversionError('Maximum 2 dimensions supported') @staticmethod - def _convert_dataarray( - da: xr.DataArray, - timesteps: pd.DatetimeIndex, - scenarios: Optional[pd.Index], - coords: Dict[str, pd.Index], - dims: Tuple[str, ...], + def _convert_ndarray_single_dim( + data: np.ndarray, coords: Dict[str, pd.Index], dims: Tuple[str, ...] ) -> xr.DataArray: """ - Convert an existing DataArray to a new DataArray with the desired dimensions. + Convert a NumPy array to a single-dimension DataArray. Args: - da: The DataArray to convert - timesteps: The time index - scenarios: Optional scenario index - coords: Dictionary mapping dimension names to coordinate indexes - dims: Tuple of dimension names + data: The NumPy array + coords: Target coordinates + dims: Target dimensions (length 1) Returns: - New DataArray with the specified coordinates and dimensions - - Raises: - ConversionError: If the DataArray dimensions don't match expected + DataArray with single dimension """ - # Case 1: DataArray with only time dimension when scenarios are provided - if scenarios is not None and set(da.dims) == {'time'}: - return DataConverter._broadcast_time_only_dataarray(da, timesteps, scenarios, coords, dims) - - # Case 2: DataArray dimensions should match expected - elif set(da.dims) != set(dims): - raise ConversionError(f"DataArray dimensions {da.dims} don't match expected {dims}") + dim_name = dims[0] + dim_length = len(coords[dim_name]) - # Validate dimensions sizes - for dim in dims: - if not np.array_equal(da.coords[dim].values, coords[dim].values): - raise ConversionError(f"DataArray dimension '{dim}' doesn't match expected {coords[dim]}") - - # Create a new DataArray with our coordinates to ensure consistency - result = xr.DataArray(da.values.copy(), coords=coords, dims=dims) - return result + if data.ndim == 1: + # 1D array must match dimension length + if data.shape[0] != dim_length: + raise ConversionError(f"Array length {data.shape[0]} doesn't match {dim_name} length {dim_length}") + return xr.DataArray(data, coords=coords, dims=dims) + else: + raise ConversionError(f'Expected 1D array for single dimension, got {data.ndim}D') @staticmethod - def _broadcast_time_only_dataarray( - da: xr.DataArray, - timesteps: pd.DatetimeIndex, - scenarios: pd.Index, - coords: Dict[str, pd.Index], - dims: Tuple[str, ...], - ) -> xr.DataArray: + def _convert_ndarray_two_dims(data: np.ndarray, coords: Dict[str, pd.Index], dims: Tuple[str, ...]) -> xr.DataArray: """ - Broadcast a time-only DataArray to include the scenarios dimension. + Convert a NumPy array to a two-dimension DataArray. Args: - da: The DataArray with only time dimension - timesteps: The time index - scenarios: The scenario index - coords: Dictionary mapping dimension names to coordinate indexes - dims: Tuple of dimension names + data: The NumPy array + coords: Target coordinates + dims: Target dimensions (length 2) Returns: - DataArray with the data broadcast to include scenarios dimension + DataArray with two dimensions + """ + scenario_length = len(coords['scenario']) + time_length = len(coords['time']) + + if data.ndim == 1: + # For 1D array, create 2D array based on which dimension it matches + if data.shape[0] == time_length: + # Broadcast across scenarios + values = np.tile(data, (scenario_length, 1)) + return xr.DataArray(values, coords=coords, dims=dims) + elif data.shape[0] == scenario_length: + # Broadcast across time + values = np.repeat(data[:, np.newaxis], time_length, axis=1) + return xr.DataArray(values, coords=coords, dims=dims) + else: + raise ConversionError(f"1D array length {data.shape[0]} doesn't match either dimension") - Raises: - ConversionError: If the DataArray time coordinates aren't compatible with timesteps - """ - # Ensure the time dimension is compatible - if not np.array_equal(da.coords['time'].values, timesteps.values): - raise ConversionError("DataArray time coordinates aren't compatible with timesteps") + elif data.ndim == 2: + # For 2D array, shape must match dimensions + expected_shape = (scenario_length, time_length) + if data.shape != expected_shape: + raise ConversionError(f"2D array shape {data.shape} doesn't match expected shape {expected_shape}") + return xr.DataArray(data, coords=coords, dims=dims) - # Broadcast to scenarios - values = np.tile(da.values.copy(), (len(scenarios), 1)) - return xr.DataArray(values, coords=coords, dims=dims) + else: + raise ConversionError(f'Expected 1D or 2D array for two dimensions, got {data.ndim}D') class TimeSeriesData: # TODO: Move to Interface.py - def __init__(self, data: NumericData, agg_group: Optional[str] = None, agg_weight: Optional[float] = None): + def __init__(self, data: TimestepData, agg_group: Optional[str] = None, agg_weight: Optional[float] = None): """ timeseries class for transmit timeseries AND special characteristics of timeseries, i.g. to define weights needed in calculation_type 'aggregated' @@ -744,11 +504,8 @@ def __init__( has_extra_timestep: Whether this series requires an extra timestep Raises: - ValueError: If data doesn't have a 'time' index or has unsupported dimensions + ValueError: If data has unsupported dimensions """ - if 'time' not in data.indexes: - raise ValueError(f'DataArray must have a "time" index. Got {data.indexes}') - allowed_dims = {'time', 'scenario'} if not set(data.dims).issubset(allowed_dims): raise ValueError(f'DataArray dimensions must be subset of {allowed_dims}. Got {data.dims}') @@ -766,8 +523,9 @@ def __init__( self._selected_timesteps: Optional[pd.DatetimeIndex] = None self._selected_scenarios: Optional[pd.Index] = None - # Flag for whether this series has scenarios - self._has_scenarios = 'scenario' in data.dims + # Flag for whether this series has various dimensions + self.has_time_dim = 'time' in data.dims + self.has_scenario_dim = 'scenario' in data.dims def reset(self) -> None: """ @@ -836,16 +594,18 @@ def selected_data(self) -> xr.DataArray: return self._stored_data.sel(**self._valid_selector) @property - def active_timesteps(self) -> pd.DatetimeIndex: - """Get the current active timesteps.""" + def active_timesteps(self) -> Optional[pd.DatetimeIndex]: + """Get the current active timesteps, or None if no time dimension.""" + if not self.has_time_dim: + return None if self._selected_timesteps is None: return self._stored_data.indexes['time'] return self._selected_timesteps @property def active_scenarios(self) -> Optional[pd.Index]: - """Get the current active scenarios.""" - if not self._has_scenarios: + """Get the current active scenarios, or None if no scenario dimension.""" + if not self.has_scenario_dim: return None if self._selected_scenarios is None: return self._stored_data.indexes['scenario'] @@ -865,8 +625,8 @@ def update_stored_data(self, value: xr.DataArray) -> None: """ new_data = DataConverter.as_dataarray( value, - timesteps=self.active_timesteps, - scenarios=self.active_scenarios if self._has_scenarios else None + timesteps=self.active_timesteps if self.has_time_dim else None, + scenarios=self.active_scenarios if self.has_scenario_dim else None, ) # Skip if data is unchanged to avoid overwriting backup @@ -883,15 +643,26 @@ def clear_selection(self, timesteps: bool = True, scenarios: bool = True) -> Non self._selected_scenarios = None def set_selection(self, timesteps: Optional[pd.DatetimeIndex] = None, scenarios: Optional[pd.Index] = None) -> None: - if timesteps is None: - self.clear_selection(timesteps=True, scenarios=False) - else: - self._selected_timesteps = timesteps + """ + Set active subset for timesteps and scenarios. - if scenarios is None: - self.clear_selection(timesteps=False, scenarios=True) - else: - self._selected_scenarios = scenarios + Args: + timesteps: Timesteps to activate, or None to clear. Ignored if series has no time dimension. + scenarios: Scenarios to activate, or None to clear. Ignored if series has no scenario dimension. + """ + # Only update timesteps if the series has time dimension + if self.has_time_dim: + if timesteps is None: + self.clear_selection(timesteps=True, scenarios=False) + else: + self._selected_timesteps = timesteps + + # Only update scenarios if the series has scenario dimension + if self.has_scenario_dim: + if scenarios is None: + self.clear_selection(timesteps=False, scenarios=True) + else: + self._selected_scenarios = scenarios @property def sel(self): @@ -906,8 +677,17 @@ def isel(self): @property def _valid_selector(self) -> Dict[str, pd.Index]: """Get the current selection as a dictionary.""" - full_selection = {'time': self._selected_timesteps, 'scenario': self._selected_scenarios} - return {dim: sel for dim, sel in full_selection.items() if dim in self._stored_data.dims and sel is not None} + selector = {} + + # Only include time in selector if series has time dimension + if self.has_time_dim and self._selected_timesteps is not None: + selector['time'] = self._selected_timesteps + + # Only include scenario in selector if series has scenario dimension + if self.has_scenario_dim and self._selected_scenarios is not None: + selector['scenario'] = self._selected_scenarios + + return selector def _apply_operation(self, other, op): """Apply an operation between this TimeSeries and another object.""" @@ -1042,6 +822,8 @@ def add_time_series( self, name: str, data: Union[NumericDataTS, TimeSeries], + has_time_dim: bool = True, + has_scenario_dim: bool = True, aggregation_weight: Optional[float] = None, aggregation_group: Optional[str] = None, has_extra_timestep: bool = False, @@ -1052,6 +834,8 @@ def add_time_series( Args: name: Name of the time series data: Data for the time series (can be raw data or an existing TimeSeries) + has_time_dim: Whether the TimeSeries has a time dimension + has_scenario_dim: Whether the TimeSeries has a scenario dimension aggregation_weight: Weight used for aggregation aggregation_group: Group name for shared aggregation weighting has_extra_timestep: Whether this series needs an extra timestep @@ -1061,9 +845,16 @@ def add_time_series( """ if name in self._time_series: raise KeyError(f"TimeSeries '{name}' already exists in allocator") + if not has_time_dim and has_extra_timestep: + raise ValueError('A not time-indexed TimeSeries cannot have an extra timestep') # Choose which timesteps to use - target_timesteps = self.timesteps_extra if has_extra_timestep else self.timesteps + if has_time_dim: + target_timesteps = self.timesteps_extra if has_extra_timestep else self.timesteps + else: + target_timesteps = None + + target_scenarios = self.scenarios if has_scenario_dim else None # Create or adapt the TimeSeries object if isinstance(data, TimeSeries): @@ -1071,7 +862,7 @@ def add_time_series( time_series = data # Update the stored data to use our timesteps and scenarios data_array = DataConverter.as_dataarray( - time_series.stored_data, timesteps=target_timesteps, scenarios=self.scenarios + time_series.stored_data, timesteps=target_timesteps, scenarios=target_scenarios ) time_series = TimeSeries( data=data_array, @@ -1086,7 +877,7 @@ def add_time_series( data=data, name=name, timesteps=target_timesteps, - scenarios=self.scenarios, + scenarios=target_scenarios, aggregation_weight=aggregation_weight, aggregation_group=aggregation_group, has_extra_timestep=has_extra_timestep, @@ -1163,7 +954,7 @@ def as_dataset(self, with_extra_timestep: bool = True, with_constants: bool = Tr Args: with_extra_timestep: Whether to exclude the extra timesteps. - Effectively, this removes the last timestep for certain TImeSeries, but mitigates the presence of NANs in others. + Effectively, this removes the last timestep for certain TimeSeries, but mitigates the presence of NANs in others. with_constants: Whether to exclude TimeSeries with a constant value from the dataset. """ if self.scenarios is None: @@ -1212,10 +1003,16 @@ def scenarios(self) -> Optional[pd.Index]: def _propagate_selection_to_time_series(self) -> None: """Apply the current selection to all TimeSeries objects.""" for ts_name, ts in self._time_series.items(): - timesteps = self._selected_timesteps_extra if ts_name in self._has_extra_timestep else self._selected_timesteps + if ts.has_time_dim: + timesteps = ( + self.timesteps_extra if ts_name in self._has_extra_timestep else self.timesteps + ) + else: + timesteps = None + ts.set_selection( timesteps=timesteps, - scenarios=self._selected_scenarios + scenarios=self.scenarios if ts.has_scenario_dim else None ) def __getitem__(self, name: str) -> TimeSeries: @@ -1245,7 +1042,7 @@ def __iter__(self) -> Iterator[TimeSeries]: """Iterate over TimeSeries objects.""" return iter(self._time_series.values()) - def update_time_series(self, name: str, data: NumericData) -> TimeSeries: + def update_time_series(self, name: str, data: TimestepData) -> TimeSeries: """ Update an existing TimeSeries with new data. @@ -1265,11 +1062,17 @@ def update_time_series(self, name: str, data: NumericData) -> TimeSeries: # Get the TimeSeries ts = self._time_series[name] + # Determine which timesteps to use if the series has a time dimension + if ts.has_time_dim: + target_timesteps = self.timesteps_extra if name in self._has_extra_timestep else self.timesteps + else: + target_timesteps = None + # Convert data to proper format data_array = DataConverter.as_dataarray( data, - self.timesteps_extra if name in self._has_extra_timestep else self.timesteps, - self.scenarios + timesteps=target_timesteps, + scenarios=self.scenarios if ts.has_scenario_dim else None ) # Update the TimeSeries diff --git a/flixopt/effects.py b/flixopt/effects.py index 9b5ea41d6..e834e339e 100644 --- a/flixopt/effects.py +++ b/flixopt/effects.py @@ -13,7 +13,7 @@ import numpy as np import pandas as pd -from .core import NumericData, NumericDataTS, Scalar, TimeSeries, TimeSeriesCollection +from .core import TimestepData, NumericDataTS, Scalar, TimeSeries, TimeSeriesCollection, ScenarioData, TimestepData from .features import ShareAllocationModel from .structure import Element, ElementModel, Interface, Model, SystemModel, register_class_for_io @@ -38,16 +38,16 @@ def __init__( meta_data: Optional[Dict] = None, is_standard: bool = False, is_objective: bool = False, - specific_share_to_other_effects_operation: Optional['EffectValuesUser'] = None, - specific_share_to_other_effects_invest: Optional['EffectValuesUser'] = None, - minimum_operation: Optional[Scalar] = None, - maximum_operation: Optional[Scalar] = None, - minimum_invest: Optional[Scalar] = None, - maximum_invest: Optional[Scalar] = None, + specific_share_to_other_effects_operation: Optional['EffectValuesUserTimestep'] = None, + specific_share_to_other_effects_invest: Optional['EffectValuesUserScenario'] = None, + minimum_operation: Optional[ScenarioData] = None, + maximum_operation: Optional[ScenarioData] = None, + minimum_invest: Optional[ScenarioData] = None, + maximum_invest: Optional[ScenarioData] = None, minimum_operation_per_hour: Optional[NumericDataTS] = None, maximum_operation_per_hour: Optional[NumericDataTS] = None, - minimum_total: Optional[Scalar] = None, - maximum_total: Optional[Scalar] = None, + minimum_total: Optional[ScenarioData] = None, + maximum_total: Optional[ScenarioData] = None, ): """ Args: @@ -76,10 +76,10 @@ def __init__( self.description = description self.is_standard = is_standard self.is_objective = is_objective - self.specific_share_to_other_effects_operation: EffectValuesUser = ( + self.specific_share_to_other_effects_operation: EffectValuesUserTimestep = ( specific_share_to_other_effects_operation or {} ) - self.specific_share_to_other_effects_invest: EffectValuesUser = specific_share_to_other_effects_invest or {} + self.specific_share_to_other_effects_invest: EffectValuesUserTimestep = specific_share_to_other_effects_invest or {} self.minimum_operation = minimum_operation self.maximum_operation = maximum_operation self.minimum_operation_per_hour: NumericDataTS = minimum_operation_per_hour @@ -171,11 +171,12 @@ def do_modeling(self): EffectValuesExpr = Dict[str, linopy.LinearExpression] # Used to create Shares EffectTimeSeries = Dict[str, TimeSeries] # Used internally to index values EffectValuesDict = Dict[str, NumericDataTS] # How effect values are stored -EffectValuesUser = Union[NumericDataTS, Dict[str, NumericDataTS]] # User-specified Shares to Effects -""" This datatype is used to define the share to an effect by a certain attribute. """ -EffectValuesUserScalar = Union[Scalar, Dict[str, Scalar]] # User-specified Shares to Effects -""" This datatype is used to define the share to an effect by a certain attribute. Only scalars are allowed. """ +EffectValuesUserScenario = Union[ScenarioData, Dict[str, ScenarioData]] +""" This datatype is used to define the share to an effect for every scenario. """ + +EffectValuesUserTimestep = Union[TimestepData, Dict[str, TimestepData]] +""" This datatype is used to define the share to an effect for every timestep. """ class EffectCollection: @@ -207,7 +208,10 @@ def add_effects(self, *effects: Effect) -> None: self._effects[effect.label] = effect logger.info(f'Registered new Effect: {effect.label}') - def create_effect_values_dict(self, effect_values_user: EffectValuesUser) -> Optional[EffectValuesDict]: + def create_effect_values_dict( + self, + effect_values_user: Union[EffectValuesUserScenario, EffectValuesUserTimestep] + ) -> Optional[EffectValuesDict]: """ Converts effect values into a dictionary. If a scalar is provided, it is associated with a default effect type. diff --git a/flixopt/elements.py b/flixopt/elements.py index 95536b910..b6de8c7c2 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -10,8 +10,8 @@ import numpy as np from .config import CONFIG -from .core import NumericData, NumericDataTS, PlausibilityError, Scalar, TimeSeriesCollection -from .effects import EffectValuesUser +from .core import TimestepData, NumericDataTS, PlausibilityError, Scalar, ScenarioData +from .effects import EffectValuesUserTimestep from .features import InvestmentModel, OnOffModel, PreventSimultaneousUsageModel from .interface import InvestParameters, OnOffParameters from .structure import Element, ElementModel, SystemModel, register_class_for_io @@ -148,16 +148,16 @@ def __init__( label: str, bus: str, size: Union[Scalar, InvestParameters] = None, - fixed_relative_profile: Optional[NumericDataTS] = None, - relative_minimum: NumericDataTS = 0, - relative_maximum: NumericDataTS = 1, - effects_per_flow_hour: Optional[EffectValuesUser] = None, + fixed_relative_profile: Optional[TimestepData] = None, + relative_minimum: TimestepData = 0, + relative_maximum: TimestepData = 1, + effects_per_flow_hour: Optional[EffectValuesUserTimestep] = None, on_off_parameters: Optional[OnOffParameters] = None, - flow_hours_total_max: Optional[Scalar] = None, - flow_hours_total_min: Optional[Scalar] = None, - load_factor_min: Optional[Scalar] = None, - load_factor_max: Optional[Scalar] = None, - previous_flow_rate: Optional[NumericData] = None, + flow_hours_total_max: Optional[ScenarioData] = None, + flow_hours_total_min: Optional[ScenarioData] = None, + load_factor_min: Optional[ScenarioData] = None, + load_factor_max: Optional[ScenarioData] = None, + previous_flow_rate: Optional[ScenarioData] = None, meta_data: Optional[Dict] = None, ): r""" @@ -240,10 +240,23 @@ def transform_data(self, flow_system: 'FlowSystem'): self.effects_per_flow_hour = flow_system.create_effect_time_series( self.label_full, self.effects_per_flow_hour, 'per_flow_hour' ) + self.flow_hours_total_max = flow_system.create_time_series( + f'{self.label_full}|flow_hours_total_max', self.flow_hours_total_max, has_time_dim=False + ) + self.flow_hours_total_min = flow_system.create_time_series( + f'{self.label_full}|flow_hours_total_min', self.flow_hours_total_min, has_time_dim=False + ) + self.load_factor_max = flow_system.create_time_series( + f'{self.label_full}|load_factor_max', self.load_factor_max, has_time_dim=False + ) + self.load_factor_min = flow_system.create_time_series( + f'{self.label_full}|load_factor_min', self.load_factor_min, has_time_dim=False + ) + if self.on_off_parameters is not None: self.on_off_parameters.transform_data(flow_system, self.label_full) if isinstance(self.size, InvestParameters): - self.size.transform_data(flow_system) + self.size.transform_data(flow_system, self.label_full) def infos(self, use_numpy: bool = True, use_element_label: bool = False) -> Dict: infos = super().infos(use_numpy, use_element_label) @@ -308,7 +321,7 @@ def do_modeling(self): self._model.add_variables( lower=self.absolute_flow_rate_bounds[0] if self.element.on_off_parameters is None else 0, upper=self.absolute_flow_rate_bounds[1], - coords=self._model.coords, + coords=self._model.get_coords(), name=f'{self.label_full}|flow_rate', ), 'flow_rate', @@ -414,7 +427,7 @@ def _create_bounds_for_load_factor(self): ) @property - def absolute_flow_rate_bounds(self) -> Tuple[NumericData, NumericData]: + def absolute_flow_rate_bounds(self) -> Tuple[TimestepData, TimestepData]: """Returns absolute flow rate bounds. Important for OnOffModel""" relative_minimum, relative_maximum = self.relative_flow_rate_bounds size = self.element.size @@ -425,7 +438,7 @@ def absolute_flow_rate_bounds(self) -> Tuple[NumericData, NumericData]: return relative_minimum * size.minimum_size, relative_maximum * size.maximum_size @property - def relative_flow_rate_bounds(self) -> Tuple[NumericData, NumericData]: + def relative_flow_rate_bounds(self) -> Tuple[TimestepData, TimestepData]: """Returns relative flow rate bounds.""" fixed_profile = self.element.fixed_relative_profile if fixed_profile is None: @@ -454,11 +467,11 @@ def do_modeling(self) -> None: self._model.hours_per_step, self.element.excess_penalty_per_flow_hour.selected_data ) self.excess_input = self.add( - self._model.add_variables(lower=0, coords=self._model.coords, name=f'{self.label_full}|excess_input'), + self._model.add_variables(lower=0, coords=self._model.get_coords(), name=f'{self.label_full}|excess_input'), 'excess_input', ) self.excess_output = self.add( - self._model.add_variables(lower=0, coords=self._model.coords, name=f'{self.label_full}|excess_output'), + self._model.add_variables(lower=0, coords=self._model.get_coords(), name=f'{self.label_full}|excess_output'), 'excess_output', ) eq_bus_balance.lhs -= -self.excess_input + self.excess_output diff --git a/flixopt/features.py b/flixopt/features.py index 32c382486..7b92396fd 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -11,7 +11,7 @@ from . import utils from .config import CONFIG -from .core import NumericData, Scalar, TimeSeries +from .core import TimestepData, Scalar, TimeSeries from .interface import InvestParameters, OnOffParameters, Piece, Piecewise, PiecewiseConversion, PiecewiseEffects from .structure import Model, SystemModel @@ -27,7 +27,7 @@ def __init__( label_of_element: str, parameters: InvestParameters, defining_variable: [linopy.Variable], - relative_bounds_of_defining_variable: Tuple[NumericData, NumericData], + relative_bounds_of_defining_variable: Tuple[TimestepData, TimestepData], label: Optional[str] = None, on_variable: Optional[linopy.Variable] = None, ): @@ -205,8 +205,8 @@ def __init__( on_off_parameters: OnOffParameters, label_of_element: str, defining_variables: List[linopy.Variable], - defining_bounds: List[Tuple[NumericData, NumericData]], - previous_values: List[Optional[NumericData]], + defining_bounds: List[Tuple[TimestepData, TimestepData]], + previous_values: List[Optional[TimestepData]], label: Optional[str] = None, ): """ @@ -246,7 +246,7 @@ def do_modeling(self): self._model.add_variables( name=f'{self.label_full}|on', binary=True, - coords=self._model.coords, + coords=self._model.get_coords(), ), 'on', ) @@ -275,7 +275,7 @@ def do_modeling(self): self._model.add_variables( name=f'{self.label_full}|off', binary=True, - coords=self._model.coords, + coords=self._model.get_coords(), ), 'off', ) @@ -303,12 +303,12 @@ def do_modeling(self): if self.parameters.use_switch_on: self.switch_on = self.add( - self._model.add_variables(binary=True, name=f'{self.label_full}|switch_on', coords=self._model.coords), + self._model.add_variables(binary=True, name=f'{self.label_full}|switch_on', coords=self._model.get_coords()), 'switch_on', ) self.switch_off = self.add( - self._model.add_variables(binary=True, name=f'{self.label_full}|switch_off', coords=self._model.coords), + self._model.add_variables(binary=True, name=f'{self.label_full}|switch_off', coords=self._model.get_coords()), 'switch_off', ) @@ -451,7 +451,7 @@ def _get_duration_in_hours( self._model.add_variables( lower=0, upper=maximum_duration.selected_data if maximum_duration is not None else mega, - coords=self._model.coords, + coords=self._model.get_coords(), name=f'{self.label_full}|{variable_name}', ), variable_name, @@ -623,7 +623,7 @@ def previous_consecutive_off_hours(self) -> Scalar: return self.compute_consecutive_duration(self.previous_off_values, self._model.hours_per_step) @staticmethod - def compute_previous_on_states(previous_values: List[Optional[NumericData]], epsilon: float = 1e-5) -> np.ndarray: + def compute_previous_on_states(previous_values: List[Optional[TimestepData]], epsilon: float = 1e-5) -> np.ndarray: """ Computes the previous 'on' states {0, 1} of defining variables as a binary array from their previous values. @@ -647,7 +647,7 @@ def compute_previous_on_states(previous_values: List[Optional[NumericData]], eps @staticmethod def compute_consecutive_duration( - binary_values: NumericData, hours_per_timestep: Union[int, float, np.ndarray] + binary_values: TimestepData, hours_per_timestep: Union[int, float, np.ndarray] ) -> Scalar: """ Computes the final consecutive duration in State 'on' (=1) in hours, from a binary. @@ -716,7 +716,7 @@ def do_modeling(self): self._model.add_variables( binary=True, name=f'{self.label_full}|inside_piece', - coords=self._model.coords if self._as_time_series else None, + coords=self._model.get_coords(time_dim=self._as_time_series), ), 'inside_piece', ) @@ -726,7 +726,7 @@ def do_modeling(self): lower=0, upper=1, name=f'{self.label_full}|lambda0', - coords=self._model.coords if self._as_time_series else None, + coords=self._model.get_coords(time_dim=self._as_time_series), ), 'lambda0', ) @@ -736,7 +736,7 @@ def do_modeling(self): lower=0, upper=1, name=f'{self.label_full}|lambda1', - coords=self._model.coords if self._as_time_series else None, + coords=self._model.get_coords(time_dim=self._as_time_series), ), 'lambda1', ) @@ -820,7 +820,7 @@ def do_modeling(self): elif self._zero_point is True: self.zero_point = self.add( self._model.add_variables( - coords=self._model.coords, binary=True, name=f'{self.label_full}|zero_point' + coords=self._model.get_coords(), binary=True, name=f'{self.label_full}|zero_point' ), 'zero_point', ) @@ -847,8 +847,8 @@ def __init__( label_full: Optional[str] = None, total_max: Optional[Scalar] = None, total_min: Optional[Scalar] = None, - max_per_hour: Optional[NumericData] = None, - min_per_hour: Optional[NumericData] = None, + max_per_hour: Optional[TimestepData] = None, + min_per_hour: Optional[TimestepData] = None, ): super().__init__(model, label_of_element=label_of_element, label=label, label_full=label_full) if not shares_are_time_series: # If the condition is True @@ -891,7 +891,7 @@ def do_modeling(self): upper=np.inf if (self._max_per_hour is None) else np.multiply(self._max_per_hour, self._model.hours_per_step), - coords=self._model.coords, + coords=self._model.get_coords(), name=f'{self.label_full}|total_per_timestep', ), 'total_per_timestep', @@ -929,7 +929,7 @@ def add_share( if isinstance(expression, linopy.LinearExpression) and expression.ndim == 0 or not isinstance(expression, linopy.LinearExpression) - else self._model.coords, + else self._model.get_coords(), #TODO: Add logic on what coords to use name=f'{name}->{self.label_full}', ), name, diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index e39d71e94..a4705371c 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -16,10 +16,10 @@ from rich.pretty import Pretty from . import io as fx_io -from .core import NumericData, NumericDataTS, TimeSeries, TimeSeriesCollection, TimeSeriesData -from .effects import Effect, EffectCollection, EffectTimeSeries, EffectValuesDict, EffectValuesUser +from .core import TimestepData, TimeSeries, TimeSeriesCollection, TimeSeriesData, Scalar +from .effects import Effect, EffectCollection, EffectTimeSeries, EffectValuesDict, EffectValuesUserScenario, EffectValuesUserTimestep from .elements import Bus, Component, Flow -from .structure import CLASS_REGISTRY, Element, SystemModel, get_compact_representation, get_str_representation +from .structure import CLASS_REGISTRY, Element, SystemModel if TYPE_CHECKING: import pyvis @@ -277,44 +277,71 @@ def transform_data(self): def create_time_series( self, name: str, - data: Optional[Union[NumericData, TimeSeriesData, TimeSeries]], + data: Optional[Union[TimestepData, TimeSeriesData, TimeSeries]], + has_time_dim: bool = True, + has_scenario_dim: bool = True, has_extra_timestep: bool = False, - ) -> Optional[TimeSeries]: + ) -> Optional[Union[Scalar, TimeSeries]]: """ Tries to create a TimeSeries from NumericData Data and adds it to the time_series_collection If the data already is a TimeSeries, nothing happens and the TimeSeries gets reset and returned If the data is a TimeSeriesData, it is converted to a TimeSeries, and the aggregation weights are applied. If the data is None, nothing happens. + + Args: + name: The name of the TimeSeries + data: The data to create a TimeSeries from + has_time_dim: Whether the data has a time dimension + has_scenario_dim: Whether the data has a scenario dimension + has_extra_timestep: Whether the data has an extra timestep """ + if not has_time_dim and not has_scenario_dim: + raise ValueError("At least one of the dimensions must be present") if data is None: return None - elif isinstance(data, TimeSeries): + + if not has_time_dim and self.time_series_collection.scenarios is None: + return data + + if isinstance(data, TimeSeries): data.restore_data() if data in self.time_series_collection: return data return self.time_series_collection.add_time_series( - data=data.selected_data, name=name, has_extra_timestep=has_extra_timestep + data=data.selected_data, + name=name, + has_time_dim=has_time_dim, + has_scenario_dim=has_scenario_dim, + has_extra_timestep=has_extra_timestep, ) elif isinstance(data, TimeSeriesData): data.label = name return self.time_series_collection.add_time_series( data=data.data, name=name, + has_time_dim=has_time_dim, + has_scenario_dim=has_scenario_dim, has_extra_timestep=has_extra_timestep, aggregation_weight=data.agg_weight, aggregation_group=data.agg_group ) return self.time_series_collection.add_time_series( - data=data, name=name, has_extra_timestep=has_extra_timestep + data=data, + name=name, + has_time_dim=has_time_dim, + has_scenario_dim=has_scenario_dim, + has_extra_timestep=has_extra_timestep, ) def create_effect_time_series( self, label_prefix: Optional[str], - effect_values: EffectValuesUser, + effect_values: Union[EffectValuesUserScenario, EffectValuesUserTimestep], label_suffix: Optional[str] = None, - ) -> Optional[EffectTimeSeries]: + has_time_dim: bool = True, + has_scenario_dim: bool = True, + ) -> Optional[Union[EffectTimeSeries, EffectValuesDict]]: """ Transform EffectValues to EffectTimeSeries. Creates a TimeSeries for each key in the nested_values dictionary, using the value as the data. @@ -322,13 +349,31 @@ def create_effect_time_series( The resulting label of the TimeSeries is the label of the parent_element, followed by the label of the Effect in the nested_values and the label_suffix. If the key in the EffectValues is None, the alias 'Standard_Effect' is used + + Args: + label_prefix: Prefix for the TimeSeries name + effect_values: Dictionary of EffectValues + label_suffix: Suffix for the TimeSeries name + has_time_dim: Whether the data has a time dimension + has_scenario_dim: Whether the data has a scenario dimension """ + if not has_time_dim and not has_scenario_dim: + raise ValueError("At least one of the dimensions must be present") + effect_values: Optional[EffectValuesDict] = self.effects.create_effect_values_dict(effect_values) if effect_values is None: return None + if not has_time_dim and self.time_series_collection.scenarios is None: + return effect_values + return { - effect: self.create_time_series('|'.join(filter(None, [label_prefix, effect, label_suffix])), value) + effect: self.create_time_series( + name='|'.join(filter(None, [label_prefix, effect, label_suffix])), + data=value, + has_time_dim=has_time_dim, + has_scenario_dim=has_scenario_dim, + ) for effect, value in effect_values.items() } diff --git a/flixopt/interface.py b/flixopt/interface.py index f9dbeb518..f57362ee3 100644 --- a/flixopt/interface.py +++ b/flixopt/interface.py @@ -7,11 +7,11 @@ from typing import TYPE_CHECKING, Dict, Iterator, List, Optional, Union from .config import CONFIG -from .core import NumericData, NumericDataTS, Scalar +from .core import TimestepData, NumericDataTS, Scalar, ScenarioData from .structure import Interface, register_class_for_io if TYPE_CHECKING: # for type checking and preventing circular imports - from .effects import EffectValuesUser, EffectValuesUserScalar + from .effects import EffectValuesUserScenario, EffectValuesUserTimestep from .flow_system import FlowSystem @@ -20,7 +20,7 @@ @register_class_for_io class Piece(Interface): - def __init__(self, start: NumericData, end: NumericData): + def __init__(self, start: TimestepData, end: TimestepData): """ Define a Piece, which is part of a Piecewise object. @@ -30,10 +30,21 @@ def __init__(self, start: NumericData, end: NumericData): """ self.start = start self.end = end + self.has_time_dim = False def transform_data(self, flow_system: 'FlowSystem', name_prefix: str): - self.start = flow_system.create_time_series(f'{name_prefix}|start', self.start) - self.end = flow_system.create_time_series(f'{name_prefix}|end', self.end) + self.start = flow_system.create_time_series( + name=f'{name_prefix}|start', + data=self.start, + has_time_dim=self.has_time_dim, + has_scenario_dim=True + ) + self.end = flow_system.create_time_series( + name=f'{name_prefix}|end', + data=self.end, + has_time_dim=self.has_time_dim, + has_scenario_dim=True + ) @register_class_for_io @@ -46,6 +57,17 @@ def __init__(self, pieces: List[Piece]): pieces: The pieces of the piecewise. """ self.pieces = pieces + self._has_time_dim = False + + @property + def has_time_dim(self): + return self._has_time_dim + + @has_time_dim.setter + def has_time_dim(self, value): + self._has_time_dim = value + for piece in self.pieces: + piece.has_time_dim = value def __len__(self): return len(self.pieces) @@ -73,6 +95,18 @@ def __init__(self, piecewises: Dict[str, Piecewise]): piecewises: Dict of Piecewises defining the conversion factors. flow labels as keys, piecewise as values """ self.piecewises = piecewises + self._has_time_dim = True + self.has_time_dim = True # Inital propagation + + @property + def has_time_dim(self): + return self._has_time_dim + + @has_time_dim.setter + def has_time_dim(self, value): + self._has_time_dim = value + for piecewise in self.piecewises.values(): + piecewise.has_time_dim = value def items(self): return self.piecewises.items() @@ -94,12 +128,24 @@ def __init__(self, piecewise_origin: Piecewise, piecewise_shares: Dict[str, Piec """ self.piecewise_origin = piecewise_origin self.piecewise_shares = piecewise_shares + self._has_time_dim = False + self.has_time_dim = False # Inital propagation + + @property + def has_time_dim(self): + return self._has_time_dim + + @has_time_dim.setter + def has_time_dim(self, value): + self._has_time_dim = value + self.piecewise_origin.has_time_dim = value + for piecewise in self.piecewise_shares.values(): + piecewise.has_time_dim = value def transform_data(self, flow_system: 'FlowSystem', name_prefix: str): - raise NotImplementedError('PiecewiseEffects is not yet implemented for non scalar shares') - # self.piecewise_origin.transform_data(flow_system, f'{name_prefix}|PiecewiseEffects|origin') - # for name, piecewise in self.piecewise_shares.items(): - # piecewise.transform_data(flow_system, f'{name_prefix}|PiecewiseEffects|{name}') + self.piecewise_origin.transform_data(flow_system, f'{name_prefix}|PiecewiseEffects|origin') + for effect, piecewise in self.piecewise_shares.items(): + piecewise.transform_data(flow_system, f'{name_prefix}|PiecewiseEffects|{effect}') @register_class_for_io @@ -110,14 +156,14 @@ class InvestParameters(Interface): def __init__( self, - fixed_size: Optional[Union[int, float]] = None, - minimum_size: Union[int, float] = 0, # TODO: Use EPSILON? - maximum_size: Optional[Union[int, float]] = None, + fixed_size: Optional[Scalar] = None, + minimum_size: Scalar = 0, # TODO: Use EPSILON? + maximum_size: Optional[Scalar] = None, optional: bool = True, # Investition ist weglassbar - fix_effects: Optional['EffectValuesUserScalar'] = None, - specific_effects: Optional['EffectValuesUserScalar'] = None, # costs per Flow-Unit/Storage-Size/... + fix_effects: Optional['EffectValuesUserScenario'] = None, + specific_effects: Optional['EffectValuesUserScenario'] = None, # costs per Flow-Unit/Storage-Size/... piecewise_effects: Optional[PiecewiseEffects] = None, - divest_effects: Optional['EffectValuesUserScalar'] = None, + divest_effects: Optional['EffectValuesUserScenario'] = None, ): """ Args: @@ -144,19 +190,40 @@ def __init__( minimum_size: Min nominal value (only if: size_is_fixed = False). maximum_size: Max nominal value (only if: size_is_fixed = False). """ - self.fix_effects: EffectValuesUser = fix_effects or {} - self.divest_effects: EffectValuesUser = divest_effects or {} + self.fix_effects: EffectValuesUserScenario = fix_effects or {} + self.divest_effects: EffectValuesUserScenario = divest_effects or {} self.fixed_size = fixed_size self.optional = optional - self.specific_effects: EffectValuesUser = specific_effects or {} + self.specific_effects: EffectValuesUserScenario = specific_effects or {} self.piecewise_effects = piecewise_effects self._minimum_size = minimum_size self._maximum_size = maximum_size or CONFIG.modeling.BIG # default maximum - def transform_data(self, flow_system: 'FlowSystem'): - self.fix_effects = flow_system.effects.create_effect_values_dict(self.fix_effects) - self.divest_effects = flow_system.effects.create_effect_values_dict(self.divest_effects) - self.specific_effects = flow_system.effects.create_effect_values_dict(self.specific_effects) + def transform_data(self, flow_system: 'FlowSystem', name_prefix: str): + self.fix_effects = flow_system.create_effect_time_series( + label_prefix=name_prefix, + effect_values=self.fix_effects, + label_suffix='fix_effects', + has_time_dim=False, + has_scenario_dim=True, + ) + self.divest_effects = flow_system.create_effect_time_series( + label_prefix=name_prefix, + effect_values=self.divest_effects, + label_suffix='divest_effects', + has_time_dim=False, + has_scenario_dim=True, + ) + self.specific_effects = flow_system.create_effect_time_series( + label_prefix=name_prefix, + effect_values=self.specific_effects, + label_suffix='specific_effects', + has_time_dim=False, + has_scenario_dim=True, + ) + if self.piecewise_effects is not None: + self.piecewise_effects.has_time_dim=False + self.piecewise_effects.transform_data(flow_system, f'{name_prefix}|PiecewiseEffects') @property def minimum_size(self): @@ -171,15 +238,15 @@ def maximum_size(self): class OnOffParameters(Interface): def __init__( self, - effects_per_switch_on: Optional['EffectValuesUser'] = None, - effects_per_running_hour: Optional['EffectValuesUser'] = None, - on_hours_total_min: Optional[int] = None, - on_hours_total_max: Optional[int] = None, - consecutive_on_hours_min: Optional[NumericData] = None, - consecutive_on_hours_max: Optional[NumericData] = None, - consecutive_off_hours_min: Optional[NumericData] = None, - consecutive_off_hours_max: Optional[NumericData] = None, - switch_on_total_max: Optional[int] = None, + effects_per_switch_on: Optional['EffectValuesUserTimestep'] = None, + effects_per_running_hour: Optional['EffectValuesUserTimestep'] = None, + on_hours_total_min: Optional[ScenarioData] = None, + on_hours_total_max: Optional[ScenarioData] = None, + consecutive_on_hours_min: Optional[TimestepData] = None, + consecutive_on_hours_max: Optional[TimestepData] = None, + consecutive_off_hours_min: Optional[TimestepData] = None, + consecutive_off_hours_max: Optional[TimestepData] = None, + switch_on_total_max: Optional[ScenarioData] = None, force_switch_on: bool = False, ): """ @@ -202,8 +269,8 @@ def __init__( switch_on_total_max: max nr of switchOn operations force_switch_on: force creation of switch on variable, even if there is no switch_on_total_max """ - self.effects_per_switch_on: EffectValuesUser = effects_per_switch_on or {} - self.effects_per_running_hour: EffectValuesUser = effects_per_running_hour or {} + self.effects_per_switch_on: EffectValuesUserTimestep = effects_per_switch_on or {} + self.effects_per_running_hour: EffectValuesUserTimestep = effects_per_running_hour or {} self.on_hours_total_min: Scalar = on_hours_total_min self.on_hours_total_max: Scalar = on_hours_total_max self.consecutive_on_hours_min: NumericDataTS = consecutive_on_hours_min @@ -232,6 +299,15 @@ def transform_data(self, flow_system: 'FlowSystem', name_prefix: str): self.consecutive_off_hours_max = flow_system.create_time_series( f'{name_prefix}|consecutive_off_hours_max', self.consecutive_off_hours_max ) + self.on_hours_total_max = flow_system.create_time_series( + f'{name_prefix}|on_hours_total_max', self.on_hours_total_max, has_time_dim=False + ) + self.on_hours_total_min = flow_system.create_time_series( + f'{name_prefix}|on_hours_total_min', self.on_hours_total_min, has_time_dim=False + ) + self.switch_on_total_max = flow_system.create_time_series( + f'{name_prefix}|switch_on_total_max', self.switch_on_total_max, has_time_dim=False + ) @property def use_off(self) -> bool: diff --git a/flixopt/io.py b/flixopt/io.py index 5cc353836..adaf52f55 100644 --- a/flixopt/io.py +++ b/flixopt/io.py @@ -23,7 +23,7 @@ def replace_timeseries(obj, mode: Literal['name', 'stats', 'data'] = 'name'): return [replace_timeseries(v, mode) for v in obj] elif isinstance(obj, TimeSeries): # Adjust this based on the actual class if obj.all_equal: - return obj.selected_data.values[0].item() + return obj.selected_data.values.max().item() elif mode == 'name': return f'::::{obj.name}' elif mode == 'stats': diff --git a/flixopt/structure.py b/flixopt/structure.py index 2e136c652..7306c97d5 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -19,7 +19,7 @@ from rich.pretty import Pretty from .config import CONFIG -from .core import NumericData, Scalar, TimeSeries, TimeSeriesCollection, TimeSeriesData +from .core import TimestepData, Scalar, TimeSeries, TimeSeriesCollection, TimeSeriesData if TYPE_CHECKING: # for type checking and preventing circular imports from .effects import EffectCollectionModel @@ -98,13 +98,32 @@ def hours_per_step(self): def hours_of_previous_timesteps(self): return self.time_series_collection.hours_of_previous_timesteps - @property - def coords(self) -> Tuple[pd.DatetimeIndex]: - return (self.time_series_collection.timesteps,) + def get_coords( + self, + scenario_dim = True, + time_dim = True, + extra_timestep = False + ) -> Optional[Union[Tuple[pd.Index], Tuple[pd.Index, pd.Index]]]: + """ + Returns the coordinates of the model - @property - def coords_extra(self) -> Tuple[pd.DatetimeIndex]: - return (self.time_series_collection.timesteps_extra,) + Args: + scenario_dim: If True, the scenario dimension is included in the coordinates + time_dim: If True, the time dimension is included in the coordinates + extra_timestep: If True, the extra timesteps are used instead of the regular timesteps + + Returns: + The coordinates of the model. Might also be None if no scenarios are present and time_dim is False + """ + scenarios = self.time_series_collection.scenarios + timesteps = self.time_series_collection.timesteps if not extra_timestep else self.time_series_collection.timesteps_extra + if scenarios is None: + if time_dim: + return (timesteps,) + return None + if scenario_dim and not time_dim: + return (scenarios,) + return scenarios, timesteps class Interface: diff --git a/flixopt/utils.py b/flixopt/utils.py index bb6e8ec40..6b5d88693 100644 --- a/flixopt/utils.py +++ b/flixopt/utils.py @@ -11,14 +11,6 @@ logger = logging.getLogger('flixopt') -def is_number(number_alias: Union[int, float, str]): - """Returns True is string is a number.""" - try: - float(number_alias) - return True - except ValueError: - return False - def round_floats(obj, decimals=2): if isinstance(obj, dict): diff --git a/site/release-notes/_template.txt b/site/release-notes/_template.txt deleted file mode 100644 index fe85a0554..000000000 --- a/site/release-notes/_template.txt +++ /dev/null @@ -1,32 +0,0 @@ -# Release v{version} - -**Release Date:** YYYY-MM-DD - -## What's New - -* Feature 1 - Description -* Feature 2 - Description - -## Improvements - -* Improvement 1 - Description -* Improvement 2 - Description - -## Bug Fixes - -* Fixed issue with X -* Resolved problem with Y - -## Breaking Changes - -* Change 1 - Migration instructions -* Change 2 - Migration instructions - -## Deprecations - -* Feature X will be removed in v{next_version} - -## Dependencies - -* Added dependency X v1.2.3 -* Updated dependency Y to v2.0.0 \ No newline at end of file diff --git a/tests/run_all_tests.py b/tests/run_all_tests.py index 5597a47f3..83b6dfacf 100644 --- a/tests/run_all_tests.py +++ b/tests/run_all_tests.py @@ -7,4 +7,4 @@ import pytest if __name__ == '__main__': - pytest.main(['test_functional.py', '--disable-warnings']) + pytest.main(['test_integration.py', '--disable-warnings']) diff --git a/tests/test_dataconverter.py b/tests/test_dataconverter.py index 579de9c00..0466f3a2e 100644 --- a/tests/test_dataconverter.py +++ b/tests/test_dataconverter.py @@ -48,48 +48,6 @@ def test_scalar_conversion(self, sample_time_index): result = DataConverter.as_dataarray(np.float32(42.5), sample_time_index) assert np.all(result.values == 42.5) - def test_series_conversion(self, sample_time_index): - """Test converting a pandas Series.""" - # Test with integer values - series = pd.Series([1, 2, 3, 4, 5], index=sample_time_index) - result = DataConverter.as_dataarray(series, sample_time_index) - assert isinstance(result, xr.DataArray) - assert result.shape == (5,) - assert result.dims == ('time',) - assert np.array_equal(result.values, series.values) - - # Test with float values - series = pd.Series([1.1, 2.2, 3.3, 4.4, 5.5], index=sample_time_index) - result = DataConverter.as_dataarray(series, sample_time_index) - assert np.array_equal(result.values, series.values) - - # Test with mixed NA values - series = pd.Series([1, np.nan, 3, None, 5], index=sample_time_index) - result = DataConverter.as_dataarray(series, sample_time_index) - assert np.array_equal(np.isnan(result.values), np.isnan(series.values)) - assert np.array_equal(result.values[~np.isnan(result.values)], series.values[~np.isnan(series.values)]) - - def test_dataframe_conversion(self, sample_time_index): - """Test converting a pandas DataFrame.""" - # Test with a single-column DataFrame - df = pd.DataFrame({'A': [1, 2, 3, 4, 5]}, index=sample_time_index) - result = DataConverter.as_dataarray(df, sample_time_index) - assert isinstance(result, xr.DataArray) - assert result.shape == (5,) - assert result.dims == ('time',) - assert np.array_equal(result.values.flatten(), df['A'].values) - - # Test with float values - df = pd.DataFrame({'A': [1.1, 2.2, 3.3, 4.4, 5.5]}, index=sample_time_index) - result = DataConverter.as_dataarray(df, sample_time_index) - assert np.array_equal(result.values.flatten(), df['A'].values) - - # Test with NA values - df = pd.DataFrame({'A': [1, np.nan, 3, None, 5]}, index=sample_time_index) - result = DataConverter.as_dataarray(df, sample_time_index) - assert np.array_equal(np.isnan(result.values), np.isnan(df['A'].values)) - assert np.array_equal(result.values[~np.isnan(result.values)], df['A'].values[~np.isnan(df['A'].values)]) - def test_ndarray_conversion(self, sample_time_index): """Test converting a numpy ndarray.""" # Test with integer 1D array @@ -153,158 +111,6 @@ def test_scalar_with_scenarios(self, sample_time_index, sample_scenario_index): result = DataConverter.as_dataarray(42.5, sample_time_index, sample_scenario_index) assert np.all(result.values == 42.5) - def test_series_with_scenarios(self, sample_time_index, sample_scenario_index): - """Test converting Series with scenario dimension.""" - # Create time series data - series = pd.Series([1, 2, 3, 4, 5], index=sample_time_index) - - # Convert with scenario dimension - result = DataConverter.as_dataarray(series, sample_time_index, sample_scenario_index) - - assert result.shape == (len(sample_scenario_index), len(sample_time_index)) - assert result.dims == ('scenario', 'time') - - # Values should be broadcast to all scenarios - for scenario in sample_scenario_index: - scenario_slice = result.sel(scenario=scenario) - assert np.array_equal(scenario_slice.values, series.values) - - # Test with series containing NaN - series = pd.Series([1, np.nan, 3, np.nan, 5], index=sample_time_index) - result = DataConverter.as_dataarray(series, sample_time_index, sample_scenario_index) - - # Each scenario should have the same pattern of NaNs - for scenario in sample_scenario_index: - scenario_slice = result.sel(scenario=scenario) - assert np.array_equal(np.isnan(scenario_slice.values), np.isnan(series.values)) - assert np.array_equal( - scenario_slice.values[~np.isnan(scenario_slice.values)], series.values[~np.isnan(series.values)] - ) - - def test_multi_index_series(self, sample_time_index, sample_scenario_index, multi_index): - """Test converting a Series with MultiIndex (scenario, time).""" - # Create a MultiIndex Series with scenario-specific values - values = [ - # baseline scenario - 10, - 20, - 30, - 40, - 50, - # high_demand scenario - 15, - 25, - 35, - 45, - 55, - # low_price scenario - 5, - 15, - 25, - 35, - 45, - ] - series_multi = pd.Series(values, index=multi_index) - - # Convert the MultiIndex Series - result = DataConverter.as_dataarray(series_multi, sample_time_index, sample_scenario_index) - - assert result.shape == (len(sample_scenario_index), len(sample_time_index)) - assert result.dims == ('scenario', 'time') - - # Check values for each scenario - baseline_values = result.sel(scenario='baseline').values - assert np.array_equal(baseline_values, [10, 20, 30, 40, 50]) - - high_demand_values = result.sel(scenario='high_demand').values - assert np.array_equal(high_demand_values, [15, 25, 35, 45, 55]) - - low_price_values = result.sel(scenario='low_price').values - assert np.array_equal(low_price_values, [5, 15, 25, 35, 45]) - - # Test with some missing values in the MultiIndex - incomplete_index = multi_index[:-2] # Remove last two entries - incomplete_values = values[:-2] # Remove corresponding values - incomplete_series = pd.Series(incomplete_values, index=incomplete_index) - - result = DataConverter.as_dataarray(incomplete_series, sample_time_index, sample_scenario_index) - - # The last value of low_price scenario should be NaN - assert np.isnan(result.sel(scenario='low_price').values[-1]) - - def test_dataframe_with_scenarios(self, sample_time_index, sample_scenario_index): - """Test converting DataFrame with scenario dimension.""" - # Create a single-column DataFrame - df = pd.DataFrame({'A': [1, 2, 3, 4, 5]}, index=sample_time_index) - - # Convert with scenario dimension - result = DataConverter.as_dataarray(df, sample_time_index, sample_scenario_index) - - assert result.shape == (len(sample_scenario_index), len(sample_time_index)) - assert result.dims == ('scenario', 'time') - - # Values should be broadcast to all scenarios - for scenario in sample_scenario_index: - scenario_slice = result.sel(scenario=scenario) - assert np.array_equal(scenario_slice.values, df['A'].values) - - def test_multi_index_dataframe(self, sample_time_index, sample_scenario_index, multi_index): - """Test converting a DataFrame with MultiIndex (scenario, time).""" - # Create a MultiIndex DataFrame with scenario-specific values - values = [ - # baseline scenario - 10, - 20, - 30, - 40, - 50, - # high_demand scenario - 15, - 25, - 35, - 45, - 55, - # low_price scenario - 5, - 15, - 25, - 35, - 45, - ] - df_multi = pd.DataFrame({'A': values}, index=multi_index) - - # Convert the MultiIndex DataFrame - result = DataConverter.as_dataarray(df_multi, sample_time_index, sample_scenario_index) - - assert result.shape == (len(sample_scenario_index), len(sample_time_index)) - assert result.dims == ('scenario', 'time') - - # Check values for each scenario - baseline_values = result.sel(scenario='baseline').values - assert np.array_equal(baseline_values, [10, 20, 30, 40, 50]) - - high_demand_values = result.sel(scenario='high_demand').values - assert np.array_equal(high_demand_values, [15, 25, 35, 45, 55]) - - low_price_values = result.sel(scenario='low_price').values - assert np.array_equal(low_price_values, [5, 15, 25, 35, 45]) - - # Test with missing values - incomplete_index = multi_index[:-2] # Remove last two entries - incomplete_values = values[:-2] # Remove corresponding values - incomplete_df = pd.DataFrame({'A': incomplete_values}, index=incomplete_index) - - result = DataConverter.as_dataarray(incomplete_df, sample_time_index, sample_scenario_index) - - # The last value of low_price scenario should be NaN - assert np.isnan(result.sel(scenario='low_price').values[-1]) - - # Test with multiple columns (should raise error) - df_multi_col = pd.DataFrame({'A': values, 'B': [v * 2 for v in values]}, index=multi_index) - - with pytest.raises(ConversionError): - DataConverter.as_dataarray(df_multi_col, sample_time_index, sample_scenario_index) - def test_1d_array_with_scenarios(self, sample_time_index, sample_scenario_index): """Test converting 1D array with scenario dimension (broadcasting).""" # Create 1D array matching timesteps length @@ -391,12 +197,12 @@ def test_time_index_validation(self): # Test with empty index empty_index = pd.DatetimeIndex([], name='time') - with pytest.raises(ValueError): + with pytest.raises(ConversionError): DataConverter.as_dataarray(42, empty_index) # Test with non-DatetimeIndex wrong_type_index = pd.Index([1, 2, 3, 4, 5], name='time') - with pytest.raises(ValueError): + with pytest.raises(ConversionError): DataConverter.as_dataarray(42, wrong_type_index) def test_scenario_index_validation(self, sample_time_index): @@ -408,11 +214,11 @@ def test_scenario_index_validation(self, sample_time_index): # Test with empty scenario index empty_index = pd.Index([], name='scenario') - with pytest.raises(ValueError): + with pytest.raises(ConversionError): DataConverter.as_dataarray(42, sample_time_index, empty_index) # Test with non-Index scenario - with pytest.raises(ValueError): + with pytest.raises(ConversionError): DataConverter.as_dataarray(42, sample_time_index, ['baseline', 'high_demand']) def test_invalid_data_types(self, sample_time_index, sample_scenario_index): @@ -572,46 +378,9 @@ def test_all_nan_data(self, sample_time_index, sample_scenario_index): assert np.all(np.isnan(result.values)) # Series of all NaNs - all_nan_series = pd.Series([np.nan, np.nan, np.nan, np.nan, np.nan], index=sample_time_index) - result = DataConverter.as_dataarray(all_nan_series, sample_time_index, sample_scenario_index) + result = DataConverter.as_dataarray(np.array([np.nan, np.nan, np.nan, np.nan, np.nan]), sample_time_index, sample_scenario_index) assert np.all(np.isnan(result.values)) - def test_subset_index_multiindex(self, sample_time_index, sample_scenario_index): - """Test handling of MultiIndex Series/DataFrames with subset of expected indices.""" - # Create a subset of the expected indexes - subset_time = sample_time_index[1:4] # Middle subset - subset_scenarios = sample_scenario_index[0:2] # First two scenarios - - # Create MultiIndex with subset - subset_multi_index = pd.MultiIndex.from_product([subset_scenarios, subset_time], names=['scenario', 'time']) - - # Create Series with subset of data - values = [ - # baseline (3 values) - 20, - 30, - 40, - # high_demand (3 values) - 25, - 35, - 45, - ] - subset_series = pd.Series(values, index=subset_multi_index) - - # Convert and test - result = DataConverter.as_dataarray(subset_series, sample_time_index, sample_scenario_index) - - # Shape should be full size - assert result.shape == (len(sample_scenario_index), len(sample_time_index)) - - # Check values - present values should match - assert result.sel(scenario='baseline', time=subset_time[0]).item() == 20 - assert result.sel(scenario='high_demand', time=subset_time[1]).item() == 35 - - # Missing values should be NaN - assert np.isnan(result.sel(scenario='baseline', time=sample_time_index[0]).item()) - assert np.isnan(result.sel(scenario='low_price', time=sample_time_index[2]).item()) - def test_mixed_data_types(self, sample_time_index, sample_scenario_index): """Test conversion of mixed integer and float data.""" # Create array with mixed types @@ -632,77 +401,6 @@ def test_mixed_data_types(self, sample_time_index, sample_scenario_index): class TestFunctionalUseCase: """Tests for realistic use cases combining multiple features.""" - def test_multiindex_with_nans_and_partial_data(self, sample_time_index, sample_scenario_index): - """Test MultiIndex Series with partial data and NaN values.""" - # Create a MultiIndex Series with missing values and partial coverage - time_subset = sample_time_index[1:4] # Middle 3 timestamps only - - # Build index with holes - idx_tuples = [] - for scenario in sample_scenario_index: - for time in time_subset: - # Skip some combinations to create holes - if scenario == 'baseline' and time == time_subset[0]: - continue - if scenario == 'high_demand' and time == time_subset[2]: - continue - idx_tuples.append((scenario, time)) - - partial_idx = pd.MultiIndex.from_tuples(idx_tuples, names=['scenario', 'time']) - - # Create values with some NaNs - values = [ - # baseline (2 values, skipping first) - 30, - 40, - # high_demand (2 values, skipping last) - 25, - 35, - # low_price (3 values) - 15, - np.nan, - 35, - ] - - # Create Series - partial_series = pd.Series(values, index=partial_idx) - - # Convert and test - result = DataConverter.as_dataarray(partial_series, sample_time_index, sample_scenario_index) - - # Shape should be full size - assert result.shape == (len(sample_scenario_index), len(sample_time_index)) - - # Check specific values - assert result.sel(scenario='baseline', time=time_subset[1]).item() == 30 - assert result.sel(scenario='high_demand', time=time_subset[0]).item() == 25 - assert np.isnan(result.sel(scenario='low_price', time=time_subset[1]).item()) - - # All skipped combinations should be NaN - assert np.isnan(result.sel(scenario='baseline', time=time_subset[0]).item()) - assert np.isnan(result.sel(scenario='high_demand', time=time_subset[2]).item()) - - # First and last timestamps should all be NaN (not in original subset) - assert np.all(np.isnan(result.sel(time=sample_time_index[0]).values)) - assert np.all(np.isnan(result.sel(time=sample_time_index[-1]).values)) - - def test_scenario_broadcast_with_nan_values(self, sample_time_index, sample_scenario_index): - """Test broadcasting a Series with NaN values to scenarios.""" - # Create Series with some NaN values - series = pd.Series([1, np.nan, 3, np.nan, 5], index=sample_time_index) - - # Convert with scenario broadcasting - result = DataConverter.as_dataarray(series, sample_time_index, sample_scenario_index) - - # All scenarios should have the same pattern of NaN values - for scenario in sample_scenario_index: - scenario_data = result.sel(scenario=scenario) - assert np.isnan(scenario_data[1].item()) - assert np.isnan(scenario_data[3].item()) - assert scenario_data[0].item() == 1 - assert scenario_data[2].item() == 3 - assert scenario_data[4].item() == 5 - def test_large_dataset(self, sample_scenario_index): """Test with a larger dataset to ensure performance.""" # Create a larger timestep array (e.g., hourly for a year) @@ -814,34 +512,6 @@ def test_preserving_scenario_order(self, sample_time_index): assert np.array_equal(result.sel(scenario='scenario1').values, data[1]) assert np.array_equal(result.sel(scenario='scenario2').values, data[2]) - def test_multiindex_reindexing(self, sample_time_index): - """Test reindexing of MultiIndex Series.""" - # Create scenarios with intentional different order - scenarios = pd.Index(['z_scenario', 'a_scenario', 'm_scenario'], name='scenario') - - # Create MultiIndex with different order than the target - source_scenarios = pd.Index(['a_scenario', 'm_scenario', 'z_scenario'], name='scenario') - multi_idx = pd.MultiIndex.from_product([source_scenarios, sample_time_index], names=['scenario', 'time']) - - # Create values - order should match the source index - values = [] - for i, _ in enumerate(source_scenarios): - values.extend([i * 10 + j for j in range(1, len(sample_time_index) + 1)]) - - # Create Series - series = pd.Series(values, index=multi_idx) - - # Convert using the target scenario order - result = DataConverter.as_dataarray(series, sample_time_index, scenarios) - - # Verify scenario order matches the target - assert list(result.coords['scenario'].values) == list(scenarios) - - # Verify values are correctly indexed - assert np.array_equal(result.sel(scenario='a_scenario').values, [1, 2, 3, 4, 5]) - assert np.array_equal(result.sel(scenario='m_scenario').values, [11, 12, 13, 14, 15]) - assert np.array_equal(result.sel(scenario='z_scenario').values, [21, 22, 23, 24, 25]) - if __name__ == '__main__': pytest.main() @@ -879,12 +549,12 @@ def test_time_index_validation(): # Test with empty index empty_index = pd.DatetimeIndex([], name='time') - with pytest.raises(ValueError): + with pytest.raises(ConversionError): DataConverter.as_dataarray(42, empty_index) # Test with non-DatetimeIndex wrong_type_index = pd.Index([1, 2, 3, 4, 5], name='time') - with pytest.raises(ValueError): + with pytest.raises(ConversionError): DataConverter.as_dataarray(42, wrong_type_index) From 99057901f13bcdcf48c82b9962e73135c4445782 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 2 Apr 2025 14:33:36 +0200 Subject: [PATCH 003/336] Feature/scenarios effects (#216) * Update ShareAllocationModel * Update EffectModel * Update Objective * Improve float conversion in main results * Update dims in ShareAllocationModel * Update dims in ShareAllocationModel * Specify dimensions for ShareAllocationModel * Improve logic to get coords * Bugfix * Apply a scaling factor to the objective if ycenarios are used * Bugfix mein results: always as floats and list of floats * Add scenarios to calculation.py * Improve timestep indexing. Now adjust duration for each timestep accordingly to the new index * Improve validation of Indexes and Bugfix calculate_hours_per_timestep() * Add example # Changes: - change how main results are converted to floats - Improved logic to get coords for vars and constraints - Add scaling factor for the objective to have a better scaled model - Change tilmestep indexing to also update the hours_per_timestep accordingly - Added minimal example --- examples/04_Scenarios/scenario_example.py | 122 ++++++++++++++++++ flixopt/calculation.py | 36 +++--- flixopt/core.py | 143 ++++++++++++++++++---- flixopt/effects.py | 51 +++++--- flixopt/features.py | 40 +++--- flixopt/structure.py | 17 ++- flixopt/utils.py | 6 + 7 files changed, 336 insertions(+), 79 deletions(-) create mode 100644 examples/04_Scenarios/scenario_example.py diff --git a/examples/04_Scenarios/scenario_example.py b/examples/04_Scenarios/scenario_example.py new file mode 100644 index 000000000..d834ff5f0 --- /dev/null +++ b/examples/04_Scenarios/scenario_example.py @@ -0,0 +1,122 @@ +""" +This script shows how to use the flixopt framework to model a simple energy system. +""" + +import numpy as np +import pandas as pd +from rich.pretty import pprint # Used for pretty printing + +import flixopt as fx + +if __name__ == '__main__': + # --- Create Time Series Data --- + # Heat demand profile (e.g., kW) over time and corresponding power prices + heat_demand_per_h = np.array([[30, 0, 90, 110, 110, 20, 20, 20, 20], + [30, 0, 100, 118, 125, 20, 20, 20, 20]]) + power_prices = np.ones(9) * 0.08 + + # Create datetime array starting from '2020-01-01' for the given time period + timesteps = pd.date_range('2020-01-01', periods=9, freq='h') + scenarios = pd.Index(['Base Case', 'High Demand']) + flow_system = fx.FlowSystem(timesteps=timesteps, scenarios=scenarios) + + # --- Define Energy Buses --- + # These represent nodes, where the used medias are balanced (electricity, heat, and gas) + flow_system.add_elements(fx.Bus(label='Strom'), fx.Bus(label='Fernwärme'), fx.Bus(label='Gas')) + + # --- Define Effects (Objective and CO2 Emissions) --- + # Cost effect: used as the optimization objective --> minimizing costs + costs = fx.Effect( + label='costs', + unit='€', + description='Kosten', + is_standard=True, # standard effect: no explicit value needed for costs + is_objective=True, # Minimizing costs as the optimization objective + ) + + # CO2 emissions effect with an associated cost impact + CO2 = fx.Effect( + label='CO2', + unit='kg', + description='CO2_e-Emissionen', + specific_share_to_other_effects_operation={costs.label: 0.2}, + maximum_operation_per_hour=1000, # Max CO2 emissions per hour + ) + + # --- Define Flow System Components --- + # Boiler: Converts fuel (gas) into thermal energy (heat) + boiler = fx.linear_converters.Boiler( + label='Boiler', + eta=0.5, + Q_th=fx.Flow(label='Q_th', bus='Fernwärme', size=50, relative_minimum=0.1, relative_maximum=1), + Q_fu=fx.Flow(label='Q_fu', bus='Gas'), + ) + + # Combined Heat and Power (CHP): Generates both electricity and heat from fuel + chp = fx.linear_converters.CHP( + label='CHP', + eta_th=0.5, + eta_el=0.4, + P_el=fx.Flow('P_el', bus='Strom', size=60, relative_minimum=5 / 60), + Q_th=fx.Flow('Q_th', bus='Fernwärme'), + Q_fu=fx.Flow('Q_fu', bus='Gas'), + ) + + # Storage: Energy storage system with charging and discharging capabilities + storage = fx.Storage( + label='Storage', + charging=fx.Flow('Q_th_load', bus='Fernwärme', size=1000), + discharging=fx.Flow('Q_th_unload', bus='Fernwärme', size=1000), + capacity_in_flow_hours=fx.InvestParameters(fix_effects=20, fixed_size=30, optional=False), + initial_charge_state=0, # Initial storage state: empty + relative_maximum_charge_state=np.array([80, 70, 80, 80, 80, 80, 80, 80, 80, 80]) * 0.01, + eta_charge=0.9, + eta_discharge=1, # Efficiency factors for charging/discharging + relative_loss_per_hour=0.08, # 8% loss per hour. Absolute loss depends on current charge state + prevent_simultaneous_charge_and_discharge=True, # Prevent charging and discharging at the same time + ) + + # Heat Demand Sink: Represents a fixed heat demand profile + heat_sink = fx.Sink( + label='Heat Demand', + sink=fx.Flow(label='Q_th_Last', bus='Fernwärme', size=1, fixed_relative_profile=heat_demand_per_h), + ) + + # Gas Source: Gas tariff source with associated costs and CO2 emissions + gas_source = fx.Source( + label='Gastarif', + source=fx.Flow(label='Q_Gas', bus='Gas', size=1000, effects_per_flow_hour={costs.label: 0.04, CO2.label: 0.3}), + ) + + # Power Sink: Represents the export of electricity to the grid + power_sink = fx.Sink( + label='Einspeisung', sink=fx.Flow(label='P_el', bus='Strom', effects_per_flow_hour=-1 * power_prices) + ) + + # --- Build the Flow System --- + # Add all defined components and effects to the flow system + flow_system.add_elements(costs, CO2, boiler, storage, chp, heat_sink, gas_source, power_sink) + + # Visualize the flow system for validation purposes + flow_system.plot_network(show=True) + + # --- Define and Run Calculation --- + # Create a calculation object to model the Flow System + calculation = fx.FullCalculation(name='Sim1', flow_system=flow_system) + calculation.do_modeling() # Translate the model to a solvable form, creating equations and Variables + + # --- Solve the Calculation and Save Results --- + calculation.solve(fx.solvers.HighsSolver(mip_gap=0, time_limit_seconds=30)) + + # --- Analyze Results --- + calculation.results['Fernwärme'].plot_node_balance_pie() + calculation.results['Fernwärme'].plot_node_balance() + calculation.results['Storage'].plot_node_balance() + calculation.results.plot_heatmap('CHP(Q_th)|flow_rate') + + # Convert the results for the storage component to a dataframe and display + df = calculation.results['Storage'].node_balance_with_charge_state() + print(df) + + # Save results to file for later usage + calculation.results.to_file() diff --git a/flixopt/calculation.py b/flixopt/calculation.py index 03cf8b9a6..2dbb6af19 100644 --- a/flixopt/calculation.py +++ b/flixopt/calculation.py @@ -44,19 +44,22 @@ def __init__( name: str, flow_system: FlowSystem, active_timesteps: Optional[pd.DatetimeIndex] = None, + selected_scenarios: Optional[pd.Index] = None, folder: Optional[pathlib.Path] = None, ): """ Args: name: name of calculation flow_system: flow_system which should be calculated - active_timesteps: list with indices, which should be used for calculation. If None, then all timesteps are used. + active_timesteps: timesteps which should be used for calculation. If None, then all timesteps are used. + selected_scenarios: scenarios which should be used for calculation. If None, then all scenarios are used. folder: folder where results should be saved. If None, then the current working directory is used. """ self.name = name self.flow_system = flow_system self.model: Optional[SystemModel] = None self.active_timesteps = active_timesteps + self.selected_scenarios = selected_scenarios self.durations = {'modeling': 0.0, 'solving': 0.0, 'saving': 0.0} self.folder = pathlib.Path.cwd() / 'results' if folder is None else pathlib.Path(folder) @@ -73,48 +76,49 @@ def __init__( @property def main_results(self) -> Dict[str, Union[Scalar, Dict]]: from flixopt.features import InvestmentModel - - return { + main_results = { 'Objective': self.model.objective.value, - 'Penalty': float(self.model.effects.penalty.total.solution.values), + 'Penalty': self.model.effects.penalty.total.solution.values, 'Effects': { f'{effect.label} [{effect.unit}]': { - 'operation': float(effect.model.operation.total.solution.values), - 'invest': float(effect.model.invest.total.solution.values), - 'total': float(effect.model.total.solution.values), + 'operation': effect.model.operation.total.solution.values, + 'invest': effect.model.invest.total.solution.values, + 'total': effect.model.total.solution.values, } for effect in self.flow_system.effects }, 'Invest-Decisions': { 'Invested': { - model.label_of_element: float(model.size.solution) + model.label_of_element: model.size.solution for component in self.flow_system.components.values() for model in component.model.all_sub_models - if isinstance(model, InvestmentModel) and float(model.size.solution) >= CONFIG.modeling.EPSILON + if isinstance(model, InvestmentModel) and model.size.solution >= CONFIG.modeling.EPSILON }, 'Not invested': { - model.label_of_element: float(model.size.solution) + model.label_of_element: model.size.solution for component in self.flow_system.components.values() for model in component.model.all_sub_models - if isinstance(model, InvestmentModel) and float(model.size.solution) < CONFIG.modeling.EPSILON + if isinstance(model, InvestmentModel) and model.size.solution < CONFIG.modeling.EPSILON }, }, 'Buses with excess': [ { bus.label_full: { - 'input': float(np.sum(bus.model.excess_input.solution.values)), - 'output': float(np.sum(bus.model.excess_output.solution.values)), + 'input': np.sum(bus.model.excess_input.solution.values), + 'output': np.sum(bus.model.excess_output.solution.values), } } for bus in self.flow_system.buses.values() if bus.with_excess and ( - float(np.sum(bus.model.excess_input.solution.values)) > 1e-3 - or float(np.sum(bus.model.excess_output.solution.values)) > 1e-3 + np.sum(bus.model.excess_input.solution.values) > 1e-3 + or np.sum(bus.model.excess_output.solution.values) > 1e-3 ) ], } + return utils.round_floats(main_results) + @property def summary(self): return { @@ -184,7 +188,7 @@ def solve(self, solver: _Solver, log_file: Optional[pathlib.Path] = None, log_ma def _activate_time_series(self): self.flow_system.transform_data() self.flow_system.time_series_collection.set_selection( - timesteps=self.active_timesteps + timesteps=self.active_timesteps, scenarios=self.selected_scenarios ) diff --git a/flixopt/core.py b/flixopt/core.py index 185236b3a..ac62bc33a 100644 --- a/flixopt/core.py +++ b/flixopt/core.py @@ -795,17 +795,19 @@ def __init__( hours_of_previous_timesteps: Optional[Union[float, np.ndarray]] = None, ): """Initialize a TimeSeriesCollection.""" - self._validate_timesteps(timesteps) + self._full_timesteps = self._validate_timesteps(timesteps) + self._full_scenarios = self._validate_scenarios(scenarios) + + self._full_timesteps_extra = self._create_timesteps_with_extra( + self._full_timesteps, + self._calculate_hours_of_final_timestep(self._full_timesteps, hours_of_final_timestep=hours_of_last_timestep) + ) + self._full_hours_per_timestep = self.calculate_hours_per_timestep(self._full_timesteps_extra, self._full_scenarios) + self.hours_of_previous_timesteps = self._calculate_hours_of_previous_timesteps( timesteps, hours_of_previous_timesteps ) #TODO: Make dynamic - self._full_timesteps = timesteps - self._full_timesteps_extra = self._create_timesteps_with_extra(timesteps, hours_of_last_timestep) - self._full_hours_per_timestep = self.calculate_hours_per_timestep(self._full_timesteps_extra) - - self._full_scenarios = scenarios - # Series that need extra timestep self._has_extra_timestep: set = set() @@ -940,13 +942,13 @@ def _update_selected_timesteps(self, timesteps: Optional[pd.DatetimeIndex]) -> N self._selected_hours_per_timestep = None return - self._validate_timesteps(timesteps, self._full_timesteps) - - self._selected_timesteps = timesteps - self._selected_hours_per_timestep = self._full_hours_per_timestep.sel(time=timesteps) + self._selected_timesteps = self._validate_timesteps(timesteps, self._full_timesteps) self._selected_timesteps_extra = self._create_timesteps_with_extra( - timesteps, self._selected_hours_per_timestep.isel(time=-1).max().item() + timesteps, + self._calculate_hours_of_final_timestep(timesteps, self._full_timesteps) ) + self._selected_hours_per_timestep = self.calculate_hours_per_timestep(self._selected_timesteps_extra, + self._selected_scenarios) def as_dataset(self, with_extra_timestep: bool = True, with_constants: bool = True) -> xr.Dataset: """ @@ -1108,7 +1110,10 @@ def _calculate_group_weights(self) -> Dict[str, float]: return {group: 1 / count for group, count in group_counts.items()} @staticmethod - def _validate_timesteps(timesteps: pd.DatetimeIndex, present_timesteps: Optional[pd.DatetimeIndex] = None): + def _validate_timesteps( + timesteps: pd.DatetimeIndex, + present_timesteps: Optional[pd.DatetimeIndex] = None + ) -> pd.DatetimeIndex: """ Validate timesteps format and rename if needed. Args: @@ -1131,7 +1136,7 @@ def _validate_timesteps(timesteps: pd.DatetimeIndex, present_timesteps: Optional # Ensure timesteps has the required name if timesteps.name != 'time': - logger.warning('Renamed timesteps to "time" (was "%s")', timesteps.name) + logger.debug('Renamed timesteps to "time" (was "%s")', timesteps.name) timesteps.name = 'time' # Ensure timesteps is sorted @@ -1146,19 +1151,56 @@ def _validate_timesteps(timesteps: pd.DatetimeIndex, present_timesteps: Optional if present_timesteps is not None and not set(timesteps).issubset(set(present_timesteps)): raise ValueError('timesteps must be a subset of present_timesteps') + return timesteps + + @staticmethod + def _validate_scenarios( + scenarios: pd.Index, + present_scenarios: Optional[pd.Index] = None + ) -> Optional[pd.Index]: + """ + Validate scenario format and rename if needed. + Args: + scenarios: The scenarios to validate + present_scenarios: The present_scenarios that are present in the dataset + + Raises: + ValueError: If timesteps is not a pandas DatetimeIndex + ValueError: If timesteps is not at least 2 timestamps + ValueError: If timesteps has a different name than 'time' + ValueError: If timesteps is not sorted + ValueError: If timesteps contains duplicates + ValueError: If timesteps is not a subset of present_timesteps + """ + if scenarios is None: + return None + + if not isinstance(scenarios, pd.Index): + logger.warning('Converting scenarios to pandas.Index') + scenarios = pd.Index(scenarios, name='scenario') + + if len(scenarios) < 2: + logger.warning('scenarios must contain at least 2 scenarios') + raise ValueError('timesteps must contain at least 2 timestamps') + + # Ensure timesteps has the required name + if scenarios.name != 'scenario': + logger.debug('Renamed scenarios to "scneario" (was "%s")', scenarios.name) + scenarios.name = 'scenario' + + # Ensure timesteps is a subset of present_timesteps + if present_scenarios is not None and not set(scenarios).issubset(set(present_scenarios)): + raise ValueError('scenarios must be a subset of present_scenarios') + + return scenarios + @staticmethod def _create_timesteps_with_extra( - timesteps: pd.DatetimeIndex, hours_of_last_timestep: Optional[float] + timesteps: pd.DatetimeIndex, + hours_of_last_timestep: float ) -> pd.DatetimeIndex: """Create timesteps with an extra step at the end.""" - if hours_of_last_timestep is not None: - # Create the extra timestep using the specified duration - last_date = pd.DatetimeIndex([timesteps[-1] + pd.Timedelta(hours=hours_of_last_timestep)], name='time') - else: - # Use the last interval as the extra timestep duration - last_date = pd.DatetimeIndex([timesteps[-1] + (timesteps[-1] - timesteps[-2])], name='time') - - # Combine with original timesteps + last_date = pd.DatetimeIndex([timesteps[-1] + pd.Timedelta(hours=hours_of_last_timestep)], name='time') return pd.DatetimeIndex(timesteps.append(last_date), name='time') @staticmethod @@ -1174,14 +1216,61 @@ def _calculate_hours_of_previous_timesteps( return first_interval.total_seconds() / 3600 # Convert to hours @staticmethod - def calculate_hours_per_timestep(timesteps_extra: pd.DatetimeIndex) -> xr.DataArray: + def _calculate_hours_of_final_timestep( + timesteps: pd.DatetimeIndex, + timesteps_superset: Optional[pd.DatetimeIndex] = None, + hours_of_final_timestep: Optional[float] = None, + ) -> float: + """ + Calculate duration of the final timestep. + If timesteps_subset is provided, the final timestep is calculated for this subset. + The hours_of_final_timestep is only used if the final timestep cant be determined from the timesteps. + + Args: + timesteps: The full timesteps + timesteps_subset: The subset of timesteps + hours_of_final_timestep: The duration of the final timestep, if already known + + Returns: + The duration of the final timestep in hours + + Raises: + ValueError: If the provided timesteps_subset does not end before the timesteps superset + """ + if timesteps_superset is None: + if hours_of_final_timestep is not None: + return hours_of_final_timestep + return (timesteps[-1] - timesteps[-2]) / pd.Timedelta(hours=1) + + final_timestep = timesteps[-1] + + if timesteps_superset[-1] == final_timestep: + if hours_of_final_timestep is not None: + return hours_of_final_timestep + return (timesteps_superset[-1] - timesteps_superset[-2]) / pd.Timedelta(hours=1) + + elif timesteps_superset[-1] <= final_timestep: + raise ValueError(f'The provided timesteps ({timesteps}) end ' + f'after the provided timesteps_superset ({timesteps_superset})') + else: + # Get the first timestep in the superset that is after the final timestep of the subset + extra_timestep = timesteps_superset[timesteps_superset > final_timestep].min() + return (extra_timestep - final_timestep) / pd.Timedelta(hours=1) + + @staticmethod + def calculate_hours_per_timestep( + timesteps_extra: pd.DatetimeIndex, + scenarios: Optional[pd.Index] = None + ) -> xr.DataArray: """Calculate duration of each timestep.""" # Calculate differences between consecutive timestamps hours_per_step = np.diff(timesteps_extra) / pd.Timedelta(hours=1) - return xr.DataArray( - data=hours_per_step, coords={'time': timesteps_extra[:-1]}, dims=('time',), name='hours_per_step' - ) + return DataConverter.as_dataarray( + hours_per_step, + timesteps=timesteps_extra[:-1], + scenarios=scenarios, + ).rename('hours_per_step') def get_numeric_stats(data: xr.DataArray, decimals: int = 2, padd: int = 10, by_scenario: bool = False) -> str: diff --git a/flixopt/effects.py b/flixopt/effects.py index e834e339e..e15fa16b1 100644 --- a/flixopt/effects.py +++ b/flixopt/effects.py @@ -118,10 +118,11 @@ def __init__(self, model: SystemModel, element: Effect): self.total: Optional[linopy.Variable] = None self.invest: ShareAllocationModel = self.add( ShareAllocationModel( - self._model, - False, - self.label_of_element, - 'invest', + model=self._model, + has_time_dim=False, + has_scenario_dim=True, + label_of_element=self.label_of_element, + label='invest', label_full=f'{self.label_full}(invest)', total_max=self.element.maximum_invest, total_min=self.element.minimum_invest, @@ -130,10 +131,11 @@ def __init__(self, model: SystemModel, element: Effect): self.operation: ShareAllocationModel = self.add( ShareAllocationModel( - self._model, - True, - self.label_of_element, - 'operation', + model=self._model, + has_time_dim=True, + has_scenario_dim=True, + label_of_element=self.label_of_element, + label='operation', label_full=f'{self.label_full}(operation)', total_max=self.element.maximum_operation, total_min=self.element.minimum_operation, @@ -154,7 +156,7 @@ def do_modeling(self): self._model.add_variables( lower=self.element.minimum_total if self.element.minimum_total is not None else -np.inf, upper=self.element.maximum_total if self.element.maximum_total is not None else np.inf, - coords=None, + coords=self._model.get_coords(time_dim=False), name=f'{self.label_full}|total', ), 'total', @@ -162,7 +164,7 @@ def do_modeling(self): self.add( self._model.add_constraints( - self.total == self.operation.total.sum() + self.invest.total.sum(), name=f'{self.label_full}|total' + self.total == self.operation.total + self.invest.total, name=f'{self.label_full}|total' ), 'total', ) @@ -350,29 +352,42 @@ def add_share_to_effects( ) -> None: for effect, expression in expressions.items(): if target == 'operation': - self.effects[effect].model.operation.add_share(name, expression) + self.effects[effect].model.operation.add_share( + name, + expression, + has_time_dim=True, + has_scenario_dim=True, + ) elif target == 'invest': - self.effects[effect].model.invest.add_share(name, expression) + self.effects[effect].model.invest.add_share( + name, + expression, + has_time_dim=False, + has_scenario_dim=True, + ) else: raise ValueError(f'Target {target} not supported!') def add_share_to_penalty(self, name: str, expression: linopy.LinearExpression) -> None: if expression.ndim != 0: raise TypeError(f'Penalty shares must be scalar expressions! ({expression.ndim=})') - self.penalty.add_share(name, expression) + self.penalty.add_share(name, expression, has_time_dim=False, has_scenario_dim=False) def do_modeling(self): for effect in self.effects: effect.create_model(self._model) self.penalty = self.add( - ShareAllocationModel(self._model, shares_are_time_series=False, label_of_element='Penalty') + ShareAllocationModel(self._model, has_time_dim=False, has_scenario_dim=False, label_of_element='Penalty') ) for model in [effect.model for effect in self.effects] + [self.penalty]: model.do_modeling() self._add_share_between_effects() - - self._model.add_objective(self.effects.objective_effect.model.total + self.penalty.total) + scaling_factor = len(self._model.time_series_collection.scenarios) if self._model.time_series_collection.scenarios is not None else 1 + self._model.add_objective( + (self.effects.objective_effect.model.total / scaling_factor).sum() + + (self.penalty.total / scaling_factor).sum() + ) def _add_share_between_effects(self): for origin_effect in self.effects: @@ -381,10 +396,14 @@ def _add_share_between_effects(self): self.effects[target_effect].model.operation.add_share( origin_effect.model.operation.label_full, origin_effect.model.operation.total_per_timestep * time_series.selected_data, + has_time_dim=True, + has_scenario_dim=True, ) # 2. invest: -> hier ist es Scalar (share) for target_effect, factor in origin_effect.specific_share_to_other_effects_invest.items(): self.effects[target_effect].model.invest.add_share( origin_effect.model.invest.label_full, origin_effect.model.invest.total * factor, + has_time_dim=False, + has_scenario_dim=True, ) diff --git a/flixopt/features.py b/flixopt/features.py index 7b92396fd..a9f50aba6 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -841,7 +841,8 @@ class ShareAllocationModel(Model): def __init__( self, model: SystemModel, - shares_are_time_series: bool, + has_time_dim: bool, + has_scenario_dim: bool, label_of_element: Optional[str] = None, label: Optional[str] = None, label_full: Optional[str] = None, @@ -851,9 +852,9 @@ def __init__( min_per_hour: Optional[TimestepData] = None, ): super().__init__(model, label_of_element=label_of_element, label=label, label_full=label_full) - if not shares_are_time_series: # If the condition is True + if not has_time_dim: # If the condition is True assert max_per_hour is None and min_per_hour is None, ( - 'Both max_per_hour and min_per_hour cannot be used when shares_are_time_series is False' + 'Both max_per_hour and min_per_hour cannot be used when has_time_dim is False' ) self.total_per_timestep: Optional[linopy.Variable] = None self.total: Optional[linopy.Variable] = None @@ -864,7 +865,8 @@ def __init__( self._eq_total: Optional[linopy.Constraint] = None # Parameters - self._shares_are_time_series = shares_are_time_series + self._has_time_dim = has_time_dim + self._has_scenario_dim = has_scenario_dim self._total_max = total_max if total_min is not None else np.inf self._total_min = total_min if total_min is not None else -np.inf self._max_per_hour = max_per_hour if max_per_hour is not None else np.inf @@ -873,7 +875,10 @@ def __init__( def do_modeling(self): self.total = self.add( self._model.add_variables( - lower=self._total_min, upper=self._total_max, coords=None, name=f'{self.label_full}|total' + lower=self._total_min, + upper=self._total_max, + coords=self._model.get_coords(time_dim=False, scenario_dim=self._has_scenario_dim), + name=f'{self.label_full}|total' ), 'total', ) @@ -882,16 +887,16 @@ def do_modeling(self): self._model.add_constraints(self.total == 0, name=f'{self.label_full}|total'), 'total' ) - if self._shares_are_time_series: + if self._has_time_dim: self.total_per_timestep = self.add( self._model.add_variables( lower=-np.inf if (self._min_per_hour is None) - else np.multiply(self._min_per_hour, self._model.hours_per_step), + else self._min_per_hour * self._model.hours_per_step, upper=np.inf if (self._max_per_hour is None) - else np.multiply(self._max_per_hour, self._model.hours_per_step), - coords=self._model.get_coords(), + else self._max_per_hour * self._model.hours_per_step, + coords=self._model.get_coords(time_dim=True, scenario_dim=self._has_scenario_dim), name=f'{self.label_full}|total_per_timestep', ), 'total_per_timestep', @@ -903,12 +908,14 @@ def do_modeling(self): ) # Add it to the total - self._eq_total.lhs -= self.total_per_timestep.sum() + self._eq_total.lhs -= self.total_per_timestep.sum(dim='time') def add_share( self, name: str, expression: linopy.LinearExpression, + has_time_dim: bool, + has_scenario_dim: bool, ): """ Add a share to the share allocation model. If the share already exists, the expression is added to the existing share. @@ -920,16 +927,17 @@ def add_share( name: The name of the share. expression: The expression of the share. Added to the right hand side of the constraint. """ + if has_time_dim and not self._has_time_dim: + raise ValueError('Cannot add share with time_dim=True to a model without time_dim') + if has_scenario_dim and not self._has_scenario_dim: + raise ValueError('Cannot add share with scenario_dim=True to a model without scenario_dim') + if name in self.shares: self.share_constraints[name].lhs -= expression else: self.shares[name] = self.add( self._model.add_variables( - coords=None - if isinstance(expression, linopy.LinearExpression) - and expression.ndim == 0 - or not isinstance(expression, linopy.LinearExpression) - else self._model.get_coords(), #TODO: Add logic on what coords to use + coords=self._model.get_coords(time_dim=has_time_dim, scenario_dim=has_scenario_dim), name=f'{name}->{self.label_full}', ), name, @@ -937,7 +945,7 @@ def add_share( self.share_constraints[name] = self.add( self._model.add_constraints(self.shares[name] == expression, name=f'{name}->{self.label_full}'), name ) - if self.shares[name].ndim == 0: + if not has_time_dim: self._eq_total.lhs -= self.shares[name] else: self._eq_total_per_timestep.lhs -= self.shares[name] diff --git a/flixopt/structure.py b/flixopt/structure.py index 7306c97d5..91fc6c1d0 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -115,15 +115,24 @@ def get_coords( Returns: The coordinates of the model. Might also be None if no scenarios are present and time_dim is False """ + if not scenario_dim and not time_dim: + return None scenarios = self.time_series_collection.scenarios timesteps = self.time_series_collection.timesteps if not extra_timestep else self.time_series_collection.timesteps_extra - if scenarios is None: - if time_dim: + + if scenario_dim and time_dim: + if scenarios is None: return (timesteps,) - return None + return scenarios, timesteps + if scenario_dim and not time_dim: + if scenarios is None: + return None return (scenarios,) - return scenarios, timesteps + if time_dim and not scenario_dim: + return (timesteps,) + + raise ValueError(f'Cannot get coordinates with both {scenario_dim=} and {time_dim=}') class Interface: diff --git a/flixopt/utils.py b/flixopt/utils.py index 6b5d88693..542f87942 100644 --- a/flixopt/utils.py +++ b/flixopt/utils.py @@ -19,6 +19,12 @@ def round_floats(obj, decimals=2): return [round_floats(v, decimals) for v in obj] elif isinstance(obj, float): return round(obj, decimals) + elif isinstance(obj, int): + return obj + elif isinstance(obj, np.ndarray): + return np.round(obj, decimals).tolist() + elif isinstance(obj, xr.DataArray): + return obj.round(decimals).values.tolist() return obj From 24af8c60ba5fc2ce008af89e8dc45cefd0344a98 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 2 Apr 2025 14:38:08 +0200 Subject: [PATCH 004/336] ruff check and format --- examples/04_Scenarios/scenario_example.py | 3 +- flixopt/calculation.py | 1 + flixopt/components.py | 18 +++--- flixopt/core.py | 53 +++++++---------- flixopt/effects.py | 16 +++-- flixopt/elements.py | 10 +++- flixopt/features.py | 21 ++++--- flixopt/flow_system.py | 17 ++++-- flixopt/interface.py | 14 ++--- flixopt/structure.py | 11 ++-- flixopt/utils.py | 1 - tests/test_dataconverter.py | 4 +- tests/test_timeseries.py | 71 +++++++++++------------ 13 files changed, 119 insertions(+), 121 deletions(-) diff --git a/examples/04_Scenarios/scenario_example.py b/examples/04_Scenarios/scenario_example.py index d834ff5f0..a004d1851 100644 --- a/examples/04_Scenarios/scenario_example.py +++ b/examples/04_Scenarios/scenario_example.py @@ -11,8 +11,7 @@ if __name__ == '__main__': # --- Create Time Series Data --- # Heat demand profile (e.g., kW) over time and corresponding power prices - heat_demand_per_h = np.array([[30, 0, 90, 110, 110, 20, 20, 20, 20], - [30, 0, 100, 118, 125, 20, 20, 20, 20]]) + heat_demand_per_h = np.array([[30, 0, 90, 110, 110, 20, 20, 20, 20], [30, 0, 100, 118, 125, 20, 20, 20, 20]]) power_prices = np.ones(9) * 0.08 # Create datetime array starting from '2020-01-01' for the given time period diff --git a/flixopt/calculation.py b/flixopt/calculation.py index 2dbb6af19..962d2c95f 100644 --- a/flixopt/calculation.py +++ b/flixopt/calculation.py @@ -76,6 +76,7 @@ def __init__( @property def main_results(self) -> Dict[str, Union[Scalar, Dict]]: from flixopt.features import InvestmentModel + main_results = { 'Objective': self.model.objective.value, 'Penalty': self.model.effects.penalty.total.solution.values, diff --git a/flixopt/components.py b/flixopt/components.py index 4726ca0f4..69b0fe47a 100644 --- a/flixopt/components.py +++ b/flixopt/components.py @@ -9,7 +9,7 @@ import numpy as np from . import utils -from .core import TimestepData, PlausibilityError, Scalar, TimeSeries, ScenarioData +from .core import PlausibilityError, Scalar, ScenarioData, TimeSeries, TimestepData from .elements import Component, ComponentModel, Flow from .features import InvestmentModel, OnOffModel, PiecewiseModel from .interface import InvestParameters, OnOffParameters, PiecewiseConversion @@ -225,9 +225,7 @@ def _plausibility_checks(self) -> None: Check for infeasible or uncommon combinations of parameters """ if isinstance(self.initial_charge_state, str) and not self.initial_charge_state == 'lastValueOfSim': - raise PlausibilityError( - f'initial_charge_state has undefined value: {self.initial_charge_state}' - ) + raise PlausibilityError(f'initial_charge_state has undefined value: {self.initial_charge_state}') else: if isinstance(self.capacity_in_flow_hours, InvestParameters): if self.capacity_in_flow_hours.fixed_size is None: @@ -244,7 +242,7 @@ def _plausibility_checks(self) -> None: minimum_inital_capacity = maximum_capacity * self.relative_minimum_charge_state.isel(time=1) # initial capacity <= allowed max for minimum_size: maximum_inital_capacity = minimum_capacity * self.relative_maximum_charge_state.isel(time=1) - #TODO: index=1 ??? I think index 0 + # TODO: index=1 ??? I think index 0 if (self.initial_charge_state > maximum_inital_capacity).any(): raise ValueError( @@ -257,6 +255,7 @@ def _plausibility_checks(self) -> None: f'is below allowed minimum charge_state {minimum_inital_capacity}' ) + @register_class_for_io class Transmission(Component): # TODO: automatic on-Value in Flows if loss_abs @@ -427,7 +426,9 @@ def do_modeling(self): self.add( self._model.add_constraints( sum([flow.model.flow_rate * conv_factors[flow.label].selected_data for flow in used_inputs]) - == sum([flow.model.flow_rate * conv_factors[flow.label].selected_data for flow in used_outputs]), + == sum( + [flow.model.flow_rate * conv_factors[flow.label].selected_data for flow in used_outputs] + ), name=f'{self.label_full}|conversion_{i}', ) ) @@ -467,7 +468,10 @@ def do_modeling(self): lb, ub = self.absolute_charge_state_bounds self.charge_state = self.add( self._model.add_variables( - lower=lb, upper=ub, coords=self._model.get_coords(extra_timestep=True), name=f'{self.label_full}|charge_state' + lower=lb, + upper=ub, + coords=self._model.get_coords(extra_timestep=True), + name=f'{self.label_full}|charge_state', ), 'charge_state', ) diff --git a/flixopt/core.py b/flixopt/core.py index ac62bc33a..68d1ddaad 100644 --- a/flixopt/core.py +++ b/flixopt/core.py @@ -787,6 +787,7 @@ class TimeSeriesCollection: Provides a way to store time series data and work with subsets of dimensions that automatically update all references when changed. """ + def __init__( self, timesteps: pd.DatetimeIndex, @@ -800,13 +801,17 @@ def __init__( self._full_timesteps_extra = self._create_timesteps_with_extra( self._full_timesteps, - self._calculate_hours_of_final_timestep(self._full_timesteps, hours_of_final_timestep=hours_of_last_timestep) + self._calculate_hours_of_final_timestep( + self._full_timesteps, hours_of_final_timestep=hours_of_last_timestep + ), + ) + self._full_hours_per_timestep = self.calculate_hours_per_timestep( + self._full_timesteps_extra, self._full_scenarios ) - self._full_hours_per_timestep = self.calculate_hours_per_timestep(self._full_timesteps_extra, self._full_scenarios) self.hours_of_previous_timesteps = self._calculate_hours_of_previous_timesteps( timesteps, hours_of_previous_timesteps - ) #TODO: Make dynamic + ) # TODO: Make dynamic # Series that need extra timestep self._has_extra_timestep: set = set() @@ -944,11 +949,11 @@ def _update_selected_timesteps(self, timesteps: Optional[pd.DatetimeIndex]) -> N self._selected_timesteps = self._validate_timesteps(timesteps, self._full_timesteps) self._selected_timesteps_extra = self._create_timesteps_with_extra( - timesteps, - self._calculate_hours_of_final_timestep(timesteps, self._full_timesteps) + timesteps, self._calculate_hours_of_final_timestep(timesteps, self._full_timesteps) + ) + self._selected_hours_per_timestep = self.calculate_hours_per_timestep( + self._selected_timesteps_extra, self._selected_scenarios ) - self._selected_hours_per_timestep = self.calculate_hours_per_timestep(self._selected_timesteps_extra, - self._selected_scenarios) def as_dataset(self, with_extra_timestep: bool = True, with_constants: bool = True) -> xr.Dataset: """ @@ -1006,16 +1011,11 @@ def _propagate_selection_to_time_series(self) -> None: """Apply the current selection to all TimeSeries objects.""" for ts_name, ts in self._time_series.items(): if ts.has_time_dim: - timesteps = ( - self.timesteps_extra if ts_name in self._has_extra_timestep else self.timesteps - ) + timesteps = self.timesteps_extra if ts_name in self._has_extra_timestep else self.timesteps else: timesteps = None - ts.set_selection( - timesteps=timesteps, - scenarios=self.scenarios if ts.has_scenario_dim else None - ) + ts.set_selection(timesteps=timesteps, scenarios=self.scenarios if ts.has_scenario_dim else None) def __getitem__(self, name: str) -> TimeSeries: """ @@ -1072,9 +1072,7 @@ def update_time_series(self, name: str, data: TimestepData) -> TimeSeries: # Convert data to proper format data_array = DataConverter.as_dataarray( - data, - timesteps=target_timesteps, - scenarios=self.scenarios if ts.has_scenario_dim else None + data, timesteps=target_timesteps, scenarios=self.scenarios if ts.has_scenario_dim else None ) # Update the TimeSeries @@ -1111,8 +1109,7 @@ def _calculate_group_weights(self) -> Dict[str, float]: @staticmethod def _validate_timesteps( - timesteps: pd.DatetimeIndex, - present_timesteps: Optional[pd.DatetimeIndex] = None + timesteps: pd.DatetimeIndex, present_timesteps: Optional[pd.DatetimeIndex] = None ) -> pd.DatetimeIndex: """ Validate timesteps format and rename if needed. @@ -1154,10 +1151,7 @@ def _validate_timesteps( return timesteps @staticmethod - def _validate_scenarios( - scenarios: pd.Index, - present_scenarios: Optional[pd.Index] = None - ) -> Optional[pd.Index]: + def _validate_scenarios(scenarios: pd.Index, present_scenarios: Optional[pd.Index] = None) -> Optional[pd.Index]: """ Validate scenario format and rename if needed. Args: @@ -1195,10 +1189,7 @@ def _validate_scenarios( return scenarios @staticmethod - def _create_timesteps_with_extra( - timesteps: pd.DatetimeIndex, - hours_of_last_timestep: float - ) -> pd.DatetimeIndex: + def _create_timesteps_with_extra(timesteps: pd.DatetimeIndex, hours_of_last_timestep: float) -> pd.DatetimeIndex: """Create timesteps with an extra step at the end.""" last_date = pd.DatetimeIndex([timesteps[-1] + pd.Timedelta(hours=hours_of_last_timestep)], name='time') return pd.DatetimeIndex(timesteps.append(last_date), name='time') @@ -1250,8 +1241,9 @@ def _calculate_hours_of_final_timestep( return (timesteps_superset[-1] - timesteps_superset[-2]) / pd.Timedelta(hours=1) elif timesteps_superset[-1] <= final_timestep: - raise ValueError(f'The provided timesteps ({timesteps}) end ' - f'after the provided timesteps_superset ({timesteps_superset})') + raise ValueError( + f'The provided timesteps ({timesteps}) end after the provided timesteps_superset ({timesteps_superset})' + ) else: # Get the first timestep in the superset that is after the final timestep of the subset extra_timestep = timesteps_superset[timesteps_superset > final_timestep].min() @@ -1259,8 +1251,7 @@ def _calculate_hours_of_final_timestep( @staticmethod def calculate_hours_per_timestep( - timesteps_extra: pd.DatetimeIndex, - scenarios: Optional[pd.Index] = None + timesteps_extra: pd.DatetimeIndex, scenarios: Optional[pd.Index] = None ) -> xr.DataArray: """Calculate duration of each timestep.""" # Calculate differences between consecutive timestamps diff --git a/flixopt/effects.py b/flixopt/effects.py index e15fa16b1..226e59a0f 100644 --- a/flixopt/effects.py +++ b/flixopt/effects.py @@ -11,9 +11,8 @@ import linopy import numpy as np -import pandas as pd -from .core import TimestepData, NumericDataTS, Scalar, TimeSeries, TimeSeriesCollection, ScenarioData, TimestepData +from .core import NumericDataTS, ScenarioData, TimeSeries, TimeSeriesCollection, TimestepData from .features import ShareAllocationModel from .structure import Element, ElementModel, Interface, Model, SystemModel, register_class_for_io @@ -79,7 +78,9 @@ def __init__( self.specific_share_to_other_effects_operation: EffectValuesUserTimestep = ( specific_share_to_other_effects_operation or {} ) - self.specific_share_to_other_effects_invest: EffectValuesUserTimestep = specific_share_to_other_effects_invest or {} + self.specific_share_to_other_effects_invest: EffectValuesUserTimestep = ( + specific_share_to_other_effects_invest or {} + ) self.minimum_operation = minimum_operation self.maximum_operation = maximum_operation self.minimum_operation_per_hour: NumericDataTS = minimum_operation_per_hour @@ -211,8 +212,7 @@ def add_effects(self, *effects: Effect) -> None: logger.info(f'Registered new Effect: {effect.label}') def create_effect_values_dict( - self, - effect_values_user: Union[EffectValuesUserScenario, EffectValuesUserTimestep] + self, effect_values_user: Union[EffectValuesUserScenario, EffectValuesUserTimestep] ) -> Optional[EffectValuesDict]: """ Converts effect values into a dictionary. If a scalar is provided, it is associated with a default effect type. @@ -383,7 +383,11 @@ def do_modeling(self): model.do_modeling() self._add_share_between_effects() - scaling_factor = len(self._model.time_series_collection.scenarios) if self._model.time_series_collection.scenarios is not None else 1 + scaling_factor = ( + len(self._model.time_series_collection.scenarios) + if self._model.time_series_collection.scenarios is not None + else 1 + ) self._model.add_objective( (self.effects.objective_effect.model.total / scaling_factor).sum() + (self.penalty.total / scaling_factor).sum() diff --git a/flixopt/elements.py b/flixopt/elements.py index b6de8c7c2..a98edff9d 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -10,7 +10,7 @@ import numpy as np from .config import CONFIG -from .core import TimestepData, NumericDataTS, PlausibilityError, Scalar, ScenarioData +from .core import NumericDataTS, PlausibilityError, Scalar, ScenarioData, TimestepData from .effects import EffectValuesUserTimestep from .features import InvestmentModel, OnOffModel, PreventSimultaneousUsageModel from .interface import InvestParameters, OnOffParameters @@ -467,11 +467,15 @@ def do_modeling(self) -> None: self._model.hours_per_step, self.element.excess_penalty_per_flow_hour.selected_data ) self.excess_input = self.add( - self._model.add_variables(lower=0, coords=self._model.get_coords(), name=f'{self.label_full}|excess_input'), + self._model.add_variables( + lower=0, coords=self._model.get_coords(), name=f'{self.label_full}|excess_input' + ), 'excess_input', ) self.excess_output = self.add( - self._model.add_variables(lower=0, coords=self._model.get_coords(), name=f'{self.label_full}|excess_output'), + self._model.add_variables( + lower=0, coords=self._model.get_coords(), name=f'{self.label_full}|excess_output' + ), 'excess_output', ) eq_bus_balance.lhs -= -self.excess_input + self.excess_output diff --git a/flixopt/features.py b/flixopt/features.py index a9f50aba6..a122a9dac 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -9,9 +9,8 @@ import linopy import numpy as np -from . import utils from .config import CONFIG -from .core import TimestepData, Scalar, TimeSeries +from .core import Scalar, TimeSeries, TimestepData from .interface import InvestParameters, OnOffParameters, Piece, Piecewise, PiecewiseConversion, PiecewiseEffects from .structure import Model, SystemModel @@ -303,12 +302,16 @@ def do_modeling(self): if self.parameters.use_switch_on: self.switch_on = self.add( - self._model.add_variables(binary=True, name=f'{self.label_full}|switch_on', coords=self._model.get_coords()), + self._model.add_variables( + binary=True, name=f'{self.label_full}|switch_on', coords=self._model.get_coords() + ), 'switch_on', ) self.switch_off = self.add( - self._model.add_variables(binary=True, name=f'{self.label_full}|switch_off', coords=self._model.get_coords()), + self._model.add_variables( + binary=True, name=f'{self.label_full}|switch_off', coords=self._model.get_coords() + ), 'switch_off', ) @@ -878,7 +881,7 @@ def do_modeling(self): lower=self._total_min, upper=self._total_max, coords=self._model.get_coords(time_dim=False, scenario_dim=self._has_scenario_dim), - name=f'{self.label_full}|total' + name=f'{self.label_full}|total', ), 'total', ) @@ -890,12 +893,8 @@ def do_modeling(self): if self._has_time_dim: self.total_per_timestep = self.add( self._model.add_variables( - lower=-np.inf - if (self._min_per_hour is None) - else self._min_per_hour * self._model.hours_per_step, - upper=np.inf - if (self._max_per_hour is None) - else self._max_per_hour * self._model.hours_per_step, + lower=-np.inf if (self._min_per_hour is None) else self._min_per_hour * self._model.hours_per_step, + upper=np.inf if (self._max_per_hour is None) else self._max_per_hour * self._model.hours_per_step, coords=self._model.get_coords(time_dim=True, scenario_dim=self._has_scenario_dim), name=f'{self.label_full}|total_per_timestep', ), diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index a4705371c..6985572c1 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -16,8 +16,15 @@ from rich.pretty import Pretty from . import io as fx_io -from .core import TimestepData, TimeSeries, TimeSeriesCollection, TimeSeriesData, Scalar -from .effects import Effect, EffectCollection, EffectTimeSeries, EffectValuesDict, EffectValuesUserScenario, EffectValuesUserTimestep +from .core import Scalar, TimeSeries, TimeSeriesCollection, TimeSeriesData, TimestepData +from .effects import ( + Effect, + EffectCollection, + EffectTimeSeries, + EffectValuesDict, + EffectValuesUserScenario, + EffectValuesUserTimestep, +) from .elements import Bus, Component, Flow from .structure import CLASS_REGISTRY, Element, SystemModel @@ -296,7 +303,7 @@ def create_time_series( has_extra_timestep: Whether the data has an extra timestep """ if not has_time_dim and not has_scenario_dim: - raise ValueError("At least one of the dimensions must be present") + raise ValueError('At least one of the dimensions must be present') if data is None: return None @@ -324,7 +331,7 @@ def create_time_series( has_scenario_dim=has_scenario_dim, has_extra_timestep=has_extra_timestep, aggregation_weight=data.agg_weight, - aggregation_group=data.agg_group + aggregation_group=data.agg_group, ) return self.time_series_collection.add_time_series( data=data, @@ -358,7 +365,7 @@ def create_effect_time_series( has_scenario_dim: Whether the data has a scenario dimension """ if not has_time_dim and not has_scenario_dim: - raise ValueError("At least one of the dimensions must be present") + raise ValueError('At least one of the dimensions must be present') effect_values: Optional[EffectValuesDict] = self.effects.create_effect_values_dict(effect_values) if effect_values is None: diff --git a/flixopt/interface.py b/flixopt/interface.py index f57362ee3..b6eb80c54 100644 --- a/flixopt/interface.py +++ b/flixopt/interface.py @@ -7,7 +7,7 @@ from typing import TYPE_CHECKING, Dict, Iterator, List, Optional, Union from .config import CONFIG -from .core import TimestepData, NumericDataTS, Scalar, ScenarioData +from .core import NumericDataTS, Scalar, ScenarioData, TimestepData from .structure import Interface, register_class_for_io if TYPE_CHECKING: # for type checking and preventing circular imports @@ -34,16 +34,10 @@ def __init__(self, start: TimestepData, end: TimestepData): def transform_data(self, flow_system: 'FlowSystem', name_prefix: str): self.start = flow_system.create_time_series( - name=f'{name_prefix}|start', - data=self.start, - has_time_dim=self.has_time_dim, - has_scenario_dim=True + name=f'{name_prefix}|start', data=self.start, has_time_dim=self.has_time_dim, has_scenario_dim=True ) self.end = flow_system.create_time_series( - name=f'{name_prefix}|end', - data=self.end, - has_time_dim=self.has_time_dim, - has_scenario_dim=True + name=f'{name_prefix}|end', data=self.end, has_time_dim=self.has_time_dim, has_scenario_dim=True ) @@ -222,7 +216,7 @@ def transform_data(self, flow_system: 'FlowSystem', name_prefix: str): has_scenario_dim=True, ) if self.piecewise_effects is not None: - self.piecewise_effects.has_time_dim=False + self.piecewise_effects.has_time_dim = False self.piecewise_effects.transform_data(flow_system, f'{name_prefix}|PiecewiseEffects') @property diff --git a/flixopt/structure.py b/flixopt/structure.py index 91fc6c1d0..37b02b122 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -19,7 +19,7 @@ from rich.pretty import Pretty from .config import CONFIG -from .core import TimestepData, Scalar, TimeSeries, TimeSeriesCollection, TimeSeriesData +from .core import Scalar, TimeSeries, TimeSeriesCollection, TimeSeriesData, TimestepData if TYPE_CHECKING: # for type checking and preventing circular imports from .effects import EffectCollectionModel @@ -99,10 +99,7 @@ def hours_of_previous_timesteps(self): return self.time_series_collection.hours_of_previous_timesteps def get_coords( - self, - scenario_dim = True, - time_dim = True, - extra_timestep = False + self, scenario_dim=True, time_dim=True, extra_timestep=False ) -> Optional[Union[Tuple[pd.Index], Tuple[pd.Index, pd.Index]]]: """ Returns the coordinates of the model @@ -118,7 +115,9 @@ def get_coords( if not scenario_dim and not time_dim: return None scenarios = self.time_series_collection.scenarios - timesteps = self.time_series_collection.timesteps if not extra_timestep else self.time_series_collection.timesteps_extra + timesteps = ( + self.time_series_collection.timesteps if not extra_timestep else self.time_series_collection.timesteps_extra + ) if scenario_dim and time_dim: if scenarios is None: diff --git a/flixopt/utils.py b/flixopt/utils.py index 542f87942..3e65328a4 100644 --- a/flixopt/utils.py +++ b/flixopt/utils.py @@ -11,7 +11,6 @@ logger = logging.getLogger('flixopt') - def round_floats(obj, decimals=2): if isinstance(obj, dict): return {k: round_floats(v, decimals) for k, v in obj.items()} diff --git a/tests/test_dataconverter.py b/tests/test_dataconverter.py index 0466f3a2e..a023b8e58 100644 --- a/tests/test_dataconverter.py +++ b/tests/test_dataconverter.py @@ -378,7 +378,9 @@ def test_all_nan_data(self, sample_time_index, sample_scenario_index): assert np.all(np.isnan(result.values)) # Series of all NaNs - result = DataConverter.as_dataarray(np.array([np.nan, np.nan, np.nan, np.nan, np.nan]), sample_time_index, sample_scenario_index) + result = DataConverter.as_dataarray( + np.array([np.nan, np.nan, np.nan, np.nan, np.nan]), sample_time_index, sample_scenario_index + ) assert np.all(np.isnan(result.values)) def test_mixed_data_types(self, sample_time_index, sample_scenario_index): diff --git a/tests/test_timeseries.py b/tests/test_timeseries.py index 50136536b..d64c13d85 100644 --- a/tests/test_timeseries.py +++ b/tests/test_timeseries.py @@ -111,7 +111,9 @@ def test_reset(self, sample_timeseries, sample_timesteps): def test_restore_data(self, sample_timeseries, simple_dataarray): """Test restore_data method.""" # Modify the stored data - new_data = xr.DataArray([1, 2, 3, 4, 5], coords={'time': sample_timeseries.stored_data.coords['time']}, dims=['time']) + new_data = xr.DataArray( + [1, 2, 3, 4, 5], coords={'time': sample_timeseries.stored_data.coords['time']}, dims=['time'] + ) # Store original data for comparison original_data = sample_timeseries.stored_data @@ -227,7 +229,9 @@ def test_all_equal(self, sample_timesteps): def test_arithmetic_operations(self, sample_timeseries): """Test arithmetic operations.""" # Create a second TimeSeries for testing - data2 = xr.DataArray([1, 2, 3, 4, 5], coords={'time': sample_timeseries.stored_data.coords['time']}, dims=['time']) + data2 = xr.DataArray( + [1, 2, 3, 4, 5], coords={'time': sample_timeseries.stored_data.coords['time']}, dims=['time'] + ) ts2 = TimeSeries(data2, 'Second Series') # Test operations between two TimeSeries objects @@ -284,7 +288,9 @@ def test_numpy_ufunc(self, sample_timeseries): ) # Test with two TimeSeries objects - data2 = xr.DataArray([1, 2, 3, 4, 5], coords={'time': sample_timeseries.stored_data.coords['time']}, dims=['time']) + data2 = xr.DataArray( + [1, 2, 3, 4, 5], coords={'time': sample_timeseries.stored_data.coords['time']}, dims=['time'] + ) ts2 = TimeSeries(data2, 'Second Series') assert np.array_equal( @@ -311,15 +317,15 @@ def sample_scenario_index(): @pytest.fixture def simple_scenario_dataarray(sample_timesteps, sample_scenario_index): """Create a DataArray with both scenario and time dimensions.""" - data = np.array([ - [10, 20, 30, 40, 50], # baseline - [15, 25, 35, 45, 55], # high_demand - [5, 15, 25, 35, 45] # low_price - ]) + data = np.array( + [ + [10, 20, 30, 40, 50], # baseline + [15, 25, 35, 45, 55], # high_demand + [5, 15, 25, 35, 45], # low_price + ] + ) return xr.DataArray( - data=data, - coords={'scenario': sample_scenario_index, 'time': sample_timesteps}, - dims=['scenario', 'time'] + data=data, coords={'scenario': sample_scenario_index, 'time': sample_timesteps}, dims=['scenario', 'time'] ) @@ -412,21 +418,23 @@ def test_all_equal_with_scenarios(self, sample_timesteps, sample_scenario_index) equal_dataarray = xr.DataArray( data=equal_data, coords={'scenario': sample_scenario_index, 'time': sample_timesteps}, - dims=['scenario', 'time'] + dims=['scenario', 'time'], ) ts_equal = TimeSeries(equal_dataarray, 'Equal Scenario Series') assert ts_equal.all_equal is True # Equal within each scenario but different between scenarios - per_scenario_equal = np.array([ - [5, 5, 5, 5, 5], # baseline - all 5 - [10, 10, 10, 10, 10], # high_demand - all 10 - [15, 15, 15, 15, 15] # low_price - all 15 - ]) + per_scenario_equal = np.array( + [ + [5, 5, 5, 5, 5], # baseline - all 5 + [10, 10, 10, 10, 10], # high_demand - all 10 + [15, 15, 15, 15, 15], # low_price - all 15 + ] + ) per_scenario_dataarray = xr.DataArray( data=per_scenario_equal, coords={'scenario': sample_scenario_index, 'time': sample_timesteps}, - dims=['scenario', 'time'] + dims=['scenario', 'time'], ) ts_per_scenario = TimeSeries(per_scenario_dataarray, 'Per-Scenario Equal Series') assert ts_per_scenario.all_equal is False @@ -436,9 +444,7 @@ def test_arithmetic_with_scenarios(self, sample_scenario_timeseries, sample_time # Create a second TimeSeries with scenarios data2 = np.ones((3, 5)) # All ones second_dataarray = xr.DataArray( - data=data2, - coords={'scenario': sample_scenario_index, 'time': sample_timesteps}, - dims=['scenario', 'time'] + data=data2, coords={'scenario': sample_scenario_index, 'time': sample_timesteps}, dims=['scenario', 'time'] ) ts2 = TimeSeries(second_dataarray, 'Second Series') @@ -624,11 +630,7 @@ def test_add_time_series_with_scenarios(self, sample_scenario_allocator): assert np.array_equal(ts2.sel(scenario=scenario).values, data) # Test 2D array (one row per scenario) - data_2d = np.array([ - [10, 20, 30, 40, 50], - [15, 25, 35, 45, 55], - [5, 15, 25, 35, 45] - ]) + data_2d = np.array([[10, 20, 30, 40, 50], [15, 25, 35, 45, 55], [5, 15, 25, 35, 45]]) ts3 = sample_scenario_allocator.add_time_series('scenario_specific_series', data_2d) assert ts3._has_scenarios assert ts3.selected_data.shape == (3, 5) @@ -637,7 +639,9 @@ def test_add_time_series_with_scenarios(self, sample_scenario_allocator): assert np.array_equal(ts3.sel(scenario='high_demand').values, data_2d[1]) assert np.array_equal(ts3.sel(scenario='low_price').values, data_2d[2]) - def test_selection_propagation_with_scenarios(self, sample_scenario_allocator, sample_timesteps, sample_scenario_index): + def test_selection_propagation_with_scenarios( + self, sample_scenario_allocator, sample_timesteps, sample_scenario_index + ): """Test scenario selection propagation.""" # Add some time series ts1 = sample_scenario_allocator.add_time_series('series1', 42) @@ -679,12 +683,7 @@ def test_as_dataset_with_scenarios(self, sample_scenario_allocator): # Add some time series sample_scenario_allocator.add_time_series('scalar_series', 42) sample_scenario_allocator.add_time_series( - 'varying_series', - np.array([ - [10, 20, 30, 40, 50], - [15, 25, 35, 45, 55], - [5, 15, 25, 35, 45] - ]) + 'varying_series', np.array([[10, 20, 30, 40, 50], [15, 25, 35, 45, 55], [5, 15, 25, 35, 45]]) ) # Get dataset @@ -728,11 +727,7 @@ def test_update_time_series_with_scenarios(self, sample_scenario_allocator, samp assert np.all(ts.selected_data.values == 42) # Update with scenario-specific data - new_data = np.array([ - [1, 2, 3, 4, 5], - [6, 7, 8, 9, 10], - [11, 12, 13, 14, 15] - ]) + new_data = np.array([[1, 2, 3, 4, 5], [6, 7, 8, 9, 10], [11, 12, 13, 14, 15]]) sample_scenario_allocator.update_time_series('series', new_data) # Check update was applied From 379252330ebd264b09147de924d45ee35691938b Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 2 Apr 2025 16:36:40 +0200 Subject: [PATCH 005/336] Fix coords in constraints and variables --- flixopt/elements.py | 8 ++++---- flixopt/features.py | 14 ++++++++++---- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/flixopt/elements.py b/flixopt/elements.py index a98edff9d..78dada129 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -361,7 +361,7 @@ def do_modeling(self): self._model.add_variables( lower=self.element.flow_hours_total_min if self.element.flow_hours_total_min is not None else -np.inf, upper=self.element.flow_hours_total_max if self.element.flow_hours_total_max is not None else np.inf, - coords=None, + coords=self._model.get_coords(time_dim=False), name=f'{self.label_full}|total_flow_hours', ), 'total_flow_hours', @@ -369,7 +369,7 @@ def do_modeling(self): self.add( self._model.add_constraints( - self.total_flow_hours == (self.flow_rate * self._model.hours_per_step).sum(), + self.total_flow_hours == (self.flow_rate * self._model.hours_per_step).sum('time'), name=f'{self.label_full}|total_flow_hours', ), 'total_flow_hours', @@ -399,7 +399,7 @@ def _create_bounds_for_load_factor(self): # eq: var_sumFlowHours <= size * dt_tot * load_factor_max if self.element.load_factor_max is not None: name_short = 'load_factor_max' - flow_hours_per_size_max = self._model.hours_per_step.sum() * self.element.load_factor_max + flow_hours_per_size_max = self._model.hours_per_step.sum('time') * self.element.load_factor_max size = self.element.size if self._investment is None else self._investment.size if self._investment is not None: @@ -414,7 +414,7 @@ def _create_bounds_for_load_factor(self): # eq: size * sum(dt)* load_factor_min <= var_sumFlowHours if self.element.load_factor_min is not None: name_short = 'load_factor_min' - flow_hours_per_size_min = self._model.hours_per_step.sum() * self.element.load_factor_min + flow_hours_per_size_min = self._model.hours_per_step.sum('time') * self.element.load_factor_min size = self.element.size if self._investment is None else self._investment.size if self._investment is not None: diff --git a/flixopt/features.py b/flixopt/features.py index a122a9dac..317c0d36f 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -254,6 +254,7 @@ def do_modeling(self): self._model.add_variables( lower=self.parameters.on_hours_total_min if self.parameters.on_hours_total_min is not None else 0, upper=self.parameters.on_hours_total_max if self.parameters.on_hours_total_max is not None else np.inf, + coords=self._model.get_coords(time_dim=False), name=f'{self.label_full}|on_hours_total', ), 'on_hours_total', @@ -261,7 +262,7 @@ def do_modeling(self): self.add( self._model.add_constraints( - self.total_on_hours == (self.on * self._model.hours_per_step).sum(), + self.total_on_hours == (self.on * self._model.hours_per_step).sum('time'), name=f'{self.label_full}|on_hours_total', ), 'on_hours_total', @@ -437,7 +438,7 @@ def _get_duration_in_hours( """ assert binary_variable is not None, f'Duration Variable of {self.label_full} must be defined to add constraints' - mega = self._model.hours_per_step.sum() + previous_duration + mega = self._model.hours_per_step.sum('time') + previous_duration if maximum_duration is not None: first_step_max: Scalar = maximum_duration.isel(time=0) @@ -582,7 +583,7 @@ def _add_switch_constraints(self): # eq: nrSwitchOn = sum(SwitchOn(t)) self.add( self._model.add_constraints( - self.switch_on_nr == self.switch_on.sum(), name=f'{self.label_full}|switch_on_nr' + self.switch_on_nr == self.switch_on.sum('time'), name=f'{self.label_full}|switch_on_nr' ), 'switch_on_nr', ) @@ -973,7 +974,12 @@ def __init__( def do_modeling(self): self.shares = { - effect: self.add(self._model.add_variables(coords=None, name=f'{self.label_full}|{effect}'), f'{effect}') + effect: self.add( + self._model.add_variables( + coords=self._model.get_coords(time_dim=False), + name=f'{self.label_full}|{effect}' + ), + f'{effect}') for effect in self._piecewise_shares } From dcc86f48ea887ede6e03378ab35da6d5e19083c4 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 5 Apr 2025 19:54:47 +0200 Subject: [PATCH 006/336] Feature/scenarios results (#220) # Improvements - Add scenarios to CalculationResults - Add Scenarios to all plotting options, defaulting to the first scenario available - Add the scenario to the plot title and filename if scenario is used - Improve `filter_solution`: Filter by time steps, scenarios and variable dims - Add options `mode` to `node_balance()` and corresponding plotting methods, to allow to get/plot the flow hours instead of the flow_rate. The plot_pie() always does that - Improve docstrings in general --- flixopt/io.py | 2 +- flixopt/results.py | 279 ++++++++++++++++++++++++++++++++++++--------- 2 files changed, 227 insertions(+), 54 deletions(-) diff --git a/flixopt/io.py b/flixopt/io.py index adaf52f55..1ef9578e5 100644 --- a/flixopt/io.py +++ b/flixopt/io.py @@ -44,7 +44,7 @@ def insert_dataarray(obj, ds: xr.Dataset): return [insert_dataarray(v, ds) for v in obj] elif isinstance(obj, str) and obj.startswith('::::'): da = ds[obj[4:]] - if da.isel(time=-1).isnull(): + if 'time' in da.dims and da.isel(time=-1).isnull().any().item(): return da.isel(time=slice(0, -1)) return da else: diff --git a/flixopt/results.py b/flixopt/results.py index d9eb5a654..bbf2dcd7a 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -161,6 +161,7 @@ def __init__( self.timesteps_extra = self.solution.indexes['time'] self.hours_per_timestep = TimeSeriesCollection.calculate_hours_per_timestep(self.timesteps_extra) + self.scenarios = self.solution.indexes['scenario'] if 'scenario' in self.solution.indexes else None def __getitem__(self, key: str) -> Union['ComponentResults', 'BusResults', 'EffectResults']: if key in self.components: @@ -196,19 +197,38 @@ def constraints(self) -> linopy.Constraints: return self.model.constraints def filter_solution( - self, variable_dims: Optional[Literal['scalar', 'time']] = None, element: Optional[str] = None + self, + variable_dims: Optional[Literal['scalar', 'time', 'scenario', 'timeonly']] = None, + element: Optional[str] = None, + timesteps: Optional[pd.DatetimeIndex] = None, + scenarios: Optional[pd.Index] = None, ) -> xr.Dataset: """ Filter the solution to a specific variable dimension and element. If no element is specified, all elements are included. Args: - variable_dims: The dimension of the variables to filter for. + variable_dims: The dimension of which to get variables from. + - 'scalar': Get scalar variables (without dimensions) + - 'time': Get time-dependent variables (with a time dimension) + - 'scenario': Get scenario-dependent variables (with ONLY a scenario dimension) + - 'timeonly': Get time-dependent variables (with ONLY a time dimension) element: The element to filter for. + timesteps: Optional time indexes to select. Can be: + - pd.DatetimeIndex: Multiple timesteps + - str/pd.Timestamp: Single timestep + Defaults to all available timesteps. + scenarios: Optional scenario indexes to select. Can be: + - pd.Index: Multiple scenarios + - str/int: Single scenario (int is treated as a label, not an index position) + Defaults to all available scenarios. """ - if element is not None: - return filter_dataset(self[element].solution, variable_dims) - return filter_dataset(self.solution, variable_dims) + return filter_dataset( + self.solution if element is None else self[element].solution, + variable_dims=variable_dims, + timesteps=timesteps, + scenarios=scenarios, + ) def plot_heatmap( self, @@ -219,10 +239,32 @@ def plot_heatmap( save: Union[bool, pathlib.Path] = False, show: bool = True, engine: plotting.PlottingEngine = 'plotly', + scenario: Optional[Union[str, int]] = None, ) -> Union[plotly.graph_objs.Figure, Tuple[plt.Figure, plt.Axes]]: + """ + Plots a heatmap of the solution of a variable. + + Args: + variable_name: The name of the variable to plot. + heatmap_timeframes: The timeframes to use for the heatmap. + heatmap_timesteps_per_frame: The timesteps per frame to use for the heatmap. + color_map: The color map to use for the heatmap. + save: Whether to save the plot or not. If a path is provided, the plot will be saved at that location. + show: Whether to show the plot or not. + engine: The engine to use for plotting. Can be either 'plotly' or 'matplotlib'. + scenario: The scenario to plot. Defaults to the first scenario. Has no effect without scenarios present + """ + dataarray = self.solution[variable_name] + + scenario_suffix = '' + if 'scenario' in dataarray.indexes: + chosen_scenario = scenario or self.scenarios[0] + dataarray = dataarray.sel(scenario=chosen_scenario).drop_vars('scenario') + scenario_suffix = f'--{chosen_scenario}' + return plot_heatmap( - dataarray=self.solution[variable_name], - name=variable_name, + dataarray=dataarray, + name=f'{variable_name}{scenario_suffix}', folder=self.folder, heatmap_timeframes=heatmap_timeframes, heatmap_timesteps_per_frame=heatmap_timesteps_per_frame, @@ -345,14 +387,37 @@ def constraints(self) -> linopy.Constraints: raise ValueError('The linopy model is not available.') return self._calculation_results.model.constraints[self._variable_names] - def filter_solution(self, variable_dims: Optional[Literal['scalar', 'time']] = None) -> xr.Dataset: + def filter_solution( + self, + variable_dims: Optional[Literal['scalar', 'time', 'scenario', 'timeonly']] = None, + timesteps: Optional[pd.DatetimeIndex] = None, + scenarios: Optional[pd.Index] = None, + ) -> xr.Dataset: """ - Filter the solution of the element by dimension. + Filter the solution to a specific variable dimension and element. + If no element is specified, all elements are included. Args: - variable_dims: The dimension of the variables to filter for. + variable_dims: The dimension of which to get variables from. + - 'scalar': Get scalar variables (without dimensions) + - 'time': Get time-dependent variables (with a time dimension) + - 'scenario': Get scenario-dependent variables (with ONLY a scenario dimension) + - 'timeonly': Get time-dependent variables (with ONLY a time dimension) + timesteps: Optional time indexes to select. Can be: + - pd.DatetimeIndex: Multiple timesteps + - str/pd.Timestamp: Single timestep + Defaults to all available timesteps. + scenarios: Optional scenario indexes to select. Can be: + - pd.Index: Multiple scenarios + - str/int: Single scenario (int is treated as a label, not an index position) + Defaults to all available scenarios. """ - return filter_dataset(self.solution, variable_dims) + return filter_dataset( + self.solution, + variable_dims=variable_dims, + timesteps=timesteps, + scenarios=scenarios, + ) class _NodeResults(_ElementResults): @@ -386,28 +451,46 @@ def plot_node_balance( show: bool = True, colors: plotting.ColorType = 'viridis', engine: plotting.PlottingEngine = 'plotly', + scenario: Optional[Union[str, int]] = None, + mode: Literal["flow_rate", "flow_hours"] = 'flow_rate', + drop_suffix: bool = True, ) -> Union[plotly.graph_objs.Figure, Tuple[plt.Figure, plt.Axes]]: """ Plots the node balance of the Component or Bus. Args: save: Whether to save the plot or not. If a path is provided, the plot will be saved at that location. show: Whether to show the plot or not. + colors: The colors to use for the plot. See `flixopt.plotting.ColorType` for options. engine: The engine to use for plotting. Can be either 'plotly' or 'matplotlib'. + scenario: The scenario to plot. Defaults to the first scenario. Has no effect without scenarios present + mode: The mode to use for the dataset. Can be 'flow_rate' or 'flow_hours'. + - 'flow_rate': Returns the flow_rates of the Node. + - 'flow_hours': Returns the flow_hours of the Node. [flow_hours(t) = flow_rate(t) * dt(t)]. Renames suffixes to |flow_hours. + drop_suffix: Whether to drop the suffix from the variable names. """ + ds = self.node_balance(with_last_timestep=True, mode=mode, drop_suffix=drop_suffix) + + title = f'{self.label} (flow rates)' if mode == 'flow_rate' else f'{self.label} (flow hours)' + + if 'scenario' in ds.indexes: + chosen_scenario = scenario or self._calculation_results.scenarios[0] + ds = ds.sel(scenario=chosen_scenario).drop_vars('scenario') + title = f'{title} - {chosen_scenario}' + if engine == 'plotly': figure_like = plotting.with_plotly( - self.node_balance(with_last_timestep=True).to_dataframe(), + ds.to_dataframe(), colors=colors, mode='area', - title=f'Flow rates of {self.label}', + title=title, ) default_filetype = '.html' elif engine == 'matplotlib': figure_like = plotting.with_matplotlib( - self.node_balance(with_last_timestep=True).to_dataframe(), + ds.to_dataframe(), colors=colors, mode='bar', - title=f'Flow rates of {self.label}', + title=title, ) default_filetype = '.png' else: @@ -415,7 +498,7 @@ def plot_node_balance( return plotting.export_figure( figure_like=figure_like, - default_path=self._calculation_results.folder / f'{self.label} (flow rates)', + default_path=self._calculation_results.folder / title, default_filetype=default_filetype, user_path=None if isinstance(save, bool) else pathlib.Path(save), show=show, @@ -430,6 +513,8 @@ def plot_node_balance_pie( save: Union[bool, pathlib.Path] = False, show: bool = True, engine: plotting.PlottingEngine = 'plotly', + scenario: Optional[Union[str, int]] = None, + drop_suffix: bool = True, ) -> plotly.graph_objects.Figure: """ Plots a pie chart of the flow hours of the inputs and outputs of buses or components. @@ -441,32 +526,45 @@ def plot_node_balance_pie( save: Whether to save the figure. show: Whether to show the figure. engine: Plotting engine to use. Only 'plotly' is implemented atm. + scenario: If scenarios are present: The scenario to plot. If None, the first scenario is used. + drop_suffix: Whether to drop the suffix from the variable names. """ - inputs = ( - sanitize_dataset( - ds=self.solution[self.inputs], - threshold=1e-5, - drop_small_vars=True, - zero_small_values=True, - ) - * self._calculation_results.hours_per_timestep + inputs = sanitize_dataset( + ds=self.solution[self.inputs], + threshold=1e-5, + drop_small_vars=True, + zero_small_values=True, ) - outputs = ( - sanitize_dataset( - ds=self.solution[self.outputs], - threshold=1e-5, - drop_small_vars=True, - zero_small_values=True, - ) - * self._calculation_results.hours_per_timestep + outputs = sanitize_dataset( + ds=self.solution[self.outputs], + threshold=1e-5, + drop_small_vars=True, + zero_small_values=True, ) + inputs = inputs.sum('time') + outputs = outputs.sum('time') + + title = f'{self.label} (total flow hours)' + + if 'scenario' in inputs.indexes: + chosen_scenario = scenario or self._calculation_results.scenarios[0] + inputs = inputs.sel(scenario=chosen_scenario).drop_vars('scenario') + outputs = outputs.sel(scenario=chosen_scenario).drop_vars('scenario') + title = f'{title} - {chosen_scenario}' + + if drop_suffix: + inputs = inputs.rename_vars({var: var.split('|flow_rate')[0] for var in inputs}) + outputs = outputs.rename_vars({var: var.split('|flow_rate')[0] for var in outputs}) + else: + inputs = inputs.rename_vars({var: var.replace('flow_rate', 'flow_hours') for var in inputs}) + outputs = outputs.rename_vars({var: var.replace('flow_rate', 'flow_hours') for var in outputs}) if engine == 'plotly': figure_like = plotting.dual_pie_with_plotly( - inputs.to_dataframe().sum(), - outputs.to_dataframe().sum(), + data_left=inputs.to_pandas(), + data_right=outputs.to_pandas(), colors=colors, - title=f'Flow hours of {self.label}', + title=title, text_info=text_info, subtitles=('Inputs', 'Outputs'), legend_title='Flows', @@ -476,10 +574,10 @@ def plot_node_balance_pie( elif engine == 'matplotlib': logger.debug('Parameter text_info is not supported for matplotlib') figure_like = plotting.dual_pie_with_matplotlib( - inputs.to_dataframe().sum(), - outputs.to_dataframe().sum(), + data_left=inputs.to_pandas(), + data_right=outputs.to_pandas(), colors=colors, - title=f'Total flow hours of {self.label}', + title=title, subtitles=('Inputs', 'Outputs'), legend_title='Flows', lower_percentage_group=lower_percentage_group, @@ -490,7 +588,7 @@ def plot_node_balance_pie( return plotting.export_figure( figure_like=figure_like, - default_path=self._calculation_results.folder / f'{self.label} (total flow hours)', + default_path=self._calculation_results.folder / title, default_filetype=default_filetype, user_path=None if isinstance(save, bool) else pathlib.Path(save), show=show, @@ -503,9 +601,29 @@ def node_balance( negate_outputs: bool = False, threshold: Optional[float] = 1e-5, with_last_timestep: bool = False, + mode: Literal["flow_rate", "flow_hours"] = 'flow_rate', + drop_suffix: bool = False, ) -> xr.Dataset: + """ + Returns a dataset with the node balance of the Component or Bus. + Args: + negate_inputs: Whether to negate the input flow_rates of the Node. + negate_outputs: Whether to negate the output flow_rates of the Node. + threshold: The threshold for small values. Variables with all values below the threshold are dropped. + with_last_timestep: Whether to include the last timestep in the dataset. + mode: The mode to use for the dataset. Can be 'flow_rate' or 'flow_hours'. + - 'flow_rate': Returns the flow_rates of the Node. + - 'flow_hours': Returns the flow_hours of the Node. [flow_hours(t) = flow_rate(t) * dt(t)]. Renames suffixes to |flow_hours. + drop_suffix: Whether to drop the suffix from the variable names. + """ + ds = self.solution[self.inputs + self.outputs] + if drop_suffix: + ds = ds.rename_vars({var: var.split('|flow_hours')[0] for var in ds.data_vars}) + if mode == 'flow_hours': + ds = ds * self._calculation_results.hours_per_timestep + ds = ds.rename_vars({var: var.replace('flow_rate', 'flow_hours') for var in ds.data_vars}) return sanitize_dataset( - ds=self.solution[self.inputs + self.outputs], + ds=ds, threshold=threshold, timesteps=self._calculation_results.timesteps_extra if with_last_timestep else None, negate=( @@ -548,6 +666,7 @@ def plot_charge_state( show: bool = True, colors: plotting.ColorType = 'viridis', engine: plotting.PlottingEngine = 'plotly', + scenario: Optional[Union[str, int]] = None, ) -> plotly.graph_objs.Figure: """ Plots the charge state of a Storage. @@ -556,6 +675,7 @@ def plot_charge_state( show: Whether to show the plot or not. colors: The c engine: Plotting engine to use. Only 'plotly' is implemented atm. + scenario: The scenario to plot. Defaults to the first scenario. Has no effect without scenarios present Raises: ValueError: If the Component is not a Storage. @@ -568,16 +688,26 @@ def plot_charge_state( if not self.is_storage: raise ValueError(f'Cant plot charge_state. "{self.label}" is not a storage') + ds = self.node_balance(with_last_timestep=True) + charge_state = self.charge_state + + scenario_suffix = '' + if 'scenario' in ds.indexes: + chosen_scenario = scenario or self._calculation_results.scenarios[0] + ds = ds.sel(scenario=chosen_scenario).drop_vars('scenario') + charge_state = charge_state.sel(scenario=chosen_scenario).drop_vars('scenario') + scenario_suffix = f'--{chosen_scenario}' + fig = plotting.with_plotly( - self.node_balance(with_last_timestep=True).to_dataframe(), + ds.to_dataframe(), colors=colors, mode='area', - title=f'Operation Balance of {self.label}', + title=f'Operation Balance of {self.label}{scenario_suffix}', ) # TODO: Use colors for charge state? - charge_state = self.charge_state.to_dataframe() + charge_state = charge_state.to_dataframe() fig.add_trace( plotly.graph_objs.Scatter( x=charge_state.index, y=charge_state.values.flatten(), mode='lines', name=self._charge_state @@ -586,7 +716,7 @@ def plot_charge_state( return plotting.export_figure( fig, - default_path=self._calculation_results.folder / f'{self.label} (charge state)', + default_path=self._calculation_results.folder / f'{self.label} (charge state){scenario_suffix}', default_filetype='.html', user_path=None if isinstance(save, bool) else pathlib.Path(save), show=show, @@ -878,21 +1008,64 @@ def sanitize_dataset( def filter_dataset( ds: xr.Dataset, - variable_dims: Optional[Literal['scalar', 'time']] = None, + variable_dims: Optional[Literal['scalar', 'time', 'scenario', 'timeonly']] = None, + timesteps: Optional[Union[pd.DatetimeIndex, str, pd.Timestamp]] = None, + scenarios: Optional[Union[pd.Index, str, int]] = None, ) -> xr.Dataset: """ - Filters a dataset by its dimensions. + Filters a dataset by its dimensions and optionally selects specific indexes. Args: ds: The dataset to filter. - variable_dims: The dimension of the variables to filter for. + variable_dims: The dimension of which to get variables from. + - 'scalar': Get scalar variables (without dimensions) + - 'time': Get time-dependent variables (with a time dimension) + - 'scenario': Get scenario-dependent variables (with ONLY a scenario dimension) + - 'timeonly': Get time-dependent variables (with ONLY a time dimension) + timesteps: Optional time indexes to select. Can be: + - pd.DatetimeIndex: Multiple timesteps + - str/pd.Timestamp: Single timestep + Defaults to all available timesteps. + scenarios: Optional scenario indexes to select. Can be: + - pd.Index: Multiple scenarios + - str/int: Single scenario (int is treated as a label, not an index position) + Defaults to all available scenarios. + + Returns: + Filtered dataset with specified variables and indexes. """ + # Return the full dataset if all dimension types are included if variable_dims is None: - return ds - - if variable_dims == 'scalar': - return ds[[name for name, da in ds.data_vars.items() if len(da.dims) == 0]] + pass + elif variable_dims == 'scalar': + ds = ds[[v for v in ds.data_vars if not ds[v].dims]] elif variable_dims == 'time': - return ds[[name for name, da in ds.data_vars.items() if 'time' in da.dims]] + ds = ds[[v for v in ds.data_vars if 'time' in ds[v].dims]] + elif variable_dims == 'scenario': + ds = ds[[v for v in ds.data_vars if ds[v].dims == ('scenario',)]] + elif variable_dims == 'timeonly': + ds = ds[[v for v in ds.data_vars if ds[v].dims == ('time',)]] else: - raise ValueError(f'Not allowed value for "filter_dataset()": {variable_dims=}') + raise ValueError(f'Unknown variable_dims "{variable_dims}" for filter_dataset') + + # Handle time selection if needed + if timesteps is not None and 'time' in ds.dims: + try: + ds = ds.sel(time=timesteps) + except KeyError: + available_times = set(ds.indexes['time']) + requested_times = set([timesteps]) if not isinstance(timesteps, pd.Index) else set(timesteps) + missing_times = requested_times - available_times + raise ValueError(f'Timesteps not found in dataset: {missing_times}. Available times: {available_times}') + + # Handle scenario selection if needed + if scenarios is not None and 'scenario' in ds.dims: + try: + ds = ds.sel(scenario=scenarios) + except KeyError: + available_scenarios = set(ds.indexes['scenario']) + requested_scenarios = set([scenarios]) if not isinstance(scenarios, pd.Index) else set(scenarios) + missing_scenarios = requested_scenarios - available_scenarios + raise ValueError(f'Scenarios not found in dataset: {missing_scenarios}. Available scenarios: {available_scenarios}') + + return ds From bd1d2b6c076bb4b956a024cd767daf7f693aaca7 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 5 Apr 2025 20:10:09 +0200 Subject: [PATCH 007/336] ruff check and format --- flixopt/features.py | 6 +++--- flixopt/results.py | 16 ++++++++++------ 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/flixopt/features.py b/flixopt/features.py index 317c0d36f..e12c0b20f 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -976,10 +976,10 @@ def do_modeling(self): self.shares = { effect: self.add( self._model.add_variables( - coords=self._model.get_coords(time_dim=False), - name=f'{self.label_full}|{effect}' + coords=self._model.get_coords(time_dim=False), name=f'{self.label_full}|{effect}' ), - f'{effect}') + f'{effect}', + ) for effect in self._piecewise_shares } diff --git a/flixopt/results.py b/flixopt/results.py index bbf2dcd7a..757adb790 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -452,7 +452,7 @@ def plot_node_balance( colors: plotting.ColorType = 'viridis', engine: plotting.PlottingEngine = 'plotly', scenario: Optional[Union[str, int]] = None, - mode: Literal["flow_rate", "flow_hours"] = 'flow_rate', + mode: Literal['flow_rate', 'flow_hours'] = 'flow_rate', drop_suffix: bool = True, ) -> Union[plotly.graph_objs.Figure, Tuple[plt.Figure, plt.Axes]]: """ @@ -601,7 +601,7 @@ def node_balance( negate_outputs: bool = False, threshold: Optional[float] = 1e-5, with_last_timestep: bool = False, - mode: Literal["flow_rate", "flow_hours"] = 'flow_rate', + mode: Literal['flow_rate', 'flow_hours'] = 'flow_rate', drop_suffix: bool = False, ) -> xr.Dataset: """ @@ -1052,20 +1052,24 @@ def filter_dataset( if timesteps is not None and 'time' in ds.dims: try: ds = ds.sel(time=timesteps) - except KeyError: + except KeyError as e: available_times = set(ds.indexes['time']) requested_times = set([timesteps]) if not isinstance(timesteps, pd.Index) else set(timesteps) missing_times = requested_times - available_times - raise ValueError(f'Timesteps not found in dataset: {missing_times}. Available times: {available_times}') + raise ValueError( + f'Timesteps not found in dataset: {missing_times}. Available times: {available_times}' + ) from e # Handle scenario selection if needed if scenarios is not None and 'scenario' in ds.dims: try: ds = ds.sel(scenario=scenarios) - except KeyError: + except KeyError as e: available_scenarios = set(ds.indexes['scenario']) requested_scenarios = set([scenarios]) if not isinstance(scenarios, pd.Index) else set(scenarios) missing_scenarios = requested_scenarios - available_scenarios - raise ValueError(f'Scenarios not found in dataset: {missing_scenarios}. Available scenarios: {available_scenarios}') + raise ValueError( + f'Scenarios not found in dataset: {missing_scenarios}. Available scenarios: {available_scenarios}' + ) from e return ds From 28a46dc8ce9060237b1f2df416210521165d4800 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 5 Apr 2025 20:48:01 +0200 Subject: [PATCH 008/336] Feature/scenarios dims order (#219) Make time to always be the first dimension, improving output and consistency across the code --- examples/04_Scenarios/scenario_example.py | 3 ++- flixopt/core.py | 14 +++++++------- flixopt/results.py | 20 ++++++++++++++++---- flixopt/structure.py | 2 +- 4 files changed, 26 insertions(+), 13 deletions(-) diff --git a/examples/04_Scenarios/scenario_example.py b/examples/04_Scenarios/scenario_example.py index a004d1851..8e3349a4a 100644 --- a/examples/04_Scenarios/scenario_example.py +++ b/examples/04_Scenarios/scenario_example.py @@ -11,7 +11,8 @@ if __name__ == '__main__': # --- Create Time Series Data --- # Heat demand profile (e.g., kW) over time and corresponding power prices - heat_demand_per_h = np.array([[30, 0, 90, 110, 110, 20, 20, 20, 20], [30, 0, 100, 118, 125, 20, 20, 20, 20]]) + heat_demand_per_h = np.array([[30, 0, 90, 110, 110, 20, 20, 20, 20], + [30, 0, 100, 118, 125, 20, 20, 20, 20]]).T power_prices = np.ones(9) * 0.08 # Create datetime array starting from '2020-01-01' for the given time period diff --git a/flixopt/core.py b/flixopt/core.py index 68d1ddaad..386a1d873 100644 --- a/flixopt/core.py +++ b/flixopt/core.py @@ -146,14 +146,14 @@ def _prepare_dimensions( coords = {} dims = [] - if scenarios is not None: - coords['scenario'] = scenarios - dims.append('scenario') - if timesteps is not None: coords['time'] = timesteps dims.append('time') + if scenarios is not None: + coords['scenario'] = scenarios + dims.append('scenario') + return coords, tuple(dims) @staticmethod @@ -340,18 +340,18 @@ def _convert_ndarray_two_dims(data: np.ndarray, coords: Dict[str, pd.Index], dim # For 1D array, create 2D array based on which dimension it matches if data.shape[0] == time_length: # Broadcast across scenarios - values = np.tile(data, (scenario_length, 1)) + values = np.repeat(data[:, np.newaxis], scenario_length, axis=1) return xr.DataArray(values, coords=coords, dims=dims) elif data.shape[0] == scenario_length: # Broadcast across time - values = np.repeat(data[:, np.newaxis], time_length, axis=1) + values = np.tile(data, (time_length, 1)) return xr.DataArray(values, coords=coords, dims=dims) else: raise ConversionError(f"1D array length {data.shape[0]} doesn't match either dimension") elif data.ndim == 2: # For 2D array, shape must match dimensions - expected_shape = (scenario_length, time_length) + expected_shape = (time_length, scenario_length) if data.shape != expected_shape: raise ConversionError(f"2D array shape {data.shape} doesn't match expected shape {expected_shape}") return xr.DataArray(data, coords=coords, dims=dims) diff --git a/flixopt/results.py b/flixopt/results.py index 757adb790..280f20d0d 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -198,7 +198,7 @@ def constraints(self) -> linopy.Constraints: def filter_solution( self, - variable_dims: Optional[Literal['scalar', 'time', 'scenario', 'timeonly']] = None, + variable_dims: Optional[Literal['scalar', 'time', 'scenario', 'timeonly', 'scenarioonly']] = None, element: Optional[str] = None, timesteps: Optional[pd.DatetimeIndex] = None, scenarios: Optional[pd.Index] = None, @@ -213,6 +213,7 @@ def filter_solution( - 'time': Get time-dependent variables (with a time dimension) - 'scenario': Get scenario-dependent variables (with ONLY a scenario dimension) - 'timeonly': Get time-dependent variables (with ONLY a time dimension) + - 'scenarioonly': Get scenario-dependent variables (with ONLY a scenario dimension) element: The element to filter for. timesteps: Optional time indexes to select. Can be: - pd.DatetimeIndex: Multiple timesteps @@ -389,7 +390,7 @@ def constraints(self) -> linopy.Constraints: def filter_solution( self, - variable_dims: Optional[Literal['scalar', 'time', 'scenario', 'timeonly']] = None, + variable_dims: Optional[Literal['scalar', 'time', 'scenario', 'timeonly', 'scenarioonly']] = None, timesteps: Optional[pd.DatetimeIndex] = None, scenarios: Optional[pd.Index] = None, ) -> xr.Dataset: @@ -403,6 +404,7 @@ def filter_solution( - 'time': Get time-dependent variables (with a time dimension) - 'scenario': Get scenario-dependent variables (with ONLY a scenario dimension) - 'timeonly': Get time-dependent variables (with ONLY a time dimension) + - 'scenarioonly': Get scenario-dependent variables (with ONLY a scenario dimension) timesteps: Optional time indexes to select. Can be: - pd.DatetimeIndex: Multiple timesteps - str/pd.Timestamp: Single timestep @@ -559,6 +561,13 @@ def plot_node_balance_pie( inputs = inputs.rename_vars({var: var.replace('flow_rate', 'flow_hours') for var in inputs}) outputs = outputs.rename_vars({var: var.replace('flow_rate', 'flow_hours') for var in outputs}) + scenario_suffix = '' + if 'scenario' in inputs.indexes: + chosen_scenario = scenario or self._calculation_results.scenarios[0] + inputs = inputs.sel(scenario=chosen_scenario).drop_vars('scenario') + outputs = outputs.sel(scenario=chosen_scenario).drop_vars('scenario') + scenario_suffix = f'--{chosen_scenario}' + if engine == 'plotly': figure_like = plotting.dual_pie_with_plotly( data_left=inputs.to_pandas(), @@ -1008,7 +1017,7 @@ def sanitize_dataset( def filter_dataset( ds: xr.Dataset, - variable_dims: Optional[Literal['scalar', 'time', 'scenario', 'timeonly']] = None, + variable_dims: Optional[Literal['scalar', 'time', 'scenario', 'timeonly', 'scenarioonly']] = None, timesteps: Optional[Union[pd.DatetimeIndex, str, pd.Timestamp]] = None, scenarios: Optional[Union[pd.Index, str, int]] = None, ) -> xr.Dataset: @@ -1022,6 +1031,7 @@ def filter_dataset( - 'time': Get time-dependent variables (with a time dimension) - 'scenario': Get scenario-dependent variables (with ONLY a scenario dimension) - 'timeonly': Get time-dependent variables (with ONLY a time dimension) + - 'scenarioonly': Get scenario-dependent variables (with ONLY a scenario dimension) timesteps: Optional time indexes to select. Can be: - pd.DatetimeIndex: Multiple timesteps - str/pd.Timestamp: Single timestep @@ -1042,9 +1052,11 @@ def filter_dataset( elif variable_dims == 'time': ds = ds[[v for v in ds.data_vars if 'time' in ds[v].dims]] elif variable_dims == 'scenario': - ds = ds[[v for v in ds.data_vars if ds[v].dims == ('scenario',)]] + ds = ds[[v for v in ds.data_vars if 'scenario' in ds[v].dims]] elif variable_dims == 'timeonly': ds = ds[[v for v in ds.data_vars if ds[v].dims == ('time',)]] + elif variable_dims == 'scenarioonly': + ds = ds[[v for v in ds.data_vars if ds[v].dims == ('scenario',)]] else: raise ValueError(f'Unknown variable_dims "{variable_dims}" for filter_dataset') diff --git a/flixopt/structure.py b/flixopt/structure.py index 37b02b122..7282d3b7c 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -122,7 +122,7 @@ def get_coords( if scenario_dim and time_dim: if scenarios is None: return (timesteps,) - return scenarios, timesteps + return timesteps, scenarios if scenario_dim and not time_dim: if scenarios is None: From 479c1eb4f1c62cba5cfeaa689f258c9808144a0d Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 9 Apr 2025 09:44:02 +0200 Subject: [PATCH 009/336] Bugfix main results --- flixopt/calculation.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/flixopt/calculation.py b/flixopt/calculation.py index 962d2c95f..5329bc0f9 100644 --- a/flixopt/calculation.py +++ b/flixopt/calculation.py @@ -105,15 +105,15 @@ def main_results(self) -> Dict[str, Union[Scalar, Dict]]: 'Buses with excess': [ { bus.label_full: { - 'input': np.sum(bus.model.excess_input.solution.values), - 'output': np.sum(bus.model.excess_output.solution.values), + 'input': bus.model.excess_input.solution.sum('time'), + 'output': bus.model.excess_output.solution.sum('time'), } } for bus in self.flow_system.buses.values() if bus.with_excess and ( - np.sum(bus.model.excess_input.solution.values) > 1e-3 - or np.sum(bus.model.excess_output.solution.values) > 1e-3 + bus.model.excess_input.solution.sum() > 1e-3 + or bus.model.excess_output.solution.sum() > 1e-3 ) ], } From a611672c39e7a0a824ee421e4c72af6d493c8c6b Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 9 Apr 2025 09:47:58 +0200 Subject: [PATCH 010/336] Remove code duplicate --- flixopt/results.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/flixopt/results.py b/flixopt/results.py index 280f20d0d..ae54b9e2e 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -561,13 +561,6 @@ def plot_node_balance_pie( inputs = inputs.rename_vars({var: var.replace('flow_rate', 'flow_hours') for var in inputs}) outputs = outputs.rename_vars({var: var.replace('flow_rate', 'flow_hours') for var in outputs}) - scenario_suffix = '' - if 'scenario' in inputs.indexes: - chosen_scenario = scenario or self._calculation_results.scenarios[0] - inputs = inputs.sel(scenario=chosen_scenario).drop_vars('scenario') - outputs = outputs.sel(scenario=chosen_scenario).drop_vars('scenario') - scenario_suffix = f'--{chosen_scenario}' - if engine == 'plotly': figure_like = plotting.dual_pie_with_plotly( data_left=inputs.to_pandas(), From 9a8724e94c2d53a0ea9a13fce5bd14474087ffc8 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 9 Apr 2025 09:57:37 +0200 Subject: [PATCH 011/336] Feature/scenarios invest (#227) Add different modes to handle the size per scenario. --- .../Mathematical Notation/Investment.md | 115 ++++++++++++++++++ flixopt/calculation.py | 4 +- flixopt/components.py | 4 +- flixopt/elements.py | 4 +- flixopt/features.py | 88 +++++++++++++- flixopt/interface.py | 56 +++++---- 6 files changed, 235 insertions(+), 36 deletions(-) create mode 100644 docs/user-guide/Mathematical Notation/Investment.md diff --git a/docs/user-guide/Mathematical Notation/Investment.md b/docs/user-guide/Mathematical Notation/Investment.md new file mode 100644 index 000000000..64467261d --- /dev/null +++ b/docs/user-guide/Mathematical Notation/Investment.md @@ -0,0 +1,115 @@ +# Investments + +## Current state +$$ +\beta_{\text{invest}} \cdot \text{max}(\epsilon, \text V^{\text L}) \leq V \leq \beta_{\text{invest}} \cdot \text V^{\text U} +$$ +With: +- $V$ = size +- $V^{\text L}$ = minimum size +- $V^{\text U}$ = maximum size +- $\epsilon$ = epsilon, a small number (such as $1e^{-5}$) +- $\beta_{invest} \in {0,1}$ = wether the size is invested or not + +_Please edit the use cases as needed_ +## Quickfix 1: Optimize the single best size overall +### Single variable +This is already possible and should be, as this is a needed use case +An additional factor to when the size is actually available might me practical (Which indicates the (fixed) time of investment) +## Math +$$ +V(p) = V * a(p) +$$ +with: +- $V$ = size +- $a(p)$ = factor for availlability per period + +Factor $a(p)$ is simply multiplied with relative minimum or maximum(t). This is already possible by doing this yourself. +Effectively, the relative minimum or maximum are altered before using the same constraiints as before. +THis might lead to some issues regariding minimum_load factor, or others, as the size is not 0 in a scenario where the component cant produce. +**Therefore this might not be the best choice. See (#Variable per Scenario) + +## Variable per Scenario +- **size** and **invest** as a variable per period $V(s)$ and $\beta_{invest}(s)$ +- with scenario $s \in S$ + +### Usecase 1: Optimize the size for each Scenario independently +Restrictions are seperatly for each scenario +No changes needed. This could be the default behaviour. + +### Usecase 2: Optimize ONE size for ALL scenarios +The size is the same globally, but not a scalar, but a variable per scenario $V(s)$ +#### 2a: The same size in all scenarios +$$ +V(s) = V(s') \quad \forall s,s' \in S +$$ + +With: +- $V(s)$ and $V(s')$ = size +- $S$ = set of scenarios + +#### 2b: The same size, but can be 0 prior to the first increment +- Find the Optimal time of investment. +- Force an investment in a certain scenario (parameter optional as a list/array ob booleans) +- Combine optional and minimum/maximum size to force an investment inside a range if scenarios + +$$ +\beta_{\text{invest}}(s) \leq \beta_{\text{invest}}(s+1) \quad \forall s \in \{1,2,\ldots,S-1\} +$$ + +$$ +V(s') - V(s) \leq M \cdot (2 - \beta_{\text{invest}}(s) - \beta_{\text{invest}}(s')) \quad \forall s, s' \in S +$$ +$$ +V(s') - V(s) \geq M \cdot (2 - \beta_{\text{invest}}(s) - \beta_{\text{invest}}(s')) \quad \forall s, s' \in S +$$ + +This could be the default behaviour. (which would be consistent with other variables) + + +### Switch + +$$ +\begin{aligned} +& \text{SWITCH}_s \in \{0,1\} \quad \forall s \in \{1,2,\ldots,S\} \\ +& \sum_{s=1}^{S} \text{SWITCH}_s = 1 \\ +& \beta_{\text{invest}}(s) = \sum_{s'=1}^{s} \text{SWITCH}_{s'} \quad \forall s \in \{1,2,\ldots,S\} \\ +\end{aligned} +$$ + +$$ +\begin{aligned} +& V(s) \leq V_{\text{actual}} \quad \forall s \in \{1,2,\ldots,S\} \\ +& V(s) \geq V_{\text{actual}} - M \cdot (1 - \beta_{\text{invest}}(s)) \quad \forall s \in \{1,2,\ldots,S\} +\end{aligned} +$$ + + + + +### Usecase 3: Find the best scenario to increment the size (Timing of the investment) +The size can only increment once (based on a starting point). This allows to optimize the timing of an investment. +#### Math +Treat $\beta_{invest}$ like an ON/OFF variable, and introduce a SwitchOn, that can only be active once. + +*Thoughts:* +- Treating $\beta_{invest}$ like an ON/OFF variable suggest using the already presentconstraints linked to On/OffModel +- The timing could be constraint to be first in scenario x, or last in scenario y +- Restrict the number of consecutive scenarios +THis might needs the OnOffModel to be more generic (HOURS). Further, the span between scenarios needs to be weighted (like dt_in_hours), or the scenarios need to be measureable (integers) + + +### Others + +#### Usecase 4: Only increase/decrease the size +Start from a certain size. For each scenario, the size can increase, but never decrease. (Or the other way around). +This would mean that a size expansion is possible, + +#### Usecase 5: Restrict the increment in size per scenario +Restrict how much the size can increase/decrease for in scenario, based on the prior scenario. + + + + + +Many more are possible diff --git a/flixopt/calculation.py b/flixopt/calculation.py index 5329bc0f9..2024739ea 100644 --- a/flixopt/calculation.py +++ b/flixopt/calculation.py @@ -93,13 +93,13 @@ def main_results(self) -> Dict[str, Union[Scalar, Dict]]: model.label_of_element: model.size.solution for component in self.flow_system.components.values() for model in component.model.all_sub_models - if isinstance(model, InvestmentModel) and model.size.solution >= CONFIG.modeling.EPSILON + if isinstance(model, InvestmentModel) and model.size.solution.max() >= CONFIG.modeling.EPSILON }, 'Not invested': { model.label_of_element: model.size.solution for component in self.flow_system.components.values() for model in component.model.all_sub_models - if isinstance(model, InvestmentModel) and model.size.solution < CONFIG.modeling.EPSILON + if isinstance(model, InvestmentModel) and model.size.solution.max() < CONFIG.modeling.EPSILON }, }, 'Buses with excess': [ diff --git a/flixopt/components.py b/flixopt/components.py index 69b0fe47a..598ff06ab 100644 --- a/flixopt/components.py +++ b/flixopt/components.py @@ -578,8 +578,8 @@ def absolute_charge_state_bounds(self) -> Tuple[TimestepData, TimestepData]: @property def relative_charge_state_bounds(self) -> Tuple[TimestepData, TimestepData]: return ( - self.element.relative_minimum_charge_state.selected_data, - self.element.relative_maximum_charge_state.selected_data, + self.element.relative_minimum_charge_state, + self.element.relative_maximum_charge_state, ) diff --git a/flixopt/elements.py b/flixopt/elements.py index 78dada129..aa1c8e69b 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -434,8 +434,8 @@ def absolute_flow_rate_bounds(self) -> Tuple[TimestepData, TimestepData]: if not isinstance(size, InvestParameters): return relative_minimum * size, relative_maximum * size if size.fixed_size is not None: - return relative_minimum * size.fixed_size, relative_maximum * size.fixed_size - return relative_minimum * size.minimum_size, relative_maximum * size.maximum_size + return size.fixed_size * relative_minimum, size.fixed_size * relative_maximum + return size.minimum_size * relative_minimum, size.maximum_size * relative_maximum @property def relative_flow_rate_bounds(self) -> Tuple[TimestepData, TimestepData]: diff --git a/flixopt/features.py b/flixopt/features.py index e12c0b20f..3d2984393 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -33,6 +33,7 @@ def __init__( super().__init__(model, label_of_element, label) self.size: Optional[Union[Scalar, linopy.Variable]] = None self.is_invested: Optional[linopy.Variable] = None + self.scenario_of_investment: Optional[linopy.Variable] = None self.piecewise_effects: Optional[PiecewiseEffectsModel] = None @@ -45,16 +46,18 @@ def do_modeling(self): if self.parameters.fixed_size and not self.parameters.optional: self.size = self.add( self._model.add_variables( - lower=self.parameters.fixed_size, upper=self.parameters.fixed_size, name=f'{self.label_full}|size' + lower=self.parameters.fixed_size, upper=self.parameters.fixed_size, name=f'{self.label_full}|size', + coords=self._model.get_coords(time_dim=False), ), 'size', ) else: self.size = self.add( self._model.add_variables( - lower=0 if self.parameters.optional else self.parameters.minimum_size, - upper=self.parameters.maximum_size, + lower=0 if self.parameters.optional else self.parameters.minimum_size*1, + upper=self.parameters.maximum_size*1, name=f'{self.label_full}|size', + coords=self._model.get_coords(time_dim=False), ), 'size', ) @@ -62,11 +65,19 @@ def do_modeling(self): # Optional if self.parameters.optional: self.is_invested = self.add( - self._model.add_variables(binary=True, name=f'{self.label_full}|is_invested'), 'is_invested' + self._model.add_variables( + binary=True, + name=f'{self.label_full}|is_invested', + coords=self._model.get_coords(time_dim=False), + ), + 'is_invested', ) self._create_bounds_for_optional_investment() + if self._model.time_series_collection.scenarios is not None: + self._create_bounds_for_scenarios() + # Bounds for defining variable self._create_bounds_for_defining_variable() @@ -181,7 +192,7 @@ def _create_bounds_for_defining_variable(self): # ... mit mega = relative_maximum * maximum_size # äquivalent zu:. # eq: - defining_variable(t) + mega * On(t) + size * relative_minimum(t) <= + mega - mega = lb_relative * self.parameters.maximum_size + mega = self.parameters.maximum_size * lb_relative on = self._on_variable self.add( self._model.add_constraints( @@ -191,6 +202,73 @@ def _create_bounds_for_defining_variable(self): ) # anmerkung: Glg bei Spezialfall relative_minimum = 0 redundant zu OnOff ?? + def _create_bounds_for_scenarios(self): + if self.parameters.size_per_scenario == 'equal': + self.add( + self._model.add_constraints( + self.size.isel(scenario=slice(None, -1)) == self.size.isel(scenario=slice(1, None)), + name=f'{self.label_full}|equalize_size_per_scenario', + ), + 'equalize_size_per_scenario', + ) + elif self.parameters.size_per_scenario == 'increment_once': + if not self.parameters.optional: + raise ValueError('Increment once can only be used if the Investment is optional') + + self.scenario_of_investment = self.add( + self._model.add_variables( + binary=True, + name=f'{self.label_full}|scenario_of_investment', + coords=self._model.get_coords(time_dim=False), + ), + 'scenario_of_investment', + ) + + # eq: scenario_of_investment(t) = is_invested(t) - is_invested(t-1) + self.add( + self._model.add_constraints( + self.scenario_of_investment.isel(scenario=slice(1, None)) + == self.is_invested.isel(scenario=slice(1, None)) - self.is_invested.isel(scenario=slice(None, -1)), + name=f'{self.label_full}|scenario_of_investment', + ), + 'scenario_of_investment', + ) + + # eq: scenario_of_investment(t=0) = is_invested(t=0) + self.add( + self._model.add_constraints( + self.scenario_of_investment.isel(scenario=0) + == self.is_invested.isel(scenario=0), + name=f'{self.label_full}|initial_scenario_of_investment', + ), + 'initial_scenario_of_investment', + ) + + big_m = self.parameters.maximum_size.isel(scenario=slice(1, None)) + + self.add( + self._model.add_constraints( + self.size.isel(scenario=slice(1, None)) - self.size.isel(scenario=slice(None, -1)) + <= self.scenario_of_investment.isel(scenario=slice(1, None)) * big_m, + name=f'{self.label_full}|invest_once_1a', + ), + 'invest_once_1a', + ) + + self.add( + self._model.add_constraints( + self.size.isel(scenario=slice(1, None)) - self.size.isel(scenario=slice(None, -1)) + >= self.scenario_of_investment.isel(scenario=slice(1, None)) * big_m, + name=f'{self.label_full}|invest_once_1b', + ), + 'invest_once_1b', + ) + + elif self.parameters.size_per_scenario == 'individual': + pass + else: + raise ValueError(f'Invalid value for size_per_scenario: {self.parameters.size_per_scenario}') + class OnOffModel(Model): """ diff --git a/flixopt/interface.py b/flixopt/interface.py index b6eb80c54..2bece9943 100644 --- a/flixopt/interface.py +++ b/flixopt/interface.py @@ -4,7 +4,7 @@ """ import logging -from typing import TYPE_CHECKING, Dict, Iterator, List, Optional, Union +from typing import TYPE_CHECKING, Dict, Iterator, List, Optional, Union, Literal from .config import CONFIG from .core import NumericDataTS, Scalar, ScenarioData, TimestepData @@ -150,14 +150,15 @@ class InvestParameters(Interface): def __init__( self, - fixed_size: Optional[Scalar] = None, - minimum_size: Scalar = 0, # TODO: Use EPSILON? - maximum_size: Optional[Scalar] = None, + fixed_size: Optional[ScenarioData] = None, + minimum_size: ScenarioData = 0, # TODO: Use EPSILON? + maximum_size: Optional[ScenarioData] = None, optional: bool = True, # Investition ist weglassbar fix_effects: Optional['EffectValuesUserScenario'] = None, specific_effects: Optional['EffectValuesUserScenario'] = None, # costs per Flow-Unit/Storage-Size/... piecewise_effects: Optional[PiecewiseEffects] = None, divest_effects: Optional['EffectValuesUserScenario'] = None, + size_per_scenario: Literal['equal', 'individual', 'increment_once'] = 'equal', ): """ Args: @@ -168,30 +169,24 @@ def __init__( specific_effects: Specific costs, e.g., in €/kW_nominal or €/m²_nominal. Example: {costs: 3, CO2: 0.3} with costs and CO2 representing an Object of class Effect (Attention: Annualize costs to chosen period!) - piecewise_effects: Linear piecewise relation [invest_pieces, cost_pieces]. - Example 1: - [ [5, 25, 25, 100], # size in kW - {costs: [50,250,250,800], # € - PE: [5, 25, 25, 100] # kWh_PrimaryEnergy - } - ] - Example 2 (if only standard-effect): - [ [5, 25, 25, 100], # kW # size in kW - [50,250,250,800] # value for standart effect, typically € - ] # € - (Attention: Annualize costs to chosen period!) - (Args 'specific_effects' and 'fix_effects' can be used in parallel to Investsizepieces) - minimum_size: Min nominal value (only if: size_is_fixed = False). - maximum_size: Max nominal value (only if: size_is_fixed = False). + piecewise_effects: Define the effects of the investment as a piecewise function of the size of the investment. + minimum_size: Minimum possible size of the investment. + maximum_size: Maximum possible size of the investment. + size_per_scenario: How to treat the size in each scenario + - 'equal': Equalize the size of all scenarios + - 'individual': Optimize the size of each scenario individually + - 'increment_once': Allow the size to increase only once. This is useful if the scenarios are related to + different periods (years, months). Tune the timing by setting the maximum size to 0 in the first scenarios. """ - self.fix_effects: EffectValuesUserScenario = fix_effects or {} - self.divest_effects: EffectValuesUserScenario = divest_effects or {} + self.fix_effects: EffectValuesUserScenario = fix_effects if fix_effects is not None else {} + self.divest_effects: EffectValuesUserScenario = divest_effects if divest_effects is not None else {} self.fixed_size = fixed_size self.optional = optional - self.specific_effects: EffectValuesUserScenario = specific_effects or {} + self.specific_effects: EffectValuesUserScenario = specific_effects if specific_effects is not None else {} self.piecewise_effects = piecewise_effects self._minimum_size = minimum_size - self._maximum_size = maximum_size or CONFIG.modeling.BIG # default maximum + self._maximum_size = CONFIG.modeling.BIG if maximum_size is None else maximum_size # default maximum + self.size_per_scenario = size_per_scenario def transform_data(self, flow_system: 'FlowSystem', name_prefix: str): self.fix_effects = flow_system.create_effect_time_series( @@ -219,13 +214,24 @@ def transform_data(self, flow_system: 'FlowSystem', name_prefix: str): self.piecewise_effects.has_time_dim = False self.piecewise_effects.transform_data(flow_system, f'{name_prefix}|PiecewiseEffects') + self._minimum_size = flow_system.create_time_series( + f'{name_prefix}|minimum_size', self.minimum_size, has_time_dim=False, has_scenario_dim=True + ) + self._maximum_size = flow_system.create_time_series( + f'{name_prefix}|maximum_size', self.maximum_size, has_time_dim=False, has_scenario_dim=True + ) + if self.fixed_size is not None: + self.fixed_size = flow_system.create_time_series( + f'{name_prefix}|fixed_size', self.fixed_size, has_time_dim=False, has_scenario_dim=True + ) + @property def minimum_size(self): - return self.fixed_size or self._minimum_size + return self.fixed_size if self.fixed_size is not None else self._minimum_size @property def maximum_size(self): - return self.fixed_size or self._maximum_size + return self.fixed_size if self.fixed_size is not None else self._maximum_size @register_class_for_io From 39f9e4bbaacb52676f30fbfeb3be90630e466e7c Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 9 Apr 2025 15:03:05 +0200 Subject: [PATCH 012/336] Feature/scenarios weights (#228) * Simplify InvestmentModel * Add Scenario Weights to the SystemModel --- flixopt/effects.py | 10 +++------- flixopt/features.py | 27 +++++++++------------------ flixopt/flow_system.py | 8 +++++++- flixopt/structure.py | 19 +++++++++++++++++++ 4 files changed, 38 insertions(+), 26 deletions(-) diff --git a/flixopt/effects.py b/flixopt/effects.py index 226e59a0f..0cf165d66 100644 --- a/flixopt/effects.py +++ b/flixopt/effects.py @@ -383,14 +383,10 @@ def do_modeling(self): model.do_modeling() self._add_share_between_effects() - scaling_factor = ( - len(self._model.time_series_collection.scenarios) - if self._model.time_series_collection.scenarios is not None - else 1 - ) + self._model.add_objective( - (self.effects.objective_effect.model.total / scaling_factor).sum() - + (self.penalty.total / scaling_factor).sum() + (self.effects.objective_effect.model.total * self._model.scenario_weights).sum() + + self.penalty.total.sum() ) def _add_share_between_effects(self): diff --git a/flixopt/features.py b/flixopt/features.py index 3d2984393..425f9382a 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -43,24 +43,15 @@ def __init__( self.parameters = parameters def do_modeling(self): - if self.parameters.fixed_size and not self.parameters.optional: - self.size = self.add( - self._model.add_variables( - lower=self.parameters.fixed_size, upper=self.parameters.fixed_size, name=f'{self.label_full}|size', - coords=self._model.get_coords(time_dim=False), - ), - 'size', - ) - else: - self.size = self.add( - self._model.add_variables( - lower=0 if self.parameters.optional else self.parameters.minimum_size*1, - upper=self.parameters.maximum_size*1, - name=f'{self.label_full}|size', - coords=self._model.get_coords(time_dim=False), - ), - 'size', - ) + self.size = self.add( + self._model.add_variables( + lower=0 if self.parameters.optional else self.parameters.minimum_size*1, + upper=self.parameters.maximum_size*1, + name=f'{self.label_full}|size', + coords=self._model.get_coords(time_dim=False), + ), + 'size', + ) # Optional if self.parameters.optional: diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index 6985572c1..a36a14af1 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -16,7 +16,7 @@ from rich.pretty import Pretty from . import io as fx_io -from .core import Scalar, TimeSeries, TimeSeriesCollection, TimeSeriesData, TimestepData +from .core import Scalar, TimeSeries, TimeSeriesCollection, TimeSeriesData, TimestepData, ScenarioData from .effects import ( Effect, EffectCollection, @@ -45,6 +45,7 @@ def __init__( scenarios: Optional[pd.Index] = None, hours_of_last_timestep: Optional[float] = None, hours_of_previous_timesteps: Optional[Union[int, float, np.ndarray]] = None, + scenario_weights: Optional[ScenarioData] = None, ): """ Args: @@ -55,6 +56,7 @@ def __init__( If None, the first time increment of time_series is used. This is needed to calculate previous durations (for example consecutive_on_hours). If you use an array, take care that its long enough to cover all previous values! + scenario_weights: The weights of the scenarios. If None, all scenarios have the same weight. All weights are normalized to 1. """ self.time_series_collection = TimeSeriesCollection( timesteps=timesteps, @@ -62,6 +64,7 @@ def __init__( hours_of_last_timestep=hours_of_last_timestep, hours_of_previous_timesteps=hours_of_previous_timesteps, ) + self.scenario_weights = scenario_weights # defaults: self.components: Dict[str, Component] = {} @@ -278,6 +281,9 @@ def network_infos(self) -> Tuple[Dict[str, Dict[str, str]], Dict[str, Dict[str, def transform_data(self): if not self._connected: self._connect_network() + self.scenario_weights = self.create_time_series( + 'scenario_weights', self.scenario_weights, has_time_dim=False, has_scenario_dim=True + ) for element in self.all_elements.values(): element.transform_data(self) diff --git a/flixopt/structure.py b/flixopt/structure.py index 7282d3b7c..6830dbb1c 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -58,6 +58,7 @@ def __init__(self, flow_system: 'FlowSystem'): self.flow_system = flow_system self.time_series_collection = flow_system.time_series_collection self.effects: Optional[EffectCollectionModel] = None + self.scenario_weights = self._calculate_scenario_weights(flow_system.scenario_weights) def do_modeling(self): self.effects = self.flow_system.effects.create_model(self) @@ -69,6 +70,24 @@ def do_modeling(self): for bus_model in bus_models: # Buses after Components, because FlowModels are created in ComponentModels bus_model.do_modeling() + def _calculate_scenario_weights(self, weights: Optional[TimeSeries] = None) -> xr.DataArray: + """Calculates the weights of the scenarios. If None, all scenarios have the same weight. All weights are normalized to 1. + If no scenarios are present, s single weight of 1 is returned. + """ + if weights is not None and not isinstance(weights, TimeSeries): + raise TypeError(f'Weights must be a TimeSeries or None, got {type(weights)}') + if self.time_series_collection.scenarios is None: + return xr.DataArray(1) + if weights is None: + weights = xr.DataArray( + np.ones(len(self.time_series_collection.scenarios)), + coords={'scenario': self.time_series_collection.scenarios} + ) + elif isinstance(weights, TimeSeries): + weights = weights.selected_data + + return weights / weights.sum() + @property def solution(self): solution = super().solution From 75cb399799c1c239c24237b56c7d39d5a69abdc1 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 9 Apr 2025 17:16:49 +0200 Subject: [PATCH 013/336] Feature/scenarios tests pandas (#229) Integrate Pandas datatypes into Conversion and update tests --- examples/04_Scenarios/scenario_example.py | 13 +- flixopt/core.py | 139 ++++++++++++- flixopt/structure.py | 3 + tests/test_dataconverter.py | 235 ++++++++++++++++++---- tests/test_timeseries.py | 48 ++--- 5 files changed, 359 insertions(+), 79 deletions(-) diff --git a/examples/04_Scenarios/scenario_example.py b/examples/04_Scenarios/scenario_example.py index 8e3349a4a..03c2a5be0 100644 --- a/examples/04_Scenarios/scenario_example.py +++ b/examples/04_Scenarios/scenario_example.py @@ -9,15 +9,16 @@ import flixopt as fx if __name__ == '__main__': - # --- Create Time Series Data --- - # Heat demand profile (e.g., kW) over time and corresponding power prices - heat_demand_per_h = np.array([[30, 0, 90, 110, 110, 20, 20, 20, 20], - [30, 0, 100, 118, 125, 20, 20, 20, 20]]).T - power_prices = np.ones(9) * 0.08 - # Create datetime array starting from '2020-01-01' for the given time period timesteps = pd.date_range('2020-01-01', periods=9, freq='h') scenarios = pd.Index(['Base Case', 'High Demand']) + + # --- Create Time Series Data --- + # Heat demand profile (e.g., kW) over time and corresponding power prices + heat_demand_per_h = pd.DataFrame({'Base Case':[30, 0, 90, 110, 110, 20, 20, 20, 20], + 'High Demand':[30, 0, 100, 118, 125, 20, 20, 20, 20]}, index=timesteps) + power_prices = np.array([0.08, 0.09]) + flow_system = fx.FlowSystem(timesteps=timesteps, scenarios=scenarios) # --- Define Energy Buses --- diff --git a/flixopt/core.py b/flixopt/core.py index 386a1d873..304048201 100644 --- a/flixopt/core.py +++ b/flixopt/core.py @@ -83,6 +83,12 @@ def as_dataarray( elif isinstance(data, np.ndarray): return DataConverter._convert_ndarray(data, coords, dims) + elif isinstance(data, pd.Series): + return DataConverter._convert_series(data, coords, dims) + + elif isinstance(data, pd.DataFrame): + return DataConverter._convert_dataframe(data, coords, dims) + else: raise ConversionError(f'Unsupported data type: {type(data).__name__}') @@ -171,6 +177,8 @@ def _convert_scalar( Returns: DataArray with the scalar value """ + if isinstance(data, (np.integer, np.floating)): + data = data.item() return xr.DataArray(data, coords=coords, dims=dims) @staticmethod @@ -192,7 +200,7 @@ def _convert_dataarray(data: xr.DataArray, coords: Dict[str, pd.Index], dims: Tu raise ConversionError('When converting to dimensionless DataArray, source must be scalar') return xr.DataArray(data.values.item()) - # Check if data already has matching dimensions + # Check if data already has matching dimensions and coordinates if set(data.dims) == set(dims): # Check if coordinates match is_compatible = True @@ -202,8 +210,13 @@ def _convert_dataarray(data: xr.DataArray, coords: Dict[str, pd.Index], dims: Tu break if is_compatible: - # Return existing DataArray if compatible - return data.copy(deep=True) + # Ensure dimensions are in the correct order + if data.dims != dims: + # Transpose to get dimensions in the right order + return data.transpose(*dims).copy(deep=True) + else: + # Return existing DataArray if compatible and order is correct + return data.copy(deep=True) # Handle dimension broadcasting if len(data.dims) == 1 and len(dims) == 2: @@ -216,8 +229,9 @@ def _convert_dataarray(data: xr.DataArray, coords: Dict[str, pd.Index], dims: Tu # Broadcast scenario dimension to include time return DataConverter._broadcast_scenario_to_time(data, coords, dims) - raise ConversionError(f'Cannot convert {data.dims} to {dims}') - + raise ConversionError( + f'Cannot convert {data.dims} to {dims}. Source coordinates: {data.coords}, Target coordinates: {coords}' + ) @staticmethod def _broadcast_time_to_scenarios( data: xr.DataArray, coords: Dict[str, pd.Index], dims: Tuple[str, ...] @@ -239,7 +253,7 @@ def _broadcast_time_to_scenarios( # Broadcast values values = np.tile(data.values, (len(coords['scenario']), 1)) - return xr.DataArray(values, coords=coords, dims=dims) + return xr.DataArray(values.copy(), coords=coords, dims=dims) @staticmethod def _broadcast_scenario_to_time( @@ -262,7 +276,7 @@ def _broadcast_scenario_to_time( # Broadcast values values = np.repeat(data.values[:, np.newaxis], len(coords['time']), axis=1) - return xr.DataArray(values, coords=coords, dims=dims) + return xr.DataArray(values.copy(), coords=coords, dims=dims) @staticmethod def _convert_ndarray(data: np.ndarray, coords: Dict[str, pd.Index], dims: Tuple[str, ...]) -> xr.DataArray: @@ -359,6 +373,113 @@ def _convert_ndarray_two_dims(data: np.ndarray, coords: Dict[str, pd.Index], dim else: raise ConversionError(f'Expected 1D or 2D array for two dimensions, got {data.ndim}D') + @staticmethod + def _convert_series(data: pd.Series, coords: Dict[str, pd.Index], dims: Tuple[str, ...]) -> xr.DataArray: + """ + Convert pandas Series to xarray DataArray. + + Args: + data: pandas Series to convert + coords: Target coordinates + dims: Target dimensions + + Returns: + DataArray from the pandas Series + """ + # Handle single dimension case + if len(dims) == 1: + dim_name = dims[0] + + # Check if series index matches the dimension + if data.index.equals(coords[dim_name]): + return xr.DataArray(data.values.copy(), coords=coords, dims=dims) + else: + raise ConversionError( + f"Series index doesn't match {dim_name} coordinates.\n" + f'Series index: {data.index}\n' + f'Target {dim_name} coordinates: {coords[dim_name]}' + ) + + # Handle two dimensions case + elif len(dims) == 2: + # Check if dimensions are time and scenario + if dims != ('time', 'scenario'): + raise ConversionError( + f'Two-dimensional conversion only supports time and scenario dimensions, got {dims}' + ) + + # Case 1: Series is indexed by time + if data.index.equals(coords['time']): + # Broadcast across scenarios + values = np.tile(data.values[:, np.newaxis], (1, len(coords['scenario']))) + return xr.DataArray(values.copy(), coords=coords, dims=dims) + + # Case 2: Series is indexed by scenario + elif data.index.equals(coords['scenario']): + # Broadcast across time + values = np.repeat(data.values[np.newaxis, :], len(coords['time']), axis=0) + return xr.DataArray(values.copy(), coords=coords, dims=dims) + + else: + raise ConversionError( + "Series index must match either 'time' or 'scenario' coordinates.\n" + f'Series index: {data.index}\n' + f'Target time coordinates: {coords["time"]}\n' + f'Target scenario coordinates: {coords["scenario"]}' + ) + + else: + raise ConversionError(f'Maximum 2 dimensions supported, got {len(dims)}') + + @staticmethod + def _convert_dataframe(data: pd.DataFrame, coords: Dict[str, pd.Index], dims: Tuple[str, ...]) -> xr.DataArray: + """ + Convert pandas DataFrame to xarray DataArray. + Only allows time as index and scenarios as columns. + + Args: + data: pandas DataFrame to convert + coords: Target coordinates + dims: Target dimensions + + Returns: + DataArray from the pandas DataFrame + """ + # Single dimension case + if len(dims) == 1: + # If DataFrame has one column, treat it like a Series + if len(data.columns) == 1: + series = data.iloc[:, 0] + return DataConverter._convert_series(series, coords, dims) + + raise ConversionError( + f'When converting DataFrame to single-dimension DataArray, DataFrame must have exactly one column, got {len(data.columns)}' + ) + + # Two dimensions case + elif len(dims) == 2: + # Check if dimensions are time and scenario + if dims != ('time', 'scenario'): + raise ConversionError( + f'Two-dimensional conversion only supports time and scenario dimensions, got {dims}' + ) + + # DataFrame must have time as index and scenarios as columns + if data.index.equals(coords['time']) and data.columns.equals(coords['scenario']): + # Create DataArray with proper dimension order + return xr.DataArray(data.values.copy(), coords=coords, dims=dims) + else: + raise ConversionError( + 'DataFrame must have time as index and scenarios as columns.\n' + f'DataFrame index: {data.index}\n' + f'DataFrame columns: {data.columns}\n' + f'Target time coordinates: {coords["time"]}\n' + f'Target scenario coordinates: {coords["scenario"]}' + ) + + else: + raise ConversionError(f'Maximum 2 dimensions supported, got {len(dims)}') + class TimeSeriesData: # TODO: Move to Interface.py @@ -913,8 +1034,8 @@ def clear_selection(self, timesteps: bool = True, scenarios: bool = True) -> Non if scenarios: self._selected_scenarios = None - # Apply the selection to all TimeSeries objects - self._propagate_selection_to_time_series() + for ts in self._time_series.values(): + ts.clear_selection(timesteps=timesteps, scenarios=scenarios) def set_selection(self, timesteps: Optional[pd.DatetimeIndex] = None, scenarios: Optional[pd.Index] = None) -> None: """ diff --git a/flixopt/structure.py b/flixopt/structure.py index 6830dbb1c..a1c9ffa0d 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -583,6 +583,9 @@ def copy_and_convert_datatypes(data: Any, use_numpy: bool = True, use_element_la return copy_and_convert_datatypes(data.selected_data, use_numpy, use_element_label) elif isinstance(data, TimeSeriesData): return copy_and_convert_datatypes(data.data, use_numpy, use_element_label) + elif isinstance(data, (pd.Series, pd.DataFrame)): + #TODO: This can be improved + return copy_and_convert_datatypes(data.values, use_numpy, use_element_label) elif isinstance(data, Interface): if use_element_label and isinstance(data, Element): diff --git a/tests/test_dataconverter.py b/tests/test_dataconverter.py index a023b8e58..61adcb284 100644 --- a/tests/test_dataconverter.py +++ b/tests/test_dataconverter.py @@ -101,8 +101,8 @@ def test_scalar_with_scenarios(self, sample_time_index, sample_scenario_index): result = DataConverter.as_dataarray(42, sample_time_index, sample_scenario_index) assert isinstance(result, xr.DataArray) - assert result.shape == (len(sample_scenario_index), len(sample_time_index)) - assert result.dims == ('scenario', 'time') + assert result.shape == (len(sample_time_index), len(sample_scenario_index)) + assert result.dims == ('time', 'scenario') assert np.all(result.values == 42) assert set(result.coords['scenario'].values) == set(sample_scenario_index.values) assert set(result.coords['time'].values) == set(sample_time_index.values) @@ -119,8 +119,8 @@ def test_1d_array_with_scenarios(self, sample_time_index, sample_scenario_index) # Convert with scenarios result = DataConverter.as_dataarray(arr_1d, sample_time_index, sample_scenario_index) - assert result.shape == (len(sample_scenario_index), len(sample_time_index)) - assert result.dims == ('scenario', 'time') + assert result.shape == (len(sample_time_index), len(sample_scenario_index)) + assert result.dims == ('time', 'scenario') # Each scenario should have the same values (broadcasting) for scenario in sample_scenario_index: @@ -139,10 +139,10 @@ def test_2d_array_with_scenarios(self, sample_time_index, sample_scenario_index) ) # Convert to DataArray - result = DataConverter.as_dataarray(arr_2d, sample_time_index, sample_scenario_index) + result = DataConverter.as_dataarray(arr_2d.T, sample_time_index, sample_scenario_index) - assert result.shape == (3, 5) - assert result.dims == ('scenario', 'time') + assert result.shape == (5, 3) + assert result.dims == ('time', 'scenario') # Check that each scenario has correct values assert np.array_equal(result.sel(scenario='baseline').values, arr_2d[0]) @@ -161,28 +161,181 @@ def test_dataarray_with_scenarios(self, sample_time_index, sample_scenario_index # Test conversion result = DataConverter.as_dataarray(original, sample_time_index, sample_scenario_index) - assert result.shape == (3, 5) - assert result.dims == ('scenario', 'time') - assert np.array_equal(result.values, original.values) + assert result.shape == (5, 3) + assert result.dims == ('time', 'scenario') + assert np.array_equal(result.values, original.values.T) # Ensure it's a copy - result.loc['baseline'] = 999 + result.loc[:, 'baseline'] = 999 assert original.sel(scenario='baseline')[0].item() == 1 # Original should be unchanged - def test_time_only_dataarray_with_scenarios(self, sample_time_index, sample_scenario_index): - """Test broadcasting a time-only DataArray to scenarios.""" - # Create a DataArray with only time dimension - time_only = xr.DataArray(data=np.array([1, 2, 3, 4, 5]), coords={'time': sample_time_index}, dims=['time']) - # Convert with scenarios - should broadcast to all scenarios - result = DataConverter.as_dataarray(time_only, sample_time_index, sample_scenario_index) +class TestSeriesConversion: + """Tests for converting pandas Series to DataArray.""" + + def test_series_single_dimension(self, sample_time_index): + """Test converting a pandas Series with time index.""" + # Create a Series with matching time index + series = pd.Series([10, 20, 30, 40, 50], index=sample_time_index) + + # Convert and check + result = DataConverter.as_dataarray(series, sample_time_index) + assert isinstance(result, xr.DataArray) + assert result.shape == (5,) + assert result.dims == ('time',) + assert np.array_equal(result.values, series.values) + assert np.array_equal(result.coords['time'].values, sample_time_index.values) + + # Test with scenario index + scenario_index = pd.Index(['baseline', 'high_demand', 'low_price'], name='scenario') + series = pd.Series([100, 200, 300], index=scenario_index) + + result = DataConverter.as_dataarray(series, scenarios=scenario_index) + assert result.shape == (3,) + assert result.dims == ('scenario',) + assert np.array_equal(result.values, series.values) + assert np.array_equal(result.coords['scenario'].values, scenario_index.values) + + def test_series_mismatched_index(self, sample_time_index): + """Test converting a Series with mismatched index.""" + # Create Series with different time index + different_times = pd.date_range('2025-01-01', periods=5, freq='D', name='time') + series = pd.Series([10, 20, 30, 40, 50], index=different_times) + + # Should raise error for mismatched index + with pytest.raises(ConversionError): + DataConverter.as_dataarray(series, sample_time_index) + + def test_series_broadcast_to_scenarios(self, sample_time_index, sample_scenario_index): + """Test broadcasting a time-indexed Series across scenarios.""" + # Create a Series with time index + series = pd.Series([10, 20, 30, 40, 50], index=sample_time_index) + + # Convert with scenarios + result = DataConverter.as_dataarray(series, sample_time_index, sample_scenario_index) - assert result.shape == (3, 5) - assert result.dims == ('scenario', 'time') + assert result.shape == (5, 3) + assert result.dims == ('time', 'scenario') - # Each scenario should have same values + # Check broadcasting - each scenario should have the same values for scenario in sample_scenario_index: - assert np.array_equal(result.sel(scenario=scenario).values, time_only.values) + scenario_slice = result.sel(scenario=scenario) + assert np.array_equal(scenario_slice.values, series.values) + + def test_series_broadcast_to_time(self, sample_time_index, sample_scenario_index): + """Test broadcasting a scenario-indexed Series across time.""" + # Create a Series with scenario index + series = pd.Series([100, 200, 300], index=sample_scenario_index) + + # Convert with time + result = DataConverter.as_dataarray(series, sample_time_index, sample_scenario_index) + + assert result.shape == (5, 3) + assert result.dims == ('time', 'scenario') + + # Check broadcasting - each time should have the same scenario values + for i, time in enumerate(sample_time_index): + time_slice = result.sel(time=time) + assert np.array_equal(time_slice.values, series.values) + + def test_series_dimension_order(self, sample_time_index, sample_scenario_index): + """Test that dimension order is respected with Series conversions.""" + # Create custom dimensions tuple with reversed order + dims = ('scenario', 'time',) + coords = {'time': sample_time_index, 'scenario': sample_scenario_index} + + # Time-indexed series + series = pd.Series([10, 20, 30, 40, 50], index=sample_time_index) + with pytest.raises(ConversionError, match="only supports time and scenario dimensions"): + _ = DataConverter._convert_series(series, coords, dims) + + # Scenario-indexed series + series = pd.Series([100, 200, 300], index=sample_scenario_index) + with pytest.raises(ConversionError, match="only supports time and scenario dimensions"): + _ = DataConverter._convert_series(series, coords, dims) + + +class TestDataFrameConversion: + """Tests for converting pandas DataFrame to DataArray.""" + + def test_dataframe_single_column(self, sample_time_index): + """Test converting a DataFrame with a single column.""" + # Create DataFrame with one column + df = pd.DataFrame({'value': [10, 20, 30, 40, 50]}, index=sample_time_index) + + # Convert and check + result = DataConverter.as_dataarray(df, sample_time_index) + assert isinstance(result, xr.DataArray) + assert result.shape == (5,) + assert result.dims == ('time',) + assert np.array_equal(result.values, df['value'].values) + + def test_dataframe_multi_column_fails(self, sample_time_index): + """Test that converting a multi-column DataFrame to 1D fails.""" + # Create DataFrame with multiple columns + df = pd.DataFrame({'val1': [10, 20, 30, 40, 50], 'val2': [15, 25, 35, 45, 55]}, index=sample_time_index) + + # Should raise error + with pytest.raises(ConversionError): + DataConverter.as_dataarray(df, sample_time_index) + + def test_dataframe_time_scenario(self, sample_time_index, sample_scenario_index): + """Test converting a DataFrame with time index and scenario columns.""" + # Create DataFrame with time as index and scenarios as columns + data = {'baseline': [10, 20, 30, 40, 50], 'high_demand': [15, 25, 35, 45, 55], 'low_price': [5, 15, 25, 35, 45]} + df = pd.DataFrame(data, index=sample_time_index) + + # Make sure columns are named properly + df.columns.name = 'scenario' + + # Convert and check + result = DataConverter.as_dataarray(df, sample_time_index, sample_scenario_index) + + assert result.shape == (5, 3) + assert result.dims == ('time', 'scenario') + assert np.array_equal(result.values, df.values) + + # Check values for specific scenarios + assert np.array_equal(result.sel(scenario='baseline').values, df['baseline'].values) + assert np.array_equal(result.sel(scenario='high_demand').values, df['high_demand'].values) + + def test_dataframe_mismatched_coordinates(self, sample_time_index, sample_scenario_index): + """Test conversion fails with mismatched coordinates.""" + # Create DataFrame with different time index + different_times = pd.date_range('2025-01-01', periods=5, freq='D', name='time') + data = {'baseline': [10, 20, 30, 40, 50], 'high_demand': [15, 25, 35, 45, 55], 'low_price': [5, 15, 25, 35, 45]} + df = pd.DataFrame(data, index=different_times) + df.columns = sample_scenario_index + + # Should raise error + with pytest.raises(ConversionError): + DataConverter.as_dataarray(df, sample_time_index, sample_scenario_index) + + # Create DataFrame with different scenario columns + different_scenarios = pd.Index(['scenario1', 'scenario2', 'scenario3'], name='scenario') + data = {'scenario1': [10, 20, 30, 40, 50], 'scenario2': [15, 25, 35, 45, 55], 'scenario3': [5, 15, 25, 35, 45]} + df = pd.DataFrame(data, index=sample_time_index) + df.columns = different_scenarios + + # Should raise error + with pytest.raises(ConversionError): + DataConverter.as_dataarray(df, sample_time_index, sample_scenario_index) + + def test_ensure_copy(self, sample_time_index, sample_scenario_index): + """Test that the returned DataArray is a copy.""" + # Create DataFrame + data = {'baseline': [10, 20, 30, 40, 50], 'high_demand': [15, 25, 35, 45, 55], 'low_price': [5, 15, 25, 35, 45]} + df = pd.DataFrame(data, index=sample_time_index) + df.columns = sample_scenario_index + + # Convert + result = DataConverter.as_dataarray(df, sample_time_index, sample_scenario_index) + + # Modify the result + result.loc[dict(time=sample_time_index[0], scenario='baseline')] = 999 + + # Original should be unchanged + assert df.loc[sample_time_index[0], 'baseline'] == 10 class TestInvalidInputs: @@ -314,8 +467,8 @@ def test_single_timestep(self, sample_scenario_index): # With scenarios result_with_scenarios = DataConverter.as_dataarray(42, single_timestep, sample_scenario_index) - assert result_with_scenarios.shape == (len(sample_scenario_index), 1) - assert result_with_scenarios.dims == ('scenario', 'time') + assert result_with_scenarios.shape == (1, len(sample_scenario_index)) + assert result_with_scenarios.dims == ('time', 'scenario') def test_single_scenario(self, sample_time_index): """Test with a single scenario.""" @@ -324,19 +477,19 @@ def test_single_scenario(self, sample_time_index): # Scalar conversion with single scenario result = DataConverter.as_dataarray(42, sample_time_index, single_scenario) - assert result.shape == (1, len(sample_time_index)) - assert result.dims == ('scenario', 'time') + assert result.shape == (len(sample_time_index), 1) + assert result.dims == ('time', 'scenario') # Array conversion with single scenario arr = np.array([1, 2, 3, 4, 5]) result_arr = DataConverter.as_dataarray(arr, sample_time_index, single_scenario) - assert result_arr.shape == (1, 5) + assert result_arr.shape == (5, 1) assert np.array_equal(result_arr.sel(scenario='baseline').values, arr) # 2D array with single scenario arr_2d = np.array([[1, 2, 3, 4, 5]]) # Note the extra dimension - result_arr_2d = DataConverter.as_dataarray(arr_2d, sample_time_index, single_scenario) - assert result_arr_2d.shape == (1, 5) + result_arr_2d = DataConverter.as_dataarray(arr_2d.T, sample_time_index, single_scenario) + assert result_arr_2d.shape == (5, 1) assert np.array_equal(result_arr_2d.sel(scenario='baseline').values, arr_2d[0]) def test_different_scenario_order(self, sample_time_index): @@ -352,7 +505,7 @@ def test_different_scenario_order(self, sample_time_index): [6, 7, 8, 9, 10], # b [11, 12, 13, 14, 15], # c ] - ) + ).T result1 = DataConverter.as_dataarray(data, sample_time_index, scenarios1) assert np.array_equal(result1.sel(scenario='a').values, [1, 2, 3, 4, 5]) @@ -374,7 +527,7 @@ def test_all_nan_data(self, sample_time_index, sample_scenario_index): # With scenarios result = DataConverter.as_dataarray(all_nan_array, sample_time_index, sample_scenario_index) - assert result.shape == (len(sample_scenario_index), len(sample_time_index)) + assert result.shape == (len(sample_time_index), len(sample_scenario_index)) assert np.all(np.isnan(result.values)) # Series of all NaNs @@ -417,11 +570,11 @@ def test_large_dataset(self, sample_scenario_index): large_data = np.random.rand(len(sample_scenario_index), len(large_timesteps)) # Convert and check - result = DataConverter.as_dataarray(large_data, large_timesteps, sample_scenario_index) + result = DataConverter.as_dataarray(large_data.T, large_timesteps, sample_scenario_index) - assert result.shape == (len(sample_scenario_index), len(large_timesteps)) - assert result.dims == ('scenario', 'time') - assert np.array_equal(result.values, large_data) + assert result.shape == (len(large_timesteps), len(sample_scenario_index)) + assert result.dims == ('time', 'scenario') + assert np.array_equal(result.values, large_data.T) class TestMultiScenarioArrayConversion: @@ -432,7 +585,7 @@ def test_1d_array_broadcasting(self, sample_time_index, sample_scenario_index): arr_1d = np.array([1, 2, 3, 4, 5]) result = DataConverter.as_dataarray(arr_1d, sample_time_index, sample_scenario_index) - assert result.shape == (len(sample_scenario_index), len(sample_time_index)) + assert result.shape == (len(sample_time_index), len(sample_scenario_index)) # Each scenario should have identical values for i, scenario in enumerate(sample_scenario_index): @@ -451,15 +604,15 @@ def test_2d_array_different_shapes(self, sample_time_index): single_scenario = pd.Index(['baseline'], name='scenario') arr_1_scenario = np.array([[1, 2, 3, 4, 5]]) - result = DataConverter.as_dataarray(arr_1_scenario, sample_time_index, single_scenario) - assert result.shape == (1, len(sample_time_index)) + result = DataConverter.as_dataarray(arr_1_scenario.T, sample_time_index, single_scenario) + assert result.shape == (len(sample_time_index), 1) # Test with 2 scenarios two_scenarios = pd.Index(['baseline', 'high_demand'], name='scenario') arr_2_scenarios = np.array([[1, 2, 3, 4, 5], [6, 7, 8, 9, 10]]) - result = DataConverter.as_dataarray(arr_2_scenarios, sample_time_index, two_scenarios) - assert result.shape == (2, len(sample_time_index)) + result = DataConverter.as_dataarray(arr_2_scenarios.T, sample_time_index, two_scenarios) + assert result.shape == (len(sample_time_index), 2) assert np.array_equal(result.sel(scenario='baseline').values, arr_2_scenarios[0]) assert np.array_equal(result.sel(scenario='high_demand').values, arr_2_scenarios[1]) @@ -474,7 +627,7 @@ def test_array_handling_edge_cases(self, sample_time_index, sample_scenario_inde bool_array = np.array([True, False, True, False, True]) result = DataConverter.as_dataarray(bool_array, sample_time_index, sample_scenario_index) assert result.dtype == bool - assert result.shape == (len(sample_scenario_index), len(sample_time_index)) + assert result.shape == (len(sample_time_index), len(sample_scenario_index)) # Test with array containing infinite values inf_array = np.array([1, np.inf, 3, -np.inf, 5]) @@ -504,7 +657,7 @@ def test_preserving_scenario_order(self, sample_time_index): ) # Convert to DataArray - result = DataConverter.as_dataarray(data, sample_time_index, scenarios) + result = DataConverter.as_dataarray(data.T, sample_time_index, scenarios) # Verify order of scenarios is preserved assert list(result.coords['scenario'].values) == list(scenarios) diff --git a/tests/test_timeseries.py b/tests/test_timeseries.py index d64c13d85..8237cf293 100644 --- a/tests/test_timeseries.py +++ b/tests/test_timeseries.py @@ -66,7 +66,7 @@ def test_initialization_validation(self, sample_timesteps): """Test validation during initialization.""" # Test missing time dimension invalid_data = xr.DataArray([1, 2, 3], dims=['invalid_dim']) - with pytest.raises(ValueError, match='must have a "time" index'): + with pytest.raises(ValueError, match='DataArray dimensions must be subset of'): TimeSeries(invalid_data, name='Invalid Series') # Test multi-dimensional data @@ -356,7 +356,7 @@ def test_initialization_with_scenarios(self, simple_scenario_dataarray): # Check basic properties assert ts.name == 'Scenario Series' - assert ts._has_scenarios is True + assert ts.has_scenario_dim is True assert ts._selected_scenarios is None # No selection initially # Check data initialization @@ -615,29 +615,29 @@ def test_add_time_series_with_scenarios(self, sample_scenario_allocator): """Test creating time series with scenarios.""" # Test scalar (broadcasts to all scenarios) ts1 = sample_scenario_allocator.add_time_series('scalar_series', 42) - assert ts1._has_scenarios + assert ts1.has_scenario_dim assert ts1.name == 'scalar_series' - assert ts1.selected_data.shape == (3, 5) # 3 scenarios, 5 timesteps + assert ts1.selected_data.shape == (5, 3) # 5 timesteps, 3 scenarios assert np.all(ts1.selected_data.values == 42) # Test 1D array (broadcasts to all scenarios) data = np.array([1, 2, 3, 4, 5]) ts2 = sample_scenario_allocator.add_time_series('array_series', data) - assert ts2._has_scenarios - assert ts2.selected_data.shape == (3, 5) + assert ts2.has_scenario_dim + assert ts2.selected_data.shape == (5, 3) # Each scenario should have the same values for scenario in sample_scenario_allocator.scenarios: assert np.array_equal(ts2.sel(scenario=scenario).values, data) # Test 2D array (one row per scenario) - data_2d = np.array([[10, 20, 30, 40, 50], [15, 25, 35, 45, 55], [5, 15, 25, 35, 45]]) + data_2d = np.array([[10, 20, 30, 40, 50], [15, 25, 35, 45, 55], [5, 15, 25, 35, 45]]).T ts3 = sample_scenario_allocator.add_time_series('scenario_specific_series', data_2d) - assert ts3._has_scenarios - assert ts3.selected_data.shape == (3, 5) + assert ts3.has_scenario_dim + assert ts3.selected_data.shape == (5, 3) # Each scenario should have its own values - assert np.array_equal(ts3.sel(scenario='baseline').values, data_2d[0]) - assert np.array_equal(ts3.sel(scenario='high_demand').values, data_2d[1]) - assert np.array_equal(ts3.sel(scenario='low_price').values, data_2d[2]) + assert np.array_equal(ts3.sel(scenario='baseline').values, data_2d[:,0]) + assert np.array_equal(ts3.sel(scenario='high_demand').values, data_2d[:,1]) + assert np.array_equal(ts3.sel(scenario='low_price').values, data_2d[:,2]) def test_selection_propagation_with_scenarios( self, sample_scenario_allocator, sample_timesteps, sample_scenario_index @@ -660,8 +660,8 @@ def test_selection_propagation_with_scenarios( assert ts2._selected_scenarios.equals(subset_scenarios) # Check data is filtered - assert ts1.selected_data.shape == (2, 5) # 2 scenarios, 5 timesteps - assert ts2.selected_data.shape == (2, 5) + assert ts1.selected_data.shape == (5, 2) # 5 timesteps, 2 scenarios + assert ts2.selected_data.shape == (5, 2) # Apply combined selection subset_timesteps = sample_timesteps[1:3] @@ -670,20 +670,22 @@ def test_selection_propagation_with_scenarios( # Check combined selection applied assert ts1._selected_timesteps.equals(subset_timesteps) assert ts1._selected_scenarios.equals(subset_scenarios) - assert ts1.selected_data.shape == (2, 2) # 2 scenarios, 2 timesteps + assert ts1.selected_data.shape == (2, 2) # 2 timesteps, 2 scenarios # Clear selections sample_scenario_allocator.clear_selection() assert ts1._selected_timesteps is None + assert ts1.active_timesteps.equals(sample_scenario_allocator.timesteps) assert ts1._selected_scenarios is None - assert ts1.selected_data.shape == (3, 5) # Back to full shape + assert ts1.active_scenarios.equals(sample_scenario_allocator.scenarios) + assert ts1.selected_data.shape == (5, 3) # Back to full shape def test_as_dataset_with_scenarios(self, sample_scenario_allocator): """Test as_dataset method with scenarios.""" # Add some time series sample_scenario_allocator.add_time_series('scalar_series', 42) sample_scenario_allocator.add_time_series( - 'varying_series', np.array([[10, 20, 30, 40, 50], [15, 25, 35, 45, 55], [5, 15, 25, 35, 45]]) + 'varying_series', np.array([[10, 20, 30, 40, 50], [15, 25, 35, 45, 55], [5, 15, 25, 35, 45]]).T ) # Get dataset @@ -723,21 +725,21 @@ def test_update_time_series_with_scenarios(self, sample_scenario_allocator, samp """Test updating a time series with scenarios.""" # Add a time series ts = sample_scenario_allocator.add_time_series('series', 42) - assert ts._has_scenarios + assert ts.has_scenario_dim assert np.all(ts.selected_data.values == 42) # Update with scenario-specific data - new_data = np.array([[1, 2, 3, 4, 5], [6, 7, 8, 9, 10], [11, 12, 13, 14, 15]]) + new_data = np.array([[1, 2, 3, 4, 5], [6, 7, 8, 9, 10], [11, 12, 13, 14, 15]]).T sample_scenario_allocator.update_time_series('series', new_data) # Check update was applied assert np.array_equal(ts.selected_data.values, new_data) - assert ts._has_scenarios + assert ts.has_scenario_dim # Check scenario-specific values - assert np.array_equal(ts.sel(scenario='baseline').values, new_data[0]) - assert np.array_equal(ts.sel(scenario='high_demand').values, new_data[1]) - assert np.array_equal(ts.sel(scenario='low_price').values, new_data[2]) + assert np.array_equal(ts.sel(scenario='baseline').values, new_data[:,0]) + assert np.array_equal(ts.sel(scenario='high_demand').values, new_data[:,1]) + assert np.array_equal(ts.sel(scenario='low_price').values, new_data[:,2]) if __name__ == '__main__': From 26bc4478ccb0d96fc743f7d7c69f2d68ead1f0eb Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 9 Apr 2025 17:42:11 +0200 Subject: [PATCH 014/336] ruff check --- flixopt/flow_system.py | 2 +- flixopt/interface.py | 2 +- tests/test_dataconverter.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index a36a14af1..d62f018bf 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -16,7 +16,7 @@ from rich.pretty import Pretty from . import io as fx_io -from .core import Scalar, TimeSeries, TimeSeriesCollection, TimeSeriesData, TimestepData, ScenarioData +from .core import Scalar, ScenarioData, TimeSeries, TimeSeriesCollection, TimeSeriesData, TimestepData from .effects import ( Effect, EffectCollection, diff --git a/flixopt/interface.py b/flixopt/interface.py index 2bece9943..a7b254fb6 100644 --- a/flixopt/interface.py +++ b/flixopt/interface.py @@ -4,7 +4,7 @@ """ import logging -from typing import TYPE_CHECKING, Dict, Iterator, List, Optional, Union, Literal +from typing import TYPE_CHECKING, Dict, Iterator, List, Literal, Optional, Union from .config import CONFIG from .core import NumericDataTS, Scalar, ScenarioData, TimestepData diff --git a/tests/test_dataconverter.py b/tests/test_dataconverter.py index 61adcb284..a50754301 100644 --- a/tests/test_dataconverter.py +++ b/tests/test_dataconverter.py @@ -234,7 +234,7 @@ def test_series_broadcast_to_time(self, sample_time_index, sample_scenario_index assert result.dims == ('time', 'scenario') # Check broadcasting - each time should have the same scenario values - for i, time in enumerate(sample_time_index): + for time in sample_time_index: time_slice = result.sel(time=time) assert np.array_equal(time_slice.values, series.values) From 6eeea72548210e7adbd898767019f34f6f229551 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 11 Apr 2025 17:34:11 +0200 Subject: [PATCH 015/336] Bugfix plausibility in Storage --- flixopt/components.py | 59 ++++++++++++++++++++++--------------------- 1 file changed, 30 insertions(+), 29 deletions(-) diff --git a/flixopt/components.py b/flixopt/components.py index 358b66e1b..30c562543 100644 --- a/flixopt/components.py +++ b/flixopt/components.py @@ -226,36 +226,37 @@ def _plausibility_checks(self) -> None: Check for infeasible or uncommon combinations of parameters """ super()._plausibility_checks() - if isinstance(self.initial_charge_state, str) and not self.initial_charge_state == 'lastValueOfSim': - raise PlausibilityError(f'initial_charge_state has undefined value: {self.initial_charge_state}') - else: - if isinstance(self.capacity_in_flow_hours, InvestParameters): - if self.capacity_in_flow_hours.fixed_size is None: - maximum_capacity = self.capacity_in_flow_hours.maximum_size - minimum_capacity = self.capacity_in_flow_hours.minimum_size - else: - maximum_capacity = self.capacity_in_flow_hours.fixed_size - minimum_capacity = self.capacity_in_flow_hours.fixed_size + if isinstance(self.initial_charge_state, str): + if self.initial_charge_state != 'lastValueOfSim': + raise PlausibilityError(f'initial_charge_state has undefined value: {self.initial_charge_state}') + return + if isinstance(self.capacity_in_flow_hours, InvestParameters): + if self.capacity_in_flow_hours.fixed_size is None: + maximum_capacity = self.capacity_in_flow_hours.maximum_size + minimum_capacity = self.capacity_in_flow_hours.minimum_size else: - maximum_capacity = self.capacity_in_flow_hours - minimum_capacity = self.capacity_in_flow_hours - - # initial capacity >= allowed min for maximum_size: - minimum_inital_capacity = maximum_capacity * self.relative_minimum_charge_state.isel(time=1) - # initial capacity <= allowed max for minimum_size: - maximum_inital_capacity = minimum_capacity * self.relative_maximum_charge_state.isel(time=1) - # TODO: index=1 ??? I think index 0 - - if (self.initial_charge_state > maximum_inital_capacity).any(): - raise ValueError( - f'{self.label_full}: {self.initial_charge_state=} ' - f'is above allowed maximum charge_state {maximum_inital_capacity}' - ) - if (self.initial_charge_state < minimum_inital_capacity).any(): - raise ValueError( - f'{self.label_full}: {self.initial_charge_state=} ' - f'is below allowed minimum charge_state {minimum_inital_capacity}' - ) + maximum_capacity = self.capacity_in_flow_hours.fixed_size + minimum_capacity = self.capacity_in_flow_hours.fixed_size + else: + maximum_capacity = self.capacity_in_flow_hours + minimum_capacity = self.capacity_in_flow_hours + + # initial capacity >= allowed min for maximum_size: + minimum_inital_capacity = maximum_capacity * self.relative_minimum_charge_state.isel(time=1) + # initial capacity <= allowed max for minimum_size: + maximum_inital_capacity = minimum_capacity * self.relative_maximum_charge_state.isel(time=1) + # TODO: index=1 ??? I think index 0 + + if (self.initial_charge_state > maximum_inital_capacity).any(): + raise ValueError( + f'{self.label_full}: {self.initial_charge_state=} ' + f'is above allowed maximum charge_state {maximum_inital_capacity}' + ) + if (self.initial_charge_state < minimum_inital_capacity).any(): + raise ValueError( + f'{self.label_full}: {self.initial_charge_state=} ' + f'is below allowed minimum charge_state {minimum_inital_capacity}' + ) @register_class_for_io From ecf64d2b74503b2dc1ba8a363d72916d4f05d527 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 11 Apr 2025 17:40:01 +0200 Subject: [PATCH 016/336] Bugfix check in Storage Model --- flixopt/components.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flixopt/components.py b/flixopt/components.py index 30c562543..32b28308e 100644 --- a/flixopt/components.py +++ b/flixopt/components.py @@ -533,7 +533,7 @@ def _initial_and_final_charge_state(self): name_short = 'initial_charge_state' name = f'{self.label_full}|{name_short}' - if self.element.initial_charge_state == 'lastValueOfSim': + if isinstance(self.element.initial_charge_state, str): self.add( self._model.add_constraints( self.charge_state.isel(time=0) == self.charge_state.isel(time=-1), name=name From 091ab71b3103f40d81dd5df8a08130e7815af78b Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 11 Apr 2025 17:40:08 +0200 Subject: [PATCH 017/336] Improve example --- examples/04_Scenarios/scenario_example.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/04_Scenarios/scenario_example.py b/examples/04_Scenarios/scenario_example.py index 03c2a5be0..c68d1bbe5 100644 --- a/examples/04_Scenarios/scenario_example.py +++ b/examples/04_Scenarios/scenario_example.py @@ -49,7 +49,7 @@ boiler = fx.linear_converters.Boiler( label='Boiler', eta=0.5, - Q_th=fx.Flow(label='Q_th', bus='Fernwärme', size=50, relative_minimum=0.1, relative_maximum=1), + Q_th=fx.Flow(label='Q_th', bus='Fernwärme', size=50, relative_minimum=0.1, relative_maximum=1, on_off_parameters=fx.OnOffParameters()), Q_fu=fx.Flow(label='Q_fu', bus='Gas'), ) @@ -58,7 +58,7 @@ label='CHP', eta_th=0.5, eta_el=0.4, - P_el=fx.Flow('P_el', bus='Strom', size=60, relative_minimum=5 / 60), + P_el=fx.Flow('P_el', bus='Strom', size=60, relative_minimum=5 / 60, on_off_parameters=fx.OnOffParameters()), Q_th=fx.Flow('Q_th', bus='Fernwärme'), Q_fu=fx.Flow('Q_fu', bus='Gas'), ) From 0a7e3367fe659b2d8d204ccf0465195ace2c31f9 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 11 Apr 2025 17:43:38 +0200 Subject: [PATCH 018/336] ruff check --- flixopt/elements.py | 2 +- flixopt/features.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/flixopt/elements.py b/flixopt/elements.py index b5b4f2344..3cc775674 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -10,7 +10,7 @@ import numpy as np from .config import CONFIG -from .core import TimestepData, PlausibilityError, Scalar, ScenarioData, TimestepData +from .core import PlausibilityError, Scalar, ScenarioData, TimestepData from .effects import EffectValuesUserTimestep from .features import InvestmentModel, OnOffModel, PreventSimultaneousUsageModel from .interface import InvestParameters, OnOffParameters diff --git a/flixopt/features.py b/flixopt/features.py index 52b49e960..0ccc7be2a 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -10,7 +10,7 @@ import numpy as np from .config import CONFIG -from .core import Scalar, TimeSeries, TimestepData, ScenarioData +from .core import Scalar, ScenarioData, TimeSeries, TimestepData from .interface import InvestParameters, OnOffParameters, Piecewise from .structure import Model, SystemModel From 8b5d2fcc06869df575f8b37bc848b98777a82764 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 14 Apr 2025 18:20:09 +0200 Subject: [PATCH 019/336] Simplifying the investment with scenarios, by a simpler approach --- flixopt/features.py | 71 ++++++++++++++------------------------------ flixopt/interface.py | 27 +++++++++++++---- 2 files changed, 43 insertions(+), 55 deletions(-) diff --git a/flixopt/features.py b/flixopt/features.py index 0ccc7be2a..17ef9928a 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -194,7 +194,10 @@ def _create_bounds_for_defining_variable(self): # anmerkung: Glg bei Spezialfall relative_minimum = 0 redundant zu OnOff ?? def _create_bounds_for_scenarios(self): - if self.parameters.size_per_scenario == 'equal': + if self.parameters.investment_scenarios == 'individual': + return + + if self.parameters.investment_scenarios is None: self.add( self._model.add_constraints( self.size.isel(scenario=slice(None, -1)) == self.size.isel(scenario=slice(1, None)), @@ -202,64 +205,34 @@ def _create_bounds_for_scenarios(self): ), 'equalize_size_per_scenario', ) - elif self.parameters.size_per_scenario == 'increment_once': - if not self.parameters.optional: - raise ValueError('Increment once can only be used if the Investment is optional') - - self.scenario_of_investment = self.add( - self._model.add_variables( - binary=True, - name=f'{self.label_full}|scenario_of_investment', - coords=self._model.get_coords(time_dim=False), - ), - 'scenario_of_investment', - ) - - # eq: scenario_of_investment(t) = is_invested(t) - is_invested(t-1) - self.add( - self._model.add_constraints( - self.scenario_of_investment.isel(scenario=slice(1, None)) - == self.is_invested.isel(scenario=slice(1, None)) - self.is_invested.isel(scenario=slice(None, -1)), - name=f'{self.label_full}|scenario_of_investment', - ), - 'scenario_of_investment', - ) - - # eq: scenario_of_investment(t=0) = is_invested(t=0) - self.add( - self._model.add_constraints( - self.scenario_of_investment.isel(scenario=0) - == self.is_invested.isel(scenario=0), - name=f'{self.label_full}|initial_scenario_of_investment', - ), - 'initial_scenario_of_investment', - ) + return + if not isinstance(self.parameters.investment_scenarios, list): + raise ValueError(f'Invalid value for investment_scenarios: {self.parameters.investment_scenarios}') + if not all(scenario in self._model.time_series_collection.scenarios for scenario in self.parameters.investment_scenarios): + raise ValueError(f'Some scenarios in investment_scenarios are not present in the time_series_collection: {self.parameters.investment_scenarios}') - big_m = self.parameters.maximum_size.isel(scenario=slice(1, None)) + investment_scenarios = self._model.time_series_collection.scenarios.intersection(self.parameters.investment_scenarios) + no_investment_scenarios = self._model.time_series_collection.scenarios.difference(self.parameters.investment_scenarios) + # eq: size(s) = size(s') for s, s' in investment_scenarios + if len(investment_scenarios) > 1: self.add( self._model.add_constraints( - self.size.isel(scenario=slice(1, None)) - self.size.isel(scenario=slice(None, -1)) - <= self.scenario_of_investment.isel(scenario=slice(1, None)) * big_m, - name=f'{self.label_full}|invest_once_1a', - ), - 'invest_once_1a', + self.size.sel(scenario=investment_scenarios[:-1]) == self.size.sel(scenario=investment_scenarios[1:]), + name=f'{self.label_full}|investment_scenarios', + ), + 'investment_scenarios', ) + if len(no_investment_scenarios) >= 1: self.add( self._model.add_constraints( - self.size.isel(scenario=slice(1, None)) - self.size.isel(scenario=slice(None, -1)) - >= self.scenario_of_investment.isel(scenario=slice(1, None)) * big_m, - name=f'{self.label_full}|invest_once_1b', - ), - 'invest_once_1b', + self.size.sel(scenario=no_investment_scenarios) == 0, + name=f'{self.label_full}|no_investment_scenarios', + ), + 'no_investment_scenarios', ) - elif self.parameters.size_per_scenario == 'individual': - pass - else: - raise ValueError(f'Invalid value for size_per_scenario: {self.parameters.size_per_scenario}') - class StateModel(Model): """ diff --git a/flixopt/interface.py b/flixopt/interface.py index 6ede2f2b9..a4936e844 100644 --- a/flixopt/interface.py +++ b/flixopt/interface.py @@ -158,7 +158,7 @@ def __init__( specific_effects: Optional['EffectValuesUserScenario'] = None, # costs per Flow-Unit/Storage-Size/... piecewise_effects: Optional[PiecewiseEffects] = None, divest_effects: Optional['EffectValuesUserScenario'] = None, - size_per_scenario: Literal['equal', 'individual', 'increment_once'] = 'equal', + investment_scenarios: Optional[Union[Literal['individual'], List[Union[int, str]]]] = None, ): """ Args: @@ -172,11 +172,10 @@ def __init__( piecewise_effects: Define the effects of the investment as a piecewise function of the size of the investment. minimum_size: Minimum possible size of the investment. maximum_size: Maximum possible size of the investment. - size_per_scenario: How to treat the size in each scenario - - 'equal': Equalize the size of all scenarios + investment_scenarios: For which scenarios to optimize the size for. - 'individual': Optimize the size of each scenario individually - - 'increment_once': Allow the size to increase only once. This is useful if the scenarios are related to - different periods (years, months). Tune the timing by setting the maximum size to 0 in the first scenarios. + - List of scenario names: Optimize the size for the passed scenario names (equal size in all). All other scenarios will have the size 0. + - None: Equals to a list of all scenarios (default) """ self.fix_effects: EffectValuesUserScenario = fix_effects if fix_effects is not None else {} self.divest_effects: EffectValuesUserScenario = divest_effects if divest_effects is not None else {} @@ -186,9 +185,10 @@ def __init__( self.piecewise_effects = piecewise_effects self._minimum_size = minimum_size if minimum_size is not None else CONFIG.modeling.EPSILON self._maximum_size = maximum_size if maximum_size is not None else CONFIG.modeling.BIG # default maximum - self.size_per_scenario = size_per_scenario + self.investment_scenarios = investment_scenarios def transform_data(self, flow_system: 'FlowSystem', name_prefix: str): + self._plausibility_checks(flow_system) self.fix_effects = flow_system.create_effect_time_series( label_prefix=name_prefix, effect_values=self.fix_effects, @@ -225,6 +225,21 @@ def transform_data(self, flow_system: 'FlowSystem', name_prefix: str): f'{name_prefix}|fixed_size', self.fixed_size, has_time_dim=False, has_scenario_dim=True ) + def _plausibility_checks(self, flow_system): + if isinstance(self.investment_scenarios, list): + if not set(self.investment_scenarios).issubset(flow_system.time_series_collection.scenarios): + raise ValueError( + f'Some scenarios in investment_scenarios are not present in the time_series_collection: ' + f'{set(self.investment_scenarios) - set(flow_system.time_series_collection.scenarios)}' + ) + if self.investment_scenarios is not None: + if not self.optional: + if self.minimum_size is not None or self.fixed_size is not None: + logger.warning( + f'When using investment_scenarios, minimum_size and fixed_size should only ne used if optional is True.' + f'Otherwise the investment cannot be 0 incertain scenarios while being non-zero in others.' + ) + @property def minimum_size(self): return self.fixed_size if self.fixed_size is not None else self._minimum_size From f28db64ddb0dc3f8d2074896e1a0a55608342968 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 14 Apr 2025 19:30:58 +0200 Subject: [PATCH 020/336] Simplifying the investment with scenarios --- flixopt/features.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/flixopt/features.py b/flixopt/features.py index 17ef9928a..31c1dbadb 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -209,7 +209,9 @@ def _create_bounds_for_scenarios(self): if not isinstance(self.parameters.investment_scenarios, list): raise ValueError(f'Invalid value for investment_scenarios: {self.parameters.investment_scenarios}') if not all(scenario in self._model.time_series_collection.scenarios for scenario in self.parameters.investment_scenarios): - raise ValueError(f'Some scenarios in investment_scenarios are not present in the time_series_collection: {self.parameters.investment_scenarios}') + raise ValueError(f'Some scenarios in investment_scenarios are not present in the time_series_collection: ' + f'{self.parameters.investment_scenarios}. This might be due to selecting a subset of ' + f'all scenarios, which is not yet supported.') investment_scenarios = self._model.time_series_collection.scenarios.intersection(self.parameters.investment_scenarios) no_investment_scenarios = self._model.time_series_collection.scenarios.difference(self.parameters.investment_scenarios) From 8696430dff19e0f969df320ae8114516cf54e873 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 14 Apr 2025 19:31:10 +0200 Subject: [PATCH 021/336] Bugfix in scenario selection --- flixopt/core.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/flixopt/core.py b/flixopt/core.py index 14b95ea5a..e1c3c361d 100644 --- a/flixopt/core.py +++ b/flixopt/core.py @@ -1109,7 +1109,7 @@ def set_selection(self, timesteps: Optional[pd.DatetimeIndex] = None, scenarios: if scenarios is None: self.clear_selection(timesteps=False, scenarios=True) else: - self._selected_scenarios = scenarios + self._selected_scenarios = self._validate_scenarios(scenarios) # Apply the selection to all TimeSeries objects self._propagate_selection_to_time_series() @@ -1350,10 +1350,6 @@ def _validate_scenarios(scenarios: pd.Index, present_scenarios: Optional[pd.Inde logger.warning('Converting scenarios to pandas.Index') scenarios = pd.Index(scenarios, name='scenario') - if len(scenarios) < 2: - logger.warning('scenarios must contain at least 2 scenarios') - raise ValueError('timesteps must contain at least 2 timestamps') - # Ensure timesteps has the required name if scenarios.name != 'scenario': logger.debug('Renamed scenarios to "scneario" (was "%s")', scenarios.name) From 2d3f0ada33061ad9fa66266b0df3fd2742ac68df Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 14 Apr 2025 19:40:11 +0200 Subject: [PATCH 022/336] ruff check --- flixopt/interface.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/flixopt/interface.py b/flixopt/interface.py index a4936e844..4b7634e96 100644 --- a/flixopt/interface.py +++ b/flixopt/interface.py @@ -236,8 +236,8 @@ def _plausibility_checks(self, flow_system): if not self.optional: if self.minimum_size is not None or self.fixed_size is not None: logger.warning( - f'When using investment_scenarios, minimum_size and fixed_size should only ne used if optional is True.' - f'Otherwise the investment cannot be 0 incertain scenarios while being non-zero in others.' + 'When using investment_scenarios, minimum_size and fixed_size should only ne used if optional is True.' + 'Otherwise the investment cannot be 0 incertain scenarios while being non-zero in others.' ) @property From c730b87dfcca74be20730c061598146580fff4f3 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 15 Apr 2025 16:35:16 +0200 Subject: [PATCH 023/336] Scenarios/io (#244) * Add scenarios to io of flow_system.py * Add test for io of scenarios * Fix tests and docstrings (#242) * Bugfix testing fixture * Bugfix tests and add new tests to check for previous states/flow_rates * Bugfix tests and add new tests to check for previous states/flow_rates * Add comment for previous flow_rates * Add comment for OnOffParameters in Component and LinearConverter * Bugfix io --- flixopt/flow_system.py | 15 ++++++++- tests/conftest.py | 74 ++++++++++++++++++++++++++++++++++++++++++ tests/test_io.py | 3 +- 3 files changed, 90 insertions(+), 2 deletions(-) diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index 7b83b2005..591b55e06 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -64,7 +64,9 @@ def __init__( hours_of_last_timestep=hours_of_last_timestep, hours_of_previous_timesteps=hours_of_previous_timesteps, ) - self.scenario_weights = scenario_weights + self.scenario_weights = self.create_time_series( + 'scenario_weights', scenario_weights, has_time_dim=False, has_scenario_dim=True + ) # defaults: self.components: Dict[str, Component] = {} @@ -79,10 +81,15 @@ def from_dataset(cls, ds: xr.Dataset): timesteps_extra = pd.DatetimeIndex(ds.attrs['timesteps_extra'], name='time') hours_of_last_timestep = TimeSeriesCollection.calculate_hours_per_timestep(timesteps_extra).isel(time=-1).item() + scenarios = pd.Index(ds.attrs['scenarios'], name='scenario') if ds.attrs.get('scenarios') is not None else None + scenario_weights = fx_io.insert_dataarray(ds.attrs['scenario_weights'], ds) + flow_system = FlowSystem( timesteps=timesteps_extra[:-1], hours_of_last_timestep=hours_of_last_timestep, hours_of_previous_timesteps=ds.attrs['hours_of_previous_timesteps'], + scenarios=scenarios, + scenario_weights=scenario_weights, ) structure = fx_io.insert_dataarray({key: ds.attrs[key] for key in ['components', 'buses', 'effects']}, ds) @@ -103,11 +110,15 @@ def from_dict(cls, data: Dict) -> 'FlowSystem': """ timesteps_extra = pd.DatetimeIndex(data['timesteps_extra'], name='time') hours_of_last_timestep = TimeSeriesCollection.calculate_hours_per_timestep(timesteps_extra).isel(time=-1).item() + scenarios = pd.Index(data['scenarios'], name='scenario') if data.get('scenarios') is not None else None + scenario_weights = data.get('scenario_weights').selected_data if data.get('scenario_weights') is not None else None flow_system = FlowSystem( timesteps=timesteps_extra[:-1], hours_of_last_timestep=hours_of_last_timestep, hours_of_previous_timesteps=data['hours_of_previous_timesteps'], + scenarios=scenarios, + scenario_weights=scenario_weights, ) flow_system.add_elements(*[Bus.from_dict(bus) for bus in data['buses'].values()]) @@ -183,6 +194,8 @@ def as_dict(self, data_mode: Literal['data', 'name', 'stats'] = 'data') -> Dict: }, 'timesteps_extra': [date.isoformat() for date in self.time_series_collection.timesteps_extra], 'hours_of_previous_timesteps': self.time_series_collection.hours_of_previous_timesteps, + 'scenarios': self.time_series_collection.scenarios.tolist() if self.time_series_collection.scenarios is not None else None, + 'scenario_weights': self.scenario_weights, } if data_mode == 'data': return fx_io.replace_timeseries(data, 'data') diff --git a/tests/conftest.py b/tests/conftest.py index 5399be72a..b2ceb1c1d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -121,6 +121,80 @@ def simple_flow_system() -> fx.FlowSystem: return flow_system +@pytest.fixture +def simple_flow_system_scenarios() -> fx.FlowSystem: + """ + Create a simple energy system for testing + """ + base_thermal_load = np.array([30.0, 0.0, 90.0, 110, 110, 20, 20, 20, 20]) + base_electrical_price = np.array([0.08, 0.1, 0.15]) + base_timesteps = pd.date_range('2020-01-01', periods=9, freq='h', name='time') + # Define effects + costs = fx.Effect('costs', '€', 'Kosten', is_standard=True, is_objective=True) + co2 = fx.Effect( + 'CO2', + 'kg', + 'CO2_e-Emissionen', + specific_share_to_other_effects_operation={costs.label: 0.2}, + maximum_operation_per_hour=1000, + ) + + # Create components + boiler = fx.linear_converters.Boiler( + 'Boiler', + eta=0.5, + Q_th=fx.Flow( + 'Q_th', + bus='Fernwärme', + size=50, + relative_minimum=5 / 50, + relative_maximum=1, + on_off_parameters=fx.OnOffParameters(), + ), + Q_fu=fx.Flow('Q_fu', bus='Gas'), + ) + + chp = fx.linear_converters.CHP( + 'CHP_unit', + eta_th=0.5, + eta_el=0.4, + P_el=fx.Flow('P_el', bus='Strom', size=60, relative_minimum=5 / 60, on_off_parameters=fx.OnOffParameters()), + Q_th=fx.Flow('Q_th', bus='Fernwärme'), + Q_fu=fx.Flow('Q_fu', bus='Gas'), + ) + + storage = fx.Storage( + 'Speicher', + charging=fx.Flow('Q_th_load', bus='Fernwärme', size=1e4), + discharging=fx.Flow('Q_th_unload', bus='Fernwärme', size=1e4), + capacity_in_flow_hours=fx.InvestParameters(fix_effects=20, fixed_size=30, optional=False), + initial_charge_state=0, + relative_maximum_charge_state=1 / 100 * np.array([80.0, 70.0, 80.0, 80, 80, 80, 80, 80, 80, 80]), + eta_charge=0.9, + eta_discharge=1, + relative_loss_per_hour=0.08, + prevent_simultaneous_charge_and_discharge=True, + ) + + heat_load = fx.Sink( + 'Wärmelast', sink=fx.Flow('Q_th_Last', bus='Fernwärme', size=1, fixed_relative_profile=base_thermal_load) + ) + + gas_tariff = fx.Source( + 'Gastarif', source=fx.Flow('Q_Gas', bus='Gas', size=1000, effects_per_flow_hour={'costs': 0.04, 'CO2': 0.3}) + ) + + electricity_feed_in = fx.Sink( + 'Einspeisung', sink=fx.Flow('P_el', bus='Strom', effects_per_flow_hour=-1 * base_electrical_price) + ) + + # Create flow system + flow_system = fx.FlowSystem(base_timesteps, scenarios=pd.Index(['A', 'B', 'C']), scenario_weights=np.array([0.5, 0.25, 0.25])) + flow_system.add_elements(fx.Bus('Strom'), fx.Bus('Fernwärme'), fx.Bus('Gas')) + flow_system.add_elements(storage, costs, co2, boiler, heat_load, gas_tariff, electricity_feed_in, chp) + + return flow_system + @pytest.fixture def basic_flow_system() -> fx.FlowSystem: diff --git a/tests/test_io.py b/tests/test_io.py index 2e6c61ccf..2b3a03399 100644 --- a/tests/test_io.py +++ b/tests/test_io.py @@ -11,10 +11,11 @@ flow_system_long, flow_system_segments_of_flows_2, simple_flow_system, + simple_flow_system_scenarios, ) -@pytest.fixture(params=[flow_system_base, flow_system_segments_of_flows_2, simple_flow_system, flow_system_long]) +@pytest.fixture(params=[flow_system_base, simple_flow_system_scenarios, flow_system_segments_of_flows_2, simple_flow_system, flow_system_long]) def flow_system(request): fs = request.getfixturevalue(request.param.__name__) if isinstance(fs, fx.FlowSystem): From 50bb559f813d6a6e0e40864a10763dfc8fa10cbb Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 15 Apr 2025 16:42:10 +0200 Subject: [PATCH 024/336] Scenarios/testing (#246) * Add tests for scenario calculations * utility method to get TimeSeries as a DataArray --- examples/04_Scenarios/scenario_example.py | 2 +- flixopt/components.py | 2 +- flixopt/core.py | 100 ++++--- flixopt/effects.py | 22 +- flixopt/elements.py | 22 +- flixopt/features.py | 30 +- tests/test_scenarios.py | 333 ++++++++++++++++++++++ tests/test_timeseries.py | 8 +- 8 files changed, 423 insertions(+), 96 deletions(-) create mode 100644 tests/test_scenarios.py diff --git a/examples/04_Scenarios/scenario_example.py b/examples/04_Scenarios/scenario_example.py index c68d1bbe5..3edb6e7c0 100644 --- a/examples/04_Scenarios/scenario_example.py +++ b/examples/04_Scenarios/scenario_example.py @@ -19,7 +19,7 @@ 'High Demand':[30, 0, 100, 118, 125, 20, 20, 20, 20]}, index=timesteps) power_prices = np.array([0.08, 0.09]) - flow_system = fx.FlowSystem(timesteps=timesteps, scenarios=scenarios) + flow_system = fx.FlowSystem(timesteps=timesteps, scenarios=scenarios, scenario_weights=np.array([0.5, 0.6])) # --- Define Energy Buses --- # These represent nodes, where the used medias are balanced (electricity, heat, and gas) diff --git a/flixopt/components.py b/flixopt/components.py index d471dcf6e..1b745b54c 100644 --- a/flixopt/components.py +++ b/flixopt/components.py @@ -211,7 +211,7 @@ def transform_data(self, flow_system: 'FlowSystem') -> None: self.relative_loss_per_hour = flow_system.create_time_series( f'{self.label_full}|relative_loss_per_hour', self.relative_loss_per_hour ) - if self.initial_charge_state != 'lastValueOfSim': + if not isinstance(self.initial_charge_state, str): self.initial_charge_state = flow_system.create_time_series( f'{self.label_full}|initial_charge_state', self.initial_charge_state, has_time_dim=False ) diff --git a/flixopt/core.py b/flixopt/core.py index e1c3c361d..850e01c04 100644 --- a/flixopt/core.py +++ b/flixopt/core.py @@ -653,7 +653,7 @@ def reset(self) -> None: Reset selections to include all timesteps and scenarios. This is equivalent to clearing all selections. """ - self.clear_selection() + self.set_selection(None, None) def restore_data(self) -> None: """ @@ -755,13 +755,7 @@ def update_stored_data(self, value: xr.DataArray) -> None: return self._stored_data = new_data - self.clear_selection() # Reset selections to full dataset - - def clear_selection(self, timesteps: bool = True, scenarios: bool = True) -> None: - if timesteps: - self._selected_timesteps = None - if scenarios: - self._selected_scenarios = None + self.set_selection(None, None) # Reset selections to full dataset def set_selection(self, timesteps: Optional[pd.DatetimeIndex] = None, scenarios: Optional[pd.Index] = None) -> None: """ @@ -773,15 +767,15 @@ def set_selection(self, timesteps: Optional[pd.DatetimeIndex] = None, scenarios: """ # Only update timesteps if the series has time dimension if self.has_time_dim: - if timesteps is None: - self.clear_selection(timesteps=True, scenarios=False) + if timesteps is None or timesteps.equals(self._stored_data.indexes['time']): + self._selected_timesteps = None else: self._selected_timesteps = timesteps # Only update scenarios if the series has scenario dimension if self.has_scenario_dim: - if scenarios is None: - self.clear_selection(timesteps=False, scenarios=True) + if scenarios is None or scenarios.equals(self._stored_data.indexes['scenario']): + self._selected_scenarios = None else: self._selected_scenarios = scenarios @@ -1077,22 +1071,6 @@ def add_time_series( # Return the TimeSeries object return time_series - def clear_selection(self, timesteps: bool = True, scenarios: bool = True) -> None: - """ - Clear selection for timesteps and/or scenarios. - - Args: - timesteps: Whether to clear timesteps selection - scenarios: Whether to clear scenarios selection - """ - if timesteps: - self._update_selected_timesteps(timesteps=None) - if scenarios: - self._selected_scenarios = None - - for ts in self._time_series.values(): - ts.clear_selection(timesteps=timesteps, scenarios=scenarios) - def set_selection(self, timesteps: Optional[pd.DatetimeIndex] = None, scenarios: Optional[pd.Index] = None) -> None: """ Set active subset for timesteps and scenarios. @@ -1102,35 +1080,30 @@ def set_selection(self, timesteps: Optional[pd.DatetimeIndex] = None, scenarios: scenarios: Scenarios to activate, or None to clear """ if timesteps is None: - self.clear_selection(timesteps=True, scenarios=False) + self._selected_timesteps = None + self._selected_timesteps_extra = None else: - self._update_selected_timesteps(timesteps) + self._selected_timesteps = self._validate_timesteps(timesteps, self._full_timesteps) + self._selected_timesteps_extra = self._create_timesteps_with_extra( + timesteps, self._calculate_hours_of_final_timestep(timesteps, self._full_timesteps) + ) if scenarios is None: - self.clear_selection(timesteps=False, scenarios=True) + self._selected_scenarios = None else: - self._selected_scenarios = self._validate_scenarios(scenarios) + self._selected_scenarios = self._validate_scenarios(scenarios, self._full_scenarios) - # Apply the selection to all TimeSeries objects - self._propagate_selection_to_time_series() + self._selected_hours_per_timestep = self.calculate_hours_per_timestep(self.timesteps_extra, self.scenarios) - def _update_selected_timesteps(self, timesteps: Optional[pd.DatetimeIndex]) -> None: - """ - Updates the timestep and related metrics (timesteps_extra, hours_per_timestep) based on the current selection. - """ - if timesteps is None: - self._selected_timesteps = None - self._selected_timesteps_extra = None - self._selected_hours_per_timestep = None - return + # Apply the selection to all TimeSeries objects + for ts_name, ts in self._time_series.items(): + if ts.has_time_dim: + timesteps = self.timesteps_extra if ts_name in self._has_extra_timestep else self.timesteps + else: + timesteps = None - self._selected_timesteps = self._validate_timesteps(timesteps, self._full_timesteps) - self._selected_timesteps_extra = self._create_timesteps_with_extra( - timesteps, self._calculate_hours_of_final_timestep(timesteps, self._full_timesteps) - ) - self._selected_hours_per_timestep = self.calculate_hours_per_timestep( - self._selected_timesteps_extra, self._selected_scenarios - ) + ts.set_selection(timesteps=timesteps, scenarios=self.scenarios if ts.has_scenario_dim else None) + self._propagate_selection_to_time_series() def as_dataset(self, with_extra_timestep: bool = True, with_constants: bool = True) -> xr.Dataset: """ @@ -1188,7 +1161,7 @@ def _propagate_selection_to_time_series(self) -> None: """Apply the current selection to all TimeSeries objects.""" for ts_name, ts in self._time_series.items(): if ts.has_time_dim: - timesteps = self.timesteps_extra if ts_name in self._has_extra_timestep else self.timesteps + timesteps = self.timesteps_extra if ts_name in self._has_extra_timestep else self.timesteps else: timesteps = None @@ -1482,3 +1455,28 @@ def get_numeric_stats(data: xr.DataArray, decimals: int = 2, padd: int = 10, by_ std = data.std().item() return f'{mean:{format_spec}} (mean), {median:{format_spec}} (median), {min_val:{format_spec}} (min), {max_val:{format_spec}} (max), {std:{format_spec}} (std)' + + +def extract_data( + data: Optional[Union[int, float, xr.DataArray, TimeSeries]], + if_none: Any = None +) -> Any: + """ + Convert data to xr.DataArray. + + Args: + data: The data to convert (scalar, array, or DataArray) + if_none: The value to return if data is None + + Returns: + DataArray with the converted data, or the value specified by if_none + """ + if data is None: + return if_none + if isinstance(data, TimeSeries): + return data.selected_data + if isinstance(data, xr.DataArray): + return data + if isinstance(data, (int, float, np.integer, np.floating)): + return xr.DataArray(data) + raise TypeError(f'Unsupported data type: {type(data).__name__}') \ No newline at end of file diff --git a/flixopt/effects.py b/flixopt/effects.py index 0cf165d66..2da561a36 100644 --- a/flixopt/effects.py +++ b/flixopt/effects.py @@ -12,7 +12,7 @@ import linopy import numpy as np -from .core import NumericDataTS, ScenarioData, TimeSeries, TimeSeriesCollection, TimestepData +from .core import NumericDataTS, ScenarioData, TimeSeries, TimeSeriesCollection, TimestepData, extract_data from .features import ShareAllocationModel from .structure import Element, ElementModel, Interface, Model, SystemModel, register_class_for_io @@ -125,8 +125,8 @@ def __init__(self, model: SystemModel, element: Effect): label_of_element=self.label_of_element, label='invest', label_full=f'{self.label_full}(invest)', - total_max=self.element.maximum_invest, - total_min=self.element.minimum_invest, + total_max=extract_data(self.element.maximum_invest), + total_min=extract_data(self.element.minimum_invest), ) ) @@ -138,14 +138,10 @@ def __init__(self, model: SystemModel, element: Effect): label_of_element=self.label_of_element, label='operation', label_full=f'{self.label_full}(operation)', - total_max=self.element.maximum_operation, - total_min=self.element.minimum_operation, - min_per_hour=self.element.minimum_operation_per_hour.selected_data - if self.element.minimum_operation_per_hour is not None - else None, - max_per_hour=self.element.maximum_operation_per_hour.selected_data - if self.element.maximum_operation_per_hour is not None - else None, + total_max=extract_data(self.element.maximum_operation), + total_min=extract_data(self.element.minimum_operation), + min_per_hour=extract_data(self.element.minimum_operation_per_hour), + max_per_hour=extract_data(self.element.maximum_operation_per_hour), ) ) @@ -155,8 +151,8 @@ def do_modeling(self): self.total = self.add( self._model.add_variables( - lower=self.element.minimum_total if self.element.minimum_total is not None else -np.inf, - upper=self.element.maximum_total if self.element.maximum_total is not None else np.inf, + lower=extract_data(self.element.minimum_total, if_none=-np.inf), + upper=extract_data(self.element.maximum_total, if_none=np.inf), coords=self._model.get_coords(time_dim=False), name=f'{self.label_full}|total', ), diff --git a/flixopt/elements.py b/flixopt/elements.py index 80085cd0c..2ff49567e 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -10,7 +10,7 @@ import numpy as np from .config import CONFIG -from .core import PlausibilityError, Scalar, ScenarioData, TimestepData +from .core import PlausibilityError, Scalar, ScenarioData, TimestepData, extract_data from .effects import EffectValuesUserTimestep from .features import InvestmentModel, OnOffModel, PreventSimultaneousUsageModel from .interface import InvestParameters, OnOffParameters @@ -375,8 +375,8 @@ def do_modeling(self): self.total_flow_hours = self.add( self._model.add_variables( - lower=self.element.flow_hours_total_min if self.element.flow_hours_total_min is not None else 0, - upper=self.element.flow_hours_total_max if self.element.flow_hours_total_max is not None else np.inf, + lower=extract_data(self.element.flow_hours_total_min, 0), + upper=extract_data(self.element.flow_hours_total_max, np.inf), coords=self._model.get_coords(time_dim=False), name=f'{self.label_full}|total_flow_hours', ), @@ -456,16 +456,16 @@ def flow_rate_lower_bound_relative(self) -> TimestepData: """Returns the lower bound of the flow_rate relative to its size""" fixed_profile = self.element.fixed_relative_profile if fixed_profile is None: - return self.element.relative_minimum.selected_data - return fixed_profile.selected_data + return extract_data(self.element.relative_minimum) + return extract_data(fixed_profile) @property def flow_rate_upper_bound_relative(self) -> TimestepData: """ Returns the upper bound of the flow_rate relative to its size""" fixed_profile = self.element.fixed_relative_profile if fixed_profile is None: - return self.element.relative_maximum.selected_data - return fixed_profile.selected_data + return extract_data(self.element.relative_maximum) + return extract_data(fixed_profile) @property def flow_rate_lower_bound(self) -> TimestepData: @@ -478,8 +478,8 @@ def flow_rate_lower_bound(self) -> TimestepData: if isinstance(self.element.size, InvestParameters): if self.element.size.optional: return 0 - return self.flow_rate_lower_bound_relative * self.element.size.minimum_size - return self.flow_rate_lower_bound_relative * self.element.size + return self.flow_rate_lower_bound_relative * extract_data(self.element.size.minimum_size) + return self.flow_rate_lower_bound_relative * extract_data(self.element.size) @property def flow_rate_upper_bound(self) -> TimestepData: @@ -488,8 +488,8 @@ def flow_rate_upper_bound(self) -> TimestepData: Further constraining might be done in OnOffModel and InvestmentModel """ if isinstance(self.element.size, InvestParameters): - return self.flow_rate_upper_bound_relative * self.element.size.maximum_size - return self.flow_rate_upper_bound_relative * self.element.size + return self.flow_rate_upper_bound_relative * extract_data(self.element.size.maximum_size) + return self.flow_rate_upper_bound_relative * extract_data(self.element.size) class BusModel(ElementModel): diff --git a/flixopt/features.py b/flixopt/features.py index 31c1dbadb..b8243d794 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -10,7 +10,7 @@ import numpy as np from .config import CONFIG -from .core import Scalar, ScenarioData, TimeSeries, TimestepData +from .core import Scalar, ScenarioData, TimeSeries, TimestepData, extract_data from .interface import InvestParameters, OnOffParameters, Piecewise from .structure import Model, SystemModel @@ -45,8 +45,8 @@ def __init__( def do_modeling(self): self.size = self.add( self._model.add_variables( - lower=0 if self.parameters.optional else self.parameters.minimum_size*1, - upper=self.parameters.maximum_size*1, + lower=0 if self.parameters.optional else extract_data(self.parameters.minimum_size), + upper=extract_data(self.parameters.maximum_size), name=f'{self.label_full}|size', coords=self._model.get_coords(time_dim=False), ), @@ -295,8 +295,8 @@ def do_modeling(self): self.total_on_hours = self.add( self._model.add_variables( - lower=self._on_hours_total_min, - upper=self._on_hours_total_max, + lower=extract_data(self._on_hours_total_min), + upper=extract_data(self._on_hours_total_max), coords=self._model.get_coords(time_dim=False), name=f'{self.label_full}|on_hours_total', ), @@ -440,7 +440,7 @@ def do_modeling(self): # Create count variable for number of switches self.switch_on_nr = self.add( self._model.add_variables( - upper=self._switch_on_max, + upper=extract_data(self._switch_on_max), lower=0, name=f'{self.label_full}|switch_on_nr', ), @@ -534,7 +534,7 @@ def do_modeling(self): self.duration = self.add( self._model.add_variables( lower=0, - upper=self._maximum_duration if self._maximum_duration is not None else mega, + upper=extract_data(self._maximum_duration, mega), coords=self._model.get_coords(), name=f'{self.label_full}|hours', ), @@ -588,7 +588,7 @@ def do_modeling(self): ) # Handle initial condition - if 0 < self.previous_duration < self._minimum_duration.isel(time=0): + if 0 < self.previous_duration < self._minimum_duration.isel(time=0).max(): self.add( self._model.add_constraints( self._state_variable.isel(time=0) == 1, name=f'{self.label_full}|initial_minimum' @@ -613,7 +613,7 @@ def previous_duration(self) -> Scalar: """Computes the previous duration of the state variable""" #TODO: Allow for other/dynamic timestep resolutions return ConsecutiveStateModel.compute_consecutive_hours_in_state( - self._previous_states, self._model.hours_per_step.isel(time=0).item() + self._previous_states, self._model.hours_per_step.isel(time=0).values.flatten()[0] ) @staticmethod @@ -715,8 +715,8 @@ def do_modeling(self): defining_bounds=self._defining_bounds, previous_values=self._previous_values, use_off=self.parameters.use_off, - on_hours_total_min=self.parameters.on_hours_total_min, - on_hours_total_max=self.parameters.on_hours_total_max, + on_hours_total_min=extract_data(self.parameters.on_hours_total_min), + on_hours_total_max=extract_data(self.parameters.on_hours_total_max), effects_per_running_hour=self.parameters.effects_per_running_hour, ) self.add(self.state_model) @@ -965,8 +965,8 @@ def __init__( label_of_element: Optional[str] = None, label: Optional[str] = None, label_full: Optional[str] = None, - total_max: Optional[Scalar] = None, - total_min: Optional[Scalar] = None, + total_max: Optional[ScenarioData] = None, + total_min: Optional[ScenarioData] = None, max_per_hour: Optional[TimestepData] = None, min_per_hour: Optional[TimestepData] = None, ): @@ -1009,8 +1009,8 @@ def do_modeling(self): if self._has_time_dim: self.total_per_timestep = self.add( self._model.add_variables( - lower=-np.inf if (self._min_per_hour is None) else self._min_per_hour * self._model.hours_per_step, - upper=np.inf if (self._max_per_hour is None) else self._max_per_hour * self._model.hours_per_step, + lower=-np.inf if (self._min_per_hour is None) else extract_data(self._min_per_hour) * self._model.hours_per_step, + upper=np.inf if (self._max_per_hour is None) else extract_data(self._max_per_hour) * self._model.hours_per_step, coords=self._model.get_coords(time_dim=True, scenario_dim=self._has_scenario_dim), name=f'{self.label_full}|total_per_timestep', ), diff --git a/tests/test_scenarios.py b/tests/test_scenarios.py new file mode 100644 index 000000000..5b9105a68 --- /dev/null +++ b/tests/test_scenarios.py @@ -0,0 +1,333 @@ +import matplotlib.pyplot as plt +import numpy as np +import pandas as pd +from linopy.testing import assert_linequal +import xarray as xr +import pytest +import flixopt as fx + +from flixopt.commons import Effect, FullCalculation, InvestParameters, Sink, Source, Storage, TimeSeriesData, solvers +from flixopt.elements import Bus, Flow +from flixopt.flow_system import FlowSystem + +from .conftest import create_linopy_model, create_calculation_and_solve + + +@pytest.fixture +def test_system(): + """Create a basic test system with scenarios.""" + # Create a two-day time index with hourly resolution + timesteps = pd.date_range( + "2023-01-01", periods=48, freq="h", name="time" + ) + + # Create two scenarios + scenarios = pd.Index(["Scenario A", "Scenario B"], name="scenario") + + # Create scenario weights as TimeSeriesData + # Using TimeSeriesData to avoid conversion issues + scenario_weights = TimeSeriesData(np.array([0.7, 0.3])) + + # Create a flow system with scenarios + flow_system = FlowSystem( + timesteps=timesteps, + scenarios=scenarios, + scenario_weights=scenario_weights # Use TimeSeriesData for weights + ) + + # Create demand profiles that differ between scenarios + # Scenario A: Higher demand in first day, lower in second day + # Scenario B: Lower demand in first day, higher in second day + demand_profile_a = np.concatenate([ + np.sin(np.linspace(0, 2*np.pi, 24)) * 5 + 10, # Day 1, max ~15 + np.sin(np.linspace(0, 2*np.pi, 24)) * 2 + 5 # Day 2, max ~7 + ]) + + demand_profile_b = np.concatenate([ + np.sin(np.linspace(0, 2*np.pi, 24)) * 2 + 5, # Day 1, max ~7 + np.sin(np.linspace(0, 2*np.pi, 24)) * 5 + 10 # Day 2, max ~15 + ]) + + # Stack the profiles into a 2D array (time, scenario) + demand_profiles = np.column_stack([demand_profile_a, demand_profile_b]) + + # Create the necessary model elements + # Create buses + electricity_bus = Bus("Electricity") + + # Create a demand sink with scenario-dependent profiles + demand = Flow( + label="Demand", + bus=electricity_bus.label_full, + fixed_relative_profile=demand_profiles + ) + demand_sink = Sink("Demand", sink=demand) + + # Create a power source with investment option + power_gen = Flow( + label="Generation", + bus=electricity_bus.label_full, + size=InvestParameters( + minimum_size=0, + maximum_size=20, + specific_effects={"Costs": 100} # €/kW + ), + effects_per_flow_hour={"Costs": 20} # €/MWh + ) + generator = Source("Generator", source=power_gen) + + # Create a storage for electricity + storage_charge = Flow( + label="Charge", + bus=electricity_bus.label_full, + size=10 + ) + storage_discharge = Flow( + label="Discharge", + bus=electricity_bus.label_full, + size=10 + ) + storage = Storage( + label="Battery", + charging=storage_charge, + discharging=storage_discharge, + capacity_in_flow_hours=InvestParameters( + minimum_size=0, + maximum_size=50, + specific_effects={"Costs": 50} # €/kWh + ), + eta_charge=0.95, + eta_discharge=0.95, + initial_charge_state="lastValueOfSim" + ) + + # Create effects and objective + cost_effect = Effect( + label="Costs", + unit="€", + description="Total costs", + is_standard=True, + is_objective=True + ) + + # Add all elements to the flow system + flow_system.add_elements( + electricity_bus, + generator, + demand_sink, + storage, + cost_effect + ) + + # Return the created system and its components + return { + "flow_system": flow_system, + "timesteps": timesteps, + "scenarios": scenarios, + "electricity_bus": electricity_bus, + "demand": demand, + "demand_sink": demand_sink, + "generator": generator, + "power_gen": power_gen, + "storage": storage, + "storage_charge": storage_charge, + "storage_discharge": storage_discharge, + "cost_effect": cost_effect + } + +@pytest.fixture +def flow_system_complex_scenarios() -> fx.FlowSystem: + """ + Helper method to create a base model with configurable parameters + """ + thermal_load = np.array([30, 0, 90, 110, 110, 20, 20, 20, 20]) + electrical_load = np.array([40, 40, 40, 40, 40, 40, 40, 40, 40]) + flow_system = fx.FlowSystem(pd.date_range('2020-01-01', periods=9, freq='h', name='time'), + pd.Index(['A', 'B', 'C'], name='scenario')) + # Define the components and flow_system + flow_system.add_elements( + fx.Effect('costs', '€', 'Kosten', is_standard=True, is_objective=True), + fx.Effect('CO2', 'kg', 'CO2_e-Emissionen', specific_share_to_other_effects_operation={'costs': 0.2}), + fx.Effect('PE', 'kWh_PE', 'Primärenergie', maximum_total=3.5e3), + fx.Bus('Strom'), + fx.Bus('Fernwärme'), + fx.Bus('Gas'), + fx.Sink('Wärmelast', sink=fx.Flow('Q_th_Last', 'Fernwärme', size=1, fixed_relative_profile=thermal_load)), + fx.Source( + 'Gastarif', source=fx.Flow('Q_Gas', 'Gas', size=1000, effects_per_flow_hour={'costs': 0.04, 'CO2': 0.3}) + ), + fx.Sink('Einspeisung', sink=fx.Flow('P_el', 'Strom', effects_per_flow_hour=-1 * electrical_load)), + ) + + boiler = fx.linear_converters.Boiler( + 'Kessel', + eta=0.5, + on_off_parameters=fx.OnOffParameters(effects_per_running_hour={'costs': 0, 'CO2': 1000}), + Q_th=fx.Flow( + 'Q_th', + bus='Fernwärme', + load_factor_max=1.0, + load_factor_min=0.1, + relative_minimum=5 / 50, + relative_maximum=1, + previous_flow_rate=50, + size=fx.InvestParameters( + fix_effects=1000, fixed_size=50, optional=False, specific_effects={'costs': 10, 'PE': 2} + ), + on_off_parameters=fx.OnOffParameters( + on_hours_total_min=0, + on_hours_total_max=1000, + consecutive_on_hours_max=10, + consecutive_on_hours_min=1, + consecutive_off_hours_max=10, + effects_per_switch_on=0.01, + switch_on_total_max=1000, + ), + flow_hours_total_max=1e6, + ), + Q_fu=fx.Flow('Q_fu', bus='Gas', size=200, relative_minimum=0, relative_maximum=1), + ) + + invest_speicher = fx.InvestParameters( + fix_effects=0, + piecewise_effects=fx.PiecewiseEffects( + piecewise_origin=fx.Piecewise([fx.Piece(5, 25), fx.Piece(25, 100)]), + piecewise_shares={ + 'costs': fx.Piecewise([fx.Piece(50, 250), fx.Piece(250, 800)]), + 'PE': fx.Piecewise([fx.Piece(5, 25), fx.Piece(25, 100)]), + }, + ), + optional=False, + specific_effects={'costs': 0.01, 'CO2': 0.01}, + minimum_size=0, + maximum_size=1000, + ) + speicher = fx.Storage( + 'Speicher', + charging=fx.Flow('Q_th_load', bus='Fernwärme', size=1e4), + discharging=fx.Flow('Q_th_unload', bus='Fernwärme', size=1e4), + capacity_in_flow_hours=invest_speicher, + initial_charge_state=0, + maximal_final_charge_state=10, + eta_charge=0.9, + eta_discharge=1, + relative_loss_per_hour=0.08, + prevent_simultaneous_charge_and_discharge=True, + ) + + flow_system.add_elements(boiler, speicher) + + return flow_system + + +@pytest.fixture +def flow_system_piecewise_conversion_scenarios(flow_system_complex_scenarios) -> fx.FlowSystem: + """ + Use segments/Piecewise with numeric data + """ + flow_system = flow_system_complex_scenarios + + flow_system.add_elements( + fx.LinearConverter( + 'KWK', + inputs=[fx.Flow('Q_fu', bus='Gas')], + outputs=[ + fx.Flow('P_el', bus='Strom', size=60, relative_maximum=55, previous_flow_rate=10), + fx.Flow('Q_th', bus='Fernwärme'), + ], + piecewise_conversion=fx.PiecewiseConversion( + { + 'P_el': fx.Piecewise( + [ + fx.Piece(np.linspace(5, 6, len(flow_system.time_series_collection.timesteps)), 30), + fx.Piece(40, np.linspace(60, 70, len(flow_system.time_series_collection.timesteps))), + ] + ), + 'Q_th': fx.Piecewise([fx.Piece(6, 35), fx.Piece(45, 100)]), + 'Q_fu': fx.Piecewise([fx.Piece(12, 70), fx.Piece(90, 200)]), + } + ), + on_off_parameters=fx.OnOffParameters(effects_per_switch_on=0.01), + ) + ) + + return flow_system + + +def test_scenario_weights(flow_system_piecewise_conversion_scenarios): + """Test that scenario weights are correctly used in the model.""" + scenarios = flow_system_piecewise_conversion_scenarios.time_series_collection.scenarios + weights = np.linspace(0.5, 1, len(scenarios)) / np.sum(np.linspace(0.5, 1, len(scenarios))) + flow_system_piecewise_conversion_scenarios.scenario_weights = weights + model = create_linopy_model(flow_system_piecewise_conversion_scenarios) + np.testing.assert_allclose(model.scenario_weights.values, weights) + assert_linequal(model.objective.expression, + (model.variables['costs|total'] * weights).sum() + model.variables['Penalty|total']) + assert np.isclose(model.scenario_weights.sum().item(), 1.0) + +def test_scenario_dimensions_in_variables(flow_system_piecewise_conversion_scenarios): + """Test that all time variables are correctly broadcasted to scenario dimensions.""" + model = create_linopy_model(flow_system_piecewise_conversion_scenarios) + for var in model.variables: + assert model.variables[var].dims in [('time', 'scenario'), ('scenario',), ()] + +def test_full_scenario_optimization(flow_system_piecewise_conversion_scenarios): + """Test a full optimization with scenarios and verify results.""" + scenarios = flow_system_piecewise_conversion_scenarios.time_series_collection.scenarios + weights = np.linspace(0.5, 1, len(scenarios)) / np.sum(np.linspace(0.5, 1, len(scenarios))) + flow_system_piecewise_conversion_scenarios.scenario_weights = weights + calc = create_calculation_and_solve(flow_system_piecewise_conversion_scenarios, + solver=fx.solvers.GurobiSolver(mip_gap=0.01, time_limit_seconds=60), + name='test_full_scenario') + calc.results.to_file() + + res = fx.results.CalculationResults.from_file('results', 'test_full_scenario') + fx.FlowSystem.from_dataset(res.flow_system) + calc = create_calculation_and_solve( + flow_system_piecewise_conversion_scenarios, + solver=fx.solvers.GurobiSolver(mip_gap=0.01, time_limit_seconds=60), + name='test_full_scenario', + ) + +@pytest.mark.slow +def test_io_persistance(flow_system_piecewise_conversion_scenarios): + """Test a full optimization with scenarios and verify results.""" + scenarios = flow_system_piecewise_conversion_scenarios.time_series_collection.scenarios + weights = np.linspace(0.5, 1, len(scenarios)) / np.sum(np.linspace(0.5, 1, len(scenarios))) + flow_system_piecewise_conversion_scenarios.scenario_weights = weights + calc = create_calculation_and_solve(flow_system_piecewise_conversion_scenarios, + solver=fx.solvers.HighsSolver(mip_gap=0.001, time_limit_seconds=60), + name='test_full_scenario') + calc.results.to_file() + + res = fx.results.CalculationResults.from_file('results', 'test_full_scenario') + flow_system_2 = fx.FlowSystem.from_dataset(res.flow_system) + calc_2 = create_calculation_and_solve( + flow_system_2, + solver=fx.solvers.HighsSolver(mip_gap=0.001, time_limit_seconds=60), + name='test_full_scenario_2', + ) + + np.testing.assert_allclose(calc.results.objective, calc_2.results.objective, rtol=0.001) + + +def test_scenarios_selection(flow_system_piecewise_conversion_scenarios): + flow_system = flow_system_piecewise_conversion_scenarios + scenarios = flow_system_piecewise_conversion_scenarios.time_series_collection.scenarios + weights = np.linspace(0.5, 1, len(scenarios)) / np.sum(np.linspace(0.5, 1, len(scenarios))) + flow_system_piecewise_conversion_scenarios.scenario_weights = weights + calc = fx.FullCalculation(flow_system=flow_system_piecewise_conversion_scenarios, + selected_scenarios=flow_system.time_series_collection.scenarios[0:2], + name='test_full_scenario') + calc.do_modeling() + calc.solve(fx.solvers.GurobiSolver(mip_gap=0.01, time_limit_seconds=60)) + + calc.results.to_file() + flow_system_2 = fx.FlowSystem.from_dataset(calc.results.flow_system) + + assert calc.results.solution.indexes['scenario'].equals(flow_system.time_series_collection.scenarios[0:2]) + + assert flow_system_2.time_series_collection.scenarios.equals(flow_system.time_series_collection.scenarios[0:2]) + + np.testing.assert_allclose(flow_system_2.scenario_weights.selected_data.values, weights[0:2]) + diff --git a/tests/test_timeseries.py b/tests/test_timeseries.py index e2432e784..237935e59 100644 --- a/tests/test_timeseries.py +++ b/tests/test_timeseries.py @@ -91,7 +91,7 @@ def test_selection_methods(self, sample_timeseries, sample_timesteps): assert sample_timeseries.selected_data.equals(sample_timeseries.stored_data.sel(time=subset_index)) # Clear selection - sample_timeseries.clear_selection() + sample_timeseries.set_selection() assert sample_timeseries._selected_timesteps is None assert sample_timeseries.selected_data.equals(sample_timeseries.stored_data) @@ -408,7 +408,7 @@ def test_scenario_selection(self, sample_scenario_timeseries, sample_scenario_in ) # Clear selection - sample_scenario_timeseries.clear_selection(timesteps=False, scenarios=True) + sample_scenario_timeseries.set_selection() assert sample_scenario_timeseries._selected_scenarios is None def test_all_equal_with_scenarios(self, sample_timesteps, sample_scenario_index): @@ -561,7 +561,7 @@ def test_selection_propagation(self, sample_allocator, sample_timesteps): assert len(ts3._selected_timesteps) == len(subset_timesteps) + 1 # Clear selection - sample_allocator.clear_selection() + sample_allocator.set_selection() # Check selection cleared assert ts1._selected_timesteps is None @@ -673,7 +673,7 @@ def test_selection_propagation_with_scenarios( assert ts1.selected_data.shape == (2, 2) # 2 timesteps, 2 scenarios # Clear selections - sample_scenario_allocator.clear_selection() + sample_scenario_allocator.set_selection() assert ts1._selected_timesteps is None assert ts1.active_timesteps.equals(sample_scenario_allocator.timesteps) assert ts1._selected_scenarios is None From 9ea8fba7fc8c4d9d46f76391b8fd3deceb93bcf9 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 15 Apr 2025 16:43:04 +0200 Subject: [PATCH 025/336] ruff check --- flixopt/core.py | 2 +- tests/test_scenarios.py | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/flixopt/core.py b/flixopt/core.py index 850e01c04..eab45e239 100644 --- a/flixopt/core.py +++ b/flixopt/core.py @@ -1479,4 +1479,4 @@ def extract_data( return data if isinstance(data, (int, float, np.integer, np.floating)): return xr.DataArray(data) - raise TypeError(f'Unsupported data type: {type(data).__name__}') \ No newline at end of file + raise TypeError(f'Unsupported data type: {type(data).__name__}') diff --git a/tests/test_scenarios.py b/tests/test_scenarios.py index 5b9105a68..62b0d3e29 100644 --- a/tests/test_scenarios.py +++ b/tests/test_scenarios.py @@ -1,16 +1,16 @@ import matplotlib.pyplot as plt import numpy as np import pandas as pd -from linopy.testing import assert_linequal -import xarray as xr import pytest -import flixopt as fx +import xarray as xr +from linopy.testing import assert_linequal +import flixopt as fx from flixopt.commons import Effect, FullCalculation, InvestParameters, Sink, Source, Storage, TimeSeriesData, solvers from flixopt.elements import Bus, Flow from flixopt.flow_system import FlowSystem -from .conftest import create_linopy_model, create_calculation_and_solve +from .conftest import create_calculation_and_solve, create_linopy_model @pytest.fixture From 7f4fddb942bda850f8cdbd25c09e8ccf34951011 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 15 Apr 2025 22:40:23 +0200 Subject: [PATCH 026/336] Bugfix in _create_bounds_for_scenarios() --- flixopt/features.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/flixopt/features.py b/flixopt/features.py index b8243d794..4eeb46337 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -194,8 +194,10 @@ def _create_bounds_for_defining_variable(self): # anmerkung: Glg bei Spezialfall relative_minimum = 0 redundant zu OnOff ?? def _create_bounds_for_scenarios(self): - if self.parameters.investment_scenarios == 'individual': - return + if isinstance(self.parameters.investment_scenarios, str): + if self.parameters.investment_scenarios == 'individual': + return + raise ValueError(f'Invalid value for investment_scenarios: {self.parameters.investment_scenarios}') if self.parameters.investment_scenarios is None: self.add( From e2192daba3f20349bbf4d16482068aa26385ee46 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 16 Apr 2025 09:46:52 +0200 Subject: [PATCH 027/336] exclude super long test --- tests/test_scenarios.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_scenarios.py b/tests/test_scenarios.py index 62b0d3e29..76da9e7ac 100644 --- a/tests/test_scenarios.py +++ b/tests/test_scenarios.py @@ -289,7 +289,7 @@ def test_full_scenario_optimization(flow_system_piecewise_conversion_scenarios): name='test_full_scenario', ) -@pytest.mark.slow +@pytest.skip def test_io_persistance(flow_system_piecewise_conversion_scenarios): """Test a full optimization with scenarios and verify results.""" scenarios = flow_system_piecewise_conversion_scenarios.time_series_collection.scenarios From 4ad86d5a3f7bf1ca2b47d97ec82349726c88023a Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 16 Apr 2025 10:10:15 +0200 Subject: [PATCH 028/336] exclude super long test - fix --- tests/test_scenarios.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/test_scenarios.py b/tests/test_scenarios.py index 76da9e7ac..89b5feced 100644 --- a/tests/test_scenarios.py +++ b/tests/test_scenarios.py @@ -289,7 +289,7 @@ def test_full_scenario_optimization(flow_system_piecewise_conversion_scenarios): name='test_full_scenario', ) -@pytest.skip +@pytest.mark.skip(reason="This test is taking too long with highs and is too big for gurobipy free") def test_io_persistance(flow_system_piecewise_conversion_scenarios): """Test a full optimization with scenarios and verify results.""" scenarios = flow_system_piecewise_conversion_scenarios.time_series_collection.scenarios @@ -330,4 +330,3 @@ def test_scenarios_selection(flow_system_piecewise_conversion_scenarios): assert flow_system_2.time_series_collection.scenarios.equals(flow_system.time_series_collection.scenarios[0:2]) np.testing.assert_allclose(flow_system_2.scenario_weights.selected_data.values, weights[0:2]) - From 967174c1f33224f2fb06b15d318fe4340ee90323 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 17 Apr 2025 15:01:19 +0200 Subject: [PATCH 029/336] Bugfix plot_node_balance_pie() --- flixopt/results.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/flixopt/results.py b/flixopt/results.py index ae54b9e2e..f5765286a 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -532,13 +532,13 @@ def plot_node_balance_pie( drop_suffix: Whether to drop the suffix from the variable names. """ inputs = sanitize_dataset( - ds=self.solution[self.inputs], + ds=self.solution[self.inputs] * self._calculation_results.hours_per_timestep, threshold=1e-5, drop_small_vars=True, zero_small_values=True, ) outputs = sanitize_dataset( - ds=self.solution[self.outputs], + ds=self.solution[self.outputs] * self._calculation_results.hours_per_timestep, threshold=1e-5, drop_small_vars=True, zero_small_values=True, From d24b5e77a57a39b767843880008e5d1a18653a4a Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 22 Apr 2025 10:28:29 +0200 Subject: [PATCH 030/336] Scenarios/fixes (#252) * BUGFIX missing conversion to TimeSeries * BUGFIX missing conversion to TimeSeries * Bugfix node_balance with flow_hours: Negate correctly --- flixopt/effects.py | 24 +++++++++++++++++++++++- flixopt/results.py | 12 ++++++++---- 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/flixopt/effects.py b/flixopt/effects.py index 2da561a36..3d93ee76a 100644 --- a/flixopt/effects.py +++ b/flixopt/effects.py @@ -97,11 +97,33 @@ def transform_data(self, flow_system: 'FlowSystem'): self.maximum_operation_per_hour = flow_system.create_time_series( f'{self.label_full}|maximum_operation_per_hour', self.maximum_operation_per_hour, flow_system ) - self.specific_share_to_other_effects_operation = flow_system.create_effect_time_series( f'{self.label_full}|operation->', self.specific_share_to_other_effects_operation, 'operation' ) + self.minimum_operation = flow_system.create_time_series( + f'{self.label_full}|minimum_operation', self.minimum_operation, has_time_dim=False + ) + self.maximum_operation = flow_system.create_time_series( + f'{self.label_full}|maximum_operation', self.maximum_operation, has_time_dim=False + ) + self.minimum_invest = flow_system.create_time_series( + f'{self.label_full}|minimum_invest', self.minimum_invest, has_time_dim=False + ) + self.maximum_invest = flow_system.create_time_series( + f'{self.label_full}|maximum_invest', self.maximum_invest, has_time_dim=False + ) + self.minimum_total = flow_system.create_time_series( + f'{self.label_full}|minimum_total', self.minimum_total, has_time_dim=False, + ) + self.maximum_total = flow_system.create_time_series( + f'{self.label_full}|maximum_total', self.maximum_total, has_time_dim=False + ) + self.specific_share_to_other_effects_invest = flow_system.create_effect_time_series( + f'{self.label_full}|invest->', self.specific_share_to_other_effects_invest, 'invest', + has_time_dim=False + ) + def create_model(self, model: SystemModel) -> 'EffectModel': self._plausibility_checks() self.model = EffectModel(model, self) diff --git a/flixopt/results.py b/flixopt/results.py index f5765286a..b57af1fb7 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -621,10 +621,8 @@ def node_balance( ds = self.solution[self.inputs + self.outputs] if drop_suffix: ds = ds.rename_vars({var: var.split('|flow_hours')[0] for var in ds.data_vars}) - if mode == 'flow_hours': - ds = ds * self._calculation_results.hours_per_timestep - ds = ds.rename_vars({var: var.replace('flow_rate', 'flow_hours') for var in ds.data_vars}) - return sanitize_dataset( + + ds = sanitize_dataset( ds=ds, threshold=threshold, timesteps=self._calculation_results.timesteps_extra if with_last_timestep else None, @@ -639,6 +637,12 @@ def node_balance( ), ) + if mode == 'flow_hours': + ds = ds * self._calculation_results.hours_per_timestep + ds = ds.rename_vars({var: var.replace('flow_rate', 'flow_hours') for var in ds.data_vars}) + + return ds + class BusResults(_NodeResults): """Results for a Bus""" From a3c7d472cbaf514a79eba4f655186908da19c765 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 22 Apr 2025 10:28:49 +0200 Subject: [PATCH 031/336] Scenarios/filter (#253) * Add containts and startswith to filter_solution --- flixopt/results.py | 90 ++++++++++++++++++++++++++++++++++------------ 1 file changed, 67 insertions(+), 23 deletions(-) diff --git a/flixopt/results.py b/flixopt/results.py index b57af1fb7..1be662975 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -202,6 +202,8 @@ def filter_solution( element: Optional[str] = None, timesteps: Optional[pd.DatetimeIndex] = None, scenarios: Optional[pd.Index] = None, + contains: Optional[Union[str, List[str]]] = None, + startswith: Optional[Union[str, List[str]]] = None, ) -> xr.Dataset: """ Filter the solution to a specific variable dimension and element. @@ -223,12 +225,18 @@ def filter_solution( - pd.Index: Multiple scenarios - str/int: Single scenario (int is treated as a label, not an index position) Defaults to all available scenarios. + contains: Filter variables that contain this string or strings. + If a list is provided, variables must contain ALL strings in the list. + startswith: Filter variables that start with this string or strings. + If a list is provided, variables must start with ANY of the strings in the list. """ return filter_dataset( self.solution if element is None else self[element].solution, variable_dims=variable_dims, timesteps=timesteps, scenarios=scenarios, + contains=contains, + startswith=startswith, ) def plot_heatmap( @@ -393,6 +401,8 @@ def filter_solution( variable_dims: Optional[Literal['scalar', 'time', 'scenario', 'timeonly', 'scenarioonly']] = None, timesteps: Optional[pd.DatetimeIndex] = None, scenarios: Optional[pd.Index] = None, + contains: Optional[Union[str, List[str]]] = None, + startswith: Optional[Union[str, List[str]]] = None, ) -> xr.Dataset: """ Filter the solution to a specific variable dimension and element. @@ -413,12 +423,18 @@ def filter_solution( - pd.Index: Multiple scenarios - str/int: Single scenario (int is treated as a label, not an index position) Defaults to all available scenarios. + contains: Filter variables that contain this string or strings. + If a list is provided, variables must contain ALL strings in the list. + startswith: Filter variables that start with this string or strings. + If a list is provided, variables must start with ANY of the strings in the list. """ return filter_dataset( self.solution, variable_dims=variable_dims, timesteps=timesteps, scenarios=scenarios, + contains=contains, + startswith=startswith, ) @@ -1017,9 +1033,11 @@ def filter_dataset( variable_dims: Optional[Literal['scalar', 'time', 'scenario', 'timeonly', 'scenarioonly']] = None, timesteps: Optional[Union[pd.DatetimeIndex, str, pd.Timestamp]] = None, scenarios: Optional[Union[pd.Index, str, int]] = None, + contains: Optional[Union[str, List[str]]] = None, + startswith: Optional[Union[str, List[str]]] = None, ) -> xr.Dataset: """ - Filters a dataset by its dimensions and optionally selects specific indexes. + Filters a dataset by its dimensions, indexes, and with string filters for variable names. Args: ds: The dataset to filter. @@ -1037,32 +1055,58 @@ def filter_dataset( - pd.Index: Multiple scenarios - str/int: Single scenario (int is treated as a label, not an index position) Defaults to all available scenarios. + contains: Filter variables that contain this string or strings. + If a list is provided, variables must contain ALL strings in the list. + startswith: Filter variables that start with this string or strings. + If a list is provided, variables must start with ANY of the strings in the list. Returns: Filtered dataset with specified variables and indexes. """ - # Return the full dataset if all dimension types are included - if variable_dims is None: - pass - elif variable_dims == 'scalar': - ds = ds[[v for v in ds.data_vars if not ds[v].dims]] - elif variable_dims == 'time': - ds = ds[[v for v in ds.data_vars if 'time' in ds[v].dims]] - elif variable_dims == 'scenario': - ds = ds[[v for v in ds.data_vars if 'scenario' in ds[v].dims]] - elif variable_dims == 'timeonly': - ds = ds[[v for v in ds.data_vars if ds[v].dims == ('time',)]] - elif variable_dims == 'scenarioonly': - ds = ds[[v for v in ds.data_vars if ds[v].dims == ('scenario',)]] - else: - raise ValueError(f'Unknown variable_dims "{variable_dims}" for filter_dataset') + # First filter by dimensions + filtered_ds = ds.copy() + if variable_dims is not None: + if variable_dims == 'scalar': + filtered_ds = filtered_ds[[v for v in filtered_ds.data_vars if not filtered_ds[v].dims]] + elif variable_dims == 'time': + filtered_ds = filtered_ds[[v for v in filtered_ds.data_vars if 'time' in filtered_ds[v].dims]] + elif variable_dims == 'scenario': + filtered_ds = filtered_ds[[v for v in filtered_ds.data_vars if 'scenario' in filtered_ds[v].dims]] + elif variable_dims == 'timeonly': + filtered_ds = filtered_ds[[v for v in filtered_ds.data_vars if filtered_ds[v].dims == ('time',)]] + elif variable_dims == 'scenarioonly': + filtered_ds = filtered_ds[[v for v in filtered_ds.data_vars if filtered_ds[v].dims == ('scenario',)]] + else: + raise ValueError(f'Unknown variable_dims "{variable_dims}" for filter_dataset') + + # Filter by 'contains' parameter + if contains is not None: + if isinstance(contains, str): + # Single string - keep variables that contain this string + filtered_ds = filtered_ds[[v for v in filtered_ds.data_vars if contains in v]] + elif isinstance(contains, list) and all(isinstance(s, str) for s in contains): + # List of strings - keep variables that contain ALL strings in the list + filtered_ds = filtered_ds[[v for v in filtered_ds.data_vars if all(s in v for s in contains)]] + else: + raise TypeError(f"'contains' must be a string or list of strings, got {type(contains)}") + + # Filter by 'startswith' parameter + if startswith is not None: + if isinstance(startswith, str): + # Single string - keep variables that start with this string + filtered_ds = filtered_ds[[v for v in filtered_ds.data_vars if v.startswith(startswith)]] + elif isinstance(startswith, list) and all(isinstance(s, str) for s in startswith): + # List of strings - keep variables that start with ANY of the strings in the list + filtered_ds = filtered_ds[[v for v in filtered_ds.data_vars if any(v.startswith(s) for s in startswith)]] + else: + raise TypeError(f"'startswith' must be a string or list of strings, got {type(startswith)}") # Handle time selection if needed - if timesteps is not None and 'time' in ds.dims: + if timesteps is not None and 'time' in filtered_ds.dims: try: - ds = ds.sel(time=timesteps) + filtered_ds = filtered_ds.sel(time=timesteps) except KeyError as e: - available_times = set(ds.indexes['time']) + available_times = set(filtered_ds.indexes['time']) requested_times = set([timesteps]) if not isinstance(timesteps, pd.Index) else set(timesteps) missing_times = requested_times - available_times raise ValueError( @@ -1070,15 +1114,15 @@ def filter_dataset( ) from e # Handle scenario selection if needed - if scenarios is not None and 'scenario' in ds.dims: + if scenarios is not None and 'scenario' in filtered_ds.dims: try: - ds = ds.sel(scenario=scenarios) + filtered_ds = filtered_ds.sel(scenario=scenarios) except KeyError as e: - available_scenarios = set(ds.indexes['scenario']) + available_scenarios = set(filtered_ds.indexes['scenario']) requested_scenarios = set([scenarios]) if not isinstance(scenarios, pd.Index) else set(scenarios) missing_scenarios = requested_scenarios - available_scenarios raise ValueError( f'Scenarios not found in dataset: {missing_scenarios}. Available scenarios: {available_scenarios}' ) from e - return ds + return filtered_ds From 0977c1fffd20383aab98a45d88af0703f448ba8c Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 22 Apr 2025 10:32:50 +0200 Subject: [PATCH 032/336] Scenarios/drop suffix (#251) Drop suffixes in plots and add the option to drop suffixes to sanitize_dataset() --- flixopt/results.py | 41 ++++++++++++++++++++++++----------------- 1 file changed, 24 insertions(+), 17 deletions(-) diff --git a/flixopt/results.py b/flixopt/results.py index 1be662975..3a4989672 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -532,7 +532,6 @@ def plot_node_balance_pie( show: bool = True, engine: plotting.PlottingEngine = 'plotly', scenario: Optional[Union[str, int]] = None, - drop_suffix: bool = True, ) -> plotly.graph_objects.Figure: """ Plots a pie chart of the flow hours of the inputs and outputs of buses or components. @@ -552,12 +551,14 @@ def plot_node_balance_pie( threshold=1e-5, drop_small_vars=True, zero_small_values=True, + drop_suffix='|', ) outputs = sanitize_dataset( ds=self.solution[self.outputs] * self._calculation_results.hours_per_timestep, threshold=1e-5, drop_small_vars=True, zero_small_values=True, + drop_suffix='|', ) inputs = inputs.sum('time') outputs = outputs.sum('time') @@ -570,13 +571,6 @@ def plot_node_balance_pie( outputs = outputs.sel(scenario=chosen_scenario).drop_vars('scenario') title = f'{title} - {chosen_scenario}' - if drop_suffix: - inputs = inputs.rename_vars({var: var.split('|flow_rate')[0] for var in inputs}) - outputs = outputs.rename_vars({var: var.split('|flow_rate')[0] for var in outputs}) - else: - inputs = inputs.rename_vars({var: var.replace('flow_rate', 'flow_hours') for var in inputs}) - outputs = outputs.rename_vars({var: var.replace('flow_rate', 'flow_hours') for var in outputs}) - if engine == 'plotly': figure_like = plotting.dual_pie_with_plotly( data_left=inputs.to_pandas(), @@ -635,10 +629,12 @@ def node_balance( drop_suffix: Whether to drop the suffix from the variable names. """ ds = self.solution[self.inputs + self.outputs] - if drop_suffix: - ds = ds.rename_vars({var: var.split('|flow_hours')[0] for var in ds.data_vars}) - ds = sanitize_dataset( + if mode == 'flow_hours': + ds = ds * self._calculation_results.hours_per_timestep + ds = ds.rename_vars({var: var.replace('flow_rate', 'flow_hours') for var in ds.data_vars}) + + return sanitize_dataset( ds=ds, threshold=threshold, timesteps=self._calculation_results.timesteps_extra if with_last_timestep else None, @@ -651,14 +647,9 @@ def node_balance( if negate_inputs else None ), + drop_suffix='|' if drop_suffix else None, ) - if mode == 'flow_hours': - ds = ds * self._calculation_results.hours_per_timestep - ds = ds.rename_vars({var: var.replace('flow_rate', 'flow_hours') for var in ds.data_vars}) - - return ds - class BusResults(_NodeResults): """Results for a Bus""" @@ -976,6 +967,7 @@ def sanitize_dataset( negate: Optional[List[str]] = None, drop_small_vars: bool = True, zero_small_values: bool = False, + drop_suffix: Optional[str] = None, ) -> xr.Dataset: """ Sanitizes a dataset by handling small values (dropping or zeroing) and optionally reindexing the time axis. @@ -987,6 +979,7 @@ def sanitize_dataset( negate: The variables to negate. If None, no variables are negated. drop_small_vars: If True, drops variables where all values are below threshold. zero_small_values: If True, sets values below threshold to zero. + drop_suffix: Drop suffix of data var names. Split by the provided str. Returns: xr.Dataset: The sanitized dataset. @@ -1025,6 +1018,20 @@ def sanitize_dataset( if timesteps is not None and not ds.indexes['time'].equals(timesteps): ds = ds.reindex({'time': timesteps}, fill_value=np.nan) + if drop_suffix is not None: + if not isinstance(drop_suffix, str): + raise ValueError(f'Only pass str values to drop suffixes. Got {drop_suffix}') + unique_dict = {} + for var in ds.data_vars: + new_name = var.split(drop_suffix)[0] + + # If name already exists, keep original name + if new_name in unique_dict.values(): + unique_dict[var] = var + else: + unique_dict[var] = new_name + ds = ds.rename(unique_dict) + return ds From 5cf6e0eb070f7dc2d318d1050b04dc7e37e86989 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 22 Apr 2025 14:05:35 +0200 Subject: [PATCH 033/336] Scenarios/bar plot (#254) * Add stacked bar style to plotting methods * Rename mode to style (line, bar, area, ...) --- .../example_calculation_types.py | 8 +-- examples/04_Scenarios/scenario_example.py | 4 +- flixopt/plotting.py | 34 ++++++------- flixopt/results.py | 50 +++++++++++-------- 4 files changed, 54 insertions(+), 42 deletions(-) diff --git a/examples/03_Calculation_types/example_calculation_types.py b/examples/03_Calculation_types/example_calculation_types.py index 97b18e3c0..3a15b4e28 100644 --- a/examples/03_Calculation_types/example_calculation_types.py +++ b/examples/03_Calculation_types/example_calculation_types.py @@ -194,28 +194,28 @@ def get_solutions(calcs: List, variable: str) -> xr.Dataset: # --- Plotting for comparison --- fx.plotting.with_plotly( get_solutions(calculations, 'Speicher|charge_state').to_dataframe(), - mode='line', + style='line', title='Charge State Comparison', ylabel='Charge state', ).write_html('results/Charge State.html') fx.plotting.with_plotly( get_solutions(calculations, 'BHKW2(Q_th)|flow_rate').to_dataframe(), - mode='line', + style='line', title='BHKW2(Q_th) Flow Rate Comparison', ylabel='Flow rate', ).write_html('results/BHKW2 Thermal Power.html') fx.plotting.with_plotly( get_solutions(calculations, 'costs(operation)|total_per_timestep').to_dataframe(), - mode='line', + style='line', title='Operation Cost Comparison', ylabel='Costs [€]', ).write_html('results/Operation Costs.html') fx.plotting.with_plotly( pd.DataFrame(get_solutions(calculations, 'costs(operation)|total_per_timestep').to_dataframe().sum()).T, - mode='bar', + style='bar', title='Total Cost Comparison', ylabel='Costs [€]', ).update_layout(barmode='group').write_html('results/Total Costs.html') diff --git a/examples/04_Scenarios/scenario_example.py b/examples/04_Scenarios/scenario_example.py index 3edb6e7c0..b9932a016 100644 --- a/examples/04_Scenarios/scenario_example.py +++ b/examples/04_Scenarios/scenario_example.py @@ -111,13 +111,15 @@ # --- Analyze Results --- calculation.results['Fernwärme'].plot_node_balance_pie() - calculation.results['Fernwärme'].plot_node_balance() + calculation.results['Fernwärme'].plot_node_balance(style='stacked_bar') calculation.results['Storage'].plot_node_balance() calculation.results.plot_heatmap('CHP(Q_th)|flow_rate') # Convert the results for the storage component to a dataframe and display df = calculation.results['Storage'].node_balance_with_charge_state() print(df) + calculation.results['Storage'].plot_charge_state(engine='matplotlib') # Save results to file for later usage calculation.results.to_file() + fig, ax = calculation.results['Storage'].plot_charge_state(engine='matplotlib') diff --git a/flixopt/plotting.py b/flixopt/plotting.py index e4c440aaf..9ea19a686 100644 --- a/flixopt/plotting.py +++ b/flixopt/plotting.py @@ -209,7 +209,7 @@ def process_colors( def with_plotly( data: pd.DataFrame, - mode: Literal['bar', 'line', 'area'] = 'area', + style: Literal['stacked_bar', 'line', 'area'] = 'area', colors: ColorType = 'viridis', title: str = '', ylabel: str = '', @@ -222,7 +222,7 @@ def with_plotly( Args: data: A DataFrame containing the data to plot, where the index represents time (e.g., hours), and each column represents a separate data series. - mode: The plotting mode. Use 'bar' for stacked bar charts, 'line' for stepped lines, + style: The plotting style. Use 'stacked_bar' for stacked bar charts, 'line' for stepped lines, or 'area' for stacked area charts. colors: Color specification, can be: - A string with a colorscale name (e.g., 'viridis', 'plasma') @@ -235,7 +235,7 @@ def with_plotly( Returns: A Plotly figure object containing the generated plot. """ - assert mode in ['bar', 'line', 'area'], f"'mode' must be one of {['bar', 'line', 'area']}" + assert style in ['stacked_bar', 'line', 'area'], f"'style' must be one of {['stacked_bar', 'line', 'area']}" if data.empty: return go.Figure() @@ -243,7 +243,7 @@ def with_plotly( fig = fig if fig is not None else go.Figure() - if mode == 'bar': + if style == 'stacked_bar': for i, column in enumerate(data.columns): fig.add_trace( go.Bar( @@ -255,22 +255,22 @@ def with_plotly( ) fig.update_layout( - barmode='relative' if mode == 'bar' else None, + barmode='relative' if style == 'stacked_bar' else None, bargap=0, # No space between bars bargroupgap=0, # No space between groups of bars ) - elif mode == 'line': + elif style == 'line': for i, column in enumerate(data.columns): fig.add_trace( go.Scatter( x=data.index, y=data[column], - mode='lines', + style='lines', name=column, line=dict(shape='hv', color=processed_colors[i]), ) ) - elif mode == 'area': + elif style == 'area': data = data.copy() data[(data > -1e-5) & (data < 1e-5)] = 0 # Preventing issues with plotting # Split columns into positive, negative, and mixed categories @@ -293,7 +293,7 @@ def with_plotly( go.Scatter( x=data.index, y=data[column], - mode='lines', + style='lines', name=column, line=dict(shape='hv', color=colors_stacked[column]), fill='tonexty', @@ -306,7 +306,7 @@ def with_plotly( go.Scatter( x=data.index, y=data[column], - mode='lines', + style='lines', name=column, line=dict(shape='hv', color=colors_stacked[column], dash='dash'), ) @@ -345,7 +345,7 @@ def with_plotly( def with_matplotlib( data: pd.DataFrame, - mode: Literal['bar', 'line'] = 'bar', + style: Literal['stacked_bar', 'line'] = 'stacked_bar', colors: ColorType = 'viridis', title: str = '', ylabel: str = '', @@ -360,7 +360,7 @@ def with_matplotlib( Args: data: A DataFrame containing the data to plot. The index should represent time (e.g., hours), and each column represents a separate data series. - mode: Plotting mode. Use 'bar' for stacked bar charts or 'line' for stepped lines. + style: Plotting style. Use 'stacked_bar' for stacked bar charts or 'line' for stepped lines. colors: Color specification, can be: - A string with a colormap name (e.g., 'viridis', 'plasma') - A list of color strings (e.g., ['#ff0000', '#00ff00']) @@ -376,19 +376,19 @@ def with_matplotlib( A tuple containing the Matplotlib figure and axes objects used for the plot. Notes: - - If `mode` is 'bar', bars are stacked for both positive and negative values. + - If `style` is 'stacked_bar', bars are stacked for both positive and negative values. Negative values are stacked separately without extra labels in the legend. - - If `mode` is 'line', stepped lines are drawn for each data series. + - If `style` is 'line', stepped lines are drawn for each data series. - The legend is placed below the plot to accommodate multiple data series. """ - assert mode in ['bar', 'line'], f"'mode' must be one of {['bar', 'line']} for matplotlib" + assert style in ['stacked_bar', 'line'], f"'style' must be one of {['stacked_bar', 'line']} for matplotlib" if fig is None or ax is None: fig, ax = plt.subplots(figsize=figsize) processed_colors = ColorProcessor(engine='matplotlib').process_colors(colors, list(data.columns)) - if mode == 'bar': + if style == 'stacked_bar': cumulative_positive = np.zeros(len(data)) cumulative_negative = np.zeros(len(data)) width = data.index.to_series().diff().dropna().min() # Minimum time difference @@ -419,7 +419,7 @@ def with_matplotlib( ) cumulative_negative += negative_values.values - elif mode == 'line': + elif style == 'line': for i, column in enumerate(data.columns): ax.step(data.index, data[column], where='post', color=processed_colors[i], label=column) diff --git a/flixopt/results.py b/flixopt/results.py index 3a4989672..47c47f288 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -471,6 +471,7 @@ def plot_node_balance( engine: plotting.PlottingEngine = 'plotly', scenario: Optional[Union[str, int]] = None, mode: Literal['flow_rate', 'flow_hours'] = 'flow_rate', + style: Literal['area', 'stacked_bar', 'line'] = 'stacked_bar', drop_suffix: bool = True, ) -> Union[plotly.graph_objs.Figure, Tuple[plt.Figure, plt.Axes]]: """ @@ -499,7 +500,7 @@ def plot_node_balance( figure_like = plotting.with_plotly( ds.to_dataframe(), colors=colors, - mode='area', + style=style, title=title, ) default_filetype = '.html' @@ -507,7 +508,7 @@ def plot_node_balance( figure_like = plotting.with_matplotlib( ds.to_dataframe(), colors=colors, - mode='bar', + style=style, title=title, ) default_filetype = '.png' @@ -679,6 +680,7 @@ def plot_charge_state( show: bool = True, colors: plotting.ColorType = 'viridis', engine: plotting.PlottingEngine = 'plotly', + style: Literal['area', 'stacked_bar', 'line'] = 'stacked_bar', scenario: Optional[Union[str, int]] = None, ) -> plotly.graph_objs.Figure: """ @@ -688,16 +690,12 @@ def plot_charge_state( show: Whether to show the plot or not. colors: The c engine: Plotting engine to use. Only 'plotly' is implemented atm. + style: The plotting mode for the flow_rate scenario: The scenario to plot. Defaults to the first scenario. Has no effect without scenarios present Raises: ValueError: If the Component is not a Storage. """ - if engine != 'plotly': - raise NotImplementedError( - f'Plotting engine "{engine}" not implemented for ComponentResults.plot_charge_state.' - ) - if not self.is_storage: raise ValueError(f'Cant plot charge_state. "{self.label}" is not a storage') @@ -710,22 +708,34 @@ def plot_charge_state( ds = ds.sel(scenario=chosen_scenario).drop_vars('scenario') charge_state = charge_state.sel(scenario=chosen_scenario).drop_vars('scenario') scenario_suffix = f'--{chosen_scenario}' + if engine == 'plotly': + fig = plotting.with_plotly( + ds.to_dataframe(), + colors=colors, + style=style, + title=f'Operation Balance of {self.label}{scenario_suffix}', + ) - fig = plotting.with_plotly( - ds.to_dataframe(), - colors=colors, - mode='area', - title=f'Operation Balance of {self.label}{scenario_suffix}', - ) - - # TODO: Use colors for charge state? + # TODO: Use colors for charge state? - charge_state = charge_state.to_dataframe() - fig.add_trace( - plotly.graph_objs.Scatter( - x=charge_state.index, y=charge_state.values.flatten(), mode='lines', name=self._charge_state + charge_state = charge_state.to_dataframe() + fig.add_trace( + plotly.graph_objs.Scatter( + x=charge_state.index, y=charge_state.values.flatten(), mode='lines', name=self._charge_state + ) ) - ) + elif engine=='matplotlib': + fig, ax = plotting.with_matplotlib( + ds.to_dataframe(), + colors=colors, + style=style, + title=f'Operation Balance of {self.label}{scenario_suffix}', + ) + + charge_state = charge_state.to_dataframe() + ax.plot(charge_state.index, charge_state.values.flatten(), label=self._charge_state) + fig.tight_layout() + fig = fig, ax return plotting.export_figure( fig, From 4cfa27fcf8e0a29e015759c23c8ee98db5a248f4 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 22 Apr 2025 14:18:43 +0200 Subject: [PATCH 034/336] Bugfix plotting --- flixopt/plotting.py | 6 +++--- tests/test_plots.py | 8 ++++---- tests/test_results_plots.py | 7 +------ 3 files changed, 8 insertions(+), 13 deletions(-) diff --git a/flixopt/plotting.py b/flixopt/plotting.py index 9ea19a686..d5b4aef0d 100644 --- a/flixopt/plotting.py +++ b/flixopt/plotting.py @@ -265,7 +265,7 @@ def with_plotly( go.Scatter( x=data.index, y=data[column], - style='lines', + mode='lines', name=column, line=dict(shape='hv', color=processed_colors[i]), ) @@ -293,7 +293,7 @@ def with_plotly( go.Scatter( x=data.index, y=data[column], - style='lines', + mode='lines', name=column, line=dict(shape='hv', color=colors_stacked[column]), fill='tonexty', @@ -306,7 +306,7 @@ def with_plotly( go.Scatter( x=data.index, y=data[column], - style='lines', + mode='lines', name=column, line=dict(shape='hv', color=colors_stacked[column], dash='dash'), ) diff --git a/tests/test_plots.py b/tests/test_plots.py index 840b4e7b3..4e00f9a51 100644 --- a/tests/test_plots.py +++ b/tests/test_plots.py @@ -53,15 +53,15 @@ def get_sample_data( def test_bar_plots(self): data = self.get_sample_data(nr_of_columns=10, nr_of_periods=1, time_steps_per_period=24) - plotly.offline.plot(plotting.with_plotly(data, 'bar')) - plotting.with_matplotlib(data, 'bar') + plotly.offline.plot(plotting.with_plotly(data, 'stacked_bar')) + plotting.with_matplotlib(data, 'stacked_bar') plt.show() data = self.get_sample_data( nr_of_columns=10, nr_of_periods=5, time_steps_per_period=24, drop_fraction_of_indices=0.3 ) - plotly.offline.plot(plotting.with_plotly(data, 'bar')) - plotting.with_matplotlib(data, 'bar') + plotly.offline.plot(plotting.with_plotly(data, 'stacked_bar')) + plotting.with_matplotlib(data, 'stacked_bar') plt.show() def test_line_plots(self): diff --git a/tests/test_results_plots.py b/tests/test_results_plots.py index 855944a48..fe8d27c3b 100644 --- a/tests/test_results_plots.py +++ b/tests/test_results_plots.py @@ -58,12 +58,7 @@ def test_results_plots(flow_system, plotting_engine, show, save, color_spec): ) results['Speicher'].plot_node_balance_pie(engine=plotting_engine, save=save, show=show, colors=color_spec) - - if plotting_engine == 'matplotlib': - with pytest.raises(NotImplementedError): - results['Speicher'].plot_charge_state(engine=plotting_engine) - else: - results['Speicher'].plot_charge_state(engine=plotting_engine) + results['Speicher'].plot_charge_state(engine=plotting_engine) plt.close('all') From 16fd74c306bb19d2788ea0b5a4cc2bfe87c66a8e Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 22 Apr 2025 14:45:11 +0200 Subject: [PATCH 035/336] Fix example_calculation_types.py --- examples/03_Calculation_types/example_calculation_types.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/03_Calculation_types/example_calculation_types.py b/examples/03_Calculation_types/example_calculation_types.py index 3a15b4e28..e9f5604f9 100644 --- a/examples/03_Calculation_types/example_calculation_types.py +++ b/examples/03_Calculation_types/example_calculation_types.py @@ -215,13 +215,13 @@ def get_solutions(calcs: List, variable: str) -> xr.Dataset: fx.plotting.with_plotly( pd.DataFrame(get_solutions(calculations, 'costs(operation)|total_per_timestep').to_dataframe().sum()).T, - style='bar', + style='stacked_bar', title='Total Cost Comparison', ylabel='Costs [€]', ).update_layout(barmode='group').write_html('results/Total Costs.html') fx.plotting.with_plotly( - pd.DataFrame([calc.durations for calc in calculations], index=[calc.name for calc in calculations]), 'bar' + pd.DataFrame([calc.durations for calc in calculations], index=[calc.name for calc in calculations]), 'stacked_bar' ).update_layout(title='Duration Comparison', xaxis_title='Calculation type', yaxis_title='Time (s)').write_html( 'results/Speed Comparison.html' ) From 67d1716a6b7280373a1c4171471f042b4f7ca984 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 24 Apr 2025 20:45:03 +0200 Subject: [PATCH 036/336] Scenarios/fixes (#255) * Fix indexing issue with only one scenario * Bugfix Cooling Tower * Add option for balanced Storage Flows (equalize size of charging and discharging) * Add option for balanced Storage Flows * Change error to warning (non-fixed size with piecewise conversion AND fixed_flow_rate with OnOff) * Bugfix in DataConverter * BUGFIX: Typo (total_max/total_min in Effect) * Bugfix in node_balance() (negating did not work when using flow_hours mode --- flixopt/components.py | 28 ++++++++++++++++++++++++++-- flixopt/core.py | 5 ++++- flixopt/elements.py | 7 +++---- flixopt/features.py | 7 +------ flixopt/linear_converters.py | 6 +++--- flixopt/results.py | 12 +++++++----- 6 files changed, 44 insertions(+), 21 deletions(-) diff --git a/flixopt/components.py b/flixopt/components.py index 1b745b54c..234418694 100644 --- a/flixopt/components.py +++ b/flixopt/components.py @@ -86,8 +86,8 @@ def _plausibility_checks(self) -> None: if self.piecewise_conversion: for flow in self.flows.values(): if isinstance(flow.size, InvestParameters) and flow.size.fixed_size is None: - raise PlausibilityError( - f'piecewise_conversion (in {self.label_full}) and variable size ' + logger.warning( + f'Piecewise_conversion (in {self.label_full}) and variable size ' f'(in flow {flow.label_full}) do not make sense together!' ) @@ -138,6 +138,7 @@ def __init__( eta_discharge: TimestepData = 1, relative_loss_per_hour: TimestepData = 0, prevent_simultaneous_charge_and_discharge: bool = True, + balanced: bool = False, meta_data: Optional[Dict] = None, ): """ @@ -163,6 +164,7 @@ def __init__( relative_loss_per_hour: loss per chargeState-Unit per hour. The default is 0. prevent_simultaneous_charge_and_discharge: If True, loading and unloading at the same time is not possible. Increases the number of binary variables, but is recommended for easier evaluation. The default is True. + balanced: Wether to equate the size of the charging and discharging flow. Only if not fixed. meta_data: used to store more information about the Element. Is not used internally, but saved in the results. Only use python native types. """ # TODO: fixed_relative_chargeState implementieren @@ -188,6 +190,7 @@ def __init__( self.eta_discharge: TimestepData = eta_discharge self.relative_loss_per_hour: TimestepData = relative_loss_per_hour self.prevent_simultaneous_charge_and_discharge = prevent_simultaneous_charge_and_discharge + self.balanced = balanced def create_model(self, model: SystemModel) -> 'StorageModel': self._plausibility_checks() @@ -261,6 +264,18 @@ def _plausibility_checks(self) -> None: f'is below allowed minimum charge_state {minimum_inital_capacity}' ) + if self.balanced: + if not isinstance(self.charging.size, InvestParameters) or not isinstance(self.discharging.size, InvestParameters): + raise PlausibilityError( + f'Balancing charging and discharging Flows in {self.label_full} ' + f'is only possible with Investments.') + if (self.charging.size.minimum_size > self.discharging.size.maximum_size or + self.charging.size.maximum_size < self.discharging.size.minimum_size): + raise PlausibilityError( + f'Balancing charging and discharging Flows in {self.label_full} need compatible minimum and maximum sizes.' + f'Got: {self.charging.size.minimum_size=}, {self.charging.size.maximum_size=} and ' + f'{self.charging.size.minimum_size=}, {self.charging.size.maximum_size=}.') + @register_class_for_io class Transmission(Component): @@ -531,6 +546,15 @@ def do_modeling(self): # Initial charge state self._initial_and_final_charge_state() + if self.element.balanced: + self.add( + self._model.add_constraints( + self.element.charging.model._investment.size * 1 == self.element.discharging.model._investment.size * 1, + name=f'{self.label_full}|balanced_sizes', + ), + 'balanced_sizes' + ) + def _initial_and_final_charge_state(self): if self.element.initial_charge_state is not None: name_short = 'initial_charge_state' diff --git a/flixopt/core.py b/flixopt/core.py index eab45e239..5d24e46e4 100644 --- a/flixopt/core.py +++ b/flixopt/core.py @@ -251,8 +251,11 @@ def _broadcast_time_to_scenarios( if not np.array_equal(data.coords['time'].values, coords['time'].values): raise ConversionError("Source time coordinates don't match target time coordinates") + if len(coords['scenario']) <= 1: + return data.copy(deep=True) + # Broadcast values - values = np.tile(data.values, (len(coords['scenario']), 1)) + values = np.tile(data.values, (len(coords['scenario']), 1)).T # Tile seems to be faster than repeat() return xr.DataArray(values.copy(), coords=coords, dims=dims) @staticmethod diff --git a/flixopt/elements.py b/flixopt/elements.py index 2ff49567e..7dda3e9cf 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -292,10 +292,9 @@ def _plausibility_checks(self) -> None: ) if self.fixed_relative_profile is not None and self.on_off_parameters is not None: - raise ValueError( - f'Flow {self.label} has both a fixed_relative_profile and an on_off_parameters. This is not supported. ' - f'Use relative_minimum and relative_maximum instead, ' - f'if you want to allow flows to be switched on and off.' + logger.warning( + f'Flow {self.label} has both a fixed_relative_profile and an on_off_parameters.' + f'This will allow the flow to be switched on and off, effectively differing from the fixed_flow_rate.' ) if (self.relative_minimum > 0).any() and self.on_off_parameters is None: diff --git a/flixopt/features.py b/flixopt/features.py index 4eeb46337..94231ccec 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -154,11 +154,6 @@ def _create_bounds_for_defining_variable(self): ), f'fix_{variable.name}', ) - if self._on_variable is not None: - raise ValueError( - f'Flow {self.label_full} has a fixed relative flow rate and an on_variable.' - f'This combination is currently not supported.' - ) return # eq: defining_variable(t) <= size * upper_bound(t) @@ -988,7 +983,7 @@ def __init__( # Parameters self._has_time_dim = has_time_dim self._has_scenario_dim = has_scenario_dim - self._total_max = total_max if total_min is not None else np.inf + self._total_max = total_max if total_max is not None else np.inf self._total_min = total_min if total_min is not None else -np.inf self._max_per_hour = max_per_hour if max_per_hour is not None else np.inf self._min_per_hour = min_per_hour if min_per_hour is not None else -np.inf diff --git a/flixopt/linear_converters.py b/flixopt/linear_converters.py index 3fd032632..b096921f0 100644 --- a/flixopt/linear_converters.py +++ b/flixopt/linear_converters.py @@ -165,7 +165,7 @@ def __init__( label, inputs=[P_el, Q_th], outputs=[], - conversion_factors=[{P_el.label: 1, Q_th.label: -specific_electricity_demand}], + conversion_factors=[{P_el.label: -1, Q_th.label: specific_electricity_demand}], on_off_parameters=on_off_parameters, meta_data=meta_data, ) @@ -177,12 +177,12 @@ def __init__( @property def specific_electricity_demand(self): - return -self.conversion_factors[0][self.Q_th.label] + return self.conversion_factors[0][self.Q_th.label] @specific_electricity_demand.setter def specific_electricity_demand(self, value): check_bounds(value, 'specific_electricity_demand', self.label_full, 0, 1) - self.conversion_factors[0][self.Q_th.label] = -value + self.conversion_factors[0][self.Q_th.label] = value @register_class_for_io diff --git a/flixopt/results.py b/flixopt/results.py index 47c47f288..4f2c7d856 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -631,11 +631,7 @@ def node_balance( """ ds = self.solution[self.inputs + self.outputs] - if mode == 'flow_hours': - ds = ds * self._calculation_results.hours_per_timestep - ds = ds.rename_vars({var: var.replace('flow_rate', 'flow_hours') for var in ds.data_vars}) - - return sanitize_dataset( + ds = sanitize_dataset( ds=ds, threshold=threshold, timesteps=self._calculation_results.timesteps_extra if with_last_timestep else None, @@ -651,6 +647,12 @@ def node_balance( drop_suffix='|' if drop_suffix else None, ) + if mode == 'flow_hours': + ds = ds * self._calculation_results.hours_per_timestep + ds = ds.rename_vars({var: var.replace('flow_rate', 'flow_hours') for var in ds.data_vars}) + + return ds + class BusResults(_NodeResults): """Results for a Bus""" From b96802757cd4019ae858c242f2331e925129942f Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 25 Apr 2025 12:32:31 +0200 Subject: [PATCH 037/336] Scenarios/effects (#256) * Add methods to track effect shares of components and Flows * Add option to include flows when retrieving effects * Add properties and methods to store effect results in a dataset * Reorder methods * Rename and improve docs * Bugfix test class name * Fix the Network algorithm to calculate the sum of parallel paths, and be independent on nr of nodes and complexity of the network * Add tests for the newtork chaining and the results of effect shares * Add methods to check for circular references * Add test to check for circular references * Update cycle checker to return the found cycles * Add checks in results to confirm effects are computed correctly * BUGFIX: Remove +1 from prior testing * Add option for grouped bars to plotting.with_plotly() and make lines of stacked bar plots invisible * Reconstruct FlowSystem in CalculationResults on demand. DEPRECATION in CalculationResults * ruff check * Bugfix: save flow_system data, not the flow_system * Update tests --- flixopt/effects.py | 197 +++++++++++++++++++-- flixopt/plotting.py | 30 +++- flixopt/results.py | 219 +++++++++++++++++++++-- tests/test_cycle_detection.py | 226 +++++++++++++++++++++++ tests/test_effect.py | 86 ++++++++- tests/test_effects_shares_summation.py | 236 +++++++++++++++++++++++++ tests/test_scenarios.py | 6 +- 7 files changed, 956 insertions(+), 44 deletions(-) create mode 100644 tests/test_cycle_detection.py create mode 100644 tests/test_effects_shares_summation.py diff --git a/flixopt/effects.py b/flixopt/effects.py index 3d93ee76a..914100362 100644 --- a/flixopt/effects.py +++ b/flixopt/effects.py @@ -7,10 +7,11 @@ import logging import warnings -from typing import TYPE_CHECKING, Dict, Iterator, List, Literal, Optional, Union +from typing import TYPE_CHECKING, Dict, Iterator, List, Literal, Optional, Set, Tuple, Union import linopy import numpy as np +import xarray as xr from .core import NumericDataTS, ScenarioData, TimeSeries, TimeSeriesCollection, TimestepData, extract_data from .features import ShareAllocationModel @@ -268,26 +269,18 @@ def get_effect_label(eff: Union[Effect, str]) -> str: def _plausibility_checks(self) -> None: # Check circular loops in effects: - # TODO: Improve checks!! Only most basic case covered... + operation, invest = self.calculate_effect_share_factors() - def error_str(effect_label: str, share_ffect_label: str): - return ( - f' {effect_label} -> has share in: {share_ffect_label}\n' - f' {share_ffect_label} -> has share in: {effect_label}' - ) + operation_cycles = detect_cycles(tuples_to_adjacency_list([key for key in operation])) + invest_cycles = detect_cycles(tuples_to_adjacency_list([key for key in invest])) - for effect in self.effects.values(): - # Effekt darf nicht selber als Share in seinen ShareEffekten auftauchen: - # operation: - for target_effect in effect.specific_share_to_other_effects_operation.keys(): - assert effect not in self[target_effect].specific_share_to_other_effects_operation.keys(), ( - f'Error: circular operation-shares \n{error_str(target_effect.label, target_effect.label)}' - ) - # invest: - for target_effect in effect.specific_share_to_other_effects_invest.keys(): - assert effect not in self[target_effect].specific_share_to_other_effects_invest.keys(), ( - f'Error: circular invest-shares \n{error_str(target_effect.label, target_effect.label)}' - ) + if operation_cycles: + cycle_str = "\n".join([" -> ".join(cycle) for cycle in operation_cycles]) + raise ValueError(f'Error: circular operation-shares detected:\n{cycle_str}') + + if invest_cycles: + cycle_str = "\n".join([" -> ".join(cycle) for cycle in invest_cycles]) + raise ValueError(f'Error: circular invest-shares detected:\n{cycle_str}') def __getitem__(self, effect: Union[str, Effect]) -> 'Effect': """ @@ -351,6 +344,30 @@ def objective_effect(self, value: Effect) -> None: raise ValueError(f'An objective-effect already exists! ({self._objective_effect.label=})') self._objective_effect = value + def calculate_effect_share_factors(self) -> Tuple[ + Dict[Tuple[str, str], xr.DataArray], + Dict[Tuple[str, str], xr.DataArray], + ]: + shares_invest = {} + for name, effect in self.effects.items(): + if effect.specific_share_to_other_effects_invest: + shares_invest[name] = { + target: extract_data(data) + for target, data in effect.specific_share_to_other_effects_invest.items() + } + shares_invest = calculate_all_conversion_paths(shares_invest) + + shares_operation = {} + for name, effect in self.effects.items(): + if effect.specific_share_to_other_effects_operation: + shares_operation[name] = { + target: extract_data(data) + for target, data in effect.specific_share_to_other_effects_operation.items() + } + shares_operation = calculate_all_conversion_paths(shares_operation) + + return shares_operation, shares_invest + class EffectCollectionModel(Model): """ @@ -425,3 +442,145 @@ def _add_share_between_effects(self): has_time_dim=False, has_scenario_dim=True, ) + + +def calculate_all_conversion_paths( + conversion_dict: Dict[str, Dict[str, xr.DataArray]], +) -> Dict[Tuple[str, str], xr.DataArray]: + """ + Calculates all possible direct and indirect conversion factors between units/domains. + This function uses Breadth-First Search (BFS) to find all possible conversion paths + between different units or domains in a conversion graph. It computes both direct + conversions (explicitly provided in the input) and indirect conversions (derived + through intermediate units). + Args: + conversion_dict: A nested dictionary where: + - Outer keys represent origin units/domains + - Inner dictionaries map target units/domains to their conversion factors + - Conversion factors can be integers, floats, or numpy arrays + Returns: + A dictionary mapping (origin, target) tuples to their respective conversion factors. + Each key is a tuple of strings representing the origin and target units/domains. + Each value is the conversion factor (int, float, or numpy array) from origin to target. + """ + # Initialize the result dictionary to accumulate all paths + result = {} + + # Add direct connections to the result first + for origin, targets in conversion_dict.items(): + for target, factor in targets.items(): + result[(origin, target)] = factor + + # Track all paths by keeping path history to avoid cycles + # Iterate over each domain in the dictionary + for origin in conversion_dict: + # Keep track of visited paths to avoid repeating calculations + processed_paths = set() + # Use a queue with (current_domain, factor, path_history) + queue = [(origin, 1, [origin])] + + while queue: + current_domain, factor, path = queue.pop(0) + + # Skip if we've processed this exact path before + path_key = tuple(path) + if path_key in processed_paths: + continue + processed_paths.add(path_key) + + # Iterate over the neighbors of the current domain + for target, conversion_factor in conversion_dict.get(current_domain, {}).items(): + # Skip if target would create a cycle + if target in path: + continue + + # Calculate the indirect conversion factor + indirect_factor = factor * conversion_factor + new_path = path + [target] + + # Only consider paths starting at origin and ending at some target + if len(new_path) > 2 and new_path[0] == origin: + # Update the result dictionary - accumulate factors from different paths + if (origin, target) in result: + result[(origin, target)] = result[(origin, target)] + indirect_factor + else: + result[(origin, target)] = indirect_factor + + # Add new path to queue for further exploration + queue.append((target, indirect_factor, new_path)) + + return result + + +def detect_cycles(graph: Dict[str, List[str]]) -> List[List[str]]: + """ + Detects cycles in a directed graph using DFS. + + Args: + graph: Adjacency list representation of the graph + + Returns: + List of cycles found, where each cycle is a list of nodes + """ + # Track nodes in current recursion stack + visiting = set() + # Track nodes that have been fully explored + visited = set() + # Store all found cycles + cycles = [] + + def dfs_find_cycles(node, path=None): + if path is None: + path = [] + + # Current path to this node + current_path = path + [node] + # Add node to current recursion stack + visiting.add(node) + + # Check all neighbors + for neighbor in graph.get(node, []): + # If neighbor is in current path, we found a cycle + if neighbor in visiting: + # Get the cycle by extracting the relevant portion of the path + cycle_start = current_path.index(neighbor) + cycle = current_path[cycle_start:] + [neighbor] + cycles.append(cycle) + # If neighbor hasn't been fully explored, check it + elif neighbor not in visited: + dfs_find_cycles(neighbor, current_path) + + # Remove node from current path and mark as fully explored + visiting.remove(node) + visited.add(node) + + # Check each unvisited node + for node in graph: + if node not in visited: + dfs_find_cycles(node) + + return cycles + + +def tuples_to_adjacency_list(edges: List[Tuple[str, str]]) -> Dict[str, List[str]]: + """ + Converts a list of edge tuples (source, target) to an adjacency list representation. + + Args: + edges: List of (source, target) tuples representing directed edges + + Returns: + Dictionary mapping each source node to a list of its target nodes + """ + graph = {} + + for source, target in edges: + if source not in graph: + graph[source] = [] + graph[source].append(target) + + # Ensure target nodes with no outgoing edges are in the graph + if target not in graph: + graph[target] = [] + + return graph diff --git a/flixopt/plotting.py b/flixopt/plotting.py index d5b4aef0d..6a2170674 100644 --- a/flixopt/plotting.py +++ b/flixopt/plotting.py @@ -209,7 +209,7 @@ def process_colors( def with_plotly( data: pd.DataFrame, - style: Literal['stacked_bar', 'line', 'area'] = 'area', + style: Literal['stacked_bar', 'line', 'area', 'grouped_bar'] = 'stacked_bar', colors: ColorType = 'viridis', title: str = '', ylabel: str = '', @@ -235,7 +235,8 @@ def with_plotly( Returns: A Plotly figure object containing the generated plot. """ - assert style in ['stacked_bar', 'line', 'area'], f"'style' must be one of {['stacked_bar', 'line', 'area']}" + if style not in ['stacked_bar', 'line', 'area', 'grouped_bar']: + raise ValueError(f"'style' must be one of {['stacked_bar', 'line', 'area', 'grouped_bar']}") if data.empty: return go.Figure() @@ -250,14 +251,31 @@ def with_plotly( x=data.index, y=data[column], name=column, - marker=dict(color=processed_colors[i]), + marker=dict(color=processed_colors[i], + line=dict(width=0, color='rgba(0,0,0,0)')), #Transparent line with 0 width ) ) fig.update_layout( - barmode='relative' if style == 'stacked_bar' else None, - bargap=0, # No space between bars - bargroupgap=0, # No space between groups of bars + barmode='relative', + bargap=0.2, # No space between bars + bargroupgap=0, # No space between grouped bars + ) + if style == 'grouped_bar': + for i, column in enumerate(data.columns): + fig.add_trace( + go.Bar( + x=data.index, + y=data[column], + name=column, + marker=dict(color=processed_colors[i]) + ) + ) + + fig.update_layout( + barmode='group', + bargap=0.2, # No space between bars + bargroupgap=0, # space between grouped bars ) elif style == 'line': for i, column in enumerate(data.columns): diff --git a/flixopt/results.py b/flixopt/results.py index 4f2c7d856..0f300bbcb 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -92,7 +92,7 @@ def from_file(cls, folder: Union[str, pathlib.Path], name: str): return cls( solution=fx_io.load_dataset_from_netcdf(paths.solution), - flow_system=fx_io.load_dataset_from_netcdf(paths.flow_system), + flow_system_data=fx_io.load_dataset_from_netcdf(paths.flow_system), name=name, folder=folder, model=model, @@ -118,7 +118,7 @@ def from_calculation(cls, calculation: 'Calculation'): """ return cls( solution=calculation.model.solution, - flow_system=calculation.flow_system.as_dataset(constants_in_dataset=True), + flow_system_data=calculation.flow_system.as_dataset(constants_in_dataset=True), summary=calculation.summary, model=calculation.model, name=calculation.name, @@ -128,7 +128,7 @@ def from_calculation(cls, calculation: 'Calculation'): def __init__( self, solution: xr.Dataset, - flow_system: xr.Dataset, + flow_system_data: xr.Dataset, name: str, summary: Dict, folder: Optional[pathlib.Path] = None, @@ -137,14 +137,16 @@ def __init__( """ Args: solution: The solution of the optimization. - flow_system: The flow_system that was used to create the calculation as a datatset. + flow_system_data: The flow_system that was used to create the calculation as a datatset. name: The name of the calculation. summary: Information about the calculation, folder: The folder where the results are saved. model: The linopy model that was used to solve the calculation. + Deprecated: + flow_system: Use flow_system_data instead. """ self.solution = solution - self.flow_system = flow_system + self.flow_system_data = flow_system_data self.summary = summary self.name = name self.model = model @@ -163,6 +165,10 @@ def __init__( self.hours_per_timestep = TimeSeriesCollection.calculate_hours_per_timestep(self.timesteps_extra) self.scenarios = self.solution.indexes['scenario'] if 'scenario' in self.solution.indexes else None + self._effect_share_factors = None + self._flow_system = None + self._effects_per_component = {'operation': None, 'invest': None, 'total': None} + def __getitem__(self, key: str) -> Union['ComponentResults', 'BusResults', 'EffectResults']: if key in self.components: return self.components[key] @@ -196,6 +202,26 @@ def constraints(self) -> linopy.Constraints: raise ValueError('The linopy model is not available.') return self.model.constraints + @property + def effect_share_factors(self): + if self._effect_share_factors is None: + effect_share_factors = self.flow_system.effects.calculate_effect_share_factors() + self._effect_share_factors = {'operation': effect_share_factors[0], + 'invest': effect_share_factors[1]} + return self._effect_share_factors + + @property + def flow_system(self): + """ The restored flow_system that was used to create the calculation. + Contains all input parameters.""" + if self._flow_system is None: + from . import FlowSystem + current_logger_level = logger.getEffectiveLevel() + logger.setLevel(logging.CRITICAL) + self._flow_system = FlowSystem.from_dataset(self.flow_system_data) + logger.setLevel(current_logger_level) + return self._flow_system + def filter_solution( self, variable_dims: Optional[Literal['scalar', 'time', 'scenario', 'timeonly', 'scenarioonly']] = None, @@ -239,6 +265,178 @@ def filter_solution( startswith=startswith, ) + def get_effects_per_component(self, mode: Literal['operation', 'invest', 'total'] = 'total') -> xr.Dataset: + """Returns a dataset containing effect totals for each components (including their flows). + + Args: + mode: Which effects to contain. (operation, invest, total) + + Returns: + An xarray Dataset with an additional component dimension and effects as variables. + """ + if mode not in ['operation', 'invest', 'total']: + raise ValueError(f'Invalid mode {mode}') + if self._effects_per_component[mode] is None: + self._effects_per_component[mode] = self._create_effects_dataset(mode) + return self._effects_per_component[mode] + + def get_effect_shares( + self, + element: str, + effect: str, + mode: Optional[Literal['operation', 'invest']] = None, + include_flows: bool = False + ) -> xr.Dataset: + """Retrieves individual effect shares for a specific element and effect. + Either for operation, investment, or both modes combined. + Only includes the direct shares. + + Args: + element: The element identifier for which to retrieve effect shares. + effect: The effect identifier for which to retrieve shares. + mode: Optional. The mode to retrieve shares for. Can be 'operation', 'invest', + or None to retrieve both. Defaults to None. + + Returns: + An xarray Dataset containing the requested effect shares. If mode is None, + returns a merged Dataset containing both operation and investment shares. + + Raises: + ValueError: If the specified effect is not available or if mode is invalid. + """ + if effect not in self.effects: + raise ValueError(f'Effect {effect} is not available.') + + if mode is None: + return xr.merge([self.get_effect_shares(element=element, effect=effect, mode='operation', include_flows=include_flows), + self.get_effect_shares(element=element, effect=effect, mode='invest', include_flows=include_flows)]) + + if mode not in ['operation', 'invest']: + raise ValueError(f'Mode {mode} is not available. Choose between "operation" and "invest".') + + ds = xr.Dataset() + + label = f'{element}->{effect}({mode})' + if label in self.solution: + ds = xr.Dataset({label: self.solution[label]}) + + if include_flows: + if element not in self.components: + raise ValueError(f'Only use Components when retrieving Effects including flows. Got {element}') + flows = [label.split('|')[0] for label in self.components[element].inputs + self.components[element].outputs] + return xr.merge( + [ds] + [self.get_effect_shares(element=flow, effect=effect, mode=mode, include_flows=False) + for flow in flows] + ) + + return ds + + def _compute_effect_total( + self, + element: str, + effect: str, + mode: Literal['operation', 'invest', 'total'] = 'total', + include_flows: bool = False + ) -> xr.DataArray: + """Calculates the total effect for a specific element and effect. + + This method computes the total direct and indirect effects for a given element + and effect, considering the conversion factors between different effects. + + Args: + element: The element identifier for which to calculate total effects. + effect: The effect identifier to calculate. + mode: The calculation mode. Options are: + 'operation': Returns operation-specific effects. + 'invest': Returns investment-specific effects. + 'total': Returns the sum of operation effects (across all timesteps) + and investment effects. Defaults to 'total'. + + Returns: + An xarray DataArray containing the total effects, named with pattern + '{element}->{effect}' for mode='total' or '{element}->{effect}({mode})' + for other modes. + + Raises: + ValueError: If the specified effect is not available. + """ + if effect not in self.effects: + raise ValueError(f'Effect {effect} is not available.') + + if mode == 'total': + operation = self._compute_effect_total(element=element, effect=effect, mode='operation', include_flows=include_flows) + if len(operation.indexes) > 0: + operation = operation.sum('time') + return (operation + self._compute_effect_total(element=element, effect=effect, mode='invest', include_flows=include_flows) + ).rename(f'{element}->{effect}') + + total = xr.DataArray(0) + + relevant_conversion_factors = { + key[0]: value for key, value in self.effect_share_factors[mode].items() if key[1] == effect + } + relevant_conversion_factors[effect] = 1 # Share to itself is 1 + + for target_effect, conversion_factor in relevant_conversion_factors.items(): + label = f'{element}->{target_effect}({mode})' + if label in self.solution: + da = self.solution[label] + total = total + da * conversion_factor + + if include_flows: + if element not in self.components: + raise ValueError(f'Only use Components when retrieving Effects including flows. Got {element}') + flows = [label.split('|')[0] for label in + self.components[element].inputs + self.components[element].outputs] + for flow in flows: + label = f'{flow}->{target_effect}({mode})' + if label in self.solution: + da = self.solution[label] + total = total + da * conversion_factor + + return total.rename(f'{element}->{effect}({mode})') + + def _create_effects_dataset(self, mode: Literal['operation', 'invest', 'total'] = 'total') -> xr.Dataset: + """Creates a dataset containing effect totals for all components (including their flows). + The dataset does contain the direct as well as the indirect effects of each component. + + Args: + mode: The calculation mode ('operation', 'invest', or 'total'). + + Returns: + An xarray Dataset with components as a dimension and effects as variables. + """ + data_vars = {} + + for effect in self.effects: + # Create a list of DataArrays, one for each component + effect_arrays = [ + self._compute_effect_total(element=component, effect=effect, mode=mode, include_flows=True) + .expand_dims(component=[component]) # Add component dimension to each array + for component in list(self.components) + ] + + # Combine all components into one DataArray for this effect + if effect_arrays: + data_vars[effect] = xr.concat(effect_arrays, dim="component", coords='minimal') + + ds = xr.Dataset(data_vars) + + # For now include a test to ensure correctness + suffix = {'operation': '(operation)|total_per_timestep', + 'invest': '(invest)|total', + 'total': '|total', + } + for effect in self.effects: + label = f'{effect}{suffix[mode]}' + computed = ds.sum('component')[effect] + found = self.solution[label] + if not np.allclose(computed.values, found.fillna(0).values): + logger.critical(f'Results for {effect}({mode}) in effects_dataset doesnt match {label}\n' + f'{computed=}\n, {found=}') + + return ds + def plot_heatmap( self, variable_name: str, @@ -295,16 +493,9 @@ def plot_network( show: bool = False, ) -> 'pyvis.network.Network': """See flixopt.flow_system.FlowSystem.plot_network""" - try: - from .flow_system import FlowSystem - - flow_system = FlowSystem.from_dataset(self.flow_system) - except Exception as e: - logger.critical(f'Could not reconstruct the flow_system from dataset: {e}') - return None if path is None: path = self.folder / f'{self.name}--network.html' - return flow_system.plot_network(controls=controls, path=path, show=show) + return self.flow_system.plot_network(controls=controls, path=path, show=show) def to_file( self, @@ -337,7 +528,7 @@ def to_file( paths = fx_io.CalculationResultsPaths(folder, name) fx_io.save_dataset_to_netcdf(self.solution, paths.solution, compression=compression) - fx_io.save_dataset_to_netcdf(self.flow_system, paths.flow_system, compression=compression) + fx_io.save_dataset_to_netcdf(self.flow_system_data, paths.flow_system, compression=compression) with open(paths.summary, 'w', encoding='utf-8') as f: yaml.dump(self.summary, f, allow_unicode=True, sort_keys=False, indent=4, width=1000) diff --git a/tests/test_cycle_detection.py b/tests/test_cycle_detection.py new file mode 100644 index 000000000..71c775b99 --- /dev/null +++ b/tests/test_cycle_detection.py @@ -0,0 +1,226 @@ +import pytest + +from flixopt.effects import detect_cycles + + +def test_empty_graph(): + """Test that an empty graph has no cycles.""" + assert detect_cycles({}) == [] + + +def test_single_node(): + """Test that a graph with a single node and no edges has no cycles.""" + assert detect_cycles({"A": []}) == [] + + +def test_self_loop(): + """Test that a graph with a self-loop has a cycle.""" + cycles = detect_cycles({"A": ["A"]}) + assert len(cycles) == 1 + assert cycles[0] == ["A", "A"] + + +def test_simple_cycle(): + """Test that a simple cycle is detected.""" + graph = { + "A": ["B"], + "B": ["C"], + "C": ["A"] + } + cycles = detect_cycles(graph) + assert len(cycles) == 1 + assert cycles[0] == ["A", "B", "C", "A"] or cycles[0] == ["B", "C", "A", "B"] or cycles[0] == ["C", "A", "B", "C"] + + +def test_no_cycles(): + """Test that a directed acyclic graph has no cycles.""" + graph = { + "A": ["B", "C"], + "B": ["D", "E"], + "C": ["F"], + "D": [], + "E": [], + "F": [] + } + assert detect_cycles(graph) == [] + + +def test_multiple_cycles(): + """Test that a graph with multiple cycles is detected.""" + graph = { + "A": ["B", "D"], + "B": ["C"], + "C": ["A"], + "D": ["E"], + "E": ["D"] + } + cycles = detect_cycles(graph) + assert len(cycles) == 2 + + # Check that both cycles are detected (order might vary) + cycle_strings = [",".join(cycle) for cycle in cycles] + assert any("A,B,C,A" in s for s in cycle_strings) or any("B,C,A,B" in s for s in cycle_strings) or any( + "C,A,B,C" in s for s in cycle_strings) + assert any("D,E,D" in s for s in cycle_strings) or any("E,D,E" in s for s in cycle_strings) + + +def test_hidden_cycle(): + """Test that a cycle hidden deep in the graph is detected.""" + graph = { + "A": ["B", "C"], + "B": ["D"], + "C": ["E"], + "D": ["F"], + "E": ["G"], + "F": ["H"], + "G": ["I"], + "H": ["J"], + "I": ["K"], + "J": ["L"], + "K": ["M"], + "L": ["N"], + "M": ["N"], + "N": ["O"], + "O": ["P"], + "P": ["Q"], + "Q": ["O"] # Hidden cycle O->P->Q->O + } + cycles = detect_cycles(graph) + assert len(cycles) == 1 + + # Check that the O-P-Q cycle is detected + cycle = cycles[0] + assert "O" in cycle and "P" in cycle and "Q" in cycle + + # Check that they appear in the correct order + o_index = cycle.index("O") + p_index = cycle.index("P") + q_index = cycle.index("Q") + + # Check the cycle order is correct (allowing for different starting points) + cycle_len = len(cycle) + assert (p_index == (o_index + 1) % cycle_len and q_index == (p_index + 1) % cycle_len) or \ + (q_index == (o_index + 1) % cycle_len and p_index == (q_index + 1) % cycle_len) or \ + (o_index == (p_index + 1) % cycle_len and q_index == (o_index + 1) % cycle_len) + + +def test_disconnected_graph(): + """Test with a disconnected graph.""" + graph = { + "A": ["B"], + "B": ["C"], + "C": [], + "D": ["E"], + "E": ["F"], + "F": [] + } + assert detect_cycles(graph) == [] + + +def test_disconnected_graph_with_cycle(): + """Test with a disconnected graph containing a cycle in one component.""" + graph = { + "A": ["B"], + "B": ["C"], + "C": [], + "D": ["E"], + "E": ["F"], + "F": ["D"] # Cycle in D->E->F->D + } + cycles = detect_cycles(graph) + assert len(cycles) == 1 + + # Check that the D-E-F cycle is detected + cycle = cycles[0] + assert "D" in cycle and "E" in cycle and "F" in cycle + + # Check if they appear in the correct order + d_index = cycle.index("D") + e_index = cycle.index("E") + f_index = cycle.index("F") + + # Check the cycle order is correct (allowing for different starting points) + cycle_len = len(cycle) + assert (e_index == (d_index + 1) % cycle_len and f_index == (e_index + 1) % cycle_len) or \ + (f_index == (d_index + 1) % cycle_len and e_index == (f_index + 1) % cycle_len) or \ + (d_index == (e_index + 1) % cycle_len and f_index == (d_index + 1) % cycle_len) + + +def test_complex_dag(): + """Test with a complex directed acyclic graph.""" + graph = { + "A": ["B", "C", "D"], + "B": ["E", "F"], + "C": ["E", "G"], + "D": ["G", "H"], + "E": ["I", "J"], + "F": ["J", "K"], + "G": ["K", "L"], + "H": ["L", "M"], + "I": ["N"], + "J": ["N", "O"], + "K": ["O", "P"], + "L": ["P", "Q"], + "M": ["Q"], + "N": ["R"], + "O": ["R", "S"], + "P": ["S"], + "Q": ["S"], + "R": [], + "S": [] + } + assert detect_cycles(graph) == [] + + +def test_missing_node_in_connections(): + """Test behavior when a node referenced in edges doesn't have its own key.""" + graph = { + "A": ["B", "C"], + "B": ["D"] + # C and D don't have their own entries + } + assert detect_cycles(graph) == [] + + +def test_non_string_keys(): + """Test with non-string keys to ensure the algorithm is generic.""" + graph = { + 1: [2, 3], + 2: [4], + 3: [4], + 4: [] + } + assert detect_cycles(graph) == [] + + graph_with_cycle = { + 1: [2], + 2: [3], + 3: [1] + } + cycles = detect_cycles(graph_with_cycle) + assert len(cycles) == 1 + assert cycles[0] == [1, 2, 3, 1] or cycles[0] == [2, 3, 1, 2] or cycles[0] == [3, 1, 2, 3] + + +def test_complex_network_with_many_nodes(): + """Test with a large network to check performance and correctness.""" + graph = {} + # Create a large DAG + for i in range(100): + # Connect each node to the next few nodes + graph[i] = [j for j in range(i + 1, min(i + 5, 100))] + + # No cycles in this arrangement + assert detect_cycles(graph) == [] + + # Add a single back edge to create a cycle + graph[99] = [0] # This creates a cycle + cycles = detect_cycles(graph) + assert len(cycles) >= 1 + # The cycle might include many nodes, but must contain both 0 and 99 + any_cycle_has_both = any(0 in cycle and 99 in cycle for cycle in cycles) + assert any_cycle_has_both + + +if __name__ == "__main__": + pytest.main(["-v"]) diff --git a/tests/test_effect.py b/tests/test_effect.py index 5cbc04ac6..93f417f22 100644 --- a/tests/test_effect.py +++ b/tests/test_effect.py @@ -5,10 +5,10 @@ import flixopt as fx -from .conftest import assert_conequal, assert_var_equal, create_linopy_model +from .conftest import assert_conequal, assert_var_equal, create_calculation_and_solve, create_linopy_model -class TestBusModel: +class TestEffectModel: """Test the FlowModel class.""" def test_minimal(self, basic_flow_system_linopy): @@ -140,3 +140,85 @@ def test_shares(self, basic_flow_system_linopy): ) +class TestEffectResults: + def test_shares(self, basic_flow_system_linopy): + flow_system = basic_flow_system_linopy + flow_system.effects['Costs'].specific_share_to_other_effects_operation['Effect1'] = 0.5 + flow_system.add_elements( + fx.Effect('Effect1', '€', 'Testing Effect', + specific_share_to_other_effects_operation={ + 'Effect2': 1.1, + 'Effect3': 1.2 + }, + specific_share_to_other_effects_invest={ + 'Effect2': 2.1, + 'Effect3': 2.2 + } + ), + fx.Effect('Effect2', '€', 'Testing Effect', specific_share_to_other_effects_operation={'Effect3': 5}), + fx.Effect('Effect3', '€', 'Testing Effect'), + fx.linear_converters.Boiler( + 'Boiler', eta=0.5, Q_th=fx.Flow('Q_th', bus='Fernwärme', ),Q_fu=fx.Flow('Q_fu', bus='Gas'), + ) + ) + + results = create_calculation_and_solve(flow_system, fx.solvers.HighsSolver(0.01, 60), 'Sim1').results + + effect_share_factors = { + 'operation': { + ('Costs', 'Effect1'): 0.5, + ('Costs', 'Effect2'): 0.5 * 1.1, + ('Costs', 'Effect3'): 0.5 * 1.1 * 5 + 0.5 * 1.2, #This is where the issue lies + ('Effect1', 'Effect2'): 1.1, + ('Effect1', 'Effect3'): 1.2 + 1.1 * 5, + ('Effect2', 'Effect3'): 5, + }, + 'invest': { + ('Effect1', 'Effect2'): 2.1, + ('Effect1', 'Effect3'): 2.2, + } + } + for key, value in effect_share_factors['operation'].items(): + np.testing.assert_allclose(results.effect_share_factors['operation'][key].values, value) + + for key, value in effect_share_factors['invest'].items(): + np.testing.assert_allclose(results.effect_share_factors['invest'][key].values, value) + + xr.testing.assert_allclose(results.get_effects_per_component('operation').sum('component')['Costs'], + results.solution['Costs(operation)|total_per_timestep'].fillna(0)) + + xr.testing.assert_allclose(results.get_effects_per_component('operation').sum('component')['Effect1'], + results.solution['Effect1(operation)|total_per_timestep'].fillna(0)) + + xr.testing.assert_allclose(results.get_effects_per_component('operation').sum('component')['Effect2'], + results.solution['Effect2(operation)|total_per_timestep'].fillna(0)) + + xr.testing.assert_allclose(results.get_effects_per_component('operation').sum('component')['Effect3'], + results.solution['Effect3(operation)|total_per_timestep'].fillna(0)) + + # Invest mode checks + xr.testing.assert_allclose(results.get_effects_per_component('invest').sum('component')['Costs'], + results.solution['Costs(invest)|total']) + + xr.testing.assert_allclose(results.get_effects_per_component('invest').sum('component')['Effect1'], + results.solution['Effect1(invest)|total']) + + xr.testing.assert_allclose(results.get_effects_per_component('invest').sum('component')['Effect2'], + results.solution['Effect2(invest)|total']) + + xr.testing.assert_allclose(results.get_effects_per_component('invest').sum('component')['Effect3'], + results.solution['Effect3(invest)|total']) + + # Total mode checks + xr.testing.assert_allclose(results.get_effects_per_component('total').sum('component')['Costs'], + results.solution['Costs|total']) + + xr.testing.assert_allclose(results.get_effects_per_component('total').sum('component')['Effect1'], + results.solution['Effect1|total']) + + xr.testing.assert_allclose(results.get_effects_per_component('total').sum('component')['Effect2'], + results.solution['Effect2|total']) + + xr.testing.assert_allclose(results.get_effects_per_component('total').sum('component')['Effect3'], + results.solution['Effect3|total']) + diff --git a/tests/test_effects_shares_summation.py b/tests/test_effects_shares_summation.py new file mode 100644 index 000000000..e2dada7e9 --- /dev/null +++ b/tests/test_effects_shares_summation.py @@ -0,0 +1,236 @@ +from typing import Dict, Tuple + +import numpy as np +import pytest +import xarray as xr + +from flixopt.effects import calculate_all_conversion_paths + + +def test_direct_conversions(): + """Test direct conversions with simple scalar values.""" + conversion_dict = { + 'A': {'B': xr.DataArray(2.0)}, + 'B': {'C': xr.DataArray(3.0)} + } + + result = calculate_all_conversion_paths(conversion_dict) + + # Check direct conversions + assert ('A', 'B') in result + assert ('B', 'C') in result + assert result[('A', 'B')].item() == 2.0 + assert result[('B', 'C')].item() == 3.0 + + # Check indirect conversion + assert ('A', 'C') in result + assert result[('A', 'C')].item() == 6.0 # 2.0 * 3.0 + + +def test_multiple_paths(): + """Test multiple paths between nodes that should be summed.""" + conversion_dict = { + 'A': {'B': xr.DataArray(2.0), 'C': xr.DataArray(3.0)}, + 'B': {'D': xr.DataArray(4.0)}, + 'C': {'D': xr.DataArray(5.0)} + } + + result = calculate_all_conversion_paths(conversion_dict) + + # A to D should sum two paths: A->B->D (2*4=8) and A->C->D (3*5=15) + assert ('A', 'D') in result + assert result[('A', 'D')].item() == 8.0 + 15.0 + + +def test_xarray_conversions(): + """Test with xarray DataArrays that have dimensions.""" + # Create DataArrays with a time dimension + time_points = [1, 2, 3] + a_to_b = xr.DataArray([2.0, 2.1, 2.2], dims=['time'], coords={'time': time_points}) + b_to_c = xr.DataArray([3.0, 3.1, 3.2], dims=['time'], coords={'time': time_points}) + + conversion_dict = { + 'A': {'B': a_to_b}, + 'B': {'C': b_to_c} + } + + result = calculate_all_conversion_paths(conversion_dict) + + # Check indirect conversion preserves dimensions + assert ('A', 'C') in result + assert result[('A', 'C')].dims == ('time',) + + # Check values at each time point + for i, t in enumerate(time_points): + expected = a_to_b.values[i] * b_to_c.values[i] + assert pytest.approx(result[('A', 'C')].sel(time=t).item()) == expected + + +def test_long_paths(): + """Test with longer paths (more than one intermediate node).""" + conversion_dict = { + 'A': {'B': xr.DataArray(2.0)}, + 'B': {'C': xr.DataArray(3.0)}, + 'C': {'D': xr.DataArray(4.0)}, + 'D': {'E': xr.DataArray(5.0)} + } + + result = calculate_all_conversion_paths(conversion_dict) + + # Check the full path A->B->C->D->E + assert ('A', 'E') in result + expected = 2.0 * 3.0 * 4.0 * 5.0 # 120.0 + assert result[('A', 'E')].item() == expected + + +def test_diamond_paths(): + """Test with a diamond shape graph with multiple paths to the same destination.""" + conversion_dict = { + 'A': {'B': xr.DataArray(2.0), 'C': xr.DataArray(3.0)}, + 'B': {'D': xr.DataArray(4.0)}, + 'C': {'D': xr.DataArray(5.0)}, + 'D': {'E': xr.DataArray(6.0)} + } + + result = calculate_all_conversion_paths(conversion_dict) + + # A to E should go through both paths: + # A->B->D->E (2*4*6=48) and A->C->D->E (3*5*6=90) + assert ('A', 'E') in result + expected = 48.0 + 90.0 # 138.0 + assert result[('A', 'E')].item() == expected + + +def test_effect_shares_example(): + """Test the specific example from the effects share factors test.""" + # Create the conversion dictionary based on test example + conversion_dict = { + 'Costs': {'Effect1': xr.DataArray(0.5)}, + 'Effect1': {'Effect2': xr.DataArray(1.1), 'Effect3': xr.DataArray(1.2)}, + 'Effect2': {'Effect3': xr.DataArray(5.0)} + } + + result = calculate_all_conversion_paths(conversion_dict) + + # Test direct paths + assert result[('Costs', 'Effect1')].item() == 0.5 + assert result[('Effect1', 'Effect2')].item() == 1.1 + assert result[('Effect2', 'Effect3')].item() == 5.0 + + # Test indirect paths + # Costs -> Effect2 = Costs -> Effect1 -> Effect2 = 0.5 * 1.1 + assert result[('Costs', 'Effect2')].item() == 0.5 * 1.1 + + # Costs -> Effect3 has two paths: + # 1. Costs -> Effect1 -> Effect3 = 0.5 * 1.2 = 0.6 + # 2. Costs -> Effect1 -> Effect2 -> Effect3 = 0.5 * 1.1 * 5 = 2.75 + # Total = 0.6 + 2.75 = 3.35 + assert result[('Costs', 'Effect3')].item() == 0.5 * 1.2 + 0.5 * 1.1 * 5 + + # Effect1 -> Effect3 has two paths: + # 1. Effect1 -> Effect2 -> Effect3 = 1.1 * 5.0 = 5.5 + # 2. Effect1 -> Effect3 = 1.2 + # Total = 0.6 + 2.75 = 3.35 + assert result[('Effect1', 'Effect3')].item() == 1.2 + 1.1 * 5.0 + + +def test_empty_conversion_dict(): + """Test with an empty conversion dictionary.""" + result = calculate_all_conversion_paths({}) + assert len(result) == 0 + + +def test_no_indirect_paths(): + """Test with a dictionary that has no indirect paths.""" + conversion_dict = { + 'A': {'B': xr.DataArray(2.0)}, + 'C': {'D': xr.DataArray(3.0)} + } + + result = calculate_all_conversion_paths(conversion_dict) + + # Only direct paths should exist + assert len(result) == 2 + assert ('A', 'B') in result + assert ('C', 'D') in result + assert result[('A', 'B')].item() == 2.0 + assert result[('C', 'D')].item() == 3.0 + + +def test_complex_network(): + """Test with a complex network of many nodes and multiple paths, without circular references.""" + # Create a directed acyclic graph with many nodes + # Structure resembles a layered network with multiple paths + conversion_dict = { + 'A': {'B': xr.DataArray(1.5), 'C': xr.DataArray(2.0), 'D': xr.DataArray(0.5)}, + 'B': {'E': xr.DataArray(3.0), 'F': xr.DataArray(1.2)}, + 'C': {'E': xr.DataArray(0.8), 'G': xr.DataArray(2.5)}, + 'D': {'G': xr.DataArray(1.8), 'H': xr.DataArray(3.2)}, + 'E': {'I': xr.DataArray(0.7), 'J': xr.DataArray(1.4)}, + 'F': {'J': xr.DataArray(2.2), 'K': xr.DataArray(0.9)}, + 'G': {'K': xr.DataArray(1.6), 'L': xr.DataArray(2.8)}, + 'H': {'L': xr.DataArray(0.4), 'M': xr.DataArray(1.1)}, + 'I': {'N': xr.DataArray(2.3)}, + 'J': {'N': xr.DataArray(1.9), 'O': xr.DataArray(0.6)}, + 'K': {'O': xr.DataArray(3.5), 'P': xr.DataArray(1.3)}, + 'L': {'P': xr.DataArray(2.7), 'Q': xr.DataArray(0.8)}, + 'M': {'Q': xr.DataArray(2.1)}, + 'N': {'R': xr.DataArray(1.7)}, + 'O': {'R': xr.DataArray(2.9), 'S': xr.DataArray(1.0)}, + 'P': {'S': xr.DataArray(2.4)}, + 'Q': {'S': xr.DataArray(1.5)} + } + + result = calculate_all_conversion_paths(conversion_dict) + + # Check some direct paths + assert result[('A', 'B')].item() == 1.5 + assert result[('D', 'H')].item() == 3.2 + assert result[('G', 'L')].item() == 2.8 + + # Check some two-step paths + assert result[('A', 'E')].item() == 1.5 * 3.0 + 2.0 * 0.8 # A->B->E + A->C->E + assert result[('B', 'J')].item() == 3.0 * 1.4 + 1.2 * 2.2 # B->E->J + B->F->J + + # Check some three-step paths + # A->B->E->I + # A->C->E->I + expected_a_to_i = 1.5 * 3.0 * 0.7 + 2.0 * 0.8 * 0.7 + assert pytest.approx(result[('A', 'I')].item()) == expected_a_to_i + + # Check some four-step paths + # A->B->E->I->N + # A->C->E->I->N + expected_a_to_n = 1.5 * 3.0 * 0.7 * 2.3 + 2.0 * 0.8 * 0.7 * 2.3 + expected_a_to_n += 1.5 * 3.0 * 1.4 * 1.9 + 2.0 * 0.8 * 1.4 * 1.9 # A->B->E->J->N + A->C->E->J->N + expected_a_to_n += 1.5 * 1.2 * 2.2 * 1.9 # A->B->F->J->N + assert pytest.approx(result[('A', 'N')].item()) == expected_a_to_n + + # Check a very long path from A to S + # This should include: + # A->B->E->J->O->S + # A->B->F->K->O->S + # A->C->E->J->O->S + # A->C->G->K->O->S + # A->D->G->K->O->S + # A->D->H->L->P->S + # A->D->H->M->Q->S + # And many more + assert ('A', 'S') in result + + # There are many paths to R from A - check their existence + assert ('A', 'R') in result + + # Check that there's no direct path from A to R + # But there should be indirect paths + assert ('A', 'R') in result + assert 'A' not in conversion_dict.get('R', {}) + + # Count the number of paths calculated to verify algorithm explored all connections + # In a DAG with 19 nodes (A through S), the maximum number of pairs is 19*18 = 342 + # But we won't have all possible connections due to the structure + # Just verify we have a reasonable number + assert len(result) > 50 + +if __name__ == '__main__': + pytest.main() diff --git a/tests/test_scenarios.py b/tests/test_scenarios.py index 89b5feced..4abdafa5f 100644 --- a/tests/test_scenarios.py +++ b/tests/test_scenarios.py @@ -282,7 +282,7 @@ def test_full_scenario_optimization(flow_system_piecewise_conversion_scenarios): calc.results.to_file() res = fx.results.CalculationResults.from_file('results', 'test_full_scenario') - fx.FlowSystem.from_dataset(res.flow_system) + fx.FlowSystem.from_dataset(res.flow_system_data) calc = create_calculation_and_solve( flow_system_piecewise_conversion_scenarios, solver=fx.solvers.GurobiSolver(mip_gap=0.01, time_limit_seconds=60), @@ -301,7 +301,7 @@ def test_io_persistance(flow_system_piecewise_conversion_scenarios): calc.results.to_file() res = fx.results.CalculationResults.from_file('results', 'test_full_scenario') - flow_system_2 = fx.FlowSystem.from_dataset(res.flow_system) + flow_system_2 = fx.FlowSystem.from_dataset(res.flow_system_data) calc_2 = create_calculation_and_solve( flow_system_2, solver=fx.solvers.HighsSolver(mip_gap=0.001, time_limit_seconds=60), @@ -323,7 +323,7 @@ def test_scenarios_selection(flow_system_piecewise_conversion_scenarios): calc.solve(fx.solvers.GurobiSolver(mip_gap=0.01, time_limit_seconds=60)) calc.results.to_file() - flow_system_2 = fx.FlowSystem.from_dataset(calc.results.flow_system) + flow_system_2 = fx.FlowSystem.from_dataset(calc.results.flow_system_data) assert calc.results.solution.indexes['scenario'].equals(flow_system.time_series_collection.scenarios[0:2]) From 9f2f38b88a95bb0ffbd572c8a10eda0fba180c83 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 28 Apr 2025 20:26:34 +0200 Subject: [PATCH 038/336] Scenarios/datasets results (#257) * Use dataarray instead of dataset * Change effects dataset to dataarray and use nan when no share was found * Add method for flow_rates dataset * Add methods to get flow_rates and flow_hours as datasets * Rename the dataarrays to the flow * Preserve index order * Improve filter_edges_dataset() * Simplify _create_flow_rates_dataarray() * Add dataset for sizes of Flows * Extend results structure to contain flows AND start/end infos * Add FlowResults Object * BUGFIX:Typo in _ElementResults.constraints * Add flows to results of Nodes * Simplify dataarray creation and improve FlowResults * Add nice docstrings * Improve filtering of flow results * Improve filtering of flow results. Add attribute of component * Add big dataarray with all variables but indexed * Revert "Add big dataarray with all variables but indexed" This reverts commit 08cd8a14fcf28248bf4a4c0a0fe1bae718269731. * Improve filtering method for coords filter and add error handling for restoring the flow system * Remove unnecessary methods in results .from_json() * Ensure consistent coord ordering in Effects dataarray * Rename get_effects_per_component() * Make effects_per_component() a dataset instead of a dataarray * Improve backwards compatability --- flixopt/elements.py | 12 +- flixopt/results.py | 356 ++++++++++++++++++++++++++++++++++++------- flixopt/structure.py | 7 +- tests/test_effect.py | 24 +-- 4 files changed, 332 insertions(+), 67 deletions(-) diff --git a/flixopt/elements.py b/flixopt/elements.py index 7dda3e9cf..11246e6d9 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -396,6 +396,14 @@ def do_modeling(self): # Shares self._create_shares() + def results_structure(self): + return { + **super().results_structure(), + 'start': self.element.bus if self.element.is_input_in_component else self.element.component, + 'end': self.element.component if self.element.is_input_in_component else self.element.bus, + 'component': self.element.component, + } + def _create_shares(self): # Arbeitskosten: if self.element.effects_per_flow_hour != {}: @@ -535,7 +543,8 @@ def results_structure(self): inputs.append(self.excess_input.name) if self.excess_output is not None: outputs.append(self.excess_output.name) - return {**super().results_structure(), 'inputs': inputs, 'outputs': outputs} + return {**super().results_structure(), 'inputs': inputs, 'outputs': outputs, + 'flows': [flow.label_full for flow in self.element.inputs + self.element.outputs]} class ComponentModel(ElementModel): @@ -588,4 +597,5 @@ def results_structure(self): **super().results_structure(), 'inputs': [flow.model.flow_rate.name for flow in self.element.inputs], 'outputs': [flow.model.flow_rate.name for flow in self.element.outputs], + 'flows': [flow.label_full for flow in self.element.inputs + self.element.outputs], } diff --git a/flixopt/results.py b/flixopt/results.py index 0f300bbcb..b1e72f4be 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -2,7 +2,8 @@ import json import logging import pathlib -from typing import TYPE_CHECKING, Dict, List, Literal, Optional, Tuple, Union +import warnings +from typing import TYPE_CHECKING, Dict, List, Literal, Optional, Tuple, Union, Any import linopy import matplotlib.pyplot as plt @@ -25,6 +26,11 @@ logger = logging.getLogger('flixopt') +class _FlowSystemRestorationError(Exception): + """Exception raised when a FlowSystem cannot be restored from dataset.""" + pass + + class CalculationResults: """Results container for Calculation results. @@ -37,7 +43,7 @@ class CalculationResults: Attributes: solution (xr.Dataset): Dataset containing optimization results. - flow_system (xr.Dataset): Dataset containing the flow system. + flow_system_data (xr.Dataset): Dataset containing the flow system. summary (Dict): Information about the calculation. name (str): Name identifier for the calculation. model (linopy.Model): The optimization model (if available). @@ -133,6 +139,7 @@ def __init__( summary: Dict, folder: Optional[pathlib.Path] = None, model: Optional[linopy.Model] = None, + **kwargs, # To accept old "flow_system" parameter ): """ Args: @@ -145,6 +152,16 @@ def __init__( Deprecated: flow_system: Use flow_system_data instead. """ + # Handle potential old "flow_system" parameter for backward compatibility + if 'flow_system' in kwargs and flow_system_data is None: + flow_system_data = kwargs.pop('flow_system') + warnings.warn( + "The 'flow_system' parameter is deprecated. Use 'flow_system_data' instead." + "Acess is now by '.flow_system_data', while '.flow_system' returns the restored FlowSystem.", + DeprecationWarning, + stacklevel=2, + ) + self.solution = solution self.flow_system_data = flow_system_data self.summary = summary @@ -152,30 +169,47 @@ def __init__( self.model = model self.folder = pathlib.Path(folder) if folder is not None else pathlib.Path.cwd() / 'results' self.components = { - label: ComponentResults.from_json(self, infos) for label, infos in self.solution.attrs['Components'].items() + label: ComponentResults(self, **infos) for label, infos in self.solution.attrs['Components'].items() } - self.buses = {label: BusResults.from_json(self, infos) for label, infos in self.solution.attrs['Buses'].items()} + self.buses = {label: BusResults(self, **infos) for label, infos in self.solution.attrs['Buses'].items()} self.effects = { - label: EffectResults.from_json(self, infos) for label, infos in self.solution.attrs['Effects'].items() + label: EffectResults(self, **infos) for label, infos in self.solution.attrs['Effects'].items() } + if 'Flows' not in self.solution.attrs: + warnings.warn( + 'No Data about flows found in the results. This data is only included since v2.2.0. Some functionality ' + 'is not availlable. We recommend to evaluate your results with a version <2.2.0.') + self.flows = {} + else: + self.flows = { + label: FlowResults(self, **infos) for label, infos in self.solution.attrs.get('Flows', {}).items() + } + self.timesteps_extra = self.solution.indexes['time'] self.hours_per_timestep = TimeSeriesCollection.calculate_hours_per_timestep(self.timesteps_extra) self.scenarios = self.solution.indexes['scenario'] if 'scenario' in self.solution.indexes else None self._effect_share_factors = None self._flow_system = None + + self._flow_rates = None + self._flow_hours = None + self._sizes = None self._effects_per_component = {'operation': None, 'invest': None, 'total': None} + self._flow_network_info_ = None - def __getitem__(self, key: str) -> Union['ComponentResults', 'BusResults', 'EffectResults']: + def __getitem__(self, key: str) -> Union['ComponentResults', 'BusResults', 'EffectResults', 'FlowResults']: if key in self.components: return self.components[key] if key in self.buses: return self.buses[key] if key in self.effects: return self.effects[key] + if key in self.flows: + return self.flows[key] raise KeyError(f'No element with label {key} found.') @property @@ -211,15 +245,20 @@ def effect_share_factors(self): return self._effect_share_factors @property - def flow_system(self): + def flow_system(self) -> 'FlowSystem': """ The restored flow_system that was used to create the calculation. Contains all input parameters.""" if self._flow_system is None: - from . import FlowSystem - current_logger_level = logger.getEffectiveLevel() - logger.setLevel(logging.CRITICAL) - self._flow_system = FlowSystem.from_dataset(self.flow_system_data) - logger.setLevel(current_logger_level) + try: + from . import FlowSystem + current_logger_level = logger.getEffectiveLevel() + logger.setLevel(logging.CRITICAL) + self._flow_system = FlowSystem.from_dataset(self.flow_system_data) + self._flow_system._connect_network() + logger.setLevel(current_logger_level) + except Exception as e: + logger.critical(f'Not able to restore FlowSystem from dataset. Some functionality is not availlable. {e}') + raise _FlowSystemRestorationError(f'Not able to restore FlowSystem from dataset. {e}') from e return self._flow_system def filter_solution( @@ -265,7 +304,7 @@ def filter_solution( startswith=startswith, ) - def get_effects_per_component(self, mode: Literal['operation', 'invest', 'total'] = 'total') -> xr.Dataset: + def effects_per_component(self, mode: Literal['operation', 'invest', 'total'] = 'total') -> xr.Dataset: """Returns a dataset containing effect totals for each components (including their flows). Args: @@ -280,6 +319,120 @@ def get_effects_per_component(self, mode: Literal['operation', 'invest', 'total' self._effects_per_component[mode] = self._create_effects_dataset(mode) return self._effects_per_component[mode] + def flow_rates( + self, + start: Optional[Union[str, List[str]]] = None, + end: Optional[Union[str, List[str]]] = None, + component: Optional[Union[str, List[str]]] = None, + ) -> xr.DataArray: + """Returns a DataArray containing the flow rates of each Flow. + + Args: + start: Optional source node(s) to filter by. Can be a single node name or a list of names. + end: Optional destination node(s) to filter by. Can be a single node name or a list of names. + component: Optional component(s) to filter by. Can be a single component name or a list of names. + + Further usage: + Convert the dataarray to a dataframe: + >>>results.flow_rates().to_pandas() + Get the max or min over time: + >>>results.flow_rates().max('time') + Sum up the flow rates of flows with the same start and end: + >>>results.flow_rates(end='Fernwärme').groupby('start').sum(dim='flow') + To recombine filtered dataarrays, use `xr.concat` with dim 'flow': + >>>xr.concat([results.flow_rates(start='Fernwärme'), results.flow_rates(end='Fernwärme')], dim='flow') + """ + if self._flow_rates is None: + self._flow_rates = self._assign_flow_coords( + xr.concat([flow.flow_rate.rename(flow.label) for flow in self.flows.values()], + dim=pd.Index(self.flows.keys(), name='flow')) + ).rename('flow_rates') + filters = {k: v for k, v in {'start': start, 'end': end, 'component': component}.items() if v is not None} + return filter_dataarray_by_coord(self._flow_rates, **filters) + + def flow_hours( + self, + start: Optional[Union[str, List[str]]] = None, + end: Optional[Union[str, List[str]]] = None, + component: Optional[Union[str, List[str]]] = None, + ) -> xr.DataArray: + """Returns a DataArray containing the flow hours of each Flow. + + Flow hours represent the total energy/material transferred over time, + calculated by multiplying flow rates by the duration of each timestep. + + Args: + start: Optional source node(s) to filter by. Can be a single node name or a list of names. + end: Optional destination node(s) to filter by. Can be a single node name or a list of names. + component: Optional component(s) to filter by. Can be a single component name or a list of names. + + Further usage: + Convert the dataarray to a dataframe: + >>>results.flow_hours().to_pandas() + Sum up the flow hours over time: + >>>results.flow_hours().sum('time') + Sum up the flow hours of flows with the same start and end: + >>>results.flow_hours(end='Fernwärme').groupby('start').sum(dim='flow') + To recombine filtered dataarrays, use `xr.concat` with dim 'flow': + >>>xr.concat([results.flow_hours(start='Fernwärme'), results.flow_hours(end='Fernwärme')], dim='flow') + + """ + if self._flow_hours is None: + self._flow_hours = (self.flow_rates() * self.hours_per_timestep).rename('flow_hours') + filters = {k: v for k, v in {'start': start, 'end': end, 'component': component}.items() if v is not None} + return filter_dataarray_by_coord(self._flow_hours, **filters) + + def sizes( + self, + start: Optional[Union[str, List[str]]] = None, + end: Optional[Union[str, List[str]]] = None, + component: Optional[Union[str, List[str]]] = None + ) -> xr.DataArray: + """Returns a dataset with the sizes of the Flows. + Args: + start: Optional source node(s) to filter by. Can be a single node name or a list of names. + end: Optional destination node(s) to filter by. Can be a single node name or a list of names. + component: Optional component(s) to filter by. Can be a single component name or a list of names. + + Further usage: + Convert the dataarray to a dataframe: + >>>results.sizes().to_pandas() + To recombine filtered dataarrays, use `xr.concat` with dim 'flow': + >>>xr.concat([results.sizes(start='Fernwärme'), results.sizes(end='Fernwärme')], dim='flow') + + """ + if self._sizes is None: + self._sizes = self._assign_flow_coords( + xr.concat([flow.size.rename(flow.label) for flow in self.flows.values()], + dim=pd.Index(self.flows.keys(), name='flow')) + ).rename('flow_sizes') + filters = {k: v for k, v in {'start': start, 'end': end, 'component': component}.items() if v is not None} + return filter_dataarray_by_coord(self._sizes, **filters) + + def _assign_flow_coords(self, da: xr.DataArray): + # Add start and end coordinates + da = da.assign_coords({ + 'start': ('flow', [flow.start for flow in self.flows.values()]), + 'end': ('flow', [flow.end for flow in self.flows.values()]), + 'component': ('flow', [flow.component for flow in self.flows.values()]), + }) + + # Ensure flow is the last dimension if needed + existing_dims = [d for d in da.dims if d != 'flow'] + da = da.transpose(*(existing_dims + ['flow'])) + return da + + def _get_flow_network_info(self) -> Dict[str, Dict[str, str]]: + flow_network_info = {} + + for flow in self.flows.values(): + flow_network_info[flow.label] = { + 'label': flow.label, + 'start': flow.start, + 'end': flow.end, + } + return flow_network_info + def get_effect_shares( self, element: str, @@ -336,7 +489,7 @@ def _compute_effect_total( element: str, effect: str, mode: Literal['operation', 'invest', 'total'] = 'total', - include_flows: bool = False + include_flows: bool = False, ) -> xr.DataArray: """Calculates the total effect for a specific element and effect. @@ -351,6 +504,7 @@ def _compute_effect_total( 'invest': Returns investment-specific effects. 'total': Returns the sum of operation effects (across all timesteps) and investment effects. Defaults to 'total'. + include_flows: Whether to include effects from flows connected to this element. Returns: An xarray DataArray containing the total effects, named with pattern @@ -365,12 +519,20 @@ def _compute_effect_total( if mode == 'total': operation = self._compute_effect_total(element=element, effect=effect, mode='operation', include_flows=include_flows) - if len(operation.indexes) > 0: + invest = self._compute_effect_total(element=element, effect=effect, mode='invest', include_flows=include_flows) + if invest.isnull().all() and operation.isnull().all(): + return xr.DataArray(np.nan) + if operation.isnull().all(): + return invest.rename(f'{element}->{effect}') + operation = operation.sum('time') + if invest.isnull().all(): + return operation.rename(f'{element}->{effect}') + if 'time' in operation.indexes: operation = operation.sum('time') - return (operation + self._compute_effect_total(element=element, effect=effect, mode='invest', include_flows=include_flows) - ).rename(f'{element}->{effect}') + return invest + operation total = xr.DataArray(0) + share_exists = False relevant_conversion_factors = { key[0]: value for key, value in self.effect_share_factors[mode].items() if key[1] == effect @@ -380,8 +542,9 @@ def _compute_effect_total( for target_effect, conversion_factor in relevant_conversion_factors.items(): label = f'{element}->{target_effect}({mode})' if label in self.solution: + share_exists = True da = self.solution[label] - total = total + da * conversion_factor + total = da * conversion_factor + total if include_flows: if element not in self.components: @@ -391,9 +554,11 @@ def _compute_effect_total( for flow in flows: label = f'{flow}->{target_effect}({mode})' if label in self.solution: + share_exists = True da = self.solution[label] - total = total + da * conversion_factor - + total = da * conversion_factor + total + if not share_exists: + total = xr.DataArray(np.nan) return total.rename(f'{element}->{effect}({mode})') def _create_effects_dataset(self, mode: Literal['operation', 'invest', 'total'] = 'total') -> xr.Dataset: @@ -404,36 +569,41 @@ def _create_effects_dataset(self, mode: Literal['operation', 'invest', 'total'] mode: The calculation mode ('operation', 'invest', or 'total'). Returns: - An xarray Dataset with components as a dimension and effects as variables. + An xarray Dataset with components as dimension and effects as variables. """ - data_vars = {} + # Create an empty dataset + ds = xr.Dataset() + # Add each effect as a variable to the dataset for effect in self.effects: # Create a list of DataArrays, one for each component - effect_arrays = [ - self._compute_effect_total(element=component, effect=effect, mode=mode, include_flows=True) - .expand_dims(component=[component]) # Add component dimension to each array + component_arrays = [ + self._compute_effect_total(element=component, effect=effect, mode=mode, include_flows=True).expand_dims( + component=[component] + ) # Add component dimension to each array for component in list(self.components) ] # Combine all components into one DataArray for this effect - if effect_arrays: - data_vars[effect] = xr.concat(effect_arrays, dim="component", coords='minimal') - - ds = xr.Dataset(data_vars) + if component_arrays: + effect_array = xr.concat(component_arrays, dim='component', coords='minimal') + # Add this effect as a variable to the dataset + ds[effect] = effect_array # For now include a test to ensure correctness - suffix = {'operation': '(operation)|total_per_timestep', - 'invest': '(invest)|total', - 'total': '|total', - } + suffix = { + 'operation': '(operation)|total_per_timestep', + 'invest': '(invest)|total', + 'total': '|total', + } for effect in self.effects: label = f'{effect}{suffix[mode]}' - computed = ds.sum('component')[effect] + computed = ds[effect].sum('component') found = self.solution[label] if not np.allclose(computed.values, found.fillna(0).values): - logger.critical(f'Results for {effect}({mode}) in effects_dataset doesnt match {label}\n' - f'{computed=}\n, {found=}') + logger.critical( + f'Results for {effect}({mode}) in effects_dataset doesnt match {label}\n{computed=}\n, {found=}' + ) return ds @@ -549,10 +719,6 @@ def to_file( class _ElementResults: - @classmethod - def from_json(cls, calculation_results, json_data: Dict) -> '_ElementResults': - return cls(calculation_results, json_data['label'], json_data['variables'], json_data['constraints']) - def __init__( self, calculation_results: CalculationResults, label: str, variables: List[str], constraints: List[str] ): @@ -585,7 +751,7 @@ def constraints(self) -> linopy.Constraints: """ if self._calculation_results.model is None: raise ValueError('The linopy model is not available.') - return self._calculation_results.model.constraints[self._variable_names] + return self._calculation_results.model.constraints[self._constraint_names] def filter_solution( self, @@ -630,17 +796,6 @@ def filter_solution( class _NodeResults(_ElementResults): - @classmethod - def from_json(cls, calculation_results, json_data: Dict) -> '_NodeResults': - return cls( - calculation_results, - json_data['label'], - json_data['variables'], - json_data['constraints'], - json_data['inputs'], - json_data['outputs'], - ) - def __init__( self, calculation_results: CalculationResults, @@ -649,10 +804,12 @@ def __init__( constraints: List[str], inputs: List[str], outputs: List[str], + flows: List[str], ): super().__init__(calculation_results, label, variables, constraints) self.inputs = inputs self.outputs = outputs + self.flows = flows def plot_node_balance( self, @@ -979,6 +1136,42 @@ def get_shares_from(self, element: str): return self.solution[[name for name in self._variable_names if name.startswith(f'{element}->')]] +class FlowResults(_ElementResults): + def __init__( + self, + calculation_results: CalculationResults, + label: str, + variables: List[str], + constraints: List[str], + start: str, + end: str, + component: str, + ): + super().__init__(calculation_results, label, variables, constraints) + self.start = start + self.end = end + self.component = component + + @property + def flow_rate(self) -> xr.DataArray: + return self.solution[f'{self.label}|flow_rate'] + + @property + def flow_hours(self) -> xr.DataArray: + return (self.flow_rate * self._calculation_results.hours_per_timestep).rename(f'{self.label}|flow_hours') + + @property + def size(self) -> xr.DataArray: + name = f'{self.label}|size' + if name in self.solution: + return self.solution[name] + try: + return xr.DataArray(self._calculation_results.flow_system.flows[self.label].size).rename(name) + except _FlowSystemRestorationError: + logger.critical(f'Size of flow {self.label}.size not availlable. Returning NaN') + return xr.DataArray(np.nan).rename(name) + + class SegmentedCalculationResults: """ Class to store the results of a SegmentedCalculation. @@ -1336,3 +1529,62 @@ def filter_dataset( ) from e return filtered_ds + + +def filter_dataarray_by_coord( + da: xr.DataArray, + **kwargs: Optional[Union[str, List[str]]] +) -> xr.DataArray: + """Filter flows by node and component attributes. + + Filters are applied in the order they are specified. All filters must match for an edge to be included. + + To recombine filtered dataarrays, use `xr.concat`. + + xr.concat([res.sizes(start='Fernwärme'), res.sizes(end='Fernwärme')], dim='flow') + + Args: + da: Flow DataArray with network metadata coordinates. + **kwargs: Coord filters as name=value pairs. + + Returns: + Filtered DataArray with matching edges. + + Raises: + AttributeError: If required coordinates are missing. + ValueError: If specified nodes don't exist or no matches found. + """ + # Helper function to process filters + def apply_filter(array, coord_name: str, coord_values: Union[Any, List[Any]]): + # Verify coord exists + if coord_name not in array.coords: + raise AttributeError(f"Missing required coordinate '{coord_name}'") + + # Convert single value to list + val_list = [coord_values] if isinstance(coord_values, str) else coord_values + + # Verify coord_values exist + available = set(array[coord_name].values) + missing = [v for v in val_list if v not in available] + if missing: + raise ValueError(f"{coord_name.title()} value(s) not found: {missing}") + + # Apply filter + return array.where( + array[coord_name].isin(val_list) if isinstance(coord_values, list) else array[coord_name] == coord_values, + drop=True + ) + + # Apply filters from kwargs + filters = {k: v for k, v in kwargs.items() if v is not None} + try: + for coord, values in filters.items(): + da = apply_filter(da, coord, values) + except ValueError as e: + raise ValueError(f"No edges match criteria: {filters}") + + # Verify results exist + if da.size == 0: + raise ValueError(f"No edges match criteria: {filters}") + + return da diff --git a/flixopt/structure.py b/flixopt/structure.py index 1285dc885..7c7772dad 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -106,6 +106,10 @@ def solution(self): effect.label_full: effect.model.results_structure() for effect in sorted(self.flow_system.effects, key=lambda effect: effect.label_full.upper()) }, + 'Flows': { + flow.label_full: flow.model.results_structure() + for flow in sorted(self.flow_system.flows.values(), key=lambda flow: flow.label_full.upper()) + }, } return solution.reindex(time=self.time_series_collection.timesteps_extra) @@ -495,8 +499,7 @@ def __init__(self, model: SystemModel, element: Element): def results_structure(self): return { - 'label': self.label, - 'label_full': self.label_full, + 'label': self.label_full, 'variables': list(self.variables), 'constraints': list(self.constraints), } diff --git a/tests/test_effect.py b/tests/test_effect.py index 93f417f22..ff85d0556 100644 --- a/tests/test_effect.py +++ b/tests/test_effect.py @@ -184,41 +184,41 @@ def test_shares(self, basic_flow_system_linopy): for key, value in effect_share_factors['invest'].items(): np.testing.assert_allclose(results.effect_share_factors['invest'][key].values, value) - xr.testing.assert_allclose(results.get_effects_per_component('operation').sum('component')['Costs'], + xr.testing.assert_allclose(results.effects_per_component('operation').sum('component')['Costs'], results.solution['Costs(operation)|total_per_timestep'].fillna(0)) - xr.testing.assert_allclose(results.get_effects_per_component('operation').sum('component')['Effect1'], + xr.testing.assert_allclose(results.effects_per_component('operation').sum('component')['Effect1'], results.solution['Effect1(operation)|total_per_timestep'].fillna(0)) - xr.testing.assert_allclose(results.get_effects_per_component('operation').sum('component')['Effect2'], + xr.testing.assert_allclose(results.effects_per_component('operation').sum('component')['Effect2'], results.solution['Effect2(operation)|total_per_timestep'].fillna(0)) - xr.testing.assert_allclose(results.get_effects_per_component('operation').sum('component')['Effect3'], + xr.testing.assert_allclose(results.effects_per_component('operation').sum('component')['Effect3'], results.solution['Effect3(operation)|total_per_timestep'].fillna(0)) # Invest mode checks - xr.testing.assert_allclose(results.get_effects_per_component('invest').sum('component')['Costs'], + xr.testing.assert_allclose(results.effects_per_component('invest').sum('component')['Costs'], results.solution['Costs(invest)|total']) - xr.testing.assert_allclose(results.get_effects_per_component('invest').sum('component')['Effect1'], + xr.testing.assert_allclose(results.effects_per_component('invest').sum('component')['Effect1'], results.solution['Effect1(invest)|total']) - xr.testing.assert_allclose(results.get_effects_per_component('invest').sum('component')['Effect2'], + xr.testing.assert_allclose(results.effects_per_component('invest').sum('component')['Effect2'], results.solution['Effect2(invest)|total']) - xr.testing.assert_allclose(results.get_effects_per_component('invest').sum('component')['Effect3'], + xr.testing.assert_allclose(results.effects_per_component('invest').sum('component')['Effect3'], results.solution['Effect3(invest)|total']) # Total mode checks - xr.testing.assert_allclose(results.get_effects_per_component('total').sum('component')['Costs'], + xr.testing.assert_allclose(results.effects_per_component('total').sum('component')['Costs'], results.solution['Costs|total']) - xr.testing.assert_allclose(results.get_effects_per_component('total').sum('component')['Effect1'], + xr.testing.assert_allclose(results.effects_per_component('total').sum('component')['Effect1'], results.solution['Effect1|total']) - xr.testing.assert_allclose(results.get_effects_per_component('total').sum('component')['Effect2'], + xr.testing.assert_allclose(results.effects_per_component('total').sum('component')['Effect2'], results.solution['Effect2|total']) - xr.testing.assert_allclose(results.get_effects_per_component('total').sum('component')['Effect3'], + xr.testing.assert_allclose(results.effects_per_component('total').sum('component')['Effect3'], results.solution['Effect3|total']) From dbfb1b53ccc6c7f8c3d8ad169bee50b7d2d14937 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 28 Apr 2025 20:27:56 +0200 Subject: [PATCH 039/336] ruff check --- flixopt/results.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/flixopt/results.py b/flixopt/results.py index b1e72f4be..e08e14636 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -3,7 +3,7 @@ import logging import pathlib import warnings -from typing import TYPE_CHECKING, Dict, List, Literal, Optional, Tuple, Union, Any +from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Tuple, Union import linopy import matplotlib.pyplot as plt @@ -1580,7 +1580,7 @@ def apply_filter(array, coord_name: str, coord_values: Union[Any, List[Any]]): try: for coord, values in filters.items(): da = apply_filter(da, coord, values) - except ValueError as e: + except ValueError: raise ValueError(f"No edges match criteria: {filters}") # Verify results exist From 6738d34c396e608d49f0206a01528a9efb4e3968 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 28 Apr 2025 20:31:46 +0200 Subject: [PATCH 040/336] ruff check --- flixopt/results.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/flixopt/results.py b/flixopt/results.py index e08e14636..41c98be15 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -16,6 +16,7 @@ from . import io as fx_io from . import plotting from .core import TimeSeriesCollection +from .flow_system import FlowSystem if TYPE_CHECKING: import pyvis @@ -181,7 +182,9 @@ def __init__( if 'Flows' not in self.solution.attrs: warnings.warn( 'No Data about flows found in the results. This data is only included since v2.2.0. Some functionality ' - 'is not availlable. We recommend to evaluate your results with a version <2.2.0.') + 'is not availlable. We recommend to evaluate your results with a version <2.2.0.', + stacklevel=2, + ) self.flows = {} else: self.flows = { @@ -393,7 +396,7 @@ def sizes( start: Optional source node(s) to filter by. Can be a single node name or a list of names. end: Optional destination node(s) to filter by. Can be a single node name or a list of names. component: Optional component(s) to filter by. Can be a single component name or a list of names. - + Further usage: Convert the dataarray to a dataframe: >>>results.sizes().to_pandas() @@ -1536,11 +1539,11 @@ def filter_dataarray_by_coord( **kwargs: Optional[Union[str, List[str]]] ) -> xr.DataArray: """Filter flows by node and component attributes. - + Filters are applied in the order they are specified. All filters must match for an edge to be included. - + To recombine filtered dataarrays, use `xr.concat`. - + xr.concat([res.sizes(start='Fernwärme'), res.sizes(end='Fernwärme')], dim='flow') Args: @@ -1580,8 +1583,8 @@ def apply_filter(array, coord_name: str, coord_values: Union[Any, List[Any]]): try: for coord, values in filters.items(): da = apply_filter(da, coord, values) - except ValueError: - raise ValueError(f"No edges match criteria: {filters}") + except ValueError as e: + raise ValueError(f"No edges match criteria: {filters}") from e # Verify results exist if da.size == 0: From c64d12eef6bf7f3d92aa326e4e0d46924f744d6c Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 28 Apr 2025 22:04:00 +0200 Subject: [PATCH 041/336] Scenarios/deprecation (#258) * Deprecate .active_timesteps * Improve logger warning * Starting release notes --- docs/release-notes/v2.2.0.md | 55 ++++++++++++++++++++++++++++++++++++ flixopt/calculation.py | 49 ++++++++++++++++++++++---------- flixopt/components.py | 4 +-- flixopt/core.py | 6 ++-- tests/test_timeseries.py | 2 +- 5 files changed, 95 insertions(+), 21 deletions(-) create mode 100644 docs/release-notes/v2.2.0.md diff --git a/docs/release-notes/v2.2.0.md b/docs/release-notes/v2.2.0.md new file mode 100644 index 000000000..3cf7eef8d --- /dev/null +++ b/docs/release-notes/v2.2.0.md @@ -0,0 +1,55 @@ +# Release v2.2.0 + +**Release Date:** YYYY-MM-DD + +## What's New + +### Scenarios +Scenarios are a new feature of flixopt. They can be used to model uncertainties in the flow system, such as: +* Different demand profiles +* Different price forecasts +* Different weather conditions +* Different climate conditions +The might also be used to model an evolving system with multiple investment periods. Each **scenario** might be a new year, a new month, or a new day, with a different set of investment decisions to take. + +The weighted sum of the total objective effect of each scenario is used as the objective of the optimization. + +#### Investments and scenarios +Scenarios allow for more flexibility in investment decisions. +You can decide to allow different investment decisions for each scenario, or to allow a single investment decision for a subset of all scnarios, while not allowing for an invest in others. +This enables the following use cases: +* Find the best investment decision for each scenario individually +* Find the best overall investment decision for possible scenarios (robust decision-making) +* Find the best overall investment decision for a subset of all scenarios + +The last one might be useful if you want to model a system with multiple investment periods, where one investment decision is made for more than one scenario. +This might occur when scenarios represent years or months, while an investment decision influences the system for multiple years or months. + + +## Other new features +* Balanced storage - Storage charging and discharging sizes can now be forced to be equal in when optimizing their size. +* Feature 2 - Description + +## Improvements + +* Improvement 1 - Description +* Improvement 2 - Description + +## Bug Fixes + +* Fixed issue with X +* Resolved problem with Y + +## Breaking Changes + +* Change 1 - Migration instructions +* Change 2 - Migration instructions + +## Deprecations + +* Feature X will be removed in v{next_version} + +## Dependencies + +* Added dependency X v1.2.3 +* Updated dependency Y to v2.0.0 \ No newline at end of file diff --git a/flixopt/calculation.py b/flixopt/calculation.py index 2024739ea..942b63a81 100644 --- a/flixopt/calculation.py +++ b/flixopt/calculation.py @@ -12,6 +12,7 @@ import math import pathlib import timeit +import warnings from typing import Any, Dict, List, Optional, Union import numpy as np @@ -43,22 +44,31 @@ def __init__( self, name: str, flow_system: FlowSystem, - active_timesteps: Optional[pd.DatetimeIndex] = None, + selected_timesteps: Optional[pd.DatetimeIndex] = None, selected_scenarios: Optional[pd.Index] = None, folder: Optional[pathlib.Path] = None, + active_timesteps: Optional[pd.DatetimeIndex] = None, ): """ Args: name: name of calculation flow_system: flow_system which should be calculated - active_timesteps: timesteps which should be used for calculation. If None, then all timesteps are used. + selected_timesteps: timesteps which should be used for calculation. If None, then all timesteps are used. selected_scenarios: scenarios which should be used for calculation. If None, then all scenarios are used. folder: folder where results should be saved. If None, then the current working directory is used. + active_timesteps: Deprecated. Use selected_timesteps instead. """ + if active_timesteps is not None: + warnings.warn( + 'active_timesteps is deprecated. Use selected_timesteps instead.', + DeprecationWarning, + stacklevel=2, + ) + selected_timesteps = active_timesteps self.name = name self.flow_system = flow_system self.model: Optional[SystemModel] = None - self.active_timesteps = active_timesteps + self.selected_timesteps = selected_timesteps self.selected_scenarios = selected_scenarios self.durations = {'modeling': 0.0, 'solving': 0.0, 'saving': 0.0} @@ -133,6 +143,15 @@ def summary(self): 'Config': CONFIG.to_dict(), } + @property + def active_timesteps(self) -> pd.DatetimeIndex: + warnings.warn( + 'active_timesteps is deprecated. Use selected_timesteps instead.', + DeprecationWarning, + stacklevel=2, + ) + return self.selected_timesteps + class FullCalculation(Calculation): """ @@ -189,7 +208,7 @@ def solve(self, solver: _Solver, log_file: Optional[pathlib.Path] = None, log_ma def _activate_time_series(self): self.flow_system.transform_data() self.flow_system.time_series_collection.set_selection( - timesteps=self.active_timesteps, scenarios=self.selected_scenarios + timesteps=self.selected_timesteps, scenarios=self.selected_scenarios ) @@ -204,7 +223,7 @@ def __init__( flow_system: FlowSystem, aggregation_parameters: AggregationParameters, components_to_clusterize: Optional[List[Component]] = None, - active_timesteps: Optional[pd.DatetimeIndex] = None, + selected_timesteps: Optional[pd.DatetimeIndex] = None, folder: Optional[pathlib.Path] = None, ): """ @@ -218,13 +237,13 @@ def __init__( components_to_clusterize: List of Components to perform aggregation on. If None, then all components are aggregated. This means, teh variables in the components are equalized to each other, according to the typical periods computed in the DataAggregation - active_timesteps: pd.DatetimeIndex or None + selected_timesteps: pd.DatetimeIndex or None list with indices, which should be used for calculation. If None, then all timesteps are used. folder: folder where results should be saved. If None, then the current working directory is used. """ if flow_system.time_series_collection.scenarios is not None: raise ValueError('Aggregation is not supported for scenarios yet. Please use FullCalculation instead.') - super().__init__(name, flow_system, active_timesteps, folder=folder) + super().__init__(name, flow_system, selected_timesteps, folder=folder) self.aggregation_parameters = aggregation_parameters self.components_to_clusterize = components_to_clusterize self.aggregation = None @@ -342,7 +361,7 @@ def __init__( self.segment_names = [ f'Segment_{i + 1}' for i in range(math.ceil(len(self.all_timesteps) / self.timesteps_per_segment)) ] - self.active_timesteps_per_segment = self._calculate_timesteps_of_segment() + self.selected_timesteps_per_segment = self._calculate_timesteps_of_segment() assert timesteps_per_segment > 2, 'The Segment length must be greater 2, due to unwanted internal side effects' assert self.timesteps_per_segment_with_overlap <= len(self.all_timesteps), ( @@ -368,7 +387,7 @@ def do_modeling_and_solve( logger.info(f'{" Segmented Solving ":#^80}') for i, (segment_name, timesteps_of_segment) in enumerate( - zip(self.segment_names, self.active_timesteps_per_segment, strict=False) + zip(self.segment_names, self.selected_timesteps_per_segment, strict=False) ): if self.sub_calculations: self._transfer_start_values(i) @@ -379,7 +398,7 @@ def do_modeling_and_solve( ) calculation = FullCalculation( - f'{self.name}-{segment_name}', self.flow_system, active_timesteps=timesteps_of_segment + f'{self.name}-{segment_name}', self.flow_system, selected_timesteps=timesteps_of_segment ) self.sub_calculations.append(calculation) calculation.do_modeling() @@ -413,9 +432,9 @@ def _transfer_start_values(self, segment_index: int): This function gets the last values of the previous solved segment and inserts them as start values for the next segment """ - timesteps_of_prior_segment = self.active_timesteps_per_segment[segment_index - 1] + timesteps_of_prior_segment = self.selected_timesteps_per_segment[segment_index - 1] - start = self.active_timesteps_per_segment[segment_index][0] + start = self.selected_timesteps_per_segment[segment_index][0] start_previous_values = timesteps_of_prior_segment[self.timesteps_per_segment - self.nr_of_previous_values] end_previous_values = timesteps_of_prior_segment[self.timesteps_per_segment - 1] @@ -444,12 +463,12 @@ def _reset_start_values(self): comp.initial_charge_state = self._original_start_values[comp.label_full] def _calculate_timesteps_of_segment(self) -> List[pd.DatetimeIndex]: - active_timesteps_per_segment = [] + selected_timesteps_per_segment = [] for i, _ in enumerate(self.segment_names): start = self.timesteps_per_segment * i end = min(start + self.timesteps_per_segment_with_overlap, len(self.all_timesteps)) - active_timesteps_per_segment.append(self.all_timesteps[start:end]) - return active_timesteps_per_segment + selected_timesteps_per_segment.append(self.all_timesteps[start:end]) + return selected_timesteps_per_segment @property def timesteps_per_segment_with_overlap(self): diff --git a/flixopt/components.py b/flixopt/components.py index 234418694..e02f5c03a 100644 --- a/flixopt/components.py +++ b/flixopt/components.py @@ -87,8 +87,8 @@ def _plausibility_checks(self) -> None: for flow in self.flows.values(): if isinstance(flow.size, InvestParameters) and flow.size.fixed_size is None: logger.warning( - f'Piecewise_conversion (in {self.label_full}) and variable size ' - f'(in flow {flow.label_full}) do not make sense together!' + f'Using a FLow with a fixed size ({flow.label_full}) AND a piecewise_conversion ' + f'(in {self.label_full}) and variable size is uncommon. Please check if this is intended!' ) def transform_data(self, flow_system: 'FlowSystem'): diff --git a/flixopt/core.py b/flixopt/core.py index 5d24e46e4..758deb1aa 100644 --- a/flixopt/core.py +++ b/flixopt/core.py @@ -688,7 +688,7 @@ def to_json(self, path: Optional[pathlib.Path] = None) -> Dict[str, Any]: # Save to file if path is provided if path is not None: - indent = 4 if len(self.active_timesteps) <= 480 else None + indent = 4 if len(self.selected_timesteps) <= 480 else None with open(path, 'w', encoding='utf-8') as f: json.dump(data, f, indent=indent, ensure_ascii=False) @@ -718,7 +718,7 @@ def selected_data(self) -> xr.DataArray: return self._stored_data.sel(**self._valid_selector) @property - def active_timesteps(self) -> Optional[pd.DatetimeIndex]: + def selected_timesteps(self) -> Optional[pd.DatetimeIndex]: """Get the current active timesteps, or None if no time dimension.""" if not self.has_time_dim: return None @@ -749,7 +749,7 @@ def update_stored_data(self, value: xr.DataArray) -> None: """ new_data = DataConverter.as_dataarray( value, - timesteps=self.active_timesteps if self.has_time_dim else None, + timesteps=self.selected_timesteps if self.has_time_dim else None, scenarios=self.active_scenarios if self.has_scenario_dim else None, ) diff --git a/tests/test_timeseries.py b/tests/test_timeseries.py index 237935e59..bb35231a6 100644 --- a/tests/test_timeseries.py +++ b/tests/test_timeseries.py @@ -675,7 +675,7 @@ def test_selection_propagation_with_scenarios( # Clear selections sample_scenario_allocator.set_selection() assert ts1._selected_timesteps is None - assert ts1.active_timesteps.equals(sample_scenario_allocator.timesteps) + assert ts1.selected_timesteps.equals(sample_scenario_allocator.timesteps) assert ts1._selected_scenarios is None assert ts1.active_scenarios.equals(sample_scenario_allocator.scenarios) assert ts1.selected_data.shape == (5, 3) # Back to full shape From 0499497d3c7dc026b8e7348cd4af9177eea83917 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 28 Apr 2025 22:13:48 +0200 Subject: [PATCH 042/336] Bugfix in plausibility_check: Index 0 --- flixopt/components.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/flixopt/components.py b/flixopt/components.py index e02f5c03a..e4c220e05 100644 --- a/flixopt/components.py +++ b/flixopt/components.py @@ -248,9 +248,9 @@ def _plausibility_checks(self) -> None: minimum_capacity = self.capacity_in_flow_hours # initial capacity >= allowed min for maximum_size: - minimum_inital_capacity = maximum_capacity * self.relative_minimum_charge_state.isel(time=1) + minimum_inital_capacity = maximum_capacity * self.relative_minimum_charge_state.isel(time=0) # initial capacity <= allowed max for minimum_size: - maximum_inital_capacity = minimum_capacity * self.relative_maximum_charge_state.isel(time=1) + maximum_inital_capacity = minimum_capacity * self.relative_maximum_charge_state.isel(time=0) # TODO: index=1 ??? I think index 0 if (self.initial_charge_state > maximum_inital_capacity).any(): From ee005777eb8a363691cde276666c95832005b056 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 29 Apr 2025 10:32:58 +0200 Subject: [PATCH 043/336] Set bargap to 0 in stacked bars --- flixopt/plotting.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flixopt/plotting.py b/flixopt/plotting.py index 6a2170674..8537d3815 100644 --- a/flixopt/plotting.py +++ b/flixopt/plotting.py @@ -258,7 +258,7 @@ def with_plotly( fig.update_layout( barmode='relative', - bargap=0.2, # No space between bars + bargap=0, # No space between bars bargroupgap=0, # No space between grouped bars ) if style == 'grouped_bar': From a11ed92a20bacf46605d504a938f506aa12922b1 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 29 Apr 2025 11:05:03 +0200 Subject: [PATCH 044/336] Ensure the size is always properly indexed in results. --- flixopt/results.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/flixopt/results.py b/flixopt/results.py index 41c98be15..26dd9a770 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -15,7 +15,7 @@ from . import io as fx_io from . import plotting -from .core import TimeSeriesCollection +from .core import TimeSeriesCollection, DataConverter from .flow_system import FlowSystem if TYPE_CHECKING: @@ -1169,7 +1169,10 @@ def size(self) -> xr.DataArray: if name in self.solution: return self.solution[name] try: - return xr.DataArray(self._calculation_results.flow_system.flows[self.label].size).rename(name) + return DataConverter.as_dataarray( + self._calculation_results.flow_system.flows[self.label].size, + scenarios=self._calculation_results.scenarios + ).rename(name) except _FlowSystemRestorationError: logger.critical(f'Size of flow {self.label}.size not availlable. Returning NaN') return xr.DataArray(np.nan).rename(name) From 240024405e1d9500aca527727f866d5db3fe981d Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 29 Apr 2025 13:48:52 +0200 Subject: [PATCH 045/336] ruff check --- flixopt/results.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flixopt/results.py b/flixopt/results.py index 26dd9a770..f0f0b2b0e 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -15,7 +15,7 @@ from . import io as fx_io from . import plotting -from .core import TimeSeriesCollection, DataConverter +from .core import DataConverter, TimeSeriesCollection from .flow_system import FlowSystem if TYPE_CHECKING: From 0f9b30ab8fc201d681721889bb92064e46618d7b Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 5 May 2025 14:28:29 +0200 Subject: [PATCH 046/336] BUGFIX in extract data, that causes coords in linopy to be incorrect (scalar xarray.DataArrays) --- flixopt/core.py | 2 +- flixopt/effects.py | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/flixopt/core.py b/flixopt/core.py index 758deb1aa..bec472aaa 100644 --- a/flixopt/core.py +++ b/flixopt/core.py @@ -1481,5 +1481,5 @@ def extract_data( if isinstance(data, xr.DataArray): return data if isinstance(data, (int, float, np.integer, np.floating)): - return xr.DataArray(data) + return data raise TypeError(f'Unsupported data type: {type(data).__name__}') diff --git a/flixopt/effects.py b/flixopt/effects.py index 914100362..44687fffe 100644 --- a/flixopt/effects.py +++ b/flixopt/effects.py @@ -509,6 +509,10 @@ def calculate_all_conversion_paths( # Add new path to queue for further exploration queue.append((target, indirect_factor, new_path)) + # Convert all values to DataArrays + result = {key: value if isinstance(value, xr.DataArray) else xr.DataArray(value) + for key, value in result.items()} + return result From 26e89a95083ae76c88059d9c42e30a621d8749b9 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 5 May 2025 14:35:23 +0200 Subject: [PATCH 047/336] Improve yaml formatting for model documentation (#259) --- flixopt/io.py | 79 +++++++++++++++++++++++++++++++++++---------------- 1 file changed, 54 insertions(+), 25 deletions(-) diff --git a/flixopt/io.py b/flixopt/io.py index 1ef9578e5..05ae6741d 100644 --- a/flixopt/io.py +++ b/flixopt/io.py @@ -79,15 +79,17 @@ def _save_to_yaml(data, output_file='formatted_output.yaml'): output_file (str): Path to output YAML file """ # Process strings to normalize all newlines and handle special patterns - processed_data = _process_complex_strings(data) + processed_data = _normalize_complex_data(data) # Define a custom representer for strings def represent_str(dumper, data): - # Use literal block style (|) for any string with newlines + # Use literal block style (|) for multi-line strings if '\n' in data: + # Clean up formatting for literal block style + data = data.strip() # Remove leading/trailing whitespace return dumper.represent_scalar('tag:yaml.org,2002:str', data, style='|') - # Use quoted style for strings with special characters to ensure proper parsing + # Use quoted style for strings with special characters elif any(char in data for char in ':`{}[]#,&*!|>%@'): return dumper.represent_scalar('tag:yaml.org,2002:str', data, style='"') @@ -97,53 +99,80 @@ def represent_str(dumper, data): # Add the string representer to SafeDumper yaml.add_representer(str, represent_str, Dumper=yaml.SafeDumper) + # Configure dumper options for better formatting + class CustomDumper(yaml.SafeDumper): + def increase_indent(self, flow=False, indentless=False): + return super(CustomDumper, self).increase_indent(flow, False) + # Write to file with settings that ensure proper formatting with open(output_file, 'w', encoding='utf-8') as file: yaml.dump( processed_data, file, - Dumper=yaml.SafeDumper, + Dumper=CustomDumper, sort_keys=False, # Preserve dictionary order default_flow_style=False, # Use block style for mappings - width=float('inf'), # Don't wrap long lines + width=1000, # Set a reasonable line width allow_unicode=True, # Support Unicode characters + indent=2, # Set consistent indentation ) -def _process_complex_strings(data): +def _normalize_complex_data(data): """ - Process dictionary data recursively with comprehensive string normalization. - Handles various types of strings and special formatting. + Recursively normalize strings in complex data structures. + + Handles dictionaries, lists, and strings, applying various text normalization + rules while preserving important formatting elements. Args: - data: The data to process (dict, list, str, or other) + data: Any data type (dict, list, str, or primitive) Returns: - Processed data with normalized strings + Data with all strings normalized according to defined rules """ if isinstance(data, dict): - return {k: _process_complex_strings(v) for k, v in data.items()} + return {key: _normalize_complex_data(value) for key, value in data.items()} + elif isinstance(data, list): - return [_process_complex_strings(item) for item in data] + return [_normalize_complex_data(item) for item in data] + elif isinstance(data, str): - # Step 1: Normalize line endings to \n - normalized = data.replace('\r\n', '\n').replace('\r', '\n') + return _normalize_string_content(data) - # Step 2: Handle escaped newlines with robust regex - normalized = re.sub(r'(? Dict[str, str]: From c0cbaaeb88cb3100866085a7ff6d79a4321634fd Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 5 May 2025 15:04:57 +0200 Subject: [PATCH 048/336] Make the size/capacity a TimeSeries (#260) --- flixopt/components.py | 6 +++++- flixopt/elements.py | 16 ++++++++++------ 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/flixopt/components.py b/flixopt/components.py index e4c220e05..5a986ede0 100644 --- a/flixopt/components.py +++ b/flixopt/components.py @@ -128,7 +128,7 @@ def __init__( label: str, charging: Flow, discharging: Flow, - capacity_in_flow_hours: Union[Scalar, InvestParameters], + capacity_in_flow_hours: Union[ScenarioData, InvestParameters], relative_minimum_charge_state: TimestepData = 0, relative_maximum_charge_state: TimestepData = 1, initial_charge_state: Union[ScenarioData, Literal['lastValueOfSim']] = 0, @@ -226,6 +226,10 @@ def transform_data(self, flow_system: 'FlowSystem') -> None: ) if isinstance(self.capacity_in_flow_hours, InvestParameters): self.capacity_in_flow_hours.transform_data(flow_system, f'{self.label_full}|InvestParameters') + else: + self.capacity_in_flow_hours = flow_system.create_time_series( + f'{self.label_full}|capacity_in_flow_hours', self.capacity_in_flow_hours, has_time_dim=False + ) def _plausibility_checks(self) -> None: """ diff --git a/flixopt/elements.py b/flixopt/elements.py index 11246e6d9..d216c2ca7 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -154,7 +154,7 @@ def __init__( self, label: str, bus: str, - size: Union[Scalar, InvestParameters] = None, + size: Union[ScenarioData, InvestParameters] = None, fixed_relative_profile: Optional[TimestepData] = None, relative_minimum: TimestepData = 0, relative_maximum: TimestepData = 1, @@ -265,6 +265,8 @@ def transform_data(self, flow_system: 'FlowSystem'): self.on_off_parameters.transform_data(flow_system, self.label_full) if isinstance(self.size, InvestParameters): self.size.transform_data(flow_system, self.label_full) + else: + self.size = flow_system.create_time_series(f'{self.label_full}|size', self.size, has_time_dim=False) def infos(self, use_numpy: bool = True, use_element_label: bool = False) -> Dict: infos = super().infos(use_numpy, use_element_label) @@ -282,8 +284,8 @@ def _plausibility_checks(self) -> None: if np.any(self.relative_minimum > self.relative_maximum): raise PlausibilityError(self.label_full + ': Take care, that relative_minimum <= relative_maximum!') - if ( - self.size == CONFIG.modeling.BIG and self.fixed_relative_profile is not None + if not isinstance(self.size, InvestParameters) and ( + np.any(self.size == CONFIG.modeling.BIG) and self.fixed_relative_profile is not None ): # Default Size --> Most likely by accident logger.warning( f'Flow "{self.label}" has no size assigned, but a "fixed_relative_profile". ' @@ -453,10 +455,12 @@ def flow_rate_bounds_on(self) -> Tuple[TimestepData, TimestepData]: relative_minimum, relative_maximum = self.flow_rate_lower_bound_relative, self.flow_rate_upper_bound_relative size = self.element.size if not isinstance(size, InvestParameters): - return relative_minimum * size, relative_maximum * size + return relative_minimum * extract_data(size), relative_maximum * extract_data(size) if size.fixed_size is not None: - return size.fixed_size * relative_minimum, size.fixed_size * relative_maximum - return size.minimum_size * relative_minimum, size.maximum_size * relative_maximum + return (relative_minimum * extract_data(size.fixed_size), + relative_maximum * extract_data(size.fixed_size)) + return (relative_minimum * extract_data(size.minimum_size), + relative_maximum * extract_data(size.maximum_size)) @property def flow_rate_lower_bound_relative(self) -> TimestepData: From 67ebfbbe75870a3f9226f9435c00748f30cc554d Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 13 May 2025 11:55:29 +0200 Subject: [PATCH 049/336] Scenarios/plot network (#262) * Catch bug in plot_network with 2D arrays * Add plot_network() to test_io.py --- flixopt/structure.py | 2 ++ tests/test_io.py | 2 ++ 2 files changed, 4 insertions(+) diff --git a/flixopt/structure.py b/flixopt/structure.py index 7c7772dad..8d4779457 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -636,6 +636,8 @@ def describe_numpy_arrays(arr: np.ndarray) -> Union[str, List]: def normalized_center_of_mass(array: Any) -> float: # position in array (0 bis 1 normiert) + if array.ndim >= 2: # No good way to calculate center of mass for 2D arrays + return np.nan positions = np.linspace(0, 1, len(array)) # weights w_i # mass center if np.sum(array) == 0: diff --git a/tests/test_io.py b/tests/test_io.py index 2b3a03399..541e5b2a4 100644 --- a/tests/test_io.py +++ b/tests/test_io.py @@ -28,6 +28,7 @@ def test_flow_system_file_io(flow_system, highs_solver): calculation_0 = fx.FullCalculation('IO', flow_system=flow_system) calculation_0.do_modeling() calculation_0.solve(highs_solver) + calculation_0.flow_system.plot_network() calculation_0.results.to_file() paths = CalculationResultsPaths(calculation_0.folder, calculation_0.name) @@ -36,6 +37,7 @@ def test_flow_system_file_io(flow_system, highs_solver): calculation_1 = fx.FullCalculation('Loaded_IO', flow_system=flow_system_1) calculation_1.do_modeling() calculation_1.solve(highs_solver) + calculation_1.flow_system.plot_network() assert_almost_equal_numeric( calculation_0.results.model.objective.value, From 9edd1fa13da2aeb527007e1e6e80f31a75ec9b0b Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 13 May 2025 12:58:11 +0200 Subject: [PATCH 050/336] Update deploy-docs.yaml: Run on Release publishing instead of creation and only run for stable releases (vx.y.z) --- .github/workflows/deploy-docs.yaml | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/deploy-docs.yaml b/.github/workflows/deploy-docs.yaml index 73a3f0b1f..d582bb3eb 100644 --- a/.github/workflows/deploy-docs.yaml +++ b/.github/workflows/deploy-docs.yaml @@ -1,9 +1,11 @@ -name: Documentation +name: Deploy Stable Documentation on: release: - types: [created] # Automatically deploy docs on release - workflow_dispatch: # Allow manual triggering + types: [published] + tags: + # Only match stable version patterns (no pre-release identifiers) + - 'v[0-9]+.[0-9]+.[0-9]+' jobs: deploy-docs: From d3c0c48b77817495971c44da3cc10713ac0912bf Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 13 May 2025 13:42:57 +0200 Subject: [PATCH 051/336] Bugfix DataConverter and add tests (#263) --- flixopt/core.py | 8 ++++---- tests/test_dataconverter.py | 39 +++++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/flixopt/core.py b/flixopt/core.py index bec472aaa..ea447e652 100644 --- a/flixopt/core.py +++ b/flixopt/core.py @@ -255,7 +255,7 @@ def _broadcast_time_to_scenarios( return data.copy(deep=True) # Broadcast values - values = np.tile(data.values, (len(coords['scenario']), 1)).T # Tile seems to be faster than repeat() + values = np.repeat(data.values[:, np.newaxis], len(coords['scenario']), axis=1) return xr.DataArray(values.copy(), coords=coords, dims=dims) @staticmethod @@ -278,7 +278,7 @@ def _broadcast_scenario_to_time( raise ConversionError("Source scenario coordinates don't match target scenario coordinates") # Broadcast values - values = np.repeat(data.values[:, np.newaxis], len(coords['time']), axis=1) + values = np.repeat(data.values[:, np.newaxis], len(coords['time']), axis=1).T return xr.DataArray(values.copy(), coords=coords, dims=dims) @staticmethod @@ -361,7 +361,7 @@ def _convert_ndarray_two_dims(data: np.ndarray, coords: Dict[str, pd.Index], dim return xr.DataArray(values, coords=coords, dims=dims) elif data.shape[0] == scenario_length: # Broadcast across time - values = np.tile(data, (time_length, 1)) + values = np.repeat(data[np.newaxis, :], time_length, axis=0) return xr.DataArray(values, coords=coords, dims=dims) else: raise ConversionError(f"1D array length {data.shape[0]} doesn't match either dimension") @@ -414,7 +414,7 @@ def _convert_series(data: pd.Series, coords: Dict[str, pd.Index], dims: Tuple[st # Case 1: Series is indexed by time if data.index.equals(coords['time']): # Broadcast across scenarios - values = np.tile(data.values[:, np.newaxis], (1, len(coords['scenario']))) + values = np.repeat(data.values[:, np.newaxis], len(coords['scenario']), axis=1) return xr.DataArray(values.copy(), coords=coords, dims=dims) # Case 2: Series is indexed by scenario diff --git a/tests/test_dataconverter.py b/tests/test_dataconverter.py index a50754301..0484d4aac 100644 --- a/tests/test_dataconverter.py +++ b/tests/test_dataconverter.py @@ -451,6 +451,45 @@ def test_dataarray_dimension_mismatch(self, sample_time_index, sample_scenario_i with pytest.raises(ConversionError): DataConverter.as_dataarray(wrong_length, sample_time_index, sample_scenario_index) +class TestDataArrayBroadcasting: + """Tests for broadcasting DataArrays.""" + def test_broadcast_1d_array_to_2d(self, sample_time_index, sample_scenario_index): + """Test broadcasting a 1D array to all scenarios.""" + arr_1d = np.array([1, 2, 3, 4, 5]) + + xr.testing.assert_equal( + DataConverter.as_dataarray(arr_1d, sample_time_index, sample_scenario_index), + xr.DataArray( + np.array([arr_1d] * 3).T, + coords=(sample_time_index, sample_scenario_index) + ) + ) + + arr_1d = np.array([1, 2, 3]) + xr.testing.assert_equal( + DataConverter.as_dataarray(arr_1d, sample_time_index, sample_scenario_index), + xr.DataArray( + np.array([arr_1d] * 5), + coords=(sample_time_index, sample_scenario_index) + ) + ) + + def test_broadcast_1d_array_to_1d(self, sample_time_index,): + """Test broadcasting a 1D array to all scenarios.""" + arr_1d = np.array([1, 2, 3, 4, 5]) + + xr.testing.assert_equal( + DataConverter.as_dataarray(arr_1d, sample_time_index), + xr.DataArray( + arr_1d, + coords=(sample_time_index,) + ) + ) + + arr_1d = np.array([1, 2, 3]) + with pytest.raises(ConversionError): + DataConverter.as_dataarray(arr_1d, sample_time_index) + class TestEdgeCases: """Tests for edge cases and special scenarios.""" From e6e680cb66423379c089e675e536f5d028838d9a Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 13 May 2025 14:28:13 +0200 Subject: [PATCH 052/336] Fix doc deployment to not publish on non stable releases --- .github/workflows/deploy-docs.yaml | 50 +++++++++++++++++++++++++----- 1 file changed, 43 insertions(+), 7 deletions(-) diff --git a/.github/workflows/deploy-docs.yaml b/.github/workflows/deploy-docs.yaml index d582bb3eb..dfb3f3f87 100644 --- a/.github/workflows/deploy-docs.yaml +++ b/.github/workflows/deploy-docs.yaml @@ -3,15 +3,38 @@ name: Deploy Stable Documentation on: release: types: [published] - tags: - # Only match stable version patterns (no pre-release identifiers) - - 'v[0-9]+.[0-9]+.[0-9]+' jobs: + check-release: + runs-on: ubuntu-latest + outputs: + is_stable: ${{ steps.check_version.outputs.is_stable }} + version: ${{ steps.check_version.outputs.version }} + steps: + - name: Check if stable release + id: check_version + run: | + # Extract version from the tag + VERSION="${GITHUB_REF#refs/tags/v}" + echo "Raw version: $VERSION" + + # Check if version contains any pre-release identifiers using regex + if [[ $VERSION =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "is_stable=true" >> $GITHUB_OUTPUT + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "Stable version detected: $VERSION" + else + echo "is_stable=false" >> $GITHUB_OUTPUT + echo "Pre-release version detected: $VERSION" + fi + deploy-docs: + needs: check-release + if: needs.check-release.outputs.is_stable == 'true' runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - name: Checkout repository + uses: actions/checkout@v4 with: fetch-depth: 0 # Fetch all history for proper versioning @@ -20,7 +43,8 @@ jobs: git config user.name github-actions[bot] git config user.email 41898282+github-actions[bot]@users.noreply.github.com - - uses: actions/setup-python@v5 + - name: Set up Python + uses: actions/setup-python@v5 with: python-version: 3.11 @@ -31,5 +55,17 @@ jobs: - name: Deploy docs run: | - VERSION=${GITHUB_REF#refs/tags/v} - mike deploy --push --update-aliases $VERSION latest \ No newline at end of file + VERSION="${{ needs.check-release.outputs.version }}" + echo "Deploying documentation for version $VERSION" + mike deploy --push --update-aliases $VERSION latest + + - name: Verify deployment + run: | + # Simple verification that the deployment succeeded + git checkout gh-pages + if [ -d "${{ needs.check-release.outputs.version }}" ]; then + echo "Documentation successfully deployed" + else + echo "Documentation deployment failed!" + exit 1 + fi \ No newline at end of file From 3d89b74f0dcab9f9bfffac7fab2d556861528baa Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 15 May 2025 15:16:23 +0200 Subject: [PATCH 053/336] Remove unused code --- flixopt/results.py | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/flixopt/results.py b/flixopt/results.py index f0f0b2b0e..bd0abaa5e 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -202,7 +202,6 @@ def __init__( self._flow_hours = None self._sizes = None self._effects_per_component = {'operation': None, 'invest': None, 'total': None} - self._flow_network_info_ = None def __getitem__(self, key: str) -> Union['ComponentResults', 'BusResults', 'EffectResults', 'FlowResults']: if key in self.components: @@ -425,17 +424,6 @@ def _assign_flow_coords(self, da: xr.DataArray): da = da.transpose(*(existing_dims + ['flow'])) return da - def _get_flow_network_info(self) -> Dict[str, Dict[str, str]]: - flow_network_info = {} - - for flow in self.flows.values(): - flow_network_info[flow.label] = { - 'label': flow.label, - 'start': flow.start, - 'end': flow.end, - } - return flow_network_info - def get_effect_shares( self, element: str, From cc772a46528f1d7361efa38f0fae90ab2eca3842 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 16 May 2025 12:17:29 +0200 Subject: [PATCH 054/336] Remove legend placing for better auto placing in plotly --- flixopt/plotting.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/flixopt/plotting.py b/flixopt/plotting.py index 8537d3815..91fc5e7e7 100644 --- a/flixopt/plotting.py +++ b/flixopt/plotting.py @@ -348,14 +348,6 @@ def with_plotly( plot_bgcolor='rgba(0,0,0,0)', # Transparent background paper_bgcolor='rgba(0,0,0,0)', # Transparent paper background font=dict(size=14), # Increase font size for better readability - legend=dict( - orientation='h', # Horizontal legend - yanchor='bottom', - y=-0.3, # Adjusts how far below the plot it appears - xanchor='center', - x=0.5, - title_text=None, # Removes legend title for a cleaner look - ), ) return fig @@ -397,7 +389,6 @@ def with_matplotlib( - If `style` is 'stacked_bar', bars are stacked for both positive and negative values. Negative values are stacked separately without extra labels in the legend. - If `style` is 'line', stepped lines are drawn for each data series. - - The legend is placed below the plot to accommodate multiple data series. """ assert style in ['stacked_bar', 'line'], f"'style' must be one of {['stacked_bar', 'line']} for matplotlib" @@ -1137,7 +1128,6 @@ def create_pie_trace(data_series, side): paper_bgcolor='rgba(0,0,0,0)', # Transparent paper background font=dict(size=14), margin=dict(t=80, b=50, l=30, r=30), - legend=dict(orientation='h', yanchor='bottom', y=-0.2, xanchor='center', x=0.5, font=dict(size=12)), ) return fig From d92349ed19be9d0a774bdb5446fbc5d20d42ac4a Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 16 May 2025 13:12:57 +0200 Subject: [PATCH 055/336] Fix plotly dependency --- pyproject.toml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index f3b48273f..0078d11a3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,10 +38,10 @@ dependencies = [ "linopy >= 0.5.1", "netcdf4 >= 1.6.1", "rich >= 13.0.1", - "highspy >= 1.5.3", # Default solver - "pandas >= 2, < 3", # Used in post-processing + "highspy >= 1.5.3", # Default solver + "pandas >= 2, < 3", # Used in post-processing "matplotlib >= 3.5.2", # Used in post-processing - "plotly >= 5.15", # Used in post-processing + "plotly >= 5.15, < 6", # Used in post-processing "tomli >= 2.0.1" # TOML parser (only needed until python 3.11) ] From 5c2900a4290479af584bb9ca3d78474be6138752 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 19 May 2025 21:45:08 +0200 Subject: [PATCH 056/336] Improve validation when adding new effects --- flixopt/effects.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/flixopt/effects.py b/flixopt/effects.py index 44687fffe..8affc933b 100644 --- a/flixopt/effects.py +++ b/flixopt/effects.py @@ -313,7 +313,10 @@ def __contains__(self, item: Union[str, 'Effect']) -> bool: if isinstance(item, str): return item in self.effects # Check if the label exists elif isinstance(item, Effect): - return item in self.effects.values() # Check if the object exists + if item.label_full in self.effects: + return True + if item in self.effects.values(): # Check if the object exists + return True return False @property From 8d3bbe920296928086f9a098cb52698cbcbde886 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 14 Jun 2025 17:35:02 +0200 Subject: [PATCH 057/336] Moved release notes to CHANGELOG.md --- CHANGELOG.md | 27 ++++++++++++++++++ docs/release-notes/v2.2.0.md | 55 ------------------------------------ 2 files changed, 27 insertions(+), 55 deletions(-) delete mode 100644 docs/release-notes/v2.2.0.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d692d5e5..ff01fa498 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## What's New + +### Scenarios +Scenarios are a new feature of flixopt. They can be used to model uncertainties in the flow system, such as: +* Different demand profiles +* Different price forecasts +* Different weather conditions +They might also be used to model an evolving system with multiple investment periods. Each **scenario** might be a new year, a new month, or a new day, with a different set of investment decisions to take. + +The weighted sum of the total objective effect of each scenario is used as the objective of the optimization. + +#### Investments and scenarios +Scenarios allow for more flexibility in investment decisions. +You can decide to allow different investment decisions for each scenario, or to allow a single investment decision for a subset of all scenarios, while not allowing for an invest in others. +This enables the following use cases: +* Find the best investment decision for each scenario individually +* Find the best overall investment decision for possible scenarios (robust decision-making) +* Find the best overall investment decision for a subset of all scenarios + +The last one might be useful if you want to model a system with multiple investment periods, where one investment decision is made for more than one scenario. +This might occur when scenarios represent years or months, while an investment decision influences the system for multiple years or months. + + +### Other new features +* Balanced storage - Storage charging and discharging sizes can now be forced to be equal in when optimizing their size. +* Feature 2 - Description + ## [2.1.2] - 2025-06-14 ### Fixed diff --git a/docs/release-notes/v2.2.0.md b/docs/release-notes/v2.2.0.md deleted file mode 100644 index 3cf7eef8d..000000000 --- a/docs/release-notes/v2.2.0.md +++ /dev/null @@ -1,55 +0,0 @@ -# Release v2.2.0 - -**Release Date:** YYYY-MM-DD - -## What's New - -### Scenarios -Scenarios are a new feature of flixopt. They can be used to model uncertainties in the flow system, such as: -* Different demand profiles -* Different price forecasts -* Different weather conditions -* Different climate conditions -The might also be used to model an evolving system with multiple investment periods. Each **scenario** might be a new year, a new month, or a new day, with a different set of investment decisions to take. - -The weighted sum of the total objective effect of each scenario is used as the objective of the optimization. - -#### Investments and scenarios -Scenarios allow for more flexibility in investment decisions. -You can decide to allow different investment decisions for each scenario, or to allow a single investment decision for a subset of all scnarios, while not allowing for an invest in others. -This enables the following use cases: -* Find the best investment decision for each scenario individually -* Find the best overall investment decision for possible scenarios (robust decision-making) -* Find the best overall investment decision for a subset of all scenarios - -The last one might be useful if you want to model a system with multiple investment periods, where one investment decision is made for more than one scenario. -This might occur when scenarios represent years or months, while an investment decision influences the system for multiple years or months. - - -## Other new features -* Balanced storage - Storage charging and discharging sizes can now be forced to be equal in when optimizing their size. -* Feature 2 - Description - -## Improvements - -* Improvement 1 - Description -* Improvement 2 - Description - -## Bug Fixes - -* Fixed issue with X -* Resolved problem with Y - -## Breaking Changes - -* Change 1 - Migration instructions -* Change 2 - Migration instructions - -## Deprecations - -* Feature X will be removed in v{next_version} - -## Dependencies - -* Added dependency X v1.2.3 -* Updated dependency Y to v2.0.0 \ No newline at end of file From be6572de5552c31c94b0ed9b9d7777eea27cc4aa Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 23 Jun 2025 14:35:49 +0200 Subject: [PATCH 058/336] Try to add to_dataset to Elements --- flixopt/flow_system.py | 521 +++++++++++++++++++++++++++-------------- flixopt/structure.py | 381 +++++++++++++++++++++++------- 2 files changed, 647 insertions(+), 255 deletions(-) diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index 93720de60..8887a6eae 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -30,6 +30,7 @@ class FlowSystem: """ A FlowSystem organizes the high level Elements (Components & Effects). + Uses xr.Dataset directly from its Interface elements instead of TimeSeriesCollection. """ def __init__( @@ -47,13 +48,15 @@ def __init__( This is needed to calculate previous durations (for example consecutive_on_hours). If you use an array, take care that its long enough to cover all previous values! """ - self.time_series_collection = TimeSeriesCollection( - timesteps=timesteps, - hours_of_last_timestep=hours_of_last_timestep, - hours_of_previous_timesteps=hours_of_previous_timesteps, + # Store timing information directly + self.timesteps = self._validate_timesteps(timesteps) + self.timesteps_extra = self._create_timesteps_with_extra(timesteps, hours_of_last_timestep) + self.hours_per_timestep = self._calculate_hours_per_timestep(self.timesteps_extra) + self.hours_of_previous_timesteps = self._calculate_hours_of_previous_timesteps( + timesteps, hours_of_previous_timesteps ) - # defaults: + # Element collections self.components: Dict[str, Component] = {} self.buses: Dict[str, Bus] = {} self.effects: EffectCollection = EffectCollection() @@ -61,60 +64,373 @@ def __init__( self._connected = False + @staticmethod + def _validate_timesteps(timesteps: pd.DatetimeIndex) -> pd.DatetimeIndex: + """Validate timesteps format and rename if needed.""" + if not isinstance(timesteps, pd.DatetimeIndex): + raise TypeError('timesteps must be a pandas DatetimeIndex') + if len(timesteps) < 2: + raise ValueError('timesteps must contain at least 2 timestamps') + if timesteps.name != 'time': + timesteps.name = 'time' + if not timesteps.is_monotonic_increasing: + raise ValueError('timesteps must be sorted') + return timesteps + + @staticmethod + def _create_timesteps_with_extra( + timesteps: pd.DatetimeIndex, hours_of_last_timestep: Optional[float] + ) -> pd.DatetimeIndex: + """Create timesteps with an extra step at the end.""" + if hours_of_last_timestep is None: + hours_of_last_timestep = (timesteps[-1] - timesteps[-2]) / pd.Timedelta(hours=1) + + last_date = pd.DatetimeIndex([timesteps[-1] + pd.Timedelta(hours=hours_of_last_timestep)], name='time') + return pd.DatetimeIndex(timesteps.append(last_date), name='time') + + @staticmethod + def _calculate_hours_per_timestep(timesteps_extra: pd.DatetimeIndex) -> xr.DataArray: + """Calculate duration of each timestep.""" + hours_per_step = np.diff(timesteps_extra) / pd.Timedelta(hours=1) + return xr.DataArray( + hours_per_step, coords={'time': timesteps_extra[:-1]}, dims=['time'], name='hours_per_timestep' + ) + + @staticmethod + def _calculate_hours_of_previous_timesteps( + timesteps: pd.DatetimeIndex, hours_of_previous_timesteps: Optional[Union[float, np.ndarray]] + ) -> Union[float, np.ndarray]: + """Calculate duration of regular timesteps.""" + if hours_of_previous_timesteps is not None: + return hours_of_previous_timesteps + # Calculate from the first interval + first_interval = timesteps[1] - timesteps[0] + return first_interval.total_seconds() / 3600 # Convert to hours + + def _create_reference_structure(self) -> Tuple[Dict, Dict[str, xr.DataArray]]: + """ + Create reference structure for FlowSystem following the Interface pattern. + Extracts all DataArrays from components, buses, and effects. + + Returns: + Tuple of (reference_structure, extracted_arrays_dict) + """ + reference_structure = { + '__class__': self.__class__.__name__, + 'timesteps_extra': [date.isoformat() for date in self.timesteps_extra], + 'hours_of_previous_timesteps': self.hours_of_previous_timesteps, + } + + all_extracted_arrays = {} + + # Add timing arrays directly + all_extracted_arrays['hours_per_timestep'] = self.hours_per_timestep + + # Extract from components + components_structure = {} + for comp_label, component in self.components.items(): + comp_structure, comp_arrays = self._extract_from_interface(component) + all_extracted_arrays.update(comp_arrays) + components_structure[comp_label] = comp_structure + reference_structure['components'] = components_structure + + # Extract from buses + buses_structure = {} + for bus_label, bus in self.buses.items(): + bus_structure, bus_arrays = self._extract_from_interface(bus) + all_extracted_arrays.update(bus_arrays) + buses_structure[bus_label] = bus_structure + reference_structure['buses'] = buses_structure + + # Extract from effects + effects_structure = {} + for effect in self.effects: + effect_structure, effect_arrays = self._extract_from_interface(effect) + all_extracted_arrays.update(effect_arrays) + effects_structure[effect.label] = effect_structure + reference_structure['effects'] = effects_structure + + return reference_structure, all_extracted_arrays + + def _extract_from_interface(self, interface_obj) -> Tuple[Dict, Dict[str, xr.DataArray]]: + """Extract arrays from an Interface object using its reference system.""" + if hasattr(interface_obj, '_create_reference_structure'): + return interface_obj._create_reference_structure() + else: + # Fallback for objects that don't have the new Interface methods + logger.warning(f"Object {interface_obj} doesn't have _create_reference_structure method") + return interface_obj.to_dict(), {} + + @classmethod + def _resolve_reference_structure(cls, structure, arrays_dict: Dict[str, xr.DataArray]): + """ + Resolve reference structure back to actual objects. + Reuses the Interface pattern for consistency. + """ + if isinstance(structure, str) and structure.startswith(':::'): + # This is a reference to a DataArray + array_name = structure[3:] # Remove ":::" prefix + if array_name in arrays_dict: + return arrays_dict[array_name] + else: + logger.critical(f"Referenced DataArray '{array_name}' not found in dataset") + return None + + elif isinstance(structure, list): + resolved_list = [] + for item in structure: + resolved_item = cls._resolve_reference_structure(item, arrays_dict) + if resolved_item is not None: + resolved_list.append(resolved_item) + return resolved_list + + elif isinstance(structure, dict): + # Check if this is a serialized Interface object + if structure.get('__class__') and structure['__class__'] in CLASS_REGISTRY: + # This is a nested Interface object - restore it recursively + nested_class = CLASS_REGISTRY[structure['__class__']] + # Remove the __class__ key and process the rest + nested_data = {k: v for k, v in structure.items() if k != '__class__'} + # Resolve references in the nested data + resolved_nested_data = cls._resolve_reference_structure(nested_data, arrays_dict) + # Create the nested Interface object + return nested_class(**resolved_nested_data) + else: + # Regular dictionary - resolve references in values + resolved_dict = {} + for key, value in structure.items(): + resolved_value = cls._resolve_reference_structure(value, arrays_dict) + if resolved_value is not None or value is None: + resolved_dict[key] = resolved_value + return resolved_dict + + else: + return structure + + def to_dataset(self, constants_in_dataset: bool = True) -> xr.Dataset: + """ + Convert the FlowSystem to an xarray Dataset using the Interface pattern. + All DataArrays become dataset variables, structure goes to attrs. + + Args: + constants_in_dataset: If True, constants are included as Dataset variables. + + Returns: + xr.Dataset: Dataset containing all DataArrays with structure in attributes + """ + reference_structure, extracted_arrays = self._create_reference_structure() + + # Create the dataset with extracted arrays as variables and structure as attrs + ds = xr.Dataset(extracted_arrays, attrs=reference_structure) + return ds + + def as_dict(self, data_mode: Literal['data', 'name', 'stats'] = 'data') -> Dict: + """ + Convert the object to a dictionary representation. + Now builds on the reference structure for consistency. + """ + reference_structure, _ = self._create_reference_structure() + + if data_mode == 'data': + return reference_structure + elif data_mode == 'stats': + # For stats mode, we might want to process the structure further + return fx_io.remove_none_and_empty(reference_structure) + else: # name mode + return reference_structure + @classmethod - def from_dataset(cls, ds: xr.Dataset): - timesteps_extra = pd.DatetimeIndex(ds.attrs['timesteps_extra'], name='time') - hours_of_last_timestep = TimeSeriesCollection.calculate_hours_per_timestep(timesteps_extra).isel(time=-1).item() + def from_dataset(cls, ds: xr.Dataset) -> 'FlowSystem': + """ + Create a FlowSystem from an xarray Dataset using the Interface pattern. - flow_system = FlowSystem( + Args: + ds: Dataset containing the FlowSystem data + + Returns: + FlowSystem instance + """ + # Get the reference structure from attrs + reference_structure = dict(ds.attrs) + + # Extract FlowSystem constructor parameters + timesteps_extra = pd.DatetimeIndex(reference_structure['timesteps_extra'], name='time') + hours_of_previous_timesteps = reference_structure['hours_of_previous_timesteps'] + + # Calculate hours_of_last_timestep from the timesteps + hours_of_last_timestep = float((timesteps_extra[-1] - timesteps_extra[-2]) / pd.Timedelta(hours=1)) + + # Create FlowSystem instance + flow_system = cls( timesteps=timesteps_extra[:-1], hours_of_last_timestep=hours_of_last_timestep, - hours_of_previous_timesteps=ds.attrs['hours_of_previous_timesteps'], + hours_of_previous_timesteps=hours_of_previous_timesteps, ) - structure = fx_io.insert_dataarray({key: ds.attrs[key] for key in ['components', 'buses', 'effects']}, ds) - flow_system.add_elements( - *[Bus.from_dict(bus) for bus in structure['buses'].values()] - + [Effect.from_dict(effect) for effect in structure['effects'].values()] - + [CLASS_REGISTRY[comp['__class__']].from_dict(comp) for comp in structure['components'].values()] - ) + # Create arrays dictionary from dataset variables + arrays_dict = {name: array for name, array in ds.data_vars.items()} + + # Restore components + components_structure = reference_structure.get('components', {}) + for comp_label, comp_data in components_structure.items(): + component = cls._resolve_reference_structure(comp_data, arrays_dict) + if not isinstance(component, Component): + logger.critical(f'Restoring component {comp_label} failed.') + flow_system._add_components(component) + + # Restore buses + buses_structure = reference_structure.get('buses', {}) + for bus_label, bus_data in buses_structure.items(): + bus = cls._resolve_reference_structure(bus_data, arrays_dict) + if not isinstance(bus, Bus): + logger.critical(f'Restoring component {bus_label} failed.') + flow_system._add_buses(bus) + + # Restore effects + effects_structure = reference_structure.get('effects', {}) + for effect_label, effect_data in effects_structure.items(): + effect = cls._resolve_reference_structure(effect_data, arrays_dict) + + if not isinstance(effect, Effect): + logger.critical(f'Restoring component {effect_label} failed.') + flow_system._add_effects(effect) + return flow_system @classmethod def from_dict(cls, data: Dict) -> 'FlowSystem': """ - Load a FlowSystem from a dictionary. + Load a FlowSystem from a dictionary using the Interface pattern. Args: data: Dictionary containing the FlowSystem data. """ - timesteps_extra = pd.DatetimeIndex(data['timesteps_extra'], name='time') - hours_of_last_timestep = TimeSeriesCollection.calculate_hours_per_timestep(timesteps_extra).isel(time=-1).item() + # For dict format, resolve with empty arrays (references may not be used) + resolved_data = cls._resolve_reference_structure(data, {}) - flow_system = FlowSystem( + # Extract constructor parameters + timesteps_extra = pd.DatetimeIndex(resolved_data['timesteps_extra'], name='time') + hours_of_last_timestep = float((timesteps_extra[-1] - timesteps_extra[-2]) / pd.Timedelta(hours=1)) + + flow_system = cls( timesteps=timesteps_extra[:-1], hours_of_last_timestep=hours_of_last_timestep, - hours_of_previous_timesteps=data['hours_of_previous_timesteps'], + hours_of_previous_timesteps=resolved_data['hours_of_previous_timesteps'], ) - flow_system.add_elements(*[Bus.from_dict(bus) for bus in data['buses'].values()]) + # Add elements using resolved data + for bus_data in resolved_data.get('buses', {}).values(): + bus = Bus.from_dict(bus_data) + flow_system.add_elements(bus) - flow_system.add_elements(*[Effect.from_dict(effect) for effect in data['effects'].values()]) + for effect_data in resolved_data.get('effects', {}).values(): + effect = Effect.from_dict(effect_data) + flow_system.add_elements(effect) - flow_system.add_elements( - *[CLASS_REGISTRY[comp['__class__']].from_dict(comp) for comp in data['components'].values()] - ) + for comp_data in resolved_data.get('components', {}).values(): + component = CLASS_REGISTRY[comp_data['__class__']].from_dict(comp_data) + flow_system.add_elements(component) flow_system.transform_data() - return flow_system @classmethod - def from_netcdf(cls, path: Union[str, pathlib.Path]): + def from_netcdf(cls, path: Union[str, pathlib.Path]) -> 'FlowSystem': + """ + Load a FlowSystem from a netcdf file using the Interface pattern. + """ + ds = fx_io.load_dataset_from_netcdf(path) + return cls.from_dataset(ds) + + def to_netcdf(self, path: Union[str, pathlib.Path], compression: int = 0, constants_in_dataset: bool = True): + """ + Save the FlowSystem to a NetCDF file using the Interface pattern. + + Args: + path: The path to the netCDF file. + compression: The compression level to use when saving the file. + constants_in_dataset: If True, constants are included as Dataset variables. + """ + ds = self.to_dataset(constants_in_dataset=constants_in_dataset) + fx_io.save_dataset_to_netcdf(ds, path, compression=compression) + logger.info(f'Saved FlowSystem to {path}') + + def to_json(self, path: Union[str, pathlib.Path]): + """ + Save the flow system to a JSON file using the Interface pattern. + This is meant for documentation and comparison, not for reloading. + + Args: + path: The path to the JSON file. + """ + # Use the stats mode for JSON export (cleaner output) + data = get_compact_representation(self.as_dict('stats')) + with open(path, 'w', encoding='utf-8') as f: + json.dump(data, f, indent=4, ensure_ascii=False) + + def create_time_series( + self, + name: str, + data: Optional[Union[NumericData, TimeSeriesData, TimeSeries]], + needs_extra_timestep: bool = False, + ) -> Optional[TimeSeries]: + """ + Create a TimeSeries-like object (now just an xr.DataArray with proper coordinates). + This method is kept for API compatibility but simplified. + + Args: + name: Name of the time series + data: Data to convert + needs_extra_timestep: Whether to use timesteps_extra + + Returns: + xr.DataArray with proper time coordinates + """ + if data is None: + return None + + # Choose appropriate timesteps + target_timesteps = self.timesteps_extra if needs_extra_timestep else self.timesteps + + if isinstance(data, TimeSeries): + # Extract the data and rename + return data.selected_data.rename(name) + elif isinstance(data, TimeSeriesData): + # Convert TimeSeriesData to DataArray + from .core import DataConverter # Assuming this exists + + return DataConverter.as_dataarray(data.data, timesteps=target_timesteps).rename(name) + else: + # Convert other data types to DataArray + from .core import DataConverter # Assuming this exists + + return DataConverter.as_dataarray(data, timesteps=target_timesteps).rename(name) + + def create_effect_time_series( + self, + label_prefix: Optional[str], + effect_values: EffectValuesUser, + label_suffix: Optional[str] = None, + ) -> Optional[Dict[str, xr.DataArray]]: """ - Load a FlowSystem from a netcdf file + Transform EffectValues to effect DataArrays. + Simplified version that returns DataArrays directly. """ - return cls.from_dataset(fx_io.load_dataset_from_netcdf(path)) + effect_values_dict: Optional[EffectValuesDict] = self.effects.create_effect_values_dict(effect_values) + if effect_values_dict is None: + return None + + return { + effect: self.create_time_series('|'.join(filter(None, [label_prefix, effect, label_suffix])), value) + for effect, value in effect_values_dict.items() + } + + def transform_data(self): + """Transform data for all elements using the new simplified approach.""" + if not self._connected: + self._connect_network() + for element in self.all_elements.values(): + element.transform_data(self) def add_elements(self, *elements: Element) -> None: """ @@ -142,63 +458,11 @@ def add_elements(self, *elements: Element) -> None: f'Tried to add incompatible object to FlowSystem: {type(new_element)=}: {new_element=} ' ) - def to_json(self, path: Union[str, pathlib.Path]): - """ - Saves the flow system to a json file. - This not meant to be reloaded and recreate the object, - but rather used to document or compare the flow_system to others. - - Args: - path: The path to the json file. - """ - with open(path, 'w', encoding='utf-8') as f: - json.dump(self.as_dict('stats'), f, indent=4, ensure_ascii=False) - - def as_dict(self, data_mode: Literal['data', 'name', 'stats'] = 'data') -> Dict: - """Convert the object to a dictionary representation.""" - data = { - 'components': { - comp.label: comp.to_dict() - for comp in sorted(self.components.values(), key=lambda component: component.label.upper()) - }, - 'buses': { - bus.label: bus.to_dict() for bus in sorted(self.buses.values(), key=lambda bus: bus.label.upper()) - }, - 'effects': { - effect.label: effect.to_dict() - for effect in sorted(self.effects, key=lambda effect: effect.label.upper()) - }, - 'timesteps_extra': [date.isoformat() for date in self.time_series_collection.timesteps_extra], - 'hours_of_previous_timesteps': self.time_series_collection.hours_of_previous_timesteps, - } - if data_mode == 'data': - return fx_io.replace_timeseries(data, 'data') - elif data_mode == 'stats': - return fx_io.remove_none_and_empty(fx_io.replace_timeseries(data, data_mode)) - return fx_io.replace_timeseries(data, data_mode) - - def as_dataset(self, constants_in_dataset: bool = False) -> xr.Dataset: - """ - Convert the FlowSystem to a xarray Dataset. - - Args: - constants_in_dataset: If True, constants are included as Dataset variables. - """ - ds = self.time_series_collection.to_dataset(include_constants=constants_in_dataset) - ds.attrs = self.as_dict(data_mode='name') - return ds - - def to_netcdf(self, path: Union[str, pathlib.Path], compression: int = 0, constants_in_dataset: bool = True): - """ - Saves the FlowSystem to a netCDF file. - Args: - path: The path to the netCDF file. - compression: The compression level to use when saving the file. - constants_in_dataset: If True, constants are included as Dataset variables. - """ - ds = self.as_dataset(constants_in_dataset=constants_in_dataset) - fx_io.save_dataset_to_netcdf(ds, path, compression=compression) - logger.info(f'Saved FlowSystem to {path}') + def create_model(self) -> SystemModel: + if not self._connected: + raise RuntimeError('FlowSystem is not connected. Call FlowSystem.connect() first.') + self.model = SystemModel(self) + return self.model def plot_network( self, @@ -213,28 +477,6 @@ def plot_network( ) -> Optional['pyvis.network.Network']: """ Visualizes the network structure of a FlowSystem using PyVis, saving it as an interactive HTML file. - - Args: - path: Path to save the HTML visualization. - - `False`: Visualization is created but not saved. - - `str` or `Path`: Specifies file path (default: 'flow_system.html'). - controls: UI controls to add to the visualization. - - `True`: Enables all available controls. - - `List`: Specify controls, e.g., ['nodes', 'layout']. - - Options: 'nodes', 'edges', 'layout', 'interaction', 'manipulation', 'physics', 'selection', 'renderer'. - show: Whether to open the visualization in the web browser. - - Returns: - - Optional[pyvis.network.Network]: The `Network` instance representing the visualization, or `None` if `pyvis` is not installed. - - Examples: - >>> flow_system.plot_network() - >>> flow_system.plot_network(show=False) - >>> flow_system.plot_network(path='output/custom_network.html', controls=['nodes', 'layout']) - - Notes: - - This function requires `pyvis`. If not installed, the function prints a warning and returns `None`. - - Nodes are styled based on type (e.g., circles for buses, boxes for components) and annotated with node information. """ from . import plotting @@ -265,67 +507,6 @@ def network_infos(self) -> Tuple[Dict[str, Dict[str, str]], Dict[str, Dict[str, return nodes, edges - def transform_data(self): - if not self._connected: - self._connect_network() - for element in self.all_elements.values(): - element.transform_data(self) - - def create_time_series( - self, - name: str, - data: Optional[Union[NumericData, TimeSeriesData, TimeSeries]], - needs_extra_timestep: bool = False, - ) -> Optional[TimeSeries]: - """ - Tries to create a TimeSeries from NumericData Data and adds it to the time_series_collection - If the data already is a TimeSeries, nothing happens and the TimeSeries gets reset and returned - If the data is a TimeSeriesData, it is converted to a TimeSeries, and the aggregation weights are applied. - If the data is None, nothing happens. - """ - - if data is None: - return None - elif isinstance(data, TimeSeries): - data.restore_data() - if data in self.time_series_collection: - return data - return self.time_series_collection.create_time_series( - data=data.active_data, name=name, needs_extra_timestep=needs_extra_timestep - ) - return self.time_series_collection.create_time_series( - data=data, name=name, needs_extra_timestep=needs_extra_timestep - ) - - def create_effect_time_series( - self, - label_prefix: Optional[str], - effect_values: EffectValuesUser, - label_suffix: Optional[str] = None, - ) -> Optional[EffectTimeSeries]: - """ - Transform EffectValues to EffectTimeSeries. - Creates a TimeSeries for each key in the nested_values dictionary, using the value as the data. - - The resulting label of the TimeSeries is the label of the parent_element, - followed by the label of the Effect in the nested_values and the label_suffix. - If the key in the EffectValues is None, the alias 'Standard_Effect' is used - """ - effect_values: Optional[EffectValuesDict] = self.effects.create_effect_values_dict(effect_values) - if effect_values is None: - return None - - return { - effect: self.create_time_series('|'.join(filter(None, [label_prefix, effect, label_suffix])), value) - for effect, value in effect_values.items() - } - - def create_model(self) -> SystemModel: - if not self._connected: - raise RuntimeError('FlowSystem is not connected. Call FlowSystem.connect() first.') - self.model = SystemModel(self) - return self.model - def _check_if_element_is_unique(self, element: Element) -> None: """ checks if element or label of element already exists in list diff --git a/flixopt/structure.py b/flixopt/structure.py index 1d0f2324f..b9dbd889c 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -116,130 +116,341 @@ def transform_data(self, flow_system: 'FlowSystem'): """Transforms the data of the interface to match the FlowSystem's dimensions""" raise NotImplementedError('Every Interface needs a transform_data() method') + def _create_reference_structure(self) -> Tuple[Dict, Dict[str, xr.DataArray]]: + """ + Convert all DataArrays/TimeSeries to references and extract them. + This is the core method that both to_dict() and to_dataset() build upon. + + Returns: + Tuple of (reference_structure, extracted_arrays_dict) + """ + # Get constructor parameters + init_params = inspect.signature(self.__init__).parameters + + # Process all constructor parameters + reference_structure = {'__class__': self.__class__.__name__} + all_extracted_arrays = {} + + for name in init_params: + if name == 'self': + continue + + value = getattr(self, name, None) + if value is None: + continue + + # Extract arrays and get reference structure + processed_value, extracted_arrays = self._extract_dataarrays_recursive(value) + + # Add extracted arrays to the collection + all_extracted_arrays.update(extracted_arrays) + + # Only store in structure if it's not None/empty after processing + if processed_value is not None and not (isinstance(processed_value, (dict, list)) and not processed_value): + reference_structure[name] = processed_value + + return reference_structure, all_extracted_arrays + + def _extract_dataarrays_recursive(self, obj) -> Tuple[Any, Dict[str, xr.DataArray]]: + """ + Recursively extract DataArrays/TimeSeries from nested structures. + + Args: + obj: Object to process + + Returns: + Tuple of (processed_object_with_references, extracted_arrays_dict) + """ + extracted_arrays = {} + + # Handle TimeSeries objects - extract their data using their unique name + if isinstance(obj, TimeSeries): + data_array = obj.active_data.rename(obj.name) + extracted_arrays[obj.name] = data_array + return f':::{obj.name}', extracted_arrays + + # Handle DataArrays directly - use their unique name + elif isinstance(obj, xr.DataArray): + if not obj.name: + raise ValueError('DataArray must have a unique name for serialization') + extracted_arrays[obj.name] = obj + return f':::{obj.name}', extracted_arrays + + # Handle Interface objects - extract their DataArrays too + elif isinstance(obj, Interface): + # Get the Interface's reference structure and arrays + interface_structure, interface_arrays = obj._create_reference_structure() + + # Add all extracted arrays from the nested Interface + extracted_arrays.update(interface_arrays) + + return interface_structure, extracted_arrays + + # Handle lists + elif isinstance(obj, list): + processed_list = [] + for item in obj: + processed_item, nested_arrays = self._extract_dataarrays_recursive(item) + extracted_arrays.update(nested_arrays) + processed_list.append(processed_item) + return processed_list, extracted_arrays + + # Handle dictionaries + elif isinstance(obj, dict): + processed_dict = {} + for key, value in obj.items(): + processed_value, nested_arrays = self._extract_dataarrays_recursive(value) + extracted_arrays.update(nested_arrays) + processed_dict[key] = processed_value + return processed_dict, extracted_arrays + + # Handle tuples (convert to list for JSON compatibility) + elif isinstance(obj, tuple): + processed_list = [] + for item in obj: + processed_item, nested_arrays = self._extract_dataarrays_recursive(item) + extracted_arrays.update(nested_arrays) + processed_list.append(processed_item) + return processed_list, extracted_arrays + + # For all other types, serialize to basic types + else: + return self._serialize_to_basic_types(obj), extracted_arrays + + @classmethod + def _resolve_reference_structure(cls, structure, arrays_dict: Dict[str, xr.DataArray]): + """ + Convert reference structure back to actual objects using provided arrays. + + Args: + structure: Structure containing references (:::name) + arrays_dict: Dictionary of available DataArrays + + Returns: + Structure with references resolved to actual DataArrays + """ + if isinstance(structure, str) and structure.startswith(':::'): + # This is a reference to a DataArray + array_name = structure[3:] # Remove ":::" prefix + if array_name in arrays_dict: + return arrays_dict[array_name] + else: + logger.critical(f"Referenced DataArray '{array_name}' not found in dataset") + return None + + elif isinstance(structure, list): + resolved_list = [] + for item in structure: + resolved_item = cls._resolve_reference_structure(item, arrays_dict) + if resolved_item is not None: # Filter out None values from missing references + resolved_list.append(resolved_item) + return resolved_list + + elif isinstance(structure, dict): + # Check if this is a serialized Interface object + if structure.get('__class__') and structure['__class__'] in CLASS_REGISTRY: + # This is a nested Interface object - restore it recursively + nested_class = CLASS_REGISTRY[structure['__class__']] + # Remove the __class__ key and process the rest + nested_data = {k: v for k, v in structure.items() if k != '__class__'} + # Resolve references in the nested data + resolved_nested_data = cls._resolve_reference_structure(nested_data, arrays_dict) + # Create the nested Interface object + return nested_class(**resolved_nested_data) + else: + # Regular dictionary - resolve references in values + resolved_dict = {} + for key, value in structure.items(): + resolved_value = cls._resolve_reference_structure(value, arrays_dict) + if resolved_value is not None or value is None: # Keep None values if they were originally None + resolved_dict[key] = resolved_value + return resolved_dict + + else: + return structure + + def _serialize_to_basic_types(self, obj): + """Convert object to basic Python types only (no DataArrays, no custom objects).""" + if obj is None or isinstance(obj, (str, int, float, bool)): + return obj + elif isinstance(obj, np.integer): + return int(obj) + elif isinstance(obj, np.floating): + return float(obj) + elif isinstance(obj, (np.ndarray, pd.Series, pd.DataFrame)): + return obj.tolist() if hasattr(obj, 'tolist') else list(obj) + elif isinstance(obj, dict): + return {k: self._serialize_to_basic_types(v) for k, v in obj.items()} + elif isinstance(obj, (list, tuple)): + return [self._serialize_to_basic_types(item) for item in obj] + elif hasattr(obj, 'isoformat'): # datetime objects + return obj.isoformat() + else: + # For any other object, try to convert to string as fallback + logger.warning(f'Converting unknown type {type(obj)} to string: {obj}') + return str(obj) + + def to_dataset(self) -> xr.Dataset: + """ + Convert the object to an xarray Dataset representation. + All DataArrays and TimeSeries become dataset variables, everything else goes to attrs. + + Returns: + xr.Dataset: Dataset containing all DataArrays with basic objects only in attributes + """ + reference_structure, extracted_arrays = self._create_reference_structure() + + # Create the dataset with extracted arrays as variables and structure as attrs + ds = xr.Dataset(extracted_arrays, attrs=reference_structure) + return ds + + def to_dict(self) -> Dict: + """ + Convert the object to a dictionary representation. + DataArrays/TimeSeries are converted to references, but structure is preserved. + + Returns: + Dict: Dictionary with references to DataArrays/TimeSeries + """ + reference_structure, _ = self._create_reference_structure() + return reference_structure + def infos(self, use_numpy: bool = True, use_element_label: bool = False) -> Dict: """ Generate a dictionary representation of the object's constructor arguments. - Excludes default values and empty dictionaries and lists. - Converts data to be compatible with JSON. + Built on top of dataset creation for better consistency and analytics capabilities. Args: use_numpy: Whether to convert NumPy arrays to lists. Defaults to True. - If True, numeric numpy arrays (`np.ndarray`) are preserved as-is. + If True, numeric numpy arrays are preserved as-is. If False, they are converted to lists. - use_element_label: Whether to use the element label instead of the infos of the element. Defaults to False. - Note that Elements used as keys in dictionaries are always converted to their labels. + use_element_label: Whether to use element labels instead of full infos for nested objects. Returns: - A dictionary representation of the object's constructor arguments. - + A dictionary representation optimized for documentation and analysis. """ - # Get the constructor arguments and their default values - init_params = sorted( - inspect.signature(self.__init__).parameters.items(), - key=lambda x: (x[0].lower() != 'label', x[0].lower()), # Prioritize 'label' - ) - # Build a dict of attribute=value pairs, excluding defaults - details = {'class': ':'.join([cls.__name__ for cls in self.__class__.__mro__])} - for name, param in init_params: - if name == 'self': - continue - value, default = getattr(self, name, None), param.default - # Ignore default values and empty dicts and list - if np.all(value == default) or (isinstance(value, (dict, list)) and not value): - continue - details[name] = copy_and_convert_datatypes(value, use_numpy, use_element_label) - return details + # Get the core dataset representation + ds = self.to_dataset() + + # Start with the reference structure from attrs + info_dict = dict(ds.attrs) + + # Process DataArrays in the dataset based on preferences + for var_name, data_array in ds.data_vars.items(): + if use_numpy: + # Keep as DataArray/numpy for analysis + info_dict[f'_data_{var_name}'] = data_array + else: + # Convert to lists for JSON compatibility + info_dict[f'_data_{var_name}'] = data_array.values.tolist() + + # Apply element label preference to nested structures + if use_element_label: + info_dict = self._apply_element_label_preference(info_dict) + + return info_dict + + def _apply_element_label_preference(self, obj): + """Apply element label preference to nested structures.""" + if isinstance(obj, dict): + if obj.get('__class__') and 'label' in obj: + # This looks like an Interface with a label - return just the label + return obj.get('label', obj.get('__class__')) + else: + return {k: self._apply_element_label_preference(v) for k, v in obj.items()} + elif isinstance(obj, list): + return [self._apply_element_label_preference(item) for item in obj] + else: + return obj def to_json(self, path: Union[str, pathlib.Path]): """ - Saves the element to a json file. - This not meant to be reloaded and recreate the object, but rather used to document or compare the object. + Save the element to a JSON file for documentation purposes. + Uses the infos() method for consistent representation. Args: - path: The path to the json file. + path: The path to the JSON file. """ - data = get_compact_representation(self.infos(use_numpy=True, use_element_label=True)) + data = get_compact_representation(self.infos(use_numpy=False, use_element_label=True)) with open(path, 'w', encoding='utf-8') as f: json.dump(data, f, indent=4, ensure_ascii=False) - def to_dict(self) -> Dict: - """Convert the object to a dictionary representation.""" - data = {'__class__': self.__class__.__name__} + def to_netcdf(self, path: Union[str, pathlib.Path], compression: int = 0): + """ + Save the object to a NetCDF file. - # Get the constructor parameters - init_params = inspect.signature(self.__init__).parameters + Args: + path: Path to save the NetCDF file + compression: Compression level (0-9) + """ + from . import io as fx_io # Assuming fx_io is available - for name in init_params: - if name == 'self': - continue + ds = self.to_dataset() + fx_io.save_dataset_to_netcdf(ds, path, compression=compression) - value = getattr(self, name, None) - data[name] = self._serialize_value(value) + @classmethod + def from_dataset(cls, ds: xr.Dataset) -> 'Interface': + """ + Create an instance from an xarray Dataset. - return data + Args: + ds: Dataset containing the object data - def _serialize_value(self, value: Any): - """Helper method to serialize a value based on its type.""" - if value is None: - return None - elif isinstance(value, Interface): - return value.to_dict() - elif isinstance(value, (list, tuple)): - return self._serialize_list(value) - elif isinstance(value, dict): - return self._serialize_dict(value) - else: - return value + Returns: + Interface instance + """ + # Get class name and verify it matches + class_name = ds.attrs.get('__class__') + if class_name != cls.__name__: + logger.warning(f"Dataset class '{class_name}' doesn't match target class '{cls.__name__}'") - def _serialize_list(self, items): - """Serialize a list of items.""" - return [self._serialize_value(item) for item in items] + # Get the reference structure from attrs + reference_structure = dict(ds.attrs) - def _serialize_dict(self, d): - """Serialize a dictionary of items.""" - return {k: self._serialize_value(v) for k, v in d.items()} + # Remove the class name since it's not a constructor parameter + reference_structure.pop('__class__', None) - @classmethod - def _deserialize_dict(cls, data: Dict) -> Union[Dict, 'Interface']: - if '__class__' in data: - class_name = data.pop('__class__') - try: - class_type = CLASS_REGISTRY[class_name] - if issubclass(class_type, Interface): - # Use _deserialize_dict to process the arguments - processed_data = {k: cls._deserialize_value(v) for k, v in data.items()} - return class_type(**processed_data) - else: - raise ValueError(f'Class "{class_name}" is not an Interface.') - except (AttributeError, KeyError) as e: - raise ValueError(f'Class "{class_name}" could not get reconstructed.') from e - else: - return {k: cls._deserialize_value(v) for k, v in data.items()} + # Create arrays dictionary from dataset variables + arrays_dict = {name: array for name, array in ds.data_vars.items()} - @classmethod - def _deserialize_list(cls, data: List) -> List: - return [cls._deserialize_value(value) for value in data] + # Resolve all references using the centralized method + resolved_params = cls._resolve_reference_structure(reference_structure, arrays_dict) + + return cls(**resolved_params) @classmethod - def _deserialize_value(cls, value: Any): - """Helper method to deserialize a value based on its type.""" - if value is None: - return None - elif isinstance(value, dict): - return cls._deserialize_dict(value) - elif isinstance(value, list): - return cls._deserialize_list(value) - return value + def from_netcdf(cls, path: Union[str, pathlib.Path]) -> 'Interface': + """ + Load an instance from a NetCDF file. + + Args: + path: Path to the NetCDF file + + Returns: + Interface instance + """ + from . import io as fx_io # Assuming fx_io is available + + ds = fx_io.load_dataset_from_netcdf(path) + return cls.from_dataset(ds) @classmethod def from_dict(cls, data: Dict) -> 'Interface': """ Create an instance from a dictionary representation. + This is now a thin wrapper around the reference resolution system. Args: data: Dictionary containing the data for the object. """ - return cls._deserialize_dict(data) + class_name = data.pop('__class__', None) + if class_name and class_name != cls.__name__: + logger.warning(f"Dict class '{class_name}' doesn't match target class '{cls.__name__}'") + + # Since dict format doesn't separate arrays, resolve with empty arrays dict + # References in dict format would need to be handled differently if they exist + resolved_params = cls._resolve_reference_structure(data, {}) + return cls(**resolved_params) def __repr__(self): # Get the constructor arguments and their current values From f63db8b54004cc2a2618c20cb561dff299dc2ce3 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 23 Jun 2025 17:04:06 +0200 Subject: [PATCH 059/336] Remove TimeSeries --- examples/01_Simple/simple_example.py | 5 + flixopt/calculation.py | 3 - flixopt/components.py | 20 +- flixopt/core.py | 815 +-------------------------- flixopt/effects.py | 8 +- flixopt/elements.py | 14 +- flixopt/features.py | 5 - flixopt/flow_system.py | 2 +- flixopt/io.py | 2 +- flixopt/structure.py | 17 +- 10 files changed, 40 insertions(+), 851 deletions(-) diff --git a/examples/01_Simple/simple_example.py b/examples/01_Simple/simple_example.py index 45550c9cc..da10aed62 100644 --- a/examples/01_Simple/simple_example.py +++ b/examples/01_Simple/simple_example.py @@ -103,9 +103,14 @@ calculation = fx.FullCalculation(name='Sim1', flow_system=flow_system) calculation.do_modeling() # Translate the model to a solvable form, creating equations and Variables + calculation2 = fx.FullCalculation(name='Sim2', flow_system=flow_system) + calculation2.do_modeling() # Translate the model to a solvable form, creating equations and Variables + # --- Solve the Calculation and Save Results --- calculation.solve(fx.solvers.HighsSolver(mip_gap=0, time_limit_seconds=30)) + calculation2.solve(fx.solvers.HighsSolver(mip_gap=0, time_limit_seconds=30)) + # --- Analyze Results --- calculation.results['Fernwärme'].plot_node_balance_pie() calculation.results['Fernwärme'].plot_node_balance() diff --git a/flixopt/calculation.py b/flixopt/calculation.py index c7367cad2..2f08dd457 100644 --- a/flixopt/calculation.py +++ b/flixopt/calculation.py @@ -183,9 +183,6 @@ def solve(self, solver: _Solver, log_file: Optional[pathlib.Path] = None, log_ma def _activate_time_series(self): self.flow_system.transform_data() - self.flow_system.time_series_collection.activate_timesteps( - active_timesteps=self.active_timesteps, - ) class AggregatedCalculation(FullCalculation): diff --git a/flixopt/components.py b/flixopt/components.py index 1f5fe5ece..81baaeea5 100644 --- a/flixopt/components.py +++ b/flixopt/components.py @@ -348,7 +348,7 @@ def __init__(self, model: SystemModel, element: Transmission): def do_modeling(self): """Initiates all FlowModels""" # Force On Variable if absolute losses are present - if (self.element.absolute_losses is not None) and np.any(self.element.absolute_losses.active_data != 0): + if (self.element.absolute_losses is not None) and np.any(self.element.absolute_losses != 0): for flow in self.element.inputs + self.element.outputs: if flow.on_off_parameters is None: flow.on_off_parameters = OnOffParameters() @@ -385,14 +385,14 @@ def create_transmission_equation(self, name: str, in_flow: Flow, out_flow: Flow) # eq: out(t) + on(t)*loss_abs(t) = in(t)*(1 - loss_rel(t)) con_transmission = self.add( self._model.add_constraints( - out_flow.model.flow_rate == -in_flow.model.flow_rate * (self.element.relative_losses.active_data - 1), + out_flow.model.flow_rate == -in_flow.model.flow_rate * (self.element.relative_losses - 1), name=f'{self.label_full}|{name}', ), name, ) if self.element.absolute_losses is not None: - con_transmission.lhs += in_flow.model.on_off.on * self.element.absolute_losses.active_data + con_transmission.lhs += in_flow.model.on_off.on * self.element.absolute_losses return con_transmission @@ -420,8 +420,8 @@ def do_modeling(self): self.add( self._model.add_constraints( - sum([flow.model.flow_rate * conv_factors[flow.label].active_data for flow in used_inputs]) - == sum([flow.model.flow_rate * conv_factors[flow.label].active_data for flow in used_outputs]), + sum([flow.model.flow_rate * conv_factors[flow.label] for flow in used_inputs]) + == sum([flow.model.flow_rate * conv_factors[flow.label] for flow in used_outputs]), name=f'{self.label_full}|conversion_{i}', ) ) @@ -481,12 +481,12 @@ def do_modeling(self): ) charge_state = self.charge_state - rel_loss = self.element.relative_loss_per_hour.active_data + rel_loss = self.element.relative_loss_per_hour hours_per_step = self._model.hours_per_step charge_rate = self.element.charging.model.flow_rate discharge_rate = self.element.discharging.model.flow_rate - eff_charge = self.element.eta_charge.active_data - eff_discharge = self.element.eta_discharge.active_data + eff_charge = self.element.eta_charge + eff_discharge = self.element.eta_discharge self.add( self._model.add_constraints( @@ -572,8 +572,8 @@ def absolute_charge_state_bounds(self) -> Tuple[NumericData, NumericData]: @property def relative_charge_state_bounds(self) -> Tuple[NumericData, NumericData]: return ( - self.element.relative_minimum_charge_state.active_data, - self.element.relative_maximum_charge_state.active_data, + self.element.relative_minimum_charge_state, + self.element.relative_maximum_charge_state, ) diff --git a/flixopt/core.py b/flixopt/core.py index 08be18f1d..022bf8e6f 100644 --- a/flixopt/core.py +++ b/flixopt/core.py @@ -136,392 +136,8 @@ def __str__(self): class TimeSeries: - """ - A class representing time series data with active and stored states. - - TimeSeries provides a way to store time-indexed data and work with temporal subsets. - It supports arithmetic operations, aggregation, and JSON serialization. - - Attributes: - name (str): The name of the time series - aggregation_weight (Optional[float]): Weight used for aggregation - aggregation_group (Optional[str]): Group name for shared aggregation weighting - needs_extra_timestep (bool): Whether this series needs an extra timestep - """ - - @classmethod - def from_datasource( - cls, - data: NumericData, - name: str, - timesteps: pd.DatetimeIndex, - aggregation_weight: Optional[float] = None, - aggregation_group: Optional[str] = None, - needs_extra_timestep: bool = False, - ) -> 'TimeSeries': - """ - Initialize the TimeSeries from multiple data sources. - - Args: - data: The time series data - name: The name of the TimeSeries - timesteps: The timesteps of the TimeSeries - aggregation_weight: The weight in aggregation calculations - aggregation_group: Group this TimeSeries belongs to for aggregation weight sharing - needs_extra_timestep: Whether this series requires an extra timestep - - Returns: - A new TimeSeries instance - """ - return cls( - DataConverter.as_dataarray(data, timesteps), - name, - aggregation_weight, - aggregation_group, - needs_extra_timestep, - ) - - @classmethod - def from_json(cls, data: Optional[Dict[str, Any]] = None, path: Optional[str] = None) -> 'TimeSeries': - """ - Load a TimeSeries from a dictionary or json file. - - Args: - data: Dictionary containing TimeSeries data - path: Path to a JSON file containing TimeSeries data - - Returns: - A new TimeSeries instance - - Raises: - ValueError: If both path and data are provided or neither is provided - """ - if (path is None and data is None) or (path is not None and data is not None): - raise ValueError("Exactly one of 'path' or 'data' must be provided") - - if path is not None: - with open(path, 'r') as f: - data = json.load(f) - - # Convert ISO date strings to datetime objects - data['data']['coords']['time']['data'] = pd.to_datetime(data['data']['coords']['time']['data']) - - # Create the TimeSeries instance - return cls( - data=xr.DataArray.from_dict(data['data']), - name=data['name'], - aggregation_weight=data['aggregation_weight'], - aggregation_group=data['aggregation_group'], - needs_extra_timestep=data['needs_extra_timestep'], - ) - - def __init__( - self, - data: xr.DataArray, - name: str, - aggregation_weight: Optional[float] = None, - aggregation_group: Optional[str] = None, - needs_extra_timestep: bool = False, - ): - """ - Initialize a TimeSeries with a DataArray. - - Args: - data: The DataArray containing time series data - name: The name of the TimeSeries - aggregation_weight: The weight in aggregation calculations - aggregation_group: Group this TimeSeries belongs to for weight sharing - needs_extra_timestep: Whether this series requires an extra timestep - - Raises: - ValueError: If data doesn't have a 'time' index or has more than 1 dimension - """ - if 'time' not in data.indexes: - raise ValueError(f'DataArray must have a "time" index. Got {data.indexes}') - if data.ndim > 1: - raise ValueError(f'Number of dimensions of DataArray must be 1. Got {data.ndim}') - - self.name = name - self.aggregation_weight = aggregation_weight - self.aggregation_group = aggregation_group - self.needs_extra_timestep = needs_extra_timestep - - # Data management - self._stored_data = data.copy(deep=True) - self._backup = self._stored_data.copy(deep=True) - self._active_timesteps = self._stored_data.indexes['time'] - self._active_data = None - self._update_active_data() - - def reset(self): - """ - Reset active timesteps to the full set of stored timesteps. - """ - self.active_timesteps = None - - def restore_data(self): - """ - Restore stored_data from the backup and reset active timesteps. - """ - self._stored_data = self._backup.copy(deep=True) - self.reset() - - def to_json(self, path: Optional[pathlib.Path] = None) -> Dict[str, Any]: - """ - Save the TimeSeries to a dictionary or JSON file. - - Args: - path: Optional path to save JSON file - - Returns: - Dictionary representation of the TimeSeries - """ - data = { - 'name': self.name, - 'aggregation_weight': self.aggregation_weight, - 'aggregation_group': self.aggregation_group, - 'needs_extra_timestep': self.needs_extra_timestep, - 'data': self.active_data.to_dict(), - } - - # Convert datetime objects to ISO strings - data['data']['coords']['time']['data'] = [date.isoformat() for date in data['data']['coords']['time']['data']] - - # Save to file if path is provided - if path is not None: - indent = 4 if len(self.active_timesteps) <= 480 else None - with open(path, 'w', encoding='utf-8') as f: - json.dump(data, f, indent=indent, ensure_ascii=False) - - return data - - @property - def stats(self) -> str: - """ - Return a statistical summary of the active data. - - Returns: - String representation of data statistics - """ - return get_numeric_stats(self.active_data, padd=0) - - def _update_active_data(self): - """ - Update the active data based on active_timesteps. - """ - self._active_data = self._stored_data.sel(time=self.active_timesteps) - - @property - def all_equal(self) -> bool: - """Check if all values in the series are equal.""" - return np.unique(self.active_data.values).size == 1 - - @property - def active_timesteps(self) -> pd.DatetimeIndex: - """Get the current active timesteps.""" - return self._active_timesteps - - @active_timesteps.setter - def active_timesteps(self, timesteps: Optional[pd.DatetimeIndex]): - """ - Set active_timesteps and refresh active_data. - - Args: - timesteps: New timesteps to activate, or None to use all stored timesteps - - Raises: - TypeError: If timesteps is not a pandas DatetimeIndex or None - """ - if timesteps is None: - self._active_timesteps = self.stored_data.indexes['time'] - elif isinstance(timesteps, pd.DatetimeIndex): - self._active_timesteps = timesteps - else: - raise TypeError('active_timesteps must be a pandas DatetimeIndex or None') - - self._update_active_data() - - @property - def active_data(self) -> xr.DataArray: - """Get a view of stored_data based on active_timesteps.""" - return self._active_data - - @property - def stored_data(self) -> xr.DataArray: - """Get a copy of the full stored data.""" - return self._stored_data.copy() - - @stored_data.setter - def stored_data(self, value: NumericData): - """ - Update stored_data and refresh active_data. - - Args: - value: New data to store - """ - new_data = DataConverter.as_dataarray(value, timesteps=self.active_timesteps) - - # Skip if data is unchanged to avoid overwriting backup - if new_data.equals(self._stored_data): - return - - self._stored_data = new_data - self.active_timesteps = None # Reset to full timeline - - @property - def sel(self): - return self.active_data.sel - - @property - def isel(self): - return self.active_data.isel - - def _apply_operation(self, other, op): - """Apply an operation between this TimeSeries and another object.""" - if isinstance(other, TimeSeries): - other = other.active_data - return op(self.active_data, other) - - def __add__(self, other): - return self._apply_operation(other, lambda x, y: x + y) - - def __sub__(self, other): - return self._apply_operation(other, lambda x, y: x - y) - - def __mul__(self, other): - return self._apply_operation(other, lambda x, y: x * y) - - def __truediv__(self, other): - return self._apply_operation(other, lambda x, y: x / y) - - def __radd__(self, other): - return other + self.active_data - - def __rsub__(self, other): - return other - self.active_data - - def __rmul__(self, other): - return other * self.active_data - - def __rtruediv__(self, other): - return other / self.active_data - - def __neg__(self) -> xr.DataArray: - return -self.active_data - - def __pos__(self) -> xr.DataArray: - return +self.active_data - - def __abs__(self) -> xr.DataArray: - return abs(self.active_data) - - def __gt__(self, other): - """ - Compare if this TimeSeries is greater than another. - - Args: - other: Another TimeSeries to compare with - - Returns: - True if all values in this TimeSeries are greater than other - """ - if isinstance(other, TimeSeries): - return self.active_data > other.active_data - return self.active_data > other - - def __ge__(self, other): - """ - Compare if this TimeSeries is greater than or equal to another. - - Args: - other: Another TimeSeries to compare with - - Returns: - True if all values in this TimeSeries are greater than or equal to other - """ - if isinstance(other, TimeSeries): - return self.active_data >= other.active_data - return self.active_data >= other - - def __lt__(self, other): - """ - Compare if this TimeSeries is less than another. - - Args: - other: Another TimeSeries to compare with - - Returns: - True if all values in this TimeSeries are less than other - """ - if isinstance(other, TimeSeries): - return self.active_data < other.active_data - return self.active_data < other - - def __le__(self, other): - """ - Compare if this TimeSeries is less than or equal to another. - - Args: - other: Another TimeSeries to compare with - - Returns: - True if all values in this TimeSeries are less than or equal to other - """ - if isinstance(other, TimeSeries): - return self.active_data <= other.active_data - return self.active_data <= other - - def __eq__(self, other): - """ - Compare if this TimeSeries is equal to another. - - Args: - other: Another TimeSeries to compare with - - Returns: - True if all values in this TimeSeries are equal to other - """ - if isinstance(other, TimeSeries): - return self.active_data == other.active_data - return self.active_data == other - - def __array_ufunc__(self, ufunc, method, *inputs, **kwargs): - """ - Handle NumPy universal functions. - - This allows NumPy functions to work with TimeSeries objects. - """ - # Convert any TimeSeries inputs to their active_data - inputs = [x.active_data if isinstance(x, TimeSeries) else x for x in inputs] - return getattr(ufunc, method)(*inputs, **kwargs) - - def __repr__(self): - """ - Get a string representation of the TimeSeries. - - Returns: - String showing TimeSeries details - """ - attrs = { - 'name': self.name, - 'aggregation_weight': self.aggregation_weight, - 'aggregation_group': self.aggregation_group, - 'needs_extra_timestep': self.needs_extra_timestep, - 'shape': self.active_data.shape, - 'time_range': f'{self.active_timesteps[0]} to {self.active_timesteps[-1]}', - } - attr_str = ', '.join(f'{k}={repr(v)}' for k, v in attrs.items()) - return f'TimeSeries({attr_str})' - - def __str__(self): - """ - Get a human-readable string representation. - - Returns: - Descriptive string with statistics - """ - return f"TimeSeries '{self.name}': {self.stats}" - + def __init__(self): + raise NotImplementedError('TimeSeries was removed') class TimeSeriesCollection: """ @@ -531,431 +147,8 @@ class TimeSeriesCollection: timesteps, provides operations on collections, and manages extra timesteps. """ - def __init__( - self, - timesteps: pd.DatetimeIndex, - hours_of_last_timestep: Optional[float] = None, - hours_of_previous_timesteps: Optional[Union[float, np.ndarray]] = None, - ): - """ - Args: - timesteps: The timesteps of the Collection. - hours_of_last_timestep: The duration of the last time step. Uses the last time interval if not specified - hours_of_previous_timesteps: The duration of previous timesteps. - If None, the first time increment of time_series is used. - This is needed to calculate previous durations (for example consecutive_on_hours). - If you use an array, take care that its long enough to cover all previous values! - """ - # Prepare and validate timesteps - self._validate_timesteps(timesteps) - self.hours_of_previous_timesteps = self._calculate_hours_of_previous_timesteps( - timesteps, hours_of_previous_timesteps - ) - - # Set up timesteps and hours - self.all_timesteps = timesteps - self.all_timesteps_extra = self._create_timesteps_with_extra(timesteps, hours_of_last_timestep) - self.all_hours_per_timestep = self.calculate_hours_per_timestep(self.all_timesteps_extra) - - # Active timestep tracking - self._active_timesteps = None - self._active_timesteps_extra = None - self._active_hours_per_timestep = None - - # Dictionary of time series by name - self.time_series_data: Dict[str, TimeSeries] = {} - - # Aggregation - self.group_weights: Dict[str, float] = {} - self.weights: Dict[str, float] = {} - - @classmethod - def with_uniform_timesteps( - cls, start_time: pd.Timestamp, periods: int, freq: str, hours_per_step: Optional[float] = None - ) -> 'TimeSeriesCollection': - """Create a collection with uniform timesteps.""" - timesteps = pd.date_range(start_time, periods=periods, freq=freq, name='time') - return cls(timesteps, hours_of_previous_timesteps=hours_per_step) - - def create_time_series( - self, data: Union[NumericData, TimeSeriesData], name: str, needs_extra_timestep: bool = False - ) -> TimeSeries: - """ - Creates a TimeSeries from the given data and adds it to the collection. - - Args: - data: The data to create the TimeSeries from. - name: The name of the TimeSeries. - needs_extra_timestep: Whether to create an additional timestep at the end of the timesteps. - The data to create the TimeSeries from. - - Returns: - The created TimeSeries. - - """ - # Check for duplicate name - if name in self.time_series_data: - raise ValueError(f"TimeSeries '{name}' already exists in this collection") - - # Determine which timesteps to use - timesteps_to_use = self.timesteps_extra if needs_extra_timestep else self.timesteps - - # Create the time series - if isinstance(data, TimeSeriesData): - time_series = TimeSeries.from_datasource( - name=name, - data=data.data, - timesteps=timesteps_to_use, - aggregation_weight=data.agg_weight, - aggregation_group=data.agg_group, - needs_extra_timestep=needs_extra_timestep, - ) - # Connect the user time series to the created TimeSeries - data.label = name - else: - time_series = TimeSeries.from_datasource( - name=name, data=data, timesteps=timesteps_to_use, needs_extra_timestep=needs_extra_timestep - ) - - # Add to the collection - self.add_time_series(time_series) - - return time_series - - def calculate_aggregation_weights(self) -> Dict[str, float]: - """Calculate and return aggregation weights for all time series.""" - self.group_weights = self._calculate_group_weights() - self.weights = self._calculate_weights() - - if np.all(np.isclose(list(self.weights.values()), 1, atol=1e-6)): - logger.info('All Aggregation weights were set to 1') - - return self.weights - - def activate_timesteps(self, active_timesteps: Optional[pd.DatetimeIndex] = None): - """ - Update active timesteps for the collection and all time series. - If no arguments are provided, the active timesteps are reset. - - Args: - active_timesteps: The active timesteps of the model. - If None, the all timesteps of the TimeSeriesCollection are taken. - """ - if active_timesteps is None: - return self.reset() - - if not np.all(np.isin(active_timesteps, self.all_timesteps)): - raise ValueError('active_timesteps must be a subset of the timesteps of the TimeSeriesCollection') - - # Calculate derived timesteps - self._active_timesteps = active_timesteps - first_ts_index = np.where(self.all_timesteps == active_timesteps[0])[0][0] - last_ts_idx = np.where(self.all_timesteps == active_timesteps[-1])[0][0] - self._active_timesteps_extra = self.all_timesteps_extra[first_ts_index : last_ts_idx + 2] - self._active_hours_per_timestep = self.all_hours_per_timestep.isel(time=slice(first_ts_index, last_ts_idx + 1)) - - # Update all time series - self._update_time_series_timesteps() - - def reset(self): - """Reset active timesteps to defaults for all time series.""" - self._active_timesteps = None - self._active_timesteps_extra = None - self._active_hours_per_timestep = None - - for time_series in self.time_series_data.values(): - time_series.reset() - - def restore_data(self): - """Restore original data for all time series.""" - for time_series in self.time_series_data.values(): - time_series.restore_data() - - def add_time_series(self, time_series: TimeSeries): - """Add an existing TimeSeries to the collection.""" - if time_series.name in self.time_series_data: - raise ValueError(f"TimeSeries '{time_series.name}' already exists in this collection") - - self.time_series_data[time_series.name] = time_series - - def insert_new_data(self, data: pd.DataFrame, include_extra_timestep: bool = False): - """ - Update time series with new data from a DataFrame. - - Args: - data: DataFrame containing new data with timestamps as index - include_extra_timestep: Whether the provided data already includes the extra timestep, by default False - """ - if not isinstance(data, pd.DataFrame): - raise TypeError(f'data must be a pandas DataFrame, got {type(data).__name__}') - - # Check if the DataFrame index matches the expected timesteps - expected_timesteps = self.timesteps_extra if include_extra_timestep else self.timesteps - if not data.index.equals(expected_timesteps): - raise ValueError( - f'DataFrame index must match {"collection timesteps with extra timestep" if include_extra_timestep else "collection timesteps"}' - ) - - for name, ts in self.time_series_data.items(): - if name in data.columns: - if not ts.needs_extra_timestep: - # For time series without extra timestep - if include_extra_timestep: - # If data includes extra timestep but series doesn't need it, exclude the last point - ts.stored_data = data[name].iloc[:-1] - else: - # Use data as is - ts.stored_data = data[name] - else: - # For time series with extra timestep - if include_extra_timestep: - # Data already includes extra timestep - ts.stored_data = data[name] - else: - # Need to add extra timestep - extrapolate from the last value - extra_step_value = data[name].iloc[-1] - extra_step_index = pd.DatetimeIndex([self.timesteps_extra[-1]], name='time') - extra_step_series = pd.Series([extra_step_value], index=extra_step_index) - - # Combine the regular data with the extra timestep - ts.stored_data = pd.concat([data[name], extra_step_series]) - - logger.debug(f'Updated data for {name}') - - def to_dataframe( - self, filtered: Literal['all', 'constant', 'non_constant'] = 'non_constant', include_extra_timestep: bool = True - ) -> pd.DataFrame: - """ - Convert collection to DataFrame with optional filtering and timestep control. - - Args: - filtered: Filter time series by variability, by default 'non_constant' - include_extra_timestep: Whether to include the extra timestep in the result, by default True - - Returns: - DataFrame representation of the collection - """ - include_constants = filtered != 'non_constant' - ds = self.to_dataset(include_constants=include_constants) - - if not include_extra_timestep: - ds = ds.isel(time=slice(None, -1)) - - df = ds.to_dataframe() - - # Apply filtering - if filtered == 'all': - return df - elif filtered == 'constant': - return df.loc[:, df.nunique() == 1] - elif filtered == 'non_constant': - return df.loc[:, df.nunique() > 1] - else: - raise ValueError("filtered must be one of: 'all', 'constant', 'non_constant'") - - def to_dataset(self, include_constants: bool = True) -> xr.Dataset: - """ - Combine all time series into a single Dataset with all timesteps. - - Args: - include_constants: Whether to include time series with constant values, by default True - - Returns: - Dataset containing all selected time series with all timesteps - """ - # Determine which series to include - if include_constants: - series_to_include = self.time_series_data.values() - else: - series_to_include = self.non_constants - - # Create individual datasets and merge them - ds = xr.merge([ts.active_data.to_dataset(name=ts.name) for ts in series_to_include]) - - # Ensure the correct time coordinates - ds = ds.reindex(time=self.timesteps_extra) - - ds.attrs.update( - { - 'timesteps_extra': f'{self.timesteps_extra[0]} ... {self.timesteps_extra[-1]} | len={len(self.timesteps_extra)}', - 'hours_per_timestep': self._format_stats(self.hours_per_timestep), - } - ) - - return ds - - def _update_time_series_timesteps(self): - """Update active timesteps for all time series.""" - for ts in self.time_series_data.values(): - if ts.needs_extra_timestep: - ts.active_timesteps = self.timesteps_extra - else: - ts.active_timesteps = self.timesteps - - @staticmethod - def _validate_timesteps(timesteps: pd.DatetimeIndex): - """Validate timesteps format and rename if needed.""" - if not isinstance(timesteps, pd.DatetimeIndex): - raise TypeError('timesteps must be a pandas DatetimeIndex') - - if len(timesteps) < 2: - raise ValueError('timesteps must contain at least 2 timestamps') - - # Ensure timesteps has the required name - if timesteps.name != 'time': - logger.warning('Renamed timesteps to "time" (was "%s")', timesteps.name) - timesteps.name = 'time' - - @staticmethod - def _create_timesteps_with_extra( - timesteps: pd.DatetimeIndex, hours_of_last_timestep: Optional[float] - ) -> pd.DatetimeIndex: - """Create timesteps with an extra step at the end.""" - if hours_of_last_timestep is not None: - # Create the extra timestep using the specified duration - last_date = pd.DatetimeIndex([timesteps[-1] + pd.Timedelta(hours=hours_of_last_timestep)], name='time') - else: - # Use the last interval as the extra timestep duration - last_date = pd.DatetimeIndex([timesteps[-1] + (timesteps[-1] - timesteps[-2])], name='time') - - # Combine with original timesteps - return pd.DatetimeIndex(timesteps.append(last_date), name='time') - - @staticmethod - def _calculate_hours_of_previous_timesteps( - timesteps: pd.DatetimeIndex, hours_of_previous_timesteps: Optional[Union[float, np.ndarray]] - ) -> Union[float, np.ndarray]: - """Calculate duration of regular timesteps.""" - if hours_of_previous_timesteps is not None: - return hours_of_previous_timesteps - - # Calculate from the first interval - first_interval = timesteps[1] - timesteps[0] - return first_interval.total_seconds() / 3600 # Convert to hours - - @staticmethod - def calculate_hours_per_timestep(timesteps_extra: pd.DatetimeIndex) -> xr.DataArray: - """Calculate duration of each timestep.""" - # Calculate differences between consecutive timestamps - hours_per_step = np.diff(timesteps_extra) / pd.Timedelta(hours=1) - - return xr.DataArray( - data=hours_per_step, coords={'time': timesteps_extra[:-1]}, dims=('time',), name='hours_per_step' - ) - - def _calculate_group_weights(self) -> Dict[str, float]: - """Calculate weights for aggregation groups.""" - # Count series in each group - groups = [ts.aggregation_group for ts in self.time_series_data.values() if ts.aggregation_group is not None] - group_counts = Counter(groups) - - # Calculate weight for each group (1/count) - return {group: 1 / count for group, count in group_counts.items()} - - def _calculate_weights(self) -> Dict[str, float]: - """Calculate weights for all time series.""" - # Calculate weight for each time series - weights = {} - for name, ts in self.time_series_data.items(): - if ts.aggregation_group is not None: - # Use group weight - weights[name] = self.group_weights.get(ts.aggregation_group, 1) - else: - # Use individual weight or default to 1 - weights[name] = ts.aggregation_weight or 1 - - return weights - - def _format_stats(self, data) -> str: - """Format statistics for a data array.""" - if hasattr(data, 'values'): - values = data.values - else: - values = np.asarray(data) - - mean_val = np.mean(values) - min_val = np.min(values) - max_val = np.max(values) - - return f'mean: {mean_val:.2f}, min: {min_val:.2f}, max: {max_val:.2f}' - - def __getitem__(self, name: str) -> TimeSeries: - """Get a TimeSeries by name.""" - try: - return self.time_series_data[name] - except KeyError as e: - raise KeyError(f'TimeSeries "{name}" not found in the TimeSeriesCollection') from e - - def __iter__(self) -> Iterator[TimeSeries]: - """Iterate through all TimeSeries in the collection.""" - return iter(self.time_series_data.values()) - - def __len__(self) -> int: - """Get the number of TimeSeries in the collection.""" - return len(self.time_series_data) - - def __contains__(self, item: Union[str, TimeSeries]) -> bool: - """Check if a TimeSeries exists in the collection.""" - if isinstance(item, str): - return item in self.time_series_data - elif isinstance(item, TimeSeries): - return any([item is ts for ts in self.time_series_data.values()]) - return False - - @property - def non_constants(self) -> List[TimeSeries]: - """Get time series with varying values.""" - return [ts for ts in self.time_series_data.values() if not ts.all_equal] - - @property - def constants(self) -> List[TimeSeries]: - """Get time series with constant values.""" - return [ts for ts in self.time_series_data.values() if ts.all_equal] - - @property - def timesteps(self) -> pd.DatetimeIndex: - """Get the active timesteps.""" - return self.all_timesteps if self._active_timesteps is None else self._active_timesteps - - @property - def timesteps_extra(self) -> pd.DatetimeIndex: - """Get the active timesteps with extra step.""" - return self.all_timesteps_extra if self._active_timesteps_extra is None else self._active_timesteps_extra - - @property - def hours_per_timestep(self) -> xr.DataArray: - """Get the duration of each active timestep.""" - return ( - self.all_hours_per_timestep if self._active_hours_per_timestep is None else self._active_hours_per_timestep - ) - - @property - def hours_of_last_timestep(self) -> float: - """Get the duration of the last timestep.""" - return float(self.hours_per_timestep[-1].item()) - - def __repr__(self): - return f'TimeSeriesCollection:\n{self.to_dataset()}' - - def __str__(self): - longest_name = max([time_series.name for time_series in self.time_series_data], key=len) - - stats_summary = '\n'.join( - [ - f' - {time_series.name:<{len(longest_name)}}: {get_numeric_stats(time_series.active_data)}' - for time_series in self.time_series_data - ] - ) - - return ( - f'TimeSeriesCollection with {len(self.time_series_data)} series\n' - f' Time Range: {self.timesteps[0]} → {self.timesteps[-1]}\n' - f' No. of timesteps: {len(self.timesteps)} + 1 extra\n' - f' Hours per timestep: {get_numeric_stats(self.hours_per_timestep)}\n' - f' Time Series Data:\n' - f'{stats_summary}' - ) - + def __init__(self): + raise NotImplementedError('TimeSeriesCollection was removed') def get_numeric_stats(data: xr.DataArray, decimals: int = 2, padd: int = 10) -> str: """Calculates the mean, median, min, max, and standard deviation of a numeric DataArray.""" diff --git a/flixopt/effects.py b/flixopt/effects.py index 82aa63a43..b043f4492 100644 --- a/flixopt/effects.py +++ b/flixopt/effects.py @@ -13,7 +13,7 @@ import numpy as np import pandas as pd -from .core import NumericData, NumericDataTS, Scalar, TimeSeries, TimeSeriesCollection +from .core import NumericData, NumericDataTS, Scalar, TimeSeriesCollection, TimeSeries from .features import ShareAllocationModel from .structure import Element, ElementModel, Interface, Model, SystemModel, register_class_for_io @@ -137,10 +137,10 @@ def __init__(self, model: SystemModel, element: Effect): label_full=f'{self.label_full}(operation)', total_max=self.element.maximum_operation, total_min=self.element.minimum_operation, - min_per_hour=self.element.minimum_operation_per_hour.active_data + min_per_hour=self.element.minimum_operation_per_hour if self.element.minimum_operation_per_hour is not None else None, - max_per_hour=self.element.maximum_operation_per_hour.active_data + max_per_hour=self.element.maximum_operation_per_hour if self.element.maximum_operation_per_hour is not None else None, ) @@ -376,7 +376,7 @@ def _add_share_between_effects(self): for target_effect, time_series in origin_effect.specific_share_to_other_effects_operation.items(): self.effects[target_effect].model.operation.add_share( origin_effect.model.operation.label_full, - origin_effect.model.operation.total_per_timestep * time_series.active_data, + origin_effect.model.operation.total_per_timestep * time_series, ) # 2. invest: -> hier ist es Scalar (share) for target_effect, factor in origin_effect.specific_share_to_other_effects_invest.items(): diff --git a/flixopt/elements.py b/flixopt/elements.py index a0bd8c91f..3ea29a09f 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -287,7 +287,7 @@ def _plausibility_checks(self) -> None: if (self.relative_minimum > 0).any() and self.on_off_parameters is None: logger.warning( - f'Flow {self.label} has a relative_minimum of {self.relative_minimum.active_data} and no on_off_parameters. ' + f'Flow {self.label} has a relative_minimum of {self.relative_minimum} and no on_off_parameters. ' f'This prevents the flow_rate from switching off (flow_rate = 0). ' f'Consider using on_off_parameters to allow the flow to be switched on and off.' ) @@ -390,7 +390,7 @@ def _create_shares(self): self._model.effects.add_share_to_effects( name=self.label_full, # Use the full label of the element expressions={ - effect: self.flow_rate * self._model.hours_per_step * factor.active_data + effect: self.flow_rate * self._model.hours_per_step * factor for effect, factor in self.element.effects_per_flow_hour.items() }, target='operation', @@ -443,16 +443,16 @@ def flow_rate_lower_bound_relative(self) -> NumericData: """Returns the lower bound of the flow_rate relative to its size""" fixed_profile = self.element.fixed_relative_profile if fixed_profile is None: - return self.element.relative_minimum.active_data - return fixed_profile.active_data + return self.element.relative_minimum + return fixed_profile @property def flow_rate_upper_bound_relative(self) -> NumericData: """ Returns the upper bound of the flow_rate relative to its size""" fixed_profile = self.element.fixed_relative_profile if fixed_profile is None: - return self.element.relative_maximum.active_data - return fixed_profile.active_data + return self.element.relative_maximum + return fixed_profile @property def flow_rate_lower_bound(self) -> NumericData: @@ -497,7 +497,7 @@ def do_modeling(self) -> None: # Fehlerplus/-minus: if self.element.with_excess: excess_penalty = np.multiply( - self._model.hours_per_step, self.element.excess_penalty_per_flow_hour.active_data + self._model.hours_per_step, self.element.excess_penalty_per_flow_hour ) self.excess_input = self.add( self._model.add_variables(lower=0, coords=self._model.coords, name=f'{self.label_full}|excess_input'), diff --git a/flixopt/features.py b/flixopt/features.py index c2a62adb1..dc719a2a6 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -474,11 +474,6 @@ def __init__( self._minimum_duration = minimum_duration self._maximum_duration = maximum_duration - if isinstance(self._minimum_duration, TimeSeries): - self._minimum_duration = self._minimum_duration.active_data - if isinstance(self._maximum_duration, TimeSeries): - self._maximum_duration = self._maximum_duration.active_data - self.duration = None def do_modeling(self): diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index 8887a6eae..ae9df6407 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -16,7 +16,7 @@ from rich.pretty import Pretty from . import io as fx_io -from .core import NumericData, NumericDataTS, TimeSeries, TimeSeriesCollection, TimeSeriesData +from .core import NumericData, NumericDataTS, TimeSeriesCollection, TimeSeriesData, TimeSeries from .effects import Effect, EffectCollection, EffectTimeSeries, EffectValuesDict, EffectValuesUser from .elements import Bus, Component, Flow from .structure import CLASS_REGISTRY, Element, SystemModel, get_compact_representation, get_str_representation diff --git a/flixopt/io.py b/flixopt/io.py index 35d927136..1376cafae 100644 --- a/flixopt/io.py +++ b/flixopt/io.py @@ -23,7 +23,7 @@ def replace_timeseries(obj, mode: Literal['name', 'stats', 'data'] = 'name'): return [replace_timeseries(v, mode) for v in obj] elif isinstance(obj, TimeSeries): # Adjust this based on the actual class if obj.all_equal: - return obj.active_data.values[0].item() + return obj.values[0].item() elif mode == 'name': return f'::::{obj.name}' elif mode == 'stats': diff --git a/flixopt/structure.py b/flixopt/structure.py index b9dbd889c..71efe31df 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -19,7 +19,7 @@ from rich.pretty import Pretty from .config import CONFIG -from .core import NumericData, Scalar, TimeSeries, TimeSeriesCollection, TimeSeriesData +from .core import NumericData, Scalar, TimeSeriesCollection, TimeSeriesData, TimeSeries if TYPE_CHECKING: # for type checking and preventing circular imports from .effects import EffectCollectionModel @@ -56,7 +56,6 @@ def __init__(self, flow_system: 'FlowSystem'): """ super().__init__(force_dim_names=True) self.flow_system = flow_system - self.time_series_collection = flow_system.time_series_collection self.effects: Optional[EffectCollectionModel] = None def do_modeling(self): @@ -88,23 +87,23 @@ def solution(self): for effect in sorted(self.flow_system.effects, key=lambda effect: effect.label_full.upper()) }, } - return solution.reindex(time=self.time_series_collection.timesteps_extra) + return solution.reindex(time=self.flow_system.timesteps_extra) @property def hours_per_step(self): - return self.time_series_collection.hours_per_timestep + return self.flow_system.hours_per_timestep @property def hours_of_previous_timesteps(self): - return self.time_series_collection.hours_of_previous_timesteps + return self.flow_system.hours_of_previous_timesteps @property def coords(self) -> Tuple[pd.DatetimeIndex]: - return (self.time_series_collection.timesteps,) + return (self.flow_system.timesteps,) @property def coords_extra(self) -> Tuple[pd.DatetimeIndex]: - return (self.time_series_collection.timesteps_extra,) + return (self.flow_system.timesteps_extra,) class Interface: @@ -165,7 +164,7 @@ def _extract_dataarrays_recursive(self, obj) -> Tuple[Any, Dict[str, xr.DataArra # Handle TimeSeries objects - extract their data using their unique name if isinstance(obj, TimeSeries): - data_array = obj.active_data.rename(obj.name) + data_array = obj.rename(obj.name) extracted_arrays[obj.name] = data_array return f':::{obj.name}', extracted_arrays @@ -745,7 +744,7 @@ def copy_and_convert_datatypes(data: Any, use_numpy: bool = True, use_element_la return copy_and_convert_datatypes(data.tolist(), use_numpy, use_element_label) elif isinstance(data, TimeSeries): - return copy_and_convert_datatypes(data.active_data, use_numpy, use_element_label) + return copy_and_convert_datatypes(data, use_numpy, use_element_label) elif isinstance(data, TimeSeriesData): return copy_and_convert_datatypes(data.data, use_numpy, use_element_label) From 167fb2ca59dc6f9ae157e64e990f8a31fba6bdc8 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 23 Jun 2025 17:05:21 +0200 Subject: [PATCH 060/336] Remove TimeSeries --- flixopt/calculation.py | 20 ++++++------ tests/conftest.py | 4 +-- tests/test_bus.py | 2 +- tests/test_component.py | 4 +-- tests/test_effect.py | 4 +-- tests/test_flow.py | 36 +++++++++++----------- tests/test_linear_converter.py | 8 ++--- tests/test_storage.py | 8 ++--- tests/test_timeseries.py | 56 +++++++++++++++++----------------- 9 files changed, 71 insertions(+), 71 deletions(-) diff --git a/flixopt/calculation.py b/flixopt/calculation.py index 2f08dd457..8439142c1 100644 --- a/flixopt/calculation.py +++ b/flixopt/calculation.py @@ -119,7 +119,7 @@ def main_results(self) -> Dict[str, Union[Scalar, Dict]]: def summary(self): return { 'Name': self.name, - 'Number of timesteps': len(self.flow_system.time_series_collection.timesteps), + 'Number of timesteps': len(self.flow_system.timesteps), 'Calculation Type': self.__class__.__name__, 'Constraints': self.model.constraints.ncons, 'Variables': self.model.variables.nvars, @@ -242,8 +242,8 @@ def _perform_aggregation(self): # Validation dt_min, dt_max = ( - np.min(self.flow_system.time_series_collection.hours_per_timestep), - np.max(self.flow_system.time_series_collection.hours_per_timestep), + np.min(self.flow_system.hours_per_timestep), + np.max(self.flow_system.hours_per_timestep), ) if not dt_min == dt_max: raise ValueError( @@ -252,11 +252,11 @@ def _perform_aggregation(self): ) steps_per_period = ( self.aggregation_parameters.hours_per_period - / self.flow_system.time_series_collection.hours_per_timestep.max() + / self.flow_system.hours_per_timestep.max() ) is_integer = ( self.aggregation_parameters.hours_per_period - % self.flow_system.time_series_collection.hours_per_timestep.max() + % self.flow_system.hours_per_timestep.max() ).item() == 0 if not (steps_per_period.size == 1 and is_integer): raise ValueError( @@ -269,13 +269,13 @@ def _perform_aggregation(self): # Aggregation - creation of aggregated timeseries: self.aggregation = Aggregation( - original_data=self.flow_system.time_series_collection.to_dataframe( + original_data=self.flow_system.to_dataframe( include_extra_timestep=False ), # Exclude last row (NaN) hours_per_time_step=float(dt_min), hours_per_period=self.aggregation_parameters.hours_per_period, nr_of_periods=self.aggregation_parameters.nr_of_periods, - weights=self.flow_system.time_series_collection.calculate_aggregation_weights(), + weights=self.flow_system.calculate_aggregation_weights(), time_series_for_high_peaks=self.aggregation_parameters.labels_for_high_peaks, time_series_for_low_peaks=self.aggregation_parameters.labels_for_low_peaks, ) @@ -283,7 +283,7 @@ def _perform_aggregation(self): self.aggregation.cluster() self.aggregation.plot(show=True, save=self.folder / 'aggregation.html') if self.aggregation_parameters.aggregate_data_and_fix_non_binary_vars: - self.flow_system.time_series_collection.insert_new_data( + self.flow_system.insert_new_data( self.aggregation.aggregated_data, include_extra_timestep=False ) self.durations['aggregation'] = round(timeit.default_timer() - t_start_agg, 2) @@ -324,8 +324,8 @@ def __init__( self.nr_of_previous_values = nr_of_previous_values self.sub_calculations: List[FullCalculation] = [] - self.all_timesteps = self.flow_system.time_series_collection.all_timesteps - self.all_timesteps_extra = self.flow_system.time_series_collection.all_timesteps_extra + self.all_timesteps = self.flow_system.all_timesteps + self.all_timesteps_extra = self.flow_system.all_timesteps_extra self.segment_names = [ f'Segment_{i + 1}' for i in range(math.ceil(len(self.all_timesteps) / self.timesteps_per_segment)) diff --git a/tests/conftest.py b/tests/conftest.py index 5399be72a..43f9f8bae 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -293,8 +293,8 @@ def flow_system_segments_of_flows_2(flow_system_complex) -> fx.FlowSystem: { 'P_el': fx.Piecewise( [ - fx.Piece(np.linspace(5, 6, len(flow_system.time_series_collection.timesteps)), 30), - fx.Piece(40, np.linspace(60, 70, len(flow_system.time_series_collection.timesteps))), + fx.Piece(np.linspace(5, 6, len(flow_system.timesteps)), 30), + fx.Piece(40, np.linspace(60, 70, len(flow_system.timesteps))), ] ), 'Q_th': fx.Piecewise([fx.Piece(6, 35), fx.Piece(45, 100)]), diff --git a/tests/test_bus.py b/tests/test_bus.py index 4a41a9f9e..136f9d2cc 100644 --- a/tests/test_bus.py +++ b/tests/test_bus.py @@ -31,7 +31,7 @@ def test_bus(self, basic_flow_system_linopy): def test_bus_penalty(self, basic_flow_system_linopy): """Test that flow model constraints are correctly generated.""" flow_system = basic_flow_system_linopy - timesteps = flow_system.time_series_collection.timesteps + timesteps = flow_system.timesteps bus = fx.Bus('TestBus') flow_system.add_elements(bus, fx.Sink('WärmelastTest', sink=fx.Flow('Q_th_Last', 'TestBus')), diff --git a/tests/test_component.py b/tests/test_component.py index d87a28c29..18ceb717a 100644 --- a/tests/test_component.py +++ b/tests/test_component.py @@ -57,7 +57,7 @@ def test_component(self, basic_flow_system_linopy): def test_on_with_multiple_flows(self, basic_flow_system_linopy): """Test that flow model constraints are correctly generated.""" flow_system = basic_flow_system_linopy - timesteps = flow_system.time_series_collection.timesteps + timesteps = flow_system.timesteps ub_out2 = np.linspace(1, 1.5, 10).round(2) inputs = [ fx.Flow('In1', 'Fernwärme', relative_minimum=np.ones(10) * 0.1, size=100), @@ -128,7 +128,7 @@ def test_on_with_multiple_flows(self, basic_flow_system_linopy): def test_on_with_single_flow(self, basic_flow_system_linopy): """Test that flow model constraints are correctly generated.""" flow_system = basic_flow_system_linopy - timesteps = flow_system.time_series_collection.timesteps + timesteps = flow_system.timesteps inputs = [ fx.Flow('In1', 'Fernwärme', relative_minimum=np.ones(10) * 0.1, size=100), ] diff --git a/tests/test_effect.py b/tests/test_effect.py index 5cbc04ac6..9b4e1012a 100644 --- a/tests/test_effect.py +++ b/tests/test_effect.py @@ -13,7 +13,7 @@ class TestBusModel: def test_minimal(self, basic_flow_system_linopy): flow_system = basic_flow_system_linopy - timesteps = flow_system.time_series_collection.timesteps + timesteps = flow_system.timesteps effect = fx.Effect('Effect1', '€', 'Testing Effect') flow_system.add_elements(effect) @@ -43,7 +43,7 @@ def test_minimal(self, basic_flow_system_linopy): def test_bounds(self, basic_flow_system_linopy): flow_system = basic_flow_system_linopy - timesteps = flow_system.time_series_collection.timesteps + timesteps = flow_system.timesteps effect = fx.Effect('Effect1', '€', 'Testing Effect', minimum_operation=1.0, maximum_operation=1.1, diff --git a/tests/test_flow.py b/tests/test_flow.py index 2308dbd31..cce10b21a 100644 --- a/tests/test_flow.py +++ b/tests/test_flow.py @@ -14,7 +14,7 @@ class TestFlowModel: def test_flow_minimal(self, basic_flow_system_linopy): """Test that flow model constraints are correctly generated.""" flow_system = basic_flow_system_linopy - timesteps = flow_system.time_series_collection.timesteps + timesteps = flow_system.timesteps flow = fx.Flow('Wärme', bus='Fernwärme', size=100) flow_system.add_elements(fx.Sink('Sink', sink=flow)) @@ -34,7 +34,7 @@ def test_flow_minimal(self, basic_flow_system_linopy): def test_flow(self, basic_flow_system_linopy): flow_system = basic_flow_system_linopy - timesteps = flow_system.time_series_collection.timesteps + timesteps = flow_system.timesteps flow = fx.Flow( 'Wärme', bus='Fernwärme', @@ -86,7 +86,7 @@ def test_flow(self, basic_flow_system_linopy): def test_effects_per_flow_hour(self, basic_flow_system_linopy): flow_system = basic_flow_system_linopy - timesteps = flow_system.time_series_collection.timesteps + timesteps = flow_system.timesteps costs_per_flow_hour = xr.DataArray(np.linspace(1,2,timesteps.size), coords=(timesteps,)) co2_per_flow_hour = xr.DataArray(np.linspace(4, 5, timesteps.size), coords=(timesteps,)) @@ -120,7 +120,7 @@ class TestFlowInvestModel: def test_flow_invest(self, basic_flow_system_linopy): flow_system = basic_flow_system_linopy - timesteps = flow_system.time_series_collection.timesteps + timesteps = flow_system.timesteps flow = fx.Flow( 'Wärme', @@ -175,7 +175,7 @@ def test_flow_invest(self, basic_flow_system_linopy): def test_flow_invest_optional(self, basic_flow_system_linopy): flow_system = basic_flow_system_linopy - timesteps = flow_system.time_series_collection.timesteps + timesteps = flow_system.timesteps flow = fx.Flow( 'Wärme', @@ -239,7 +239,7 @@ def test_flow_invest_optional(self, basic_flow_system_linopy): def test_flow_invest_optional_wo_min_size(self, basic_flow_system_linopy): flow_system = basic_flow_system_linopy - timesteps = flow_system.time_series_collection.timesteps + timesteps = flow_system.timesteps flow = fx.Flow( 'Wärme', @@ -303,7 +303,7 @@ def test_flow_invest_optional_wo_min_size(self, basic_flow_system_linopy): def test_flow_invest_wo_min_size_non_optional(self, basic_flow_system_linopy): flow_system = basic_flow_system_linopy - timesteps = flow_system.time_series_collection.timesteps + timesteps = flow_system.timesteps flow = fx.Flow( 'Wärme', @@ -354,7 +354,7 @@ def test_flow_invest_wo_min_size_non_optional(self, basic_flow_system_linopy): def test_flow_invest_fixed_size(self, basic_flow_system_linopy): """Test flow with fixed size investment.""" flow_system = basic_flow_system_linopy - timesteps = flow_system.time_series_collection.timesteps + timesteps = flow_system.timesteps flow = fx.Flow( 'Wärme', @@ -446,7 +446,7 @@ class TestFlowOnModel: def test_flow_on(self, basic_flow_system_linopy): flow_system = basic_flow_system_linopy - timesteps = flow_system.time_series_collection.timesteps + timesteps = flow_system.timesteps flow = fx.Flow( 'Wärme', bus='Fernwärme', @@ -506,7 +506,7 @@ def test_flow_on(self, basic_flow_system_linopy): def test_effects_per_running_hour(self, basic_flow_system_linopy): flow_system = basic_flow_system_linopy - timesteps = flow_system.time_series_collection.timesteps + timesteps = flow_system.timesteps costs_per_running_hour = xr.DataArray(np.linspace(1, 2, timesteps.size), coords=(timesteps,)) co2_per_running_hour = xr.DataArray(np.linspace(4, 5, timesteps.size), coords=(timesteps,)) @@ -553,7 +553,7 @@ def test_effects_per_running_hour(self, basic_flow_system_linopy): def test_consecutive_on_hours(self, basic_flow_system_linopy): """Test flow with minimum and maximum consecutive on hours.""" flow_system = basic_flow_system_linopy - timesteps = flow_system.time_series_collection.timesteps + timesteps = flow_system.timesteps flow = fx.Flow( 'Wärme', @@ -619,7 +619,7 @@ def test_consecutive_on_hours(self, basic_flow_system_linopy): def test_consecutive_on_hours_previous(self, basic_flow_system_linopy): """Test flow with minimum and maximum consecutive on hours.""" flow_system = basic_flow_system_linopy - timesteps = flow_system.time_series_collection.timesteps + timesteps = flow_system.timesteps flow = fx.Flow( 'Wärme', @@ -686,7 +686,7 @@ def test_consecutive_on_hours_previous(self, basic_flow_system_linopy): def test_consecutive_off_hours(self, basic_flow_system_linopy): """Test flow with minimum and maximum consecutive off hours.""" flow_system = basic_flow_system_linopy - timesteps = flow_system.time_series_collection.timesteps + timesteps = flow_system.timesteps flow = fx.Flow( 'Wärme', @@ -753,7 +753,7 @@ def test_consecutive_off_hours(self, basic_flow_system_linopy): def test_consecutive_off_hours_previous(self, basic_flow_system_linopy): """Test flow with minimum and maximum consecutive off hours.""" flow_system = basic_flow_system_linopy - timesteps = flow_system.time_series_collection.timesteps + timesteps = flow_system.timesteps flow = fx.Flow( 'Wärme', @@ -906,7 +906,7 @@ class TestFlowOnInvestModel: def test_flow_on_invest_optional(self, basic_flow_system_linopy): flow_system = basic_flow_system_linopy - timesteps = flow_system.time_series_collection.timesteps + timesteps = flow_system.timesteps flow = fx.Flow( 'Wärme', bus='Fernwärme', @@ -991,7 +991,7 @@ def test_flow_on_invest_optional(self, basic_flow_system_linopy): def test_flow_on_invest_non_optional(self, basic_flow_system_linopy): flow_system = basic_flow_system_linopy - timesteps = flow_system.time_series_collection.timesteps + timesteps = flow_system.timesteps flow = fx.Flow( 'Wärme', bus='Fernwärme', @@ -1078,7 +1078,7 @@ class TestFlowWithFixedProfile: def test_fixed_relative_profile(self, basic_flow_system_linopy): """Test flow with a fixed relative profile.""" flow_system = basic_flow_system_linopy - timesteps = flow_system.time_series_collection.timesteps + timesteps = flow_system.timesteps # Create a time-varying profile (e.g., for a load or renewable generation) profile = np.sin(np.linspace(0, 2 * np.pi, len(timesteps))) * 0.5 + 0.5 # Values between 0 and 1 @@ -1100,7 +1100,7 @@ def test_fixed_relative_profile(self, basic_flow_system_linopy): def test_fixed_profile_with_investment(self, basic_flow_system_linopy): """Test flow with fixed profile and investment.""" flow_system = basic_flow_system_linopy - timesteps = flow_system.time_series_collection.timesteps + timesteps = flow_system.timesteps # Create a fixed profile profile = np.sin(np.linspace(0, 2 * np.pi, len(timesteps))) * 0.5 + 0.5 diff --git a/tests/test_linear_converter.py b/tests/test_linear_converter.py index aaab60dcc..a01c17ef2 100644 --- a/tests/test_linear_converter.py +++ b/tests/test_linear_converter.py @@ -52,7 +52,7 @@ def test_basic_linear_converter(self, basic_flow_system_linopy): def test_linear_converter_time_varying(self, basic_flow_system_linopy): """Test a LinearConverter with time-varying conversion factors.""" flow_system = basic_flow_system_linopy - timesteps = flow_system.time_series_collection.timesteps + timesteps = flow_system.timesteps # Create time-varying efficiency (e.g., temperature-dependent) varying_efficiency = np.linspace(0.7, 0.9, len(timesteps)) @@ -268,7 +268,7 @@ def test_linear_converter_multidimensional(self, basic_flow_system_linopy): def test_edge_case_time_varying_conversion(self, basic_flow_system_linopy): """Test edge case with extreme time-varying conversion factors.""" flow_system = basic_flow_system_linopy - timesteps = flow_system.time_series_collection.timesteps + timesteps = flow_system.timesteps # Create fluctuating conversion efficiency (e.g., for a heat pump) # Values range from very low (0.1) to very high (5.0) @@ -317,7 +317,7 @@ def test_edge_case_time_varying_conversion(self, basic_flow_system_linopy): def test_piecewise_conversion(self, basic_flow_system_linopy): """Test a LinearConverter with PiecewiseConversion.""" flow_system = basic_flow_system_linopy - timesteps = flow_system.time_series_collection.timesteps + timesteps = flow_system.timesteps # Create input and output flows input_flow = fx.Flow('input', bus='input_bus', size=100) @@ -423,7 +423,7 @@ def test_piecewise_conversion(self, basic_flow_system_linopy): def test_piecewise_conversion_with_onoff(self, basic_flow_system_linopy): """Test a LinearConverter with PiecewiseConversion and OnOffParameters.""" flow_system = basic_flow_system_linopy - timesteps = flow_system.time_series_collection.timesteps + timesteps = flow_system.timesteps # Create input and output flows input_flow = fx.Flow('input', bus='input_bus', size=100) diff --git a/tests/test_storage.py b/tests/test_storage.py index a3b453c2b..472ba4add 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -14,8 +14,8 @@ class TestStorageModel: def test_basic_storage(self, basic_flow_system_linopy): """Test that basic storage model variables and constraints are correctly generated.""" flow_system = basic_flow_system_linopy - timesteps = flow_system.time_series_collection.timesteps - timesteps_extra = flow_system.time_series_collection.timesteps_extra + timesteps = flow_system.timesteps + timesteps_extra = flow_system.timesteps_extra # Create a simple storage storage = fx.Storage( @@ -91,8 +91,8 @@ def test_basic_storage(self, basic_flow_system_linopy): def test_lossy_storage(self, basic_flow_system_linopy): """Test that basic storage model variables and constraints are correctly generated.""" flow_system = basic_flow_system_linopy - timesteps = flow_system.time_series_collection.timesteps - timesteps_extra = flow_system.time_series_collection.timesteps_extra + timesteps = flow_system.timesteps + timesteps_extra = flow_system.timesteps_extra # Create a simple storage storage = fx.Storage( diff --git a/tests/test_timeseries.py b/tests/test_timeseries.py index a8bc5fa85..8702a57fe 100644 --- a/tests/test_timeseries.py +++ b/tests/test_timeseries.py @@ -8,7 +8,7 @@ import pytest import xarray as xr -from flixopt.core import ConversionError, DataConverter, TimeSeries, TimeSeriesCollection, TimeSeriesData +from flixopt.core import ConversionError, DataConverter, TimeSeriesCollection, TimeSeriesData @pytest.fixture @@ -44,7 +44,7 @@ def test_initialization(self, simple_dataarray): # Check data initialization assert isinstance(ts.stored_data, xr.DataArray) assert ts.stored_data.equals(simple_dataarray) - assert ts.active_data.equals(simple_dataarray) + assert ts.equals(simple_dataarray) # Check backup was created assert ts._backup.equals(simple_dataarray) @@ -87,7 +87,7 @@ def test_active_timesteps_getter_setter(self, sample_timeseries, sample_timestep assert sample_timeseries.active_timesteps.equals(subset_index) # Active data should reflect the subset - assert sample_timeseries.active_data.equals(sample_timeseries.stored_data.sel(time=subset_index)) + assert sample_timeseries.equals(sample_timeseries.stored_data.sel(time=subset_index)) # Reset to full index sample_timeseries.active_timesteps = None @@ -108,7 +108,7 @@ def test_reset(self, sample_timeseries, sample_timesteps): # Should be back to full index assert sample_timeseries.active_timesteps.equals(sample_timesteps) - assert sample_timeseries.active_data.equals(sample_timeseries.stored_data) + assert sample_timeseries.equals(sample_timeseries.stored_data) def test_restore_data(self, sample_timeseries, simple_dataarray): """Test restore_data method.""" @@ -127,7 +127,7 @@ def test_restore_data(self, sample_timeseries, simple_dataarray): # Should be back to original data assert sample_timeseries.stored_data.equals(original_data) - assert sample_timeseries.active_data.equals(original_data) + assert sample_timeseries.equals(original_data) def test_stored_data_setter(self, sample_timeseries, sample_timesteps): """Test stored_data setter with different data types.""" @@ -234,30 +234,30 @@ def test_arithmetic_operations(self, sample_timeseries): # Test operations between two TimeSeries objects assert np.array_equal( - (sample_timeseries + ts2).values, sample_timeseries.active_data.values + ts2.active_data.values + (sample_timeseries + ts2).values, sample_timeseries.values + ts2.values ) assert np.array_equal( - (sample_timeseries - ts2).values, sample_timeseries.active_data.values - ts2.active_data.values + (sample_timeseries - ts2).values, sample_timeseries.values - ts2.values ) assert np.array_equal( - (sample_timeseries * ts2).values, sample_timeseries.active_data.values * ts2.active_data.values + (sample_timeseries * ts2).values, sample_timeseries.values * ts2.values ) assert np.array_equal( - (sample_timeseries / ts2).values, sample_timeseries.active_data.values / ts2.active_data.values + (sample_timeseries / ts2).values, sample_timeseries.values / ts2.values ) # Test operations with DataArrays - assert np.array_equal((sample_timeseries + data2).values, sample_timeseries.active_data.values + data2.values) - assert np.array_equal((data2 + sample_timeseries).values, data2.values + sample_timeseries.active_data.values) + assert np.array_equal((sample_timeseries + data2).values, sample_timeseries.values + data2.values) + assert np.array_equal((data2 + sample_timeseries).values, data2.values + sample_timeseries.values) # Test operations with scalars - assert np.array_equal((sample_timeseries + 5).values, sample_timeseries.active_data.values + 5) - assert np.array_equal((5 + sample_timeseries).values, 5 + sample_timeseries.active_data.values) + assert np.array_equal((sample_timeseries + 5).values, sample_timeseries.values + 5) + assert np.array_equal((5 + sample_timeseries).values, 5 + sample_timeseries.values) # Test unary operations - assert np.array_equal((-sample_timeseries).values, -sample_timeseries.active_data.values) - assert np.array_equal((+sample_timeseries).values, +sample_timeseries.active_data.values) - assert np.array_equal((abs(sample_timeseries)).values, abs(sample_timeseries.active_data.values)) + assert np.array_equal((-sample_timeseries).values, -sample_timeseries.values) + assert np.array_equal((+sample_timeseries).values, +sample_timeseries.values) + assert np.array_equal((abs(sample_timeseries)).values, abs(sample_timeseries.values)) def test_comparison_operations(self, sample_timesteps): """Test comparison operations.""" @@ -279,10 +279,10 @@ def test_comparison_operations(self, sample_timesteps): def test_numpy_ufunc(self, sample_timeseries): """Test numpy ufunc compatibility.""" # Test basic numpy functions - assert np.array_equal(np.add(sample_timeseries, 5).values, np.add(sample_timeseries.active_data, 5).values) + assert np.array_equal(np.add(sample_timeseries, 5).values, np.add(sample_timeseries, 5).values) assert np.array_equal( - np.multiply(sample_timeseries, 2).values, np.multiply(sample_timeseries.active_data, 2).values + np.multiply(sample_timeseries, 2).values, np.multiply(sample_timeseries, 2).values ) # Test with two TimeSeries objects @@ -290,18 +290,18 @@ def test_numpy_ufunc(self, sample_timeseries): ts2 = TimeSeries(data2, 'Second Series') assert np.array_equal( - np.add(sample_timeseries, ts2).values, np.add(sample_timeseries.active_data, ts2.active_data).values + np.add(sample_timeseries, ts2).values, np.add(sample_timeseries, ts2).values ) def test_sel_and_isel_properties(self, sample_timeseries): """Test sel and isel properties.""" # Test that sel property works selected = sample_timeseries.sel(time=sample_timeseries.active_timesteps[0]) - assert selected.item() == sample_timeseries.active_data.values[0] + assert selected.item() == sample_timeseries.values[0] # Test that isel property works indexed = sample_timeseries.isel(time=0) - assert indexed.item() == sample_timeseries.active_data.values[0] + assert indexed.item() == sample_timeseries.values[0] @pytest.fixture @@ -372,12 +372,12 @@ def test_create_time_series(self, sample_collection): # Test scalar ts1 = sample_collection.create_time_series(42, 'scalar_series') assert ts1.name == 'scalar_series' - assert np.all(ts1.active_data.values == 42) + assert np.all(ts1.values == 42) # Test numpy array data = np.array([1, 2, 3, 4, 5]) ts2 = sample_collection.create_time_series(data, 'array_series') - assert np.array_equal(ts2.active_data.values, data) + assert np.array_equal(ts2.values, data) # Test with TimeSeriesData ts3 = sample_collection.create_time_series(TimeSeriesData(10, agg_weight=0.7), 'weighted_series') @@ -386,7 +386,7 @@ def test_create_time_series(self, sample_collection): # Test with extra timestep ts4 = sample_collection.create_time_series(5, 'extra_series', needs_extra_timestep=True) assert ts4.needs_extra_timestep - assert len(ts4.active_data) == len(sample_collection.timesteps_extra) + assert len(ts4) == len(sample_collection.timesteps_extra) # Test duplicate name with pytest.raises(ValueError, match='already exists'): @@ -509,12 +509,12 @@ def test_insert_new_data(self, populated_collection, sample_timesteps): populated_collection.insert_new_data(new_data) # Verify updates - assert np.all(populated_collection['constant_series'].active_data.values == 100) - assert np.array_equal(populated_collection['varying_series'].active_data.values, np.array([5, 10, 15, 20, 25])) + assert np.all(populated_collection['constant_series'].values == 100) + assert np.array_equal(populated_collection['varying_series'].values, np.array([5, 10, 15, 20, 25])) # Series not in the DataFrame should be unchanged assert np.array_equal( - populated_collection['extra_timestep_series'].active_data.values[:-1], np.array([1, 2, 3, 4, 5]) + populated_collection['extra_timestep_series'].values[:-1], np.array([1, 2, 3, 4, 5]) ) # Test with mismatched index @@ -542,7 +542,7 @@ def test_restore_data(self, populated_collection): populated_collection.insert_new_data(new_data) # Verify data was changed - assert np.all(populated_collection['constant_series'].active_data.values == 999) + assert np.all(populated_collection['constant_series'].values == 999) # Restore data populated_collection.restore_data() From fc76adf7e2a9aa9010cb9a04dc57fd65ce3829f2 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 23 Jun 2025 17:22:00 +0200 Subject: [PATCH 061/336] Rename conversion method to pattern: to_... --- flixopt/core.py | 2 +- flixopt/flow_system.py | 10 +++++----- flixopt/results.py | 2 +- tests/test_dataconverter.py | 26 +++++++++++++------------- tests/test_io.py | 4 ++-- 5 files changed, 22 insertions(+), 22 deletions(-) diff --git a/flixopt/core.py b/flixopt/core.py index 022bf8e6f..73ad098ba 100644 --- a/flixopt/core.py +++ b/flixopt/core.py @@ -46,7 +46,7 @@ class DataConverter: """ @staticmethod - def as_dataarray(data: NumericData, timesteps: pd.DatetimeIndex) -> xr.DataArray: + def to_dataarray(data: NumericData, timesteps: pd.DatetimeIndex) -> xr.DataArray: """Convert data to xarray.DataArray with specified timesteps index.""" if not isinstance(timesteps, pd.DatetimeIndex) or len(timesteps) == 0: raise ValueError(f'Timesteps must be a non-empty DatetimeIndex, got {type(timesteps).__name__}') diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index ae9df6407..de94c14e5 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -224,7 +224,7 @@ def to_dataset(self, constants_in_dataset: bool = True) -> xr.Dataset: ds = xr.Dataset(extracted_arrays, attrs=reference_structure) return ds - def as_dict(self, data_mode: Literal['data', 'name', 'stats'] = 'data') -> Dict: + def to_dict(self, data_mode: Literal['data', 'name', 'stats'] = 'data') -> Dict: """ Convert the object to a dictionary representation. Now builds on the reference structure for consistency. @@ -364,7 +364,7 @@ def to_json(self, path: Union[str, pathlib.Path]): path: The path to the JSON file. """ # Use the stats mode for JSON export (cleaner output) - data = get_compact_representation(self.as_dict('stats')) + data = get_compact_representation(self.to_dict('stats')) with open(path, 'w', encoding='utf-8') as f: json.dump(data, f, indent=4, ensure_ascii=False) @@ -399,12 +399,12 @@ def create_time_series( # Convert TimeSeriesData to DataArray from .core import DataConverter # Assuming this exists - return DataConverter.as_dataarray(data.data, timesteps=target_timesteps).rename(name) + return DataConverter.to_dataarray(data.data, timesteps=target_timesteps).rename(name) else: # Convert other data types to DataArray from .core import DataConverter # Assuming this exists - return DataConverter.as_dataarray(data, timesteps=target_timesteps).rename(name) + return DataConverter.to_dataarray(data, timesteps=target_timesteps).rename(name) def create_effect_time_series( self, @@ -576,7 +576,7 @@ def __repr__(self): def __str__(self): with StringIO() as output_buffer: console = Console(file=output_buffer, width=1000) # Adjust width as needed - console.print(Pretty(self.as_dict('stats'), expand_all=True, indent_guides=True)) + console.print(Pretty(self.to_dict('stats'), expand_all=True, indent_guides=True)) value = output_buffer.getvalue() return value diff --git a/flixopt/results.py b/flixopt/results.py index 223e3708e..9c0f7245b 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -118,7 +118,7 @@ def from_calculation(cls, calculation: 'Calculation'): """ return cls( solution=calculation.model.solution, - flow_system=calculation.flow_system.as_dataset(constants_in_dataset=True), + flow_system=calculation.flow_system.to_dataset(constants_in_dataset=True), summary=calculation.summary, model=calculation.model, name=calculation.name, diff --git a/tests/test_dataconverter.py b/tests/test_dataconverter.py index 49f1438e7..329da7f92 100644 --- a/tests/test_dataconverter.py +++ b/tests/test_dataconverter.py @@ -14,7 +14,7 @@ def sample_time_index(request): def test_scalar_conversion(sample_time_index): # Test scalar conversion - result = DataConverter.as_dataarray(42, sample_time_index) + result = DataConverter.to_dataarray(42, sample_time_index) assert isinstance(result, xr.DataArray) assert result.shape == (len(sample_time_index),) assert result.dims == ('time',) @@ -25,7 +25,7 @@ def test_series_conversion(sample_time_index): series = pd.Series([1, 2, 3, 4, 5], index=sample_time_index) # Test Series conversion - result = DataConverter.as_dataarray(series, sample_time_index) + result = DataConverter.to_dataarray(series, sample_time_index) assert isinstance(result, xr.DataArray) assert result.shape == (5,) assert result.dims == ('time',) @@ -37,7 +37,7 @@ def test_dataframe_conversion(sample_time_index): df = pd.DataFrame({'A': [1, 2, 3, 4, 5]}, index=sample_time_index) # Test DataFrame conversion - result = DataConverter.as_dataarray(df, sample_time_index) + result = DataConverter.to_dataarray(df, sample_time_index) assert isinstance(result, xr.DataArray) assert result.shape == (5,) assert result.dims == ('time',) @@ -47,7 +47,7 @@ def test_dataframe_conversion(sample_time_index): def test_ndarray_conversion(sample_time_index): # Test 1D array conversion arr_1d = np.array([1, 2, 3, 4, 5]) - result = DataConverter.as_dataarray(arr_1d, sample_time_index) + result = DataConverter.to_dataarray(arr_1d, sample_time_index) assert result.shape == (5,) assert result.dims == ('time',) assert np.array_equal(result.values, arr_1d) @@ -58,7 +58,7 @@ def test_dataarray_conversion(sample_time_index): original = xr.DataArray(data=np.array([1, 2, 3, 4, 5]), coords={'time': sample_time_index}, dims=['time']) # Test DataArray conversion - result = DataConverter.as_dataarray(original, sample_time_index) + result = DataConverter.to_dataarray(original, sample_time_index) assert result.shape == (5,) assert result.dims == ('time',) assert np.array_equal(result.values, original.values) @@ -71,42 +71,42 @@ def test_dataarray_conversion(sample_time_index): def test_invalid_inputs(sample_time_index): # Test invalid input type with pytest.raises(ConversionError): - DataConverter.as_dataarray('invalid_string', sample_time_index) + DataConverter.to_dataarray('invalid_string', sample_time_index) # Test mismatched Series index mismatched_series = pd.Series([1, 2, 3, 4, 5, 6], index=pd.date_range('2025-01-01', periods=6, freq='D')) with pytest.raises(ConversionError): - DataConverter.as_dataarray(mismatched_series, sample_time_index) + DataConverter.to_dataarray(mismatched_series, sample_time_index) # Test DataFrame with multiple columns df_multi_col = pd.DataFrame({'A': [1, 2, 3, 4, 5], 'B': [6, 7, 8, 9, 10]}, index=sample_time_index) with pytest.raises(ConversionError): - DataConverter.as_dataarray(df_multi_col, sample_time_index) + DataConverter.to_dataarray(df_multi_col, sample_time_index) # Test mismatched array shape with pytest.raises(ConversionError): - DataConverter.as_dataarray(np.array([1, 2, 3]), sample_time_index) # Wrong length + DataConverter.to_dataarray(np.array([1, 2, 3]), sample_time_index) # Wrong length # Test multi-dimensional array with pytest.raises(ConversionError): - DataConverter.as_dataarray(np.array([[1, 2], [3, 4]]), sample_time_index) # 2D array not allowed + DataConverter.to_dataarray(np.array([[1, 2], [3, 4]]), sample_time_index) # 2D array not allowed def test_time_index_validation(): # Test with unnamed index unnamed_index = pd.date_range('2024-01-01', periods=5, freq='D') with pytest.raises(ConversionError): - DataConverter.as_dataarray(42, unnamed_index) + DataConverter.to_dataarray(42, unnamed_index) # Test with empty index empty_index = pd.DatetimeIndex([], name='time') with pytest.raises(ValueError): - DataConverter.as_dataarray(42, empty_index) + DataConverter.to_dataarray(42, empty_index) # Test with non-DatetimeIndex wrong_type_index = pd.Index([1, 2, 3, 4, 5], name='time') with pytest.raises(ValueError): - DataConverter.as_dataarray(42, wrong_type_index) + DataConverter.to_dataarray(42, wrong_type_index) if __name__ == '__main__': diff --git a/tests/test_io.py b/tests/test_io.py index 2e6c61ccf..8bcdb050e 100644 --- a/tests/test_io.py +++ b/tests/test_io.py @@ -50,10 +50,10 @@ def test_flow_system_file_io(flow_system, highs_solver): def test_flow_system_io(flow_system): - di = flow_system.as_dict() + di = flow_system.to_dict() _ = fx.FlowSystem.from_dict(di) - ds = flow_system.as_dataset() + ds = flow_system.to_dataset() _ = fx.FlowSystem.from_dataset(ds) print(flow_system) From cc7b15555e321cf3779edba21cb8cd7b6eeb860f Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 23 Jun 2025 17:23:49 +0200 Subject: [PATCH 062/336] Move methods to FlowSystem --- flixopt/flow_system.py | 4 ++-- flixopt/results.py | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index de94c14e5..6b65d8d00 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -51,7 +51,7 @@ def __init__( # Store timing information directly self.timesteps = self._validate_timesteps(timesteps) self.timesteps_extra = self._create_timesteps_with_extra(timesteps, hours_of_last_timestep) - self.hours_per_timestep = self._calculate_hours_per_timestep(self.timesteps_extra) + self.hours_per_timestep = self.calculate_hours_per_timestep(self.timesteps_extra) self.hours_of_previous_timesteps = self._calculate_hours_of_previous_timesteps( timesteps, hours_of_previous_timesteps ) @@ -89,7 +89,7 @@ def _create_timesteps_with_extra( return pd.DatetimeIndex(timesteps.append(last_date), name='time') @staticmethod - def _calculate_hours_per_timestep(timesteps_extra: pd.DatetimeIndex) -> xr.DataArray: + def calculate_hours_per_timestep(timesteps_extra: pd.DatetimeIndex) -> xr.DataArray: """Calculate duration of each timestep.""" hours_per_step = np.diff(timesteps_extra) / pd.Timedelta(hours=1) return xr.DataArray( diff --git a/flixopt/results.py b/flixopt/results.py index 9c0f7245b..232aaf5af 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -14,7 +14,7 @@ from . import io as fx_io from . import plotting -from .core import TimeSeriesCollection +from .flow_system import FlowSystem if TYPE_CHECKING: import pyvis @@ -160,7 +160,7 @@ def __init__( } self.timesteps_extra = self.solution.indexes['time'] - self.hours_per_timestep = TimeSeriesCollection.calculate_hours_per_timestep(self.timesteps_extra) + self.hours_per_timestep = FlowSystem.calculate_hours_per_timestep(self.timesteps_extra) def __getitem__(self, key: str) -> Union['ComponentResults', 'BusResults', 'EffectResults']: if key in self.components: @@ -684,7 +684,7 @@ def __init__( self.overlap_timesteps = overlap_timesteps self.name = name self.folder = pathlib.Path(folder) if folder is not None else pathlib.Path.cwd() / 'results' - self.hours_per_timestep = TimeSeriesCollection.calculate_hours_per_timestep(self.all_timesteps) + self.hours_per_timestep = FlowSystem.calculate_hours_per_timestep(self.all_timesteps) @property def meta_data(self) -> Dict[str, Union[int, List[str]]]: From ec6e792bf059a641e29fce72b34ee8d5761174de Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 23 Jun 2025 17:41:59 +0200 Subject: [PATCH 063/336] Drop nan values across time dimension if present --- flixopt/flow_system.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index 6b65d8d00..039cd2bfa 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -171,7 +171,12 @@ def _resolve_reference_structure(cls, structure, arrays_dict: Dict[str, xr.DataA # This is a reference to a DataArray array_name = structure[3:] # Remove ":::" prefix if array_name in arrays_dict: - return arrays_dict[array_name] + #TODO: Improve this! + da = arrays_dict[array_name] + if da.isnull().any(): + logger.warning(f"DataArray '{array_name}' contains null values. Dropping them.") + return da.dropna(dim='time', how='all') + return da else: logger.critical(f"Referenced DataArray '{array_name}' not found in dataset") return None From b42aad2b1dbecd3cfad88ebe201e846acee57de6 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 24 Jun 2025 09:12:17 +0200 Subject: [PATCH 064/336] Allow lists of values to create DataArray --- flixopt/core.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/flixopt/core.py b/flixopt/core.py index 73ad098ba..d629787bb 100644 --- a/flixopt/core.py +++ b/flixopt/core.py @@ -84,6 +84,9 @@ def to_dataarray(data: NumericData, timesteps: pd.DatetimeIndex) -> xr.DataArray f"DataArray length {data.sizes[dims[0]]} doesn't match expected {len(coords[0])}" ) return data.copy(deep=True) + elif isinstance(data, list): + logger.warning(f'Converting list to DataArray. This is not reccomended.') + return xr.DataArray(data, coords=coords, dims=dims) else: raise ConversionError(f'Unsupported type: {type(data).__name__}') except Exception as e: From b55af45a2e6d3538e098dda4586c519237239da9 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 24 Jun 2025 09:12:32 +0200 Subject: [PATCH 065/336] Update resolving of FlowSystem --- flixopt/flow_system.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index 039cd2bfa..9a28e1ad0 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -324,16 +324,13 @@ def from_dict(cls, data: Dict) -> 'FlowSystem': ) # Add elements using resolved data - for bus_data in resolved_data.get('buses', {}).values(): - bus = Bus.from_dict(bus_data) + for bus in resolved_data.get('buses', {}).values(): flow_system.add_elements(bus) - for effect_data in resolved_data.get('effects', {}).values(): - effect = Effect.from_dict(effect_data) + for effect in resolved_data.get('effects', {}).values(): flow_system.add_elements(effect) - for comp_data in resolved_data.get('components', {}).values(): - component = CLASS_REGISTRY[comp_data['__class__']].from_dict(comp_data) + for component in resolved_data.get('components', {}).values(): flow_system.add_elements(component) flow_system.transform_data() From d5ace96959015aabe4f869f4e9a12fb1f0e8419f Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 24 Jun 2025 09:12:45 +0200 Subject: [PATCH 066/336] Simplify TimeSeriesData --- flixopt/core.py | 81 +++++++++++++++++++++++++++++++------------------ 1 file changed, 51 insertions(+), 30 deletions(-) diff --git a/flixopt/core.py b/flixopt/core.py index d629787bb..3aad560b2 100644 --- a/flixopt/core.py +++ b/flixopt/core.py @@ -96,43 +96,64 @@ def to_dataarray(data: NumericData, timesteps: pd.DatetimeIndex) -> xr.DataArray class TimeSeriesData: - # TODO: Move to Interface.py - def __init__(self, data: NumericData, agg_group: Optional[str] = None, agg_weight: Optional[float] = None): + """Minimal wrapper around xr.DataArray with aggregation metadata.""" + + def __init__( + self, + data: Union[NumericData, xr.DataArray], + agg_group: Optional[str] = None, + agg_weight: Optional[float] = None, + ): """ - timeseries class for transmit timeseries AND special characteristics of timeseries, - i.g. to define weights needed in calculation_type 'aggregated' - EXAMPLE solar: - you have several solar timeseries. These should not be overweighted - compared to the remaining timeseries (i.g. heat load, price)! - fixed_relative_profile_solar1 = TimeSeriesData(sol_array_1, type = 'solar') - fixed_relative_profile_solar2 = TimeSeriesData(sol_array_2, type = 'solar') - fixed_relative_profile_solar3 = TimeSeriesData(sol_array_3, type = 'solar') - --> this 3 series of same type share one weight, i.e. internally assigned each weight = 1/3 - (instead of standard weight = 1) - Args: - data: The timeseries data, which can be a scalar, array, or numpy array. - agg_group: The group this TimeSeriesData is a part of. agg_weight is split between members of a group. Default is None. - agg_weight: The weight for calculation_type 'aggregated', should be between 0 and 1. Default is None. - - Raises: - Exception: If both agg_group and agg_weight are set, an exception is raised. + data: Numeric data or DataArray + agg_group: Aggregation group name + agg_weight: Aggregation weight (0-1) """ - self.data = data + if (agg_group is not None) and (agg_weight is not None): + raise ValueError('Use either agg_group or agg_weight, not both') + self.agg_group = agg_group self.agg_weight = agg_weight - if (agg_group is not None) and (agg_weight is not None): - raise ValueError('Either or explicit can be used. Not both!') - self.label: Optional[str] = None - def __repr__(self): - # Get the constructor arguments and their current values - init_signature = inspect.signature(self.__init__) - init_args = init_signature.parameters + # Store as DataArray + if isinstance(data, xr.DataArray): + self.data = data + else: + # Simple conversion - let caller handle timesteps/coords + self.data = xr.DataArray(np.asarray(data)) + + @property + def label(self) -> Optional[str]: + return self.data.name + + @label.setter + def label(self, value: Optional[str]): + self.data.name = value + + def to_dataarray(self) -> xr.DataArray: + """Return the DataArray with metadata in attrs.""" + attrs = {} + if self.agg_group is not None: + attrs['agg_group'] = self.agg_group + if self.agg_weight is not None: + attrs['agg_weight'] = self.agg_weight + + da = self.data.copy() + da.attrs.update(attrs) + return da + + @classmethod + def from_dataarray(cls, da: xr.DataArray) -> 'TimeSeriesData': + """Create from DataArray, extracting metadata from attrs.""" + return cls(data=da, agg_group=da.attrs.get('agg_group'), agg_weight=da.attrs.get('agg_weight')) + + def __getattr__(self, name): + """Delegate to underlying DataArray.""" + return getattr(self.data, name) - # Create a dictionary with argument names and their values - args_str = ', '.join(f'{name}={repr(getattr(self, name, None))}' for name in init_args if name != 'self') - return f'{self.__class__.__name__}({args_str})' + def __repr__(self): + return f'TimeSeriesData(agg_group={self.agg_group!r}, agg_weight={self.agg_weight!r})' def __str__(self): return str(self.data) From 4187f305f4d6d73a71aea686604772908525197f Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 24 Jun 2025 09:54:02 +0200 Subject: [PATCH 067/336] Move TImeSeriesData to Structure and simplyfy to inherrit from xarray.DataArray --- flixopt/aggregation.py | 3 +- flixopt/commons.py | 2 +- flixopt/core.py | 66 +----------------------------- flixopt/flow_system.py | 4 +- flixopt/linear_converters.py | 4 +- flixopt/structure.py | 79 +++++++++++++++++++++++++++++++----- 6 files changed, 77 insertions(+), 81 deletions(-) diff --git a/flixopt/aggregation.py b/flixopt/aggregation.py index f149d5f20..e558dc19b 100644 --- a/flixopt/aggregation.py +++ b/flixopt/aggregation.py @@ -22,13 +22,14 @@ TSAM_AVAILABLE = False from .components import Storage -from .core import Scalar, TimeSeriesData +from .core import Scalar from .elements import Component from .flow_system import FlowSystem from .structure import ( Element, Model, SystemModel, + TimeSeriesData, ) if TYPE_CHECKING: diff --git a/flixopt/commons.py b/flixopt/commons.py index 68412d6fe..7d03909c0 100644 --- a/flixopt/commons.py +++ b/flixopt/commons.py @@ -14,11 +14,11 @@ Transmission, ) from .config import CONFIG, change_logging_level -from .core import TimeSeriesData from .effects import Effect from .elements import Bus, Flow from .flow_system import FlowSystem from .interface import InvestParameters, OnOffParameters, Piece, Piecewise, PiecewiseConversion, PiecewiseEffects +from .structure import TimeSeriesData __all__ = [ 'TimeSeriesData', diff --git a/flixopt/core.py b/flixopt/core.py index 3aad560b2..43056cedb 100644 --- a/flixopt/core.py +++ b/flixopt/core.py @@ -95,74 +95,11 @@ def to_dataarray(data: NumericData, timesteps: pd.DatetimeIndex) -> xr.DataArray raise ConversionError(f'Converting data {type(data)} to xarray.Dataset raised an error: {str(e)}') from e -class TimeSeriesData: - """Minimal wrapper around xr.DataArray with aggregation metadata.""" - - def __init__( - self, - data: Union[NumericData, xr.DataArray], - agg_group: Optional[str] = None, - agg_weight: Optional[float] = None, - ): - """ - Args: - data: Numeric data or DataArray - agg_group: Aggregation group name - agg_weight: Aggregation weight (0-1) - """ - if (agg_group is not None) and (agg_weight is not None): - raise ValueError('Use either agg_group or agg_weight, not both') - - self.agg_group = agg_group - self.agg_weight = agg_weight - - # Store as DataArray - if isinstance(data, xr.DataArray): - self.data = data - else: - # Simple conversion - let caller handle timesteps/coords - self.data = xr.DataArray(np.asarray(data)) - - @property - def label(self) -> Optional[str]: - return self.data.name - - @label.setter - def label(self, value: Optional[str]): - self.data.name = value - - def to_dataarray(self) -> xr.DataArray: - """Return the DataArray with metadata in attrs.""" - attrs = {} - if self.agg_group is not None: - attrs['agg_group'] = self.agg_group - if self.agg_weight is not None: - attrs['agg_weight'] = self.agg_weight - - da = self.data.copy() - da.attrs.update(attrs) - return da - - @classmethod - def from_dataarray(cls, da: xr.DataArray) -> 'TimeSeriesData': - """Create from DataArray, extracting metadata from attrs.""" - return cls(data=da, agg_group=da.attrs.get('agg_group'), agg_weight=da.attrs.get('agg_weight')) - - def __getattr__(self, name): - """Delegate to underlying DataArray.""" - return getattr(self.data, name) - - def __repr__(self): - return f'TimeSeriesData(agg_group={self.agg_group!r}, agg_weight={self.agg_weight!r})' - - def __str__(self): - return str(self.data) - - class TimeSeries: def __init__(self): raise NotImplementedError('TimeSeries was removed') + class TimeSeriesCollection: """ Collection of TimeSeries objects with shared timestep management. @@ -174,6 +111,7 @@ class TimeSeriesCollection: def __init__(self): raise NotImplementedError('TimeSeriesCollection was removed') + def get_numeric_stats(data: xr.DataArray, decimals: int = 2, padd: int = 10) -> str: """Calculates the mean, median, min, max, and standard deviation of a numeric DataArray.""" format_spec = f'>{padd}.{decimals}f' if padd else f'.{decimals}f' diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index 9a28e1ad0..097b3af83 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -16,10 +16,10 @@ from rich.pretty import Pretty from . import io as fx_io -from .core import NumericData, NumericDataTS, TimeSeriesCollection, TimeSeriesData, TimeSeries +from .core import NumericData, NumericDataTS, TimeSeriesCollection, TimeSeries from .effects import Effect, EffectCollection, EffectTimeSeries, EffectValuesDict, EffectValuesUser from .elements import Bus, Component, Flow -from .structure import CLASS_REGISTRY, Element, SystemModel, get_compact_representation, get_str_representation +from .structure import CLASS_REGISTRY, Element, SystemModel, get_compact_representation, get_str_representation, TimeSeriesData if TYPE_CHECKING: import pyvis diff --git a/flixopt/linear_converters.py b/flixopt/linear_converters.py index 3fd032632..83527fef0 100644 --- a/flixopt/linear_converters.py +++ b/flixopt/linear_converters.py @@ -8,10 +8,10 @@ import numpy as np from .components import LinearConverter -from .core import NumericDataTS, TimeSeriesData +from .core import NumericDataTS from .elements import Flow from .interface import OnOffParameters -from .structure import register_class_for_io +from .structure import register_class_for_io, TimeSeriesData logger = logging.getLogger('flixopt') diff --git a/flixopt/structure.py b/flixopt/structure.py index 71efe31df..fadc1a06f 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -19,7 +19,7 @@ from rich.pretty import Pretty from .config import CONFIG -from .core import NumericData, Scalar, TimeSeriesCollection, TimeSeriesData, TimeSeries +from .core import NumericData, Scalar, TimeSeriesCollection, TimeSeries if TYPE_CHECKING: # for type checking and preventing circular imports from .effects import EffectCollectionModel @@ -162,14 +162,8 @@ def _extract_dataarrays_recursive(self, obj) -> Tuple[Any, Dict[str, xr.DataArra """ extracted_arrays = {} - # Handle TimeSeries objects - extract their data using their unique name - if isinstance(obj, TimeSeries): - data_array = obj.rename(obj.name) - extracted_arrays[obj.name] = data_array - return f':::{obj.name}', extracted_arrays - # Handle DataArrays directly - use their unique name - elif isinstance(obj, xr.DataArray): + if isinstance(obj, xr.DataArray): if not obj.name: raise ValueError('DataArray must have a unique name for serialization') extracted_arrays[obj.name] = obj @@ -222,12 +216,13 @@ def _resolve_reference_structure(cls, structure, arrays_dict: Dict[str, xr.DataA Convert reference structure back to actual objects using provided arrays. Args: - structure: Structure containing references (:::name) + structure: Structure containing references (:::name) or special type markers arrays_dict: Dictionary of available DataArrays Returns: - Structure with references resolved to actual DataArrays + Structure with references resolved to actual DataArrays or TimeSeriesData objects """ + # Handle regular DataArray references if isinstance(structure, str) and structure.startswith(':::'): # This is a reference to a DataArray array_name = structure[3:] # Remove ":::" prefix @@ -246,7 +241,6 @@ def _resolve_reference_structure(cls, structure, arrays_dict: Dict[str, xr.DataA return resolved_list elif isinstance(structure, dict): - # Check if this is a serialized Interface object if structure.get('__class__') and structure['__class__'] in CLASS_REGISTRY: # This is a nested Interface object - restore it recursively nested_class = CLASS_REGISTRY[structure['__class__']] @@ -256,6 +250,7 @@ def _resolve_reference_structure(cls, structure, arrays_dict: Dict[str, xr.DataA resolved_nested_data = cls._resolve_reference_structure(nested_data, arrays_dict) # Create the nested Interface object return nested_class(**resolved_nested_data) + else: # Regular dictionary - resolve references in values resolved_dict = {} @@ -355,6 +350,9 @@ def _apply_element_label_preference(self, obj): if obj.get('__class__') and 'label' in obj: # This looks like an Interface with a label - return just the label return obj.get('label', obj.get('__class__')) + elif obj.get('__class__') == 'TimeSeriesData': + # For TimeSeriesData, show a compact representation + return f'TimeSeriesData(agg_group={obj.get("agg_group")}, agg_weight={obj.get("agg_weight")})' else: return {k: self._apply_element_label_preference(v) for k, v in obj.items()} elif isinstance(obj, list): @@ -666,6 +664,65 @@ def results_structure(self): } +class TimeSeriesData(xr.DataArray): + """Minimal TimeSeriesData that inherits from xr.DataArray with aggregation metadata.""" + + def __init__(self, *args, agg_group: Optional[str] = None, agg_weight: Optional[float] = None, **kwargs): + """ + Args: + *args: Arguments passed to DataArray + agg_group: Aggregation group name + agg_weight: Aggregation weight (0-1) + **kwargs: Additional arguments passed to DataArray + """ + if (agg_group is not None) and (agg_weight is not None): + raise ValueError('Use either agg_group or agg_weight, not both') + + # Let xarray handle all the initialization complexity + super().__init__(*args, **kwargs) + + # Add our metadata to attrs after initialization + if agg_group is not None: + self.attrs['agg_group'] = agg_group + if agg_weight is not None: + self.attrs['agg_weight'] = agg_weight + + # Always mark as TimeSeriesData + self.attrs['__timeseries_data__'] = True + + @property + def agg_group(self) -> Optional[str]: + return self.attrs.get('agg_group') + + @property + def agg_weight(self) -> Optional[float]: + return self.attrs.get('agg_weight') + + @classmethod + def from_dataarray(cls, da: xr.DataArray, agg_group: Optional[str] = None, agg_weight: Optional[float] = None): + """Create TimeSeriesData from DataArray, extracting metadata from attrs.""" + # Get aggregation metadata from attrs or parameters + final_agg_group = agg_group if agg_group is not None else da.attrs.get('agg_group') + final_agg_weight = agg_weight if agg_weight is not None else da.attrs.get('agg_weight') + + return cls(da, agg_group=final_agg_group, agg_weight=final_agg_weight) + + @classmethod + def is_timeseries_data(cls, obj) -> bool: + """Check if an object is TimeSeriesData.""" + return isinstance(obj, xr.DataArray) and obj.attrs.get('__timeseries_data__', False) + + def __repr__(self): + agg_info = [] + if self.agg_group: + agg_info.append(f"agg_group='{self.agg_group}'") + if self.agg_weight is not None: + agg_info.append(f'agg_weight={self.agg_weight}') + + info_str = f'TimeSeriesData({", ".join(agg_info)})' if agg_info else 'TimeSeriesData' + return f'{info_str}\n{super().__repr__()}' + + def copy_and_convert_datatypes(data: Any, use_numpy: bool = True, use_element_label: bool = False) -> Any: """ Converts values in a nested data structure into JSON-compatible types while preserving or transforming numpy arrays From 617600fe833fc4ee4448bd1936a0bbb484e44212 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 24 Jun 2025 09:58:05 +0200 Subject: [PATCH 068/336] Adjust IO --- flixopt/structure.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/flixopt/structure.py b/flixopt/structure.py index fadc1a06f..166d2182c 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -176,7 +176,6 @@ def _extract_dataarrays_recursive(self, obj) -> Tuple[Any, Dict[str, xr.DataArra # Add all extracted arrays from the nested Interface extracted_arrays.update(interface_arrays) - return interface_structure, extracted_arrays # Handle lists @@ -222,12 +221,17 @@ def _resolve_reference_structure(cls, structure, arrays_dict: Dict[str, xr.DataA Returns: Structure with references resolved to actual DataArrays or TimeSeriesData objects """ - # Handle regular DataArray references + # Handle DataArray references (including TimeSeriesData) if isinstance(structure, str) and structure.startswith(':::'): - # This is a reference to a DataArray array_name = structure[3:] # Remove ":::" prefix if array_name in arrays_dict: - return arrays_dict[array_name] + array = arrays_dict[array_name] + + # Check if this should be restored as TimeSeriesData + if TimeSeriesData.is_timeseries_data(array): + return TimeSeriesData.from_dataarray(array) + else: + return array else: logger.critical(f"Referenced DataArray '{array_name}' not found in dataset") return None @@ -250,7 +254,6 @@ def _resolve_reference_structure(cls, structure, arrays_dict: Dict[str, xr.DataA resolved_nested_data = cls._resolve_reference_structure(nested_data, arrays_dict) # Create the nested Interface object return nested_class(**resolved_nested_data) - else: # Regular dictionary - resolve references in values resolved_dict = {} @@ -350,9 +353,6 @@ def _apply_element_label_preference(self, obj): if obj.get('__class__') and 'label' in obj: # This looks like an Interface with a label - return just the label return obj.get('label', obj.get('__class__')) - elif obj.get('__class__') == 'TimeSeriesData': - # For TimeSeriesData, show a compact representation - return f'TimeSeriesData(agg_group={obj.get("agg_group")}, agg_weight={obj.get("agg_weight")})' else: return {k: self._apply_element_label_preference(v) for k, v in obj.items()} elif isinstance(obj, list): From e80bba0dadc9ec4246a95b40de6e53882cb35286 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 24 Jun 2025 10:22:30 +0200 Subject: [PATCH 069/336] Move TimeSeriesData back to core.py and fix Conversion --- flixopt/aggregation.py | 3 +- flixopt/commons.py | 2 +- flixopt/core.py | 149 +++++++++++++++++++++++++++++++++++++++-- flixopt/flow_system.py | 24 +++---- flixopt/structure.py | 63 +---------------- 5 files changed, 160 insertions(+), 81 deletions(-) diff --git a/flixopt/aggregation.py b/flixopt/aggregation.py index e558dc19b..f149d5f20 100644 --- a/flixopt/aggregation.py +++ b/flixopt/aggregation.py @@ -22,14 +22,13 @@ TSAM_AVAILABLE = False from .components import Storage -from .core import Scalar +from .core import Scalar, TimeSeriesData from .elements import Component from .flow_system import FlowSystem from .structure import ( Element, Model, SystemModel, - TimeSeriesData, ) if TYPE_CHECKING: diff --git a/flixopt/commons.py b/flixopt/commons.py index 7d03909c0..222c07324 100644 --- a/flixopt/commons.py +++ b/flixopt/commons.py @@ -18,7 +18,7 @@ from .elements import Bus, Flow from .flow_system import FlowSystem from .interface import InvestParameters, OnOffParameters, Piece, Piecewise, PiecewiseConversion, PiecewiseEffects -from .structure import TimeSeriesData +from .core import TimeSeriesData __all__ = [ 'TimeSeriesData', diff --git a/flixopt/core.py b/flixopt/core.py index 43056cedb..31738f6c7 100644 --- a/flixopt/core.py +++ b/flixopt/core.py @@ -37,14 +37,134 @@ class ConversionError(Exception): pass +class TimeSeriesData(xr.DataArray): + """Minimal TimeSeriesData that inherits from xr.DataArray with aggregation metadata.""" + + __slots__ = () # No additional instance attributes - everything goes in attrs + + def __init__(self, *args, agg_group: Optional[str] = None, agg_weight: Optional[float] = None, **kwargs): + """ + Args: + *args: Arguments passed to DataArray + agg_group: Aggregation group name + agg_weight: Aggregation weight (0-1) + **kwargs: Additional arguments passed to DataArray + """ + if (agg_group is not None) and (agg_weight is not None): + raise ValueError('Use either agg_group or agg_weight, not both') + + # Let xarray handle all the initialization complexity + super().__init__(*args, **kwargs) + + # Add our metadata to attrs after initialization + if agg_group is not None: + self.attrs['agg_group'] = agg_group + if agg_weight is not None: + self.attrs['agg_weight'] = agg_weight + + # Always mark as TimeSeriesData + self.attrs['__timeseries_data__'] = True + + @property + def agg_group(self) -> Optional[str]: + return self.attrs.get('agg_group') + + @property + def agg_weight(self) -> Optional[float]: + return self.attrs.get('agg_weight') + + @classmethod + def from_dataarray(cls, da: xr.DataArray, agg_group: Optional[str] = None, agg_weight: Optional[float] = None): + """Create TimeSeriesData from DataArray, extracting metadata from attrs.""" + # Get aggregation metadata from attrs or parameters + final_agg_group = agg_group if agg_group is not None else da.attrs.get('agg_group') + final_agg_weight = agg_weight if agg_weight is not None else da.attrs.get('agg_weight') + + return cls(da, agg_group=final_agg_group, agg_weight=final_agg_weight) + + @classmethod + def is_timeseries_data(cls, obj) -> bool: + """Check if an object is TimeSeriesData.""" + return isinstance(obj, xr.DataArray) and obj.attrs.get('__timeseries_data__', False) + + def __repr__(self): + agg_info = [] + if self.agg_group: + agg_info.append(f"agg_group='{self.agg_group}'") + if self.agg_weight is not None: + agg_info.append(f'agg_weight={self.agg_weight}') + + info_str = f'TimeSeriesData({", ".join(agg_info)})' if agg_info else 'TimeSeriesData' + return f'{info_str}\n{super().__repr__()}' + class DataConverter: """ Converts various data types into xarray.DataArray with a timesteps index. - Supports: scalars, arrays, Series, DataFrames, and DataArrays. + Supports: scalars, arrays, Series, DataFrames, DataArrays, and TimeSeriesData. """ + @staticmethod + def _fix_timeseries_data_indexing( + data: TimeSeriesData, timesteps: pd.DatetimeIndex, dims: list, coords: list + ) -> TimeSeriesData: + """ + Fix TimeSeriesData indexing issues and return properly indexed TimeSeriesData. + + Args: + data: TimeSeriesData that might have indexing issues + timesteps: Target timesteps + dims: Expected dimensions + coords: Expected coordinates + + Returns: + TimeSeriesData with correct indexing + + Raises: + ConversionError: If data cannot be fixed to match expected indexing + """ + expected_shape = (len(timesteps),) + + # Check if dimensions match + if data.dims != tuple(dims): + logger.warning(f'TimeSeriesData has dimensions {data.dims}, expected {dims}. Reshaping to match timesteps.') + # Try to reshape the data to match expected dimensions + if data.size != len(timesteps): + raise ConversionError( + f'TimeSeriesData has {data.size} elements, cannot reshape to match {len(timesteps)} timesteps' + ) + # Create new DataArray with correct coordinates, preserving metadata + reshaped_data = xr.DataArray( + data.values.reshape(expected_shape), coords=coords, dims=dims, name=data.name, attrs=data.attrs.copy() + ) + return TimeSeriesData(reshaped_data) + + # Check if time coordinate length matches + elif data.sizes[dims[0]] != len(coords[0]): + logger.warning( + f'TimeSeriesData has {data.sizes[dims[0]]} time points, ' + f"expected {len(coords[0])}. Cannot reindex - lengths don't match." + ) + raise ConversionError( + f"TimeSeriesData length {data.sizes[dims[0]]} doesn't match expected {len(coords[0])}" + ) + + # Check if time coordinates are identical + elif not data.coords['time'].equals(timesteps): + logger.warning( + f'TimeSeriesData has different time coordinates than expected. Replacing with provided timesteps.' + ) + # Replace time coordinates while preserving data and metadata + recoordinated_data = xr.DataArray( + data.values, coords=coords, dims=dims, name=data.name, attrs=data.attrs.copy() + ) + return TimeSeriesData(recoordinated_data) + + else: + # Everything matches - return copy to avoid modifying original + return data.copy(deep=True) + @staticmethod def to_dataarray(data: NumericData, timesteps: pd.DatetimeIndex) -> xr.DataArray: """Convert data to xarray.DataArray with specified timesteps index.""" @@ -58,24 +178,38 @@ def to_dataarray(data: NumericData, timesteps: pd.DatetimeIndex) -> xr.DataArray expected_shape = (len(timesteps),) try: - if isinstance(data, (int, float, np.integer, np.floating)): - return xr.DataArray(data, coords=coords, dims=dims) + # Handle TimeSeriesData first (before generic DataArray check) + if isinstance(data, TimeSeriesData): + return DataConverter._fix_timeseries_data_indexing(data, timesteps, dims, coords) + + elif isinstance(data, TimeSeries): + # Handle TimeSeries objects (your existing logic) + pass # Add your TimeSeries handling here + + elif isinstance(data, (int, float, np.integer, np.floating)): + # Scalar: broadcast to all timesteps + scalar_data = np.full(expected_shape, data) + return xr.DataArray(scalar_data, coords=coords, dims=dims) + elif isinstance(data, pd.DataFrame): if not data.index.equals(timesteps): raise ConversionError("DataFrame index doesn't match timesteps index") if not len(data.columns) == 1: raise ConversionError('DataFrame must have exactly one column') return xr.DataArray(data.values.flatten(), coords=coords, dims=dims) + elif isinstance(data, pd.Series): if not data.index.equals(timesteps): raise ConversionError("Series index doesn't match timesteps index") return xr.DataArray(data.values, coords=coords, dims=dims) + elif isinstance(data, np.ndarray): if data.ndim != 1: raise ConversionError(f'Array must be 1-dimensional, got {data.ndim}') elif data.shape[0] != expected_shape[0]: raise ConversionError(f"Array shape {data.shape} doesn't match expected {expected_shape}") return xr.DataArray(data, coords=coords, dims=dims) + elif isinstance(data, xr.DataArray): if data.dims != tuple(dims): raise ConversionError(f"DataArray dimensions {data.dims} don't match expected {dims}") @@ -84,15 +218,20 @@ def to_dataarray(data: NumericData, timesteps: pd.DatetimeIndex) -> xr.DataArray f"DataArray length {data.sizes[dims[0]]} doesn't match expected {len(coords[0])}" ) return data.copy(deep=True) + elif isinstance(data, list): - logger.warning(f'Converting list to DataArray. This is not reccomended.') + logger.warning(f'Converting list to DataArray. This is not recommended.') + if len(data) != expected_shape[0]: + raise ConversionError(f"List length {len(data)} doesn't match expected {expected_shape[0]}") return xr.DataArray(data, coords=coords, dims=dims) + else: raise ConversionError(f'Unsupported type: {type(data).__name__}') + except Exception as e: if isinstance(e, ConversionError): raise - raise ConversionError(f'Converting data {type(data)} to xarray.Dataset raised an error: {str(e)}') from e + raise ConversionError(f'Converting data {type(data)} to xarray.DataArray raised an error: {str(e)}') from e class TimeSeries: diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index 097b3af83..48b9d5296 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -16,10 +16,10 @@ from rich.pretty import Pretty from . import io as fx_io -from .core import NumericData, NumericDataTS, TimeSeriesCollection, TimeSeries +from .core import NumericData, NumericDataTS, TimeSeriesCollection, TimeSeries, DataConverter, ConversionError, TimeSeriesData from .effects import Effect, EffectCollection, EffectTimeSeries, EffectValuesDict, EffectValuesUser from .elements import Bus, Component, Flow -from .structure import CLASS_REGISTRY, Element, SystemModel, get_compact_representation, get_str_representation, TimeSeriesData +from .structure import CLASS_REGISTRY, Element, SystemModel, get_compact_representation, get_str_representation if TYPE_CHECKING: import pyvis @@ -394,18 +394,16 @@ def create_time_series( # Choose appropriate timesteps target_timesteps = self.timesteps_extra if needs_extra_timestep else self.timesteps - if isinstance(data, TimeSeries): - # Extract the data and rename - return data.selected_data.rename(name) - elif isinstance(data, TimeSeriesData): - # Convert TimeSeriesData to DataArray - from .core import DataConverter # Assuming this exists - - return DataConverter.to_dataarray(data.data, timesteps=target_timesteps).rename(name) + if isinstance(data, TimeSeriesData): + try: + return TimeSeriesData( + DataConverter.to_dataarray(data, timesteps=target_timesteps), + agg_group=data.agg_group, agg_weight=data.agg_weight + ).rename(name) + except ConversionError as e: + logger.critical(f'Could not convert time series data "{name}" to DataArray: {e}. \n' + f'Take care to use the correct (time) index.') else: - # Convert other data types to DataArray - from .core import DataConverter # Assuming this exists - return DataConverter.to_dataarray(data, timesteps=target_timesteps).rename(name) def create_effect_time_series( diff --git a/flixopt/structure.py b/flixopt/structure.py index 166d2182c..e39a7d0ac 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -165,7 +165,9 @@ def _extract_dataarrays_recursive(self, obj) -> Tuple[Any, Dict[str, xr.DataArra # Handle DataArrays directly - use their unique name if isinstance(obj, xr.DataArray): if not obj.name: - raise ValueError('DataArray must have a unique name for serialization') + raise ValueError(f'DataArrays must have a unique name for serialization. Unnamed DataArrays are not supported. {obj}') + if obj.name in extracted_arrays: + raise ValueError(f' must have a unique name for serialization. "{obj.name}" is a duplicate. {obj}') extracted_arrays[obj.name] = obj return f':::{obj.name}', extracted_arrays @@ -664,65 +666,6 @@ def results_structure(self): } -class TimeSeriesData(xr.DataArray): - """Minimal TimeSeriesData that inherits from xr.DataArray with aggregation metadata.""" - - def __init__(self, *args, agg_group: Optional[str] = None, agg_weight: Optional[float] = None, **kwargs): - """ - Args: - *args: Arguments passed to DataArray - agg_group: Aggregation group name - agg_weight: Aggregation weight (0-1) - **kwargs: Additional arguments passed to DataArray - """ - if (agg_group is not None) and (agg_weight is not None): - raise ValueError('Use either agg_group or agg_weight, not both') - - # Let xarray handle all the initialization complexity - super().__init__(*args, **kwargs) - - # Add our metadata to attrs after initialization - if agg_group is not None: - self.attrs['agg_group'] = agg_group - if agg_weight is not None: - self.attrs['agg_weight'] = agg_weight - - # Always mark as TimeSeriesData - self.attrs['__timeseries_data__'] = True - - @property - def agg_group(self) -> Optional[str]: - return self.attrs.get('agg_group') - - @property - def agg_weight(self) -> Optional[float]: - return self.attrs.get('agg_weight') - - @classmethod - def from_dataarray(cls, da: xr.DataArray, agg_group: Optional[str] = None, agg_weight: Optional[float] = None): - """Create TimeSeriesData from DataArray, extracting metadata from attrs.""" - # Get aggregation metadata from attrs or parameters - final_agg_group = agg_group if agg_group is not None else da.attrs.get('agg_group') - final_agg_weight = agg_weight if agg_weight is not None else da.attrs.get('agg_weight') - - return cls(da, agg_group=final_agg_group, agg_weight=final_agg_weight) - - @classmethod - def is_timeseries_data(cls, obj) -> bool: - """Check if an object is TimeSeriesData.""" - return isinstance(obj, xr.DataArray) and obj.attrs.get('__timeseries_data__', False) - - def __repr__(self): - agg_info = [] - if self.agg_group: - agg_info.append(f"agg_group='{self.agg_group}'") - if self.agg_weight is not None: - agg_info.append(f'agg_weight={self.agg_weight}') - - info_str = f'TimeSeriesData({", ".join(agg_info)})' if agg_info else 'TimeSeriesData' - return f'{info_str}\n{super().__repr__()}' - - def copy_and_convert_datatypes(data: Any, use_numpy: bool = True, use_element_label: bool = False) -> Any: """ Converts values in a nested data structure into JSON-compatible types while preserving or transforming numpy arrays From 387cac64cd0e874788ad16edca1ed77a774b7e26 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 24 Jun 2025 10:31:04 +0200 Subject: [PATCH 070/336] Adjust IO to account for attrs of DataArrays in a Dataset --- flixopt/io.py | 35 +++++++++++++++++++++++++++++++---- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/flixopt/io.py b/flixopt/io.py index 1376cafae..23b06cacd 100644 --- a/flixopt/io.py +++ b/flixopt/io.py @@ -206,7 +206,7 @@ def save_dataset_to_netcdf( compression: int = 0, ) -> None: """ - Save a dataset to a netcdf file. Store the attrs as a json string in the 'attrs' attribute. + Save a dataset to a netcdf file. Store all attrs as JSON strings in 'attrs' attributes. Args: ds: Dataset to save. @@ -216,6 +216,7 @@ def save_dataset_to_netcdf( Raises: ValueError: If the path has an invalid file extension. """ + path = pathlib.Path(path) if path.suffix not in ['.nc', '.nc4']: raise ValueError(f'Invalid file extension for path {path}. Only .nc and .nc4 are supported') @@ -228,8 +229,20 @@ def save_dataset_to_netcdf( 'Dataset was exported without compression due to missing dependency "netcdf4".' 'Install netcdf4 via `pip install netcdf4`.' ) + ds = ds.copy(deep=True) ds.attrs = {'attrs': json.dumps(ds.attrs)} + + # Convert all DataArray attrs to JSON strings + for var_name, data_var in ds.data_vars.items(): + if data_var.attrs: # Only if there are attrs + ds[var_name].attrs = {'attrs': json.dumps(data_var.attrs)} + + # Also handle coordinate attrs if they exist + for coord_name, coord_var in ds.coords.items(): + if hasattr(coord_var, 'attrs') and coord_var.attrs: + ds[coord_name].attrs = {'attrs': json.dumps(coord_var.attrs)} + ds.to_netcdf( path, encoding=None @@ -240,16 +253,30 @@ def save_dataset_to_netcdf( def load_dataset_from_netcdf(path: Union[str, pathlib.Path]) -> xr.Dataset: """ - Load a dataset from a netcdf file. Load the attrs from the 'attrs' attribute. + Load a dataset from a netcdf file. Load all attrs from 'attrs' attributes. Args: path: Path to load the dataset from. Returns: - Dataset: Loaded dataset. + Dataset: Loaded dataset with restored attrs. """ ds = xr.load_dataset(path) - ds.attrs = json.loads(ds.attrs['attrs']) + + # Restore Dataset attrs + if 'attrs' in ds.attrs: + ds.attrs = json.loads(ds.attrs['attrs']) + + # Restore DataArray attrs + for var_name, data_var in ds.data_vars.items(): + if 'attrs' in data_var.attrs: + ds[var_name].attrs = json.loads(data_var.attrs['attrs']) + + # Restore coordinate attrs + for coord_name, coord_var in ds.coords.items(): + if hasattr(coord_var, 'attrs') and 'attrs' in coord_var.attrs: + ds[coord_name].attrs = json.loads(coord_var.attrs['attrs']) + return ds From 27734cf67a3ac69a3d4977dfdc477898d956bf98 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 24 Jun 2025 10:42:24 +0200 Subject: [PATCH 071/336] Rename transforming and connection methods in FlowSystem --- flixopt/calculation.py | 7 ++----- flixopt/flow_system.py | 40 +++++++++++++++++++++++++++------------- 2 files changed, 29 insertions(+), 18 deletions(-) diff --git a/flixopt/calculation.py b/flixopt/calculation.py index 8439142c1..e477f6c11 100644 --- a/flixopt/calculation.py +++ b/flixopt/calculation.py @@ -136,7 +136,7 @@ class for defined way of solving a flow_system optimization def do_modeling(self) -> SystemModel: t_start = timeit.default_timer() - self._activate_time_series() + self.flow_system.connect_and_transform() self.model = self.flow_system.create_model() self.model.do_modeling() @@ -181,9 +181,6 @@ def solve(self, solver: _Solver, log_file: Optional[pathlib.Path] = None, log_ma self.results = CalculationResults.from_calculation(self) - def _activate_time_series(self): - self.flow_system.transform_data() - class AggregatedCalculation(FullCalculation): """ @@ -221,7 +218,7 @@ def __init__( def do_modeling(self) -> SystemModel: t_start = timeit.default_timer() - self._activate_time_series() + self.flow_system.connect_and_transform() self._perform_aggregation() # Model the System diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index 48b9d5296..ed374319d 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -62,7 +62,7 @@ def __init__( self.effects: EffectCollection = EffectCollection() self.model: Optional[SystemModel] = None - self._connected = False + self._connected_and_transformed = False @staticmethod def _validate_timesteps(timesteps: pd.DatetimeIndex) -> pd.DatetimeIndex: @@ -223,6 +223,10 @@ def to_dataset(self, constants_in_dataset: bool = True) -> xr.Dataset: Returns: xr.Dataset: Dataset containing all DataArrays with structure in attributes """ + if not self._connected_and_transformed: + logger.warning('FlowSystem is not connected_and_transformed..') + self.connect_and_transform() + reference_structure, extracted_arrays = self._create_reference_structure() # Create the dataset with extracted arrays as variables and structure as attrs @@ -234,6 +238,10 @@ def to_dict(self, data_mode: Literal['data', 'name', 'stats'] = 'data') -> Dict: Convert the object to a dictionary representation. Now builds on the reference structure for consistency. """ + if not self._connected_and_transformed: + logger.warning('FlowSystem is not connected. Calling connect() now.') + self.connect_and_transform() + reference_structure, _ = self._create_reference_structure() if data_mode == 'data': @@ -333,7 +341,7 @@ def from_dict(cls, data: Dict) -> 'FlowSystem': for component in resolved_data.get('components', {}).values(): flow_system.add_elements(component) - flow_system.transform_data() + flow_system.connect_and_transform() return flow_system @classmethod @@ -353,6 +361,10 @@ def to_netcdf(self, path: Union[str, pathlib.Path], compression: int = 0, consta compression: The compression level to use when saving the file. constants_in_dataset: If True, constants are included as Dataset variables. """ + if not self._connected_and_transformed: + logger.warning('FlowSystem is not connected. Calling connect() now.') + self.connect_and_transform() + ds = self.to_dataset(constants_in_dataset=constants_in_dataset) fx_io.save_dataset_to_netcdf(ds, path, compression=compression) logger.info(f'Saved FlowSystem to {path}') @@ -365,6 +377,9 @@ def to_json(self, path: Union[str, pathlib.Path]): Args: path: The path to the JSON file. """ + if not self._connected_and_transformed: + logger.warning('FlowSystem needs to be connected and transformed before saving to JSON. Calling connect_and_transform() now.') + self.connect_and_transform() # Use the stats mode for JSON export (cleaner output) data = get_compact_representation(self.to_dict('stats')) with open(path, 'w', encoding='utf-8') as f: @@ -425,12 +440,12 @@ def create_effect_time_series( for effect, value in effect_values_dict.items() } - def transform_data(self): + def connect_and_transform(self): """Transform data for all elements using the new simplified approach.""" - if not self._connected: + if not self._connected_and_transformed: self._connect_network() - for element in self.all_elements.values(): - element.transform_data(self) + for element in self.all_elements.values(): + element.transform_data(self) def add_elements(self, *elements: Element) -> None: """ @@ -440,12 +455,12 @@ def add_elements(self, *elements: Element) -> None: *elements: childs of Element like Boiler, HeatPump, Bus,... modeling Elements """ - if self._connected: + if self._connected_and_transformed: warnings.warn( 'You are adding elements to an already connected FlowSystem. This is not recommended (But it works).', stacklevel=2, ) - self._connected = False + self._connected_and_transformed = False for new_element in list(elements): if isinstance(new_element, Component): self._add_components(new_element) @@ -459,8 +474,8 @@ def add_elements(self, *elements: Element) -> None: ) def create_model(self) -> SystemModel: - if not self._connected: - raise RuntimeError('FlowSystem is not connected. Call FlowSystem.connect() first.') + if not self._connected_and_transformed: + raise RuntimeError('FlowSystem is not connected_and_transformed. Call FlowSystem.connect_and_transform() first.') self.model = SystemModel(self) return self.model @@ -484,8 +499,8 @@ def plot_network( return plotting.plot_network(node_infos, edge_infos, path, controls, show) def network_infos(self) -> Tuple[Dict[str, Dict[str, str]], Dict[str, Dict[str, str]]]: - if not self._connected: - self._connect_network() + if not self._connected_and_transformed: + self.connect_and_transform() nodes = { node.label_full: { 'label': node.label, @@ -568,7 +583,6 @@ def _connect_network(self): f'Connected {len(self.buses)} Buses and {len(self.components)} ' f'via {len(self.flows)} Flows inside the FlowSystem.' ) - self._connected = True def __repr__(self): return f'<{self.__class__.__name__} with {len(self.components)} components and {len(self.effects)} effects>' From 4915b81f876e30578f977ea201ac2de030510d70 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 24 Jun 2025 11:27:00 +0200 Subject: [PATCH 072/336] Compacted IO methods --- flixopt/flow_system.py | 114 ++++++++++++++--------------------- flixopt/linear_converters.py | 4 +- flixopt/results.py | 2 +- flixopt/structure.py | 23 ------- 4 files changed, 47 insertions(+), 96 deletions(-) diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index ed374319d..3737c6e58 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -129,7 +129,7 @@ def _create_reference_structure(self) -> Tuple[Dict, Dict[str, xr.DataArray]]: # Extract from components components_structure = {} for comp_label, component in self.components.items(): - comp_structure, comp_arrays = self._extract_from_interface(component) + comp_structure, comp_arrays = component._create_reference_structure() all_extracted_arrays.update(comp_arrays) components_structure[comp_label] = comp_structure reference_structure['components'] = components_structure @@ -137,7 +137,7 @@ def _create_reference_structure(self) -> Tuple[Dict, Dict[str, xr.DataArray]]: # Extract from buses buses_structure = {} for bus_label, bus in self.buses.items(): - bus_structure, bus_arrays = self._extract_from_interface(bus) + bus_structure, bus_arrays = bus._create_reference_structure() all_extracted_arrays.update(bus_arrays) buses_structure[bus_label] = bus_structure reference_structure['buses'] = buses_structure @@ -145,22 +145,13 @@ def _create_reference_structure(self) -> Tuple[Dict, Dict[str, xr.DataArray]]: # Extract from effects effects_structure = {} for effect in self.effects: - effect_structure, effect_arrays = self._extract_from_interface(effect) + effect_structure, effect_arrays = effect._create_reference_structure() all_extracted_arrays.update(effect_arrays) effects_structure[effect.label] = effect_structure reference_structure['effects'] = effects_structure return reference_structure, all_extracted_arrays - def _extract_from_interface(self, interface_obj) -> Tuple[Dict, Dict[str, xr.DataArray]]: - """Extract arrays from an Interface object using its reference system.""" - if hasattr(interface_obj, '_create_reference_structure'): - return interface_obj._create_reference_structure() - else: - # Fallback for objects that don't have the new Interface methods - logger.warning(f"Object {interface_obj} doesn't have _create_reference_structure method") - return interface_obj.to_dict(), {} - @classmethod def _resolve_reference_structure(cls, structure, arrays_dict: Dict[str, xr.DataArray]): """ @@ -212,7 +203,7 @@ def _resolve_reference_structure(cls, structure, arrays_dict: Dict[str, xr.DataA else: return structure - def to_dataset(self, constants_in_dataset: bool = True) -> xr.Dataset: + def to_dataset(self) -> xr.Dataset: """ Convert the FlowSystem to an xarray Dataset using the Interface pattern. All DataArrays become dataset variables, structure goes to attrs. @@ -233,25 +224,6 @@ def to_dataset(self, constants_in_dataset: bool = True) -> xr.Dataset: ds = xr.Dataset(extracted_arrays, attrs=reference_structure) return ds - def to_dict(self, data_mode: Literal['data', 'name', 'stats'] = 'data') -> Dict: - """ - Convert the object to a dictionary representation. - Now builds on the reference structure for consistency. - """ - if not self._connected_and_transformed: - logger.warning('FlowSystem is not connected. Calling connect() now.') - self.connect_and_transform() - - reference_structure, _ = self._create_reference_structure() - - if data_mode == 'data': - return reference_structure - elif data_mode == 'stats': - # For stats mode, we might want to process the structure further - return fx_io.remove_none_and_empty(reference_structure) - else: # name mode - return reference_structure - @classmethod def from_dataset(cls, ds: xr.Dataset) -> 'FlowSystem': """ @@ -310,39 +282,22 @@ def from_dataset(cls, ds: xr.Dataset) -> 'FlowSystem': return flow_system - @classmethod - def from_dict(cls, data: Dict) -> 'FlowSystem': + def to_netcdf(self, path: Union[str, pathlib.Path], compression: int = 0): """ - Load a FlowSystem from a dictionary using the Interface pattern. + Save the FlowSystem to a NetCDF file using the Interface pattern. Args: - data: Dictionary containing the FlowSystem data. + path: The path to the netCDF file. + compression: The compression level to use when saving the file. + constants_in_dataset: If True, constants are included as Dataset variables. """ - # For dict format, resolve with empty arrays (references may not be used) - resolved_data = cls._resolve_reference_structure(data, {}) - - # Extract constructor parameters - timesteps_extra = pd.DatetimeIndex(resolved_data['timesteps_extra'], name='time') - hours_of_last_timestep = float((timesteps_extra[-1] - timesteps_extra[-2]) / pd.Timedelta(hours=1)) - - flow_system = cls( - timesteps=timesteps_extra[:-1], - hours_of_last_timestep=hours_of_last_timestep, - hours_of_previous_timesteps=resolved_data['hours_of_previous_timesteps'], - ) - - # Add elements using resolved data - for bus in resolved_data.get('buses', {}).values(): - flow_system.add_elements(bus) - - for effect in resolved_data.get('effects', {}).values(): - flow_system.add_elements(effect) - - for component in resolved_data.get('components', {}).values(): - flow_system.add_elements(component) + if not self._connected_and_transformed: + logger.warning('FlowSystem is not connected. Calling connect() now.') + self.connect_and_transform() - flow_system.connect_and_transform() - return flow_system + ds = self.to_dataset() + fx_io.save_dataset_to_netcdf(ds, path, compression=compression) + logger.info(f'Saved FlowSystem to {path}') @classmethod def from_netcdf(cls, path: Union[str, pathlib.Path]) -> 'FlowSystem': @@ -352,22 +307,22 @@ def from_netcdf(cls, path: Union[str, pathlib.Path]) -> 'FlowSystem': ds = fx_io.load_dataset_from_netcdf(path) return cls.from_dataset(ds) - def to_netcdf(self, path: Union[str, pathlib.Path], compression: int = 0, constants_in_dataset: bool = True): + def get_structure(self, clean: bool = False) -> Dict: """ - Save the FlowSystem to a NetCDF file using the Interface pattern. + Get FlowSystem structure. Args: - path: The path to the netCDF file. - compression: The compression level to use when saving the file. - constants_in_dataset: If True, constants are included as Dataset variables. + clean: If True, remove None and empty dicts and lists. """ if not self._connected_and_transformed: logger.warning('FlowSystem is not connected. Calling connect() now.') self.connect_and_transform() - ds = self.to_dataset(constants_in_dataset=constants_in_dataset) - fx_io.save_dataset_to_netcdf(ds, path, compression=compression) - logger.info(f'Saved FlowSystem to {path}') + reference_structure, _ = self._create_reference_structure() + if clean: + return fx_io.remove_none_and_empty(reference_structure) + else: + return reference_structure def to_json(self, path: Union[str, pathlib.Path]): """ @@ -381,7 +336,7 @@ def to_json(self, path: Union[str, pathlib.Path]): logger.warning('FlowSystem needs to be connected and transformed before saving to JSON. Calling connect_and_transform() now.') self.connect_and_transform() # Use the stats mode for JSON export (cleaner output) - data = get_compact_representation(self.to_dict('stats')) + data = get_compact_representation(self.get_structure(clean=True)) with open(path, 'w', encoding='utf-8') as f: json.dump(data, f, indent=4, ensure_ascii=False) @@ -446,6 +401,7 @@ def connect_and_transform(self): self._connect_network() for element in self.all_elements.values(): element.transform_data(self) + self._connected_and_transformed = True def add_elements(self, *elements: Element) -> None: """ @@ -590,10 +546,28 @@ def __repr__(self): def __str__(self): with StringIO() as output_buffer: console = Console(file=output_buffer, width=1000) # Adjust width as needed - console.print(Pretty(self.to_dict('stats'), expand_all=True, indent_guides=True)) + console.print(Pretty(self.get_structure(clean=True), expand_all=True, indent_guides=True)) value = output_buffer.getvalue() return value + def __eq__(self, other: 'FlowSystem'): + """Check if two FlowSystems are equal by comparing their dataset representations.""" + if not isinstance(other, FlowSystem): + raise NotImplementedError('Comparison with other types is not implemented for class FlowSystem') + + ds_me = self.to_dataset() + ds_other = other.to_dataset() + + try: + xr.testing.assert_equal(ds_me, ds_other) + except AssertionError: + return False + + if ds_me.attrs != ds_other.attrs: + return False + + return True + @property def flows(self) -> Dict[str, Flow]: set_of_flows = {flow for comp in self.components.values() for flow in comp.inputs + comp.outputs} diff --git a/flixopt/linear_converters.py b/flixopt/linear_converters.py index 83527fef0..3fd032632 100644 --- a/flixopt/linear_converters.py +++ b/flixopt/linear_converters.py @@ -8,10 +8,10 @@ import numpy as np from .components import LinearConverter -from .core import NumericDataTS +from .core import NumericDataTS, TimeSeriesData from .elements import Flow from .interface import OnOffParameters -from .structure import register_class_for_io, TimeSeriesData +from .structure import register_class_for_io logger = logging.getLogger('flixopt') diff --git a/flixopt/results.py b/flixopt/results.py index 232aaf5af..e13cb0785 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -118,7 +118,7 @@ def from_calculation(cls, calculation: 'Calculation'): """ return cls( solution=calculation.model.solution, - flow_system=calculation.flow_system.to_dataset(constants_in_dataset=True), + flow_system=calculation.flow_system.to_dataset(), summary=calculation.summary, model=calculation.model, name=calculation.name, diff --git a/flixopt/structure.py b/flixopt/structure.py index e39a7d0ac..10ab7ad8c 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -303,17 +303,6 @@ def to_dataset(self) -> xr.Dataset: ds = xr.Dataset(extracted_arrays, attrs=reference_structure) return ds - def to_dict(self) -> Dict: - """ - Convert the object to a dictionary representation. - DataArrays/TimeSeries are converted to references, but structure is preserved. - - Returns: - Dict: Dictionary with references to DataArrays/TimeSeries - """ - reference_structure, _ = self._create_reference_structure() - return reference_structure - def infos(self, use_numpy: bool = True, use_element_label: bool = False) -> Dict: """ Generate a dictionary representation of the object's constructor arguments. @@ -362,18 +351,6 @@ def _apply_element_label_preference(self, obj): else: return obj - def to_json(self, path: Union[str, pathlib.Path]): - """ - Save the element to a JSON file for documentation purposes. - Uses the infos() method for consistent representation. - - Args: - path: The path to the JSON file. - """ - data = get_compact_representation(self.infos(use_numpy=False, use_element_label=True)) - with open(path, 'w', encoding='utf-8') as f: - json.dump(data, f, indent=4, ensure_ascii=False) - def to_netcdf(self, path: Union[str, pathlib.Path], compression: int = 0): """ Save the object to a NetCDF file. From fc5549a20a6d1e6ebc35d760b72a524ad18457fc Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 24 Jun 2025 11:33:41 +0200 Subject: [PATCH 073/336] Remove infos() --- flixopt/elements.py | 11 ----------- flixopt/structure.py | 35 ----------------------------------- 2 files changed, 46 deletions(-) diff --git a/flixopt/elements.py b/flixopt/elements.py index 3ea29a09f..ba74030cb 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -72,12 +72,6 @@ def transform_data(self, flow_system: 'FlowSystem') -> None: if self.on_off_parameters is not None: self.on_off_parameters.transform_data(flow_system, self.label_full) - def infos(self, use_numpy=True, use_element_label: bool = False) -> Dict: - infos = super().infos(use_numpy, use_element_label) - infos['inputs'] = [flow.infos(use_numpy, use_element_label) for flow in self.inputs] - infos['outputs'] = [flow.infos(use_numpy, use_element_label) for flow in self.outputs] - return infos - def _check_unique_flow_labels(self): all_flow_labels = [flow.label for flow in self.inputs + self.outputs] @@ -253,11 +247,6 @@ def transform_data(self, flow_system: 'FlowSystem'): if isinstance(self.size, InvestParameters): self.size.transform_data(flow_system) - def infos(self, use_numpy: bool = True, use_element_label: bool = False) -> Dict: - infos = super().infos(use_numpy, use_element_label) - infos['is_input_in_component'] = self.is_input_in_component - return infos - def to_dict(self) -> Dict: data = super().to_dict() if isinstance(data.get('previous_flow_rate'), np.ndarray): diff --git a/flixopt/structure.py b/flixopt/structure.py index 10ab7ad8c..abcfdf9d2 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -303,41 +303,6 @@ def to_dataset(self) -> xr.Dataset: ds = xr.Dataset(extracted_arrays, attrs=reference_structure) return ds - def infos(self, use_numpy: bool = True, use_element_label: bool = False) -> Dict: - """ - Generate a dictionary representation of the object's constructor arguments. - Built on top of dataset creation for better consistency and analytics capabilities. - - Args: - use_numpy: Whether to convert NumPy arrays to lists. Defaults to True. - If True, numeric numpy arrays are preserved as-is. - If False, they are converted to lists. - use_element_label: Whether to use element labels instead of full infos for nested objects. - - Returns: - A dictionary representation optimized for documentation and analysis. - """ - # Get the core dataset representation - ds = self.to_dataset() - - # Start with the reference structure from attrs - info_dict = dict(ds.attrs) - - # Process DataArrays in the dataset based on preferences - for var_name, data_array in ds.data_vars.items(): - if use_numpy: - # Keep as DataArray/numpy for analysis - info_dict[f'_data_{var_name}'] = data_array - else: - # Convert to lists for JSON compatibility - info_dict[f'_data_{var_name}'] = data_array.values.tolist() - - # Apply element label preference to nested structures - if use_element_label: - info_dict = self._apply_element_label_preference(info_dict) - - return info_dict - def _apply_element_label_preference(self, obj): """Apply element label preference to nested structures.""" if isinstance(obj, dict): From 299ff433e31682088a91f51f4e1669c513236a95 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 24 Jun 2025 11:35:15 +0200 Subject: [PATCH 074/336] remove from_dict() and to_dict() --- flixopt/elements.py | 6 ------ flixopt/structure.py | 31 ------------------------------- 2 files changed, 37 deletions(-) diff --git a/flixopt/elements.py b/flixopt/elements.py index ba74030cb..48e73ef76 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -247,12 +247,6 @@ def transform_data(self, flow_system: 'FlowSystem'): if isinstance(self.size, InvestParameters): self.size.transform_data(flow_system) - def to_dict(self) -> Dict: - data = super().to_dict() - if isinstance(data.get('previous_flow_rate'), np.ndarray): - data['previous_flow_rate'] = data['previous_flow_rate'].tolist() - return data - def _plausibility_checks(self) -> None: # TODO: Incorporate into Variable? (Lower_bound can not be greater than upper bound if np.any(self.relative_minimum > self.relative_maximum): diff --git a/flixopt/structure.py b/flixopt/structure.py index abcfdf9d2..4f94073e7 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -303,19 +303,6 @@ def to_dataset(self) -> xr.Dataset: ds = xr.Dataset(extracted_arrays, attrs=reference_structure) return ds - def _apply_element_label_preference(self, obj): - """Apply element label preference to nested structures.""" - if isinstance(obj, dict): - if obj.get('__class__') and 'label' in obj: - # This looks like an Interface with a label - return just the label - return obj.get('label', obj.get('__class__')) - else: - return {k: self._apply_element_label_preference(v) for k, v in obj.items()} - elif isinstance(obj, list): - return [self._apply_element_label_preference(item) for item in obj] - else: - return obj - def to_netcdf(self, path: Union[str, pathlib.Path], compression: int = 0): """ Save the object to a NetCDF file. @@ -375,24 +362,6 @@ def from_netcdf(cls, path: Union[str, pathlib.Path]) -> 'Interface': ds = fx_io.load_dataset_from_netcdf(path) return cls.from_dataset(ds) - @classmethod - def from_dict(cls, data: Dict) -> 'Interface': - """ - Create an instance from a dictionary representation. - This is now a thin wrapper around the reference resolution system. - - Args: - data: Dictionary containing the data for the object. - """ - class_name = data.pop('__class__', None) - if class_name and class_name != cls.__name__: - logger.warning(f"Dict class '{class_name}' doesn't match target class '{cls.__name__}'") - - # Since dict format doesn't separate arrays, resolve with empty arrays dict - # References in dict format would need to be handled differently if they exist - resolved_params = cls._resolve_reference_structure(data, {}) - return cls(**resolved_params) - def __repr__(self): # Get the constructor arguments and their current values init_signature = inspect.signature(self.__init__) From abc22b108b207072a53d8de4ac50b89c803e72ca Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 24 Jun 2025 11:39:22 +0200 Subject: [PATCH 075/336] Update __str__ of Interface --- flixopt/structure.py | 33 ++++++++++++++++++++++++++++----- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/flixopt/structure.py b/flixopt/structure.py index 4f94073e7..d19e371d1 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -20,6 +20,7 @@ from .config import CONFIG from .core import NumericData, Scalar, TimeSeriesCollection, TimeSeries +from . import io as fx_io if TYPE_CHECKING: # for type checking and preventing circular imports from .effects import EffectCollectionModel @@ -311,8 +312,6 @@ def to_netcdf(self, path: Union[str, pathlib.Path], compression: int = 0): path: Path to save the NetCDF file compression: Compression level (0-9) """ - from . import io as fx_io # Assuming fx_io is available - ds = self.to_dataset() fx_io.save_dataset_to_netcdf(ds, path, compression=compression) @@ -357,11 +356,35 @@ def from_netcdf(cls, path: Union[str, pathlib.Path]) -> 'Interface': Returns: Interface instance """ - from . import io as fx_io # Assuming fx_io is available - ds = fx_io.load_dataset_from_netcdf(path) return cls.from_dataset(ds) + def get_structure(self, clean: bool = False) -> Dict: + """ + Get FlowSystem structure. + + Args: + clean: If True, remove None and empty dicts and lists. + """ + + reference_structure, _ = self._create_reference_structure() + if clean: + return fx_io.remove_none_and_empty(reference_structure) + return reference_structure + + def to_json(self, path: Union[str, pathlib.Path]): + """ + Save the Element to a JSON file using the Interface pattern. + This is meant for documentation and comparison, not for reloading. + + Args: + path: The path to the JSON file. + """ + # Use the stats mode for JSON export (cleaner output) + data = get_compact_representation(self.get_structure(clean=True)) + with open(path, 'w', encoding='utf-8') as f: + json.dump(data, f, indent=4, ensure_ascii=False) + def __repr__(self): # Get the constructor arguments and their current values init_signature = inspect.signature(self.__init__) @@ -372,7 +395,7 @@ def __repr__(self): return f'{self.__class__.__name__}({args_str})' def __str__(self): - return get_str_representation(self.infos(use_numpy=True, use_element_label=True)) + return get_str_representation(self.get_structure(clean=True)) class Element(Interface): From 9b4c44c8315abf2cea89c953f96b5535158e0a2c Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 24 Jun 2025 14:01:06 +0200 Subject: [PATCH 076/336] Improve str and repr --- flixopt/flow_system.py | 41 ++++++++++++++++++++++++++++++++--------- 1 file changed, 32 insertions(+), 9 deletions(-) diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index 3737c6e58..f5077434d 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -540,15 +540,38 @@ def _connect_network(self): f'via {len(self.flows)} Flows inside the FlowSystem.' ) - def __repr__(self): - return f'<{self.__class__.__name__} with {len(self.components)} components and {len(self.effects)} effects>' - - def __str__(self): - with StringIO() as output_buffer: - console = Console(file=output_buffer, width=1000) # Adjust width as needed - console.print(Pretty(self.get_structure(clean=True), expand_all=True, indent_guides=True)) - value = output_buffer.getvalue() - return value + def __repr__(self) -> str: + """Compact representation for debugging.""" + status = '✓' if self._connected_and_transformed else '⚠' + return ( + f'FlowSystem({len(self.timesteps)} timesteps ' + f'[{self.timesteps[0].strftime("%Y-%m-%d")} to {self.timesteps[-1].strftime("%Y-%m-%d")}], ' + f'{len(self.components)} Components / {len(self.buses)} Buses / {len(self.effects)} Effects, {status})' + ) + + def __str__(self) -> str: + """Structured summary for users.""" + + def format_elements(parts: list, label: str): + if not parts: + return f'{label}:{"":>8} {len(parts)}' + name_list = ', '.join(parts[:3]) + if len(parts) > 3: + name_list += f' ... (+{len(parts) - 3} more)' + return f'{label}:{"":>8} {len(parts)} ({name_list})' + + lines = [ + f'FlowSystem Overview:', + f'{"─" * 50}', + f'Time period: {self.timesteps[0].date()} to {self.timesteps[-1].date()}', + f'Timesteps: {len(self.timesteps)} ({self.timesteps.freq or "irregular frequency"})', + format_elements(list(self.components), 'Components'), + format_elements(list(self.buses), 'Buses'), + format_elements(list(self.effects.effects), 'Effects'), + f'Status: {"Connected & Transformed" if self._connected_and_transformed else "Not connected"}', + ] + + return '\n'.join(lines) def __eq__(self, other: 'FlowSystem'): """Check if two FlowSystems are equal by comparing their dataset representations.""" From 0ab7ea6f75d2b3b6c68339a1e770386a2f5b6f62 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 24 Jun 2025 14:03:44 +0200 Subject: [PATCH 077/336] Improve str and repr --- flixopt/flow_system.py | 30 +++++++++++++++++------------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index f5077434d..7d62c35ca 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -546,28 +546,32 @@ def __repr__(self) -> str: return ( f'FlowSystem({len(self.timesteps)} timesteps ' f'[{self.timesteps[0].strftime("%Y-%m-%d")} to {self.timesteps[-1].strftime("%Y-%m-%d")}], ' - f'{len(self.components)} Components / {len(self.buses)} Buses / {len(self.effects)} Effects, {status})' + f'{len(self.components)} Components, {len(self.buses)} Buses, {len(self.effects)} Effects, {status})' ) def __str__(self) -> str: """Structured summary for users.""" - def format_elements(parts: list, label: str): - if not parts: - return f'{label}:{"":>8} {len(parts)}' - name_list = ', '.join(parts[:3]) - if len(parts) > 3: - name_list += f' ... (+{len(parts) - 3} more)' - return f'{label}:{"":>8} {len(parts)} ({name_list})' + def format_elements(element_names: list, label: str, alignment: int = 12): + name_list = ', '.join(element_names[:3]) + if len(element_names) > 3: + name_list += f' ... (+{len(element_names) - 3} more)' + + suffix = f' ({name_list})' if element_names else '' + padding = alignment - len(label) - 1 # -1 for the colon + return f'{label}:{"":<{padding}} {len(element_names)}{suffix}' + + time_period = f'Time period: {self.timesteps[0].date()} to {self.timesteps[-1].date()}' + freq_str = str(self.timesteps.freq).replace('<', '').replace('>', '') if self.timesteps.freq else 'irregular' lines = [ f'FlowSystem Overview:', f'{"─" * 50}', - f'Time period: {self.timesteps[0].date()} to {self.timesteps[-1].date()}', - f'Timesteps: {len(self.timesteps)} ({self.timesteps.freq or "irregular frequency"})', - format_elements(list(self.components), 'Components'), - format_elements(list(self.buses), 'Buses'), - format_elements(list(self.effects.effects), 'Effects'), + time_period, + f'Timesteps: {len(self.timesteps)} ({freq_str})', + format_elements(list(self.components.keys()), 'Components'), + format_elements(list(self.buses.keys()), 'Buses'), + format_elements(list(self.effects.effects.keys()), 'Effects'), f'Status: {"Connected & Transformed" if self._connected_and_transformed else "Not connected"}', ] From 1dcbbb05c25074cc4e0c4d5dd8463431f02d9527 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 24 Jun 2025 19:20:56 +0200 Subject: [PATCH 078/336] Add docstring --- flixopt/flow_system.py | 14 ++++++++++++-- flixopt/structure.py | 2 +- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index 7d62c35ca..4a227df9c 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -29,8 +29,18 @@ class FlowSystem: """ - A FlowSystem organizes the high level Elements (Components & Effects). - Uses xr.Dataset directly from its Interface elements instead of TimeSeriesCollection. + FlowSystem serves as the main container for energy system modeling, organizing + high-level elements including Components (like boilers, heat pumps, storages), + Buses (connection points), and Effects (system-wide influences). It handles + time series data management, network connectivity, and provides serialization + capabilities for saving and loading complete system configurations. + + The system uses xarray.Dataset for efficient time series data handling. It can be exported and restored to NETCDF. + + See Also: + Component: Base class for system components like boilers, heat pumps. + Bus: Connection points for flows between components. + Effect: System-wide effects, like the optimization objective. """ def __init__( diff --git a/flixopt/structure.py b/flixopt/structure.py index d19e371d1..7dc19318d 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -19,7 +19,7 @@ from rich.pretty import Pretty from .config import CONFIG -from .core import NumericData, Scalar, TimeSeriesCollection, TimeSeries +from .core import NumericData, Scalar, TimeSeriesCollection, TimeSeries, TimeSeriesData from . import io as fx_io if TYPE_CHECKING: # for type checking and preventing circular imports From 9aec99081486388b6152ac6ea748ec8fbf0851d6 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 24 Jun 2025 19:28:06 +0200 Subject: [PATCH 079/336] Unify IO stuff in Interface class --- flixopt/flow_system.py | 145 +++++++++++------------------------------ flixopt/structure.py | 6 ++ 2 files changed, 43 insertions(+), 108 deletions(-) diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index 4a227df9c..ff99725a5 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -19,7 +19,7 @@ from .core import NumericData, NumericDataTS, TimeSeriesCollection, TimeSeries, DataConverter, ConversionError, TimeSeriesData from .effects import Effect, EffectCollection, EffectTimeSeries, EffectValuesDict, EffectValuesUser from .elements import Bus, Component, Flow -from .structure import CLASS_REGISTRY, Element, SystemModel, get_compact_representation, get_str_representation +from .structure import CLASS_REGISTRY, Element, SystemModel, get_compact_representation, get_str_representation, Interface if TYPE_CHECKING: import pyvis @@ -27,7 +27,7 @@ logger = logging.getLogger('flixopt') -class FlowSystem: +class FlowSystem(Interface): """ FlowSystem serves as the main container for energy system modeling, organizing high-level elements including Components (like boilers, heat pumps, storages), @@ -44,10 +44,10 @@ class FlowSystem: """ def __init__( - self, - timesteps: pd.DatetimeIndex, - hours_of_last_timestep: Optional[float] = None, - hours_of_previous_timesteps: Optional[Union[int, float, np.ndarray]] = None, + self, + timesteps: pd.DatetimeIndex, + hours_of_last_timestep: Optional[float] = None, + hours_of_previous_timesteps: Optional[Union[int, float, np.ndarray]] = None, ): """ Args: @@ -89,7 +89,7 @@ def _validate_timesteps(timesteps: pd.DatetimeIndex) -> pd.DatetimeIndex: @staticmethod def _create_timesteps_with_extra( - timesteps: pd.DatetimeIndex, hours_of_last_timestep: Optional[float] + timesteps: pd.DatetimeIndex, hours_of_last_timestep: Optional[float] ) -> pd.DatetimeIndex: """Create timesteps with an extra step at the end.""" if hours_of_last_timestep is None: @@ -108,7 +108,7 @@ def calculate_hours_per_timestep(timesteps_extra: pd.DatetimeIndex) -> xr.DataAr @staticmethod def _calculate_hours_of_previous_timesteps( - timesteps: pd.DatetimeIndex, hours_of_previous_timesteps: Optional[Union[float, np.ndarray]] + timesteps: pd.DatetimeIndex, hours_of_previous_timesteps: Optional[Union[float, np.ndarray]] ) -> Union[float, np.ndarray]: """Calculate duration of regular timesteps.""" if hours_of_previous_timesteps is not None: @@ -119,21 +119,22 @@ def _calculate_hours_of_previous_timesteps( def _create_reference_structure(self) -> Tuple[Dict, Dict[str, xr.DataArray]]: """ - Create reference structure for FlowSystem following the Interface pattern. - Extracts all DataArrays from components, buses, and effects. + Override Interface method to handle FlowSystem-specific serialization. + Combines custom FlowSystem logic with Interface pattern for nested objects. Returns: Tuple of (reference_structure, extracted_arrays_dict) """ - reference_structure = { - '__class__': self.__class__.__name__, - 'timesteps_extra': [date.isoformat() for date in self.timesteps_extra], - 'hours_of_previous_timesteps': self.hours_of_previous_timesteps, - } + # Start with Interface base functionality for constructor parameters + reference_structure, all_extracted_arrays = super()._create_reference_structure() + + # Override timesteps serialization (we need timesteps_extra instead of timesteps) + reference_structure['timesteps_extra'] = [date.isoformat() for date in self.timesteps_extra] - all_extracted_arrays = {} + # Remove timesteps from structure since we're using timesteps_extra + reference_structure.pop('timesteps', None) - # Add timing arrays directly + # Add timing arrays directly (not handled by Interface introspection) all_extracted_arrays['hours_per_timestep'] = self.hours_per_timestep # Extract from components @@ -162,64 +163,10 @@ def _create_reference_structure(self) -> Tuple[Dict, Dict[str, xr.DataArray]]: return reference_structure, all_extracted_arrays - @classmethod - def _resolve_reference_structure(cls, structure, arrays_dict: Dict[str, xr.DataArray]): - """ - Resolve reference structure back to actual objects. - Reuses the Interface pattern for consistency. - """ - if isinstance(structure, str) and structure.startswith(':::'): - # This is a reference to a DataArray - array_name = structure[3:] # Remove ":::" prefix - if array_name in arrays_dict: - #TODO: Improve this! - da = arrays_dict[array_name] - if da.isnull().any(): - logger.warning(f"DataArray '{array_name}' contains null values. Dropping them.") - return da.dropna(dim='time', how='all') - return da - else: - logger.critical(f"Referenced DataArray '{array_name}' not found in dataset") - return None - - elif isinstance(structure, list): - resolved_list = [] - for item in structure: - resolved_item = cls._resolve_reference_structure(item, arrays_dict) - if resolved_item is not None: - resolved_list.append(resolved_item) - return resolved_list - - elif isinstance(structure, dict): - # Check if this is a serialized Interface object - if structure.get('__class__') and structure['__class__'] in CLASS_REGISTRY: - # This is a nested Interface object - restore it recursively - nested_class = CLASS_REGISTRY[structure['__class__']] - # Remove the __class__ key and process the rest - nested_data = {k: v for k, v in structure.items() if k != '__class__'} - # Resolve references in the nested data - resolved_nested_data = cls._resolve_reference_structure(nested_data, arrays_dict) - # Create the nested Interface object - return nested_class(**resolved_nested_data) - else: - # Regular dictionary - resolve references in values - resolved_dict = {} - for key, value in structure.items(): - resolved_value = cls._resolve_reference_structure(value, arrays_dict) - if resolved_value is not None or value is None: - resolved_dict[key] = resolved_value - return resolved_dict - - else: - return structure - def to_dataset(self) -> xr.Dataset: """ - Convert the FlowSystem to an xarray Dataset using the Interface pattern. - All DataArrays become dataset variables, structure goes to attrs. - - Args: - constants_in_dataset: If True, constants are included as Dataset variables. + Convert the FlowSystem to an xarray Dataset. + Ensures FlowSystem is connected before serialization. Returns: xr.Dataset: Dataset containing all DataArrays with structure in attributes @@ -228,16 +175,13 @@ def to_dataset(self) -> xr.Dataset: logger.warning('FlowSystem is not connected_and_transformed..') self.connect_and_transform() - reference_structure, extracted_arrays = self._create_reference_structure() - - # Create the dataset with extracted arrays as variables and structure as attrs - ds = xr.Dataset(extracted_arrays, attrs=reference_structure) - return ds + return super().to_dataset() @classmethod def from_dataset(cls, ds: xr.Dataset) -> 'FlowSystem': """ - Create a FlowSystem from an xarray Dataset using the Interface pattern. + Create a FlowSystem from an xarray Dataset. + Handles FlowSystem-specific reconstruction logic. Args: ds: Dataset containing the FlowSystem data @@ -255,7 +199,7 @@ def from_dataset(cls, ds: xr.Dataset) -> 'FlowSystem': # Calculate hours_of_last_timestep from the timesteps hours_of_last_timestep = float((timesteps_extra[-1] - timesteps_extra[-2]) / pd.Timedelta(hours=1)) - # Create FlowSystem instance + # Create FlowSystem instance with constructor parameters flow_system = cls( timesteps=timesteps_extra[:-1], hours_of_last_timestep=hours_of_last_timestep, @@ -278,66 +222,53 @@ def from_dataset(cls, ds: xr.Dataset) -> 'FlowSystem': for bus_label, bus_data in buses_structure.items(): bus = cls._resolve_reference_structure(bus_data, arrays_dict) if not isinstance(bus, Bus): - logger.critical(f'Restoring component {bus_label} failed.') + logger.critical(f'Restoring bus {bus_label} failed.') flow_system._add_buses(bus) # Restore effects effects_structure = reference_structure.get('effects', {}) for effect_label, effect_data in effects_structure.items(): effect = cls._resolve_reference_structure(effect_data, arrays_dict) - if not isinstance(effect, Effect): - logger.critical(f'Restoring component {effect_label} failed.') + logger.critical(f'Restoring effect {effect_label} failed.') flow_system._add_effects(effect) return flow_system def to_netcdf(self, path: Union[str, pathlib.Path], compression: int = 0): """ - Save the FlowSystem to a NetCDF file using the Interface pattern. + Save the FlowSystem to a NetCDF file. + Ensures FlowSystem is connected before saving. Args: path: The path to the netCDF file. compression: The compression level to use when saving the file. - constants_in_dataset: If True, constants are included as Dataset variables. """ if not self._connected_and_transformed: - logger.warning('FlowSystem is not connected. Calling connect() now.') + logger.warning('FlowSystem is not connected. Calling connect_and_transform() now.') self.connect_and_transform() - ds = self.to_dataset() - fx_io.save_dataset_to_netcdf(ds, path, compression=compression) + super().to_netcdf(path, compression) logger.info(f'Saved FlowSystem to {path}') - @classmethod - def from_netcdf(cls, path: Union[str, pathlib.Path]) -> 'FlowSystem': - """ - Load a FlowSystem from a netcdf file using the Interface pattern. - """ - ds = fx_io.load_dataset_from_netcdf(path) - return cls.from_dataset(ds) - def get_structure(self, clean: bool = False) -> Dict: """ Get FlowSystem structure. + Ensures FlowSystem is connected before getting structure. Args: clean: If True, remove None and empty dicts and lists. """ if not self._connected_and_transformed: - logger.warning('FlowSystem is not connected. Calling connect() now.') + logger.warning('FlowSystem is not connected. Calling connect_and_transform() now.') self.connect_and_transform() - reference_structure, _ = self._create_reference_structure() - if clean: - return fx_io.remove_none_and_empty(reference_structure) - else: - return reference_structure + return super().get_structure(clean) def to_json(self, path: Union[str, pathlib.Path]): """ - Save the flow system to a JSON file using the Interface pattern. - This is meant for documentation and comparison, not for reloading. + Save the flow system to a JSON file. + Ensures FlowSystem is connected before saving. Args: path: The path to the JSON file. @@ -345,10 +276,8 @@ def to_json(self, path: Union[str, pathlib.Path]): if not self._connected_and_transformed: logger.warning('FlowSystem needs to be connected and transformed before saving to JSON. Calling connect_and_transform() now.') self.connect_and_transform() - # Use the stats mode for JSON export (cleaner output) - data = get_compact_representation(self.get_structure(clean=True)) - with open(path, 'w', encoding='utf-8') as f: - json.dump(data, f, indent=4, ensure_ascii=False) + + super().to_json(path) def create_time_series( self, diff --git a/flixopt/structure.py b/flixopt/structure.py index 7dc19318d..55a347651 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -230,6 +230,12 @@ def _resolve_reference_structure(cls, structure, arrays_dict: Dict[str, xr.DataA if array_name in arrays_dict: array = arrays_dict[array_name] + #TODO: Improve this! + if array.isnull().any(): + logger.warning(f"DataArray '{array_name}' contains null values. Dropping them.") + return array.dropna(dim='time', how='all') + return array + # Check if this should be restored as TimeSeriesData if TimeSeriesData.is_timeseries_data(array): return TimeSeriesData.from_dataarray(array) From e3703117883822044a4e5497abc012abcfc26ad3 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 24 Jun 2025 19:29:16 +0200 Subject: [PATCH 080/336] Improve test tu utilize __eq__ method --- tests/test_io.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/test_io.py b/tests/test_io.py index 8bcdb050e..497b334c8 100644 --- a/tests/test_io.py +++ b/tests/test_io.py @@ -50,11 +50,12 @@ def test_flow_system_file_io(flow_system, highs_solver): def test_flow_system_io(flow_system): - di = flow_system.to_dict() - _ = fx.FlowSystem.from_dict(di) + flow_system.to_json('fs.json') ds = flow_system.to_dataset() - _ = fx.FlowSystem.from_dataset(ds) + new_fs = fx.FlowSystem.from_dataset(ds) + + assert flow_system == new_fs print(flow_system) flow_system.__repr__() From 793e820de5cba614ca2b10fd8b4b683d60fdc412 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 24 Jun 2025 21:42:27 +0200 Subject: [PATCH 081/336] Make Interface class more robust and improve exceptions --- flixopt/structure.py | 340 +++++++++++++++++++++++++++++++------------ 1 file changed, 243 insertions(+), 97 deletions(-) diff --git a/flixopt/structure.py b/flixopt/structure.py index 55a347651..36f723ad1 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -109,12 +109,46 @@ def coords_extra(self) -> Tuple[pd.DatetimeIndex]: class Interface: """ - This class is used to collect arguments about a Model. Its the base class for all Elements and Models in flixopt. + Base class for all Elements and Models in flixopt that provides serialization capabilities. + + This class enables automatic serialization/deserialization of objects containing xarray DataArrays + and nested Interface objects to/from xarray Datasets and NetCDF files. It uses introspection + of constructor parameters to automatically handle most serialization scenarios. + + Key Features: + - Automatic extraction and restoration of xarray DataArrays + - Support for nested Interface objects + - NetCDF and JSON export/import + - Recursive handling of complex nested structures + + Subclasses must implement: + transform_data(flow_system): Transform data to match FlowSystem dimensions + + Example: + >>> class MyComponent(Interface): + ... def __init__(self, name: str, power_data: xr.DataArray): + ... self.name = name + ... self.power_data = power_data + ... + ... def transform_data(self, flow_system): + ... # Transform power_data to match flow_system timesteps + ... pass + >>> + >>> component = MyComponent('gen1', power_array) + >>> component.to_netcdf('component.nc') # Save to file + >>> restored = MyComponent.from_netcdf('component.nc') # Load from file """ def transform_data(self, flow_system: 'FlowSystem'): - """Transforms the data of the interface to match the FlowSystem's dimensions""" - raise NotImplementedError('Every Interface needs a transform_data() method') + """Transform the data of the interface to match the FlowSystem's dimensions. + + Args: + flow_system: The FlowSystem containing timing and dimensional information + + Raises: + NotImplementedError: Must be implemented by subclasses + """ + raise NotImplementedError('Every Interface subclass needs a transform_data() method') def _create_reference_structure(self) -> Tuple[Dict, Dict[str, xr.DataArray]]: """ @@ -123,15 +157,19 @@ def _create_reference_structure(self) -> Tuple[Dict, Dict[str, xr.DataArray]]: Returns: Tuple of (reference_structure, extracted_arrays_dict) + + Raises: + ValueError: If DataArrays don't have unique names or are duplicated """ - # Get constructor parameters - init_params = inspect.signature(self.__init__).parameters + # Get constructor parameters using caching for performance + if not hasattr(self, '_cached_init_params'): + self._cached_init_params = list(inspect.signature(self.__init__).parameters.keys()) # Process all constructor parameters reference_structure = {'__class__': self.__class__.__name__} all_extracted_arrays = {} - for name in init_params: + for name in self._cached_init_params: if name == 'self': continue @@ -140,73 +178,102 @@ def _create_reference_structure(self) -> Tuple[Dict, Dict[str, xr.DataArray]]: continue # Extract arrays and get reference structure - processed_value, extracted_arrays = self._extract_dataarrays_recursive(value) + processed_value, extracted_arrays = self._extract_dataarrays_recursive(value, name) + + # Check for array name conflicts + conflicts = set(all_extracted_arrays.keys()) & set(extracted_arrays.keys()) + if conflicts: + raise ValueError( + f'DataArray name conflicts detected: {conflicts}. ' + f'Each DataArray must have a unique name for serialization.' + ) # Add extracted arrays to the collection all_extracted_arrays.update(extracted_arrays) # Only store in structure if it's not None/empty after processing - if processed_value is not None and not (isinstance(processed_value, (dict, list)) and not processed_value): + if processed_value is not None and not self._is_empty_container(processed_value): reference_structure[name] = processed_value return reference_structure, all_extracted_arrays - def _extract_dataarrays_recursive(self, obj) -> Tuple[Any, Dict[str, xr.DataArray]]: + @staticmethod + def _is_empty_container(obj) -> bool: + """Check if object is an empty container (dict, list, tuple, set).""" + return isinstance(obj, (dict, list, tuple, set)) and len(obj) == 0 + + def _extract_dataarrays_recursive(self, obj, context_name: str = '') -> Tuple[Any, Dict[str, xr.DataArray]]: """ Recursively extract DataArrays/TimeSeries from nested structures. Args: obj: Object to process + context_name: Name context for better error messages Returns: Tuple of (processed_object_with_references, extracted_arrays_dict) + + Raises: + ValueError: If DataArrays don't have unique names """ extracted_arrays = {} # Handle DataArrays directly - use their unique name if isinstance(obj, xr.DataArray): if not obj.name: - raise ValueError(f'DataArrays must have a unique name for serialization. Unnamed DataArrays are not supported. {obj}') - if obj.name in extracted_arrays: - raise ValueError(f' must have a unique name for serialization. "{obj.name}" is a duplicate. {obj}') - extracted_arrays[obj.name] = obj - return f':::{obj.name}', extracted_arrays + raise ValueError( + f'DataArrays must have a unique name for serialization. ' + f'Unnamed DataArray found in {context_name}. Please set array.name = "unique_name"' + ) + + array_name = str(obj.name) # Ensure string type + if array_name in extracted_arrays: + raise ValueError( + f'DataArray name "{array_name}" is duplicated in {context_name}. ' + f'Each DataArray must have a unique name for serialization.' + ) + + extracted_arrays[array_name] = obj + return f':::{array_name}', extracted_arrays # Handle Interface objects - extract their DataArrays too elif isinstance(obj, Interface): - # Get the Interface's reference structure and arrays - interface_structure, interface_arrays = obj._create_reference_structure() - - # Add all extracted arrays from the nested Interface - extracted_arrays.update(interface_arrays) - return interface_structure, extracted_arrays - - # Handle lists - elif isinstance(obj, list): - processed_list = [] - for item in obj: - processed_item, nested_arrays = self._extract_dataarrays_recursive(item) + try: + interface_structure, interface_arrays = obj._create_reference_structure() + extracted_arrays.update(interface_arrays) + return interface_structure, extracted_arrays + except Exception as e: + raise ValueError(f'Failed to process nested Interface object in {context_name}: {e}') from e + + # Handle sequences (lists, tuples) + elif isinstance(obj, (list, tuple)): + processed_items = [] + for i, item in enumerate(obj): + item_context = f'{context_name}[{i}]' if context_name else f'item[{i}]' + processed_item, nested_arrays = self._extract_dataarrays_recursive(item, item_context) extracted_arrays.update(nested_arrays) - processed_list.append(processed_item) - return processed_list, extracted_arrays + processed_items.append(processed_item) + return processed_items, extracted_arrays # Handle dictionaries elif isinstance(obj, dict): processed_dict = {} for key, value in obj.items(): - processed_value, nested_arrays = self._extract_dataarrays_recursive(value) + key_context = f'{context_name}.{key}' if context_name else str(key) + processed_value, nested_arrays = self._extract_dataarrays_recursive(value, key_context) extracted_arrays.update(nested_arrays) processed_dict[key] = processed_value return processed_dict, extracted_arrays - # Handle tuples (convert to list for JSON compatibility) - elif isinstance(obj, tuple): - processed_list = [] - for item in obj: - processed_item, nested_arrays = self._extract_dataarrays_recursive(item) + # Handle sets (convert to list for JSON compatibility) + elif isinstance(obj, set): + processed_items = [] + for i, item in enumerate(obj): + item_context = f'{context_name}.set_item[{i}]' if context_name else f'set_item[{i}]' + processed_item, nested_arrays = self._extract_dataarrays_recursive(item, item_context) extracted_arrays.update(nested_arrays) - processed_list.append(processed_item) - return processed_list, extracted_arrays + processed_items.append(processed_item) + return processed_items, extracted_arrays # For all other types, serialize to basic types else: @@ -222,28 +289,29 @@ def _resolve_reference_structure(cls, structure, arrays_dict: Dict[str, xr.DataA arrays_dict: Dictionary of available DataArrays Returns: - Structure with references resolved to actual DataArrays or TimeSeriesData objects + Structure with references resolved to actual DataArrays or objects + + Raises: + ValueError: If referenced arrays are not found or class is not registered """ - # Handle DataArray references (including TimeSeriesData) + # Handle DataArray references if isinstance(structure, str) and structure.startswith(':::'): array_name = structure[3:] # Remove ":::" prefix - if array_name in arrays_dict: - array = arrays_dict[array_name] - - #TODO: Improve this! - if array.isnull().any(): - logger.warning(f"DataArray '{array_name}' contains null values. Dropping them.") - return array.dropna(dim='time', how='all') - return array - - # Check if this should be restored as TimeSeriesData - if TimeSeriesData.is_timeseries_data(array): - return TimeSeriesData.from_dataarray(array) - else: - return array - else: - logger.critical(f"Referenced DataArray '{array_name}' not found in dataset") - return None + if array_name not in arrays_dict: + raise ValueError(f"Referenced DataArray '{array_name}' not found in dataset") + + array = arrays_dict[array_name] + + # Handle null values with warning + if array.isnull().any(): + logger.warning(f"DataArray '{array_name}' contains null values. Dropping them.") + array = array.dropna(dim='time', how='all') + + # Check if this should be restored as TimeSeriesData + if TimeSeriesData.is_timeseries_data(array): + return TimeSeriesData.from_dataarray(array) + + return array elif isinstance(structure, list): resolved_list = [] @@ -254,15 +322,25 @@ def _resolve_reference_structure(cls, structure, arrays_dict: Dict[str, xr.DataA return resolved_list elif isinstance(structure, dict): - if structure.get('__class__') and structure['__class__'] in CLASS_REGISTRY: + if structure.get('__class__'): + class_name = structure['__class__'] + if class_name not in CLASS_REGISTRY: + raise ValueError( + f"Class '{class_name}' not found in CLASS_REGISTRY. " + f'Available classes: {list(CLASS_REGISTRY.keys())}' + ) + # This is a nested Interface object - restore it recursively - nested_class = CLASS_REGISTRY[structure['__class__']] + nested_class = CLASS_REGISTRY[class_name] # Remove the __class__ key and process the rest nested_data = {k: v for k, v in structure.items() if k != '__class__'} # Resolve references in the nested data resolved_nested_data = cls._resolve_reference_structure(nested_data, arrays_dict) - # Create the nested Interface object - return nested_class(**resolved_nested_data) + + try: + return nested_class(**resolved_nested_data) + except Exception as e: + raise ValueError(f'Failed to create instance of {class_name}: {e}') from e else: # Regular dictionary - resolve references in values resolved_dict = {} @@ -276,21 +354,36 @@ def _resolve_reference_structure(cls, structure, arrays_dict: Dict[str, xr.DataA return structure def _serialize_to_basic_types(self, obj): - """Convert object to basic Python types only (no DataArrays, no custom objects).""" + """ + Convert object to basic Python types only (no DataArrays, no custom objects). + + Args: + obj: Object to serialize + + Returns: + Object converted to basic Python types (str, int, float, bool, list, dict) + """ if obj is None or isinstance(obj, (str, int, float, bool)): return obj elif isinstance(obj, np.integer): return int(obj) elif isinstance(obj, np.floating): return float(obj) + elif isinstance(obj, np.bool_): + return bool(obj) elif isinstance(obj, (np.ndarray, pd.Series, pd.DataFrame)): return obj.tolist() if hasattr(obj, 'tolist') else list(obj) elif isinstance(obj, dict): return {k: self._serialize_to_basic_types(v) for k, v in obj.items()} elif isinstance(obj, (list, tuple)): return [self._serialize_to_basic_types(item) for item in obj] + elif isinstance(obj, set): + return [self._serialize_to_basic_types(item) for item in obj] elif hasattr(obj, 'isoformat'): # datetime objects return obj.isoformat() + elif hasattr(obj, '__dict__'): # Custom objects with attributes + logger.warning(f'Converting custom object {type(obj)} to dict representation: {obj}') + return {str(k): self._serialize_to_basic_types(v) for k, v in obj.__dict__.items()} else: # For any other object, try to convert to string as fallback logger.warning(f'Converting unknown type {type(obj)} to string: {obj}') @@ -303,12 +396,16 @@ def to_dataset(self) -> xr.Dataset: Returns: xr.Dataset: Dataset containing all DataArrays with basic objects only in attributes - """ - reference_structure, extracted_arrays = self._create_reference_structure() - # Create the dataset with extracted arrays as variables and structure as attrs - ds = xr.Dataset(extracted_arrays, attrs=reference_structure) - return ds + Raises: + ValueError: If serialization fails due to naming conflicts or invalid data + """ + try: + reference_structure, extracted_arrays = self._create_reference_structure() + # Create the dataset with extracted arrays as variables and structure as attrs + return xr.Dataset(extracted_arrays, attrs=reference_structure) + except Exception as e: + raise ValueError(f'Failed to convert {self.__class__.__name__} to dataset: {e}') from e def to_netcdf(self, path: Union[str, pathlib.Path], compression: int = 0): """ @@ -317,9 +414,16 @@ def to_netcdf(self, path: Union[str, pathlib.Path], compression: int = 0): Args: path: Path to save the NetCDF file compression: Compression level (0-9) + + Raises: + ValueError: If serialization fails + IOError: If file cannot be written """ - ds = self.to_dataset() - fx_io.save_dataset_to_netcdf(ds, path, compression=compression) + try: + ds = self.to_dataset() + fx_io.save_dataset_to_netcdf(ds, path, compression=compression) + except Exception as e: + raise IOError(f'Failed to save {self.__class__.__name__} to NetCDF file {path}: {e}') from e @classmethod def from_dataset(cls, ds: xr.Dataset) -> 'Interface': @@ -331,25 +435,31 @@ def from_dataset(cls, ds: xr.Dataset) -> 'Interface': Returns: Interface instance + + Raises: + ValueError: If dataset format is invalid or class mismatch """ - # Get class name and verify it matches - class_name = ds.attrs.get('__class__') - if class_name != cls.__name__: - logger.warning(f"Dataset class '{class_name}' doesn't match target class '{cls.__name__}'") + try: + # Get class name and verify it matches + class_name = ds.attrs.get('__class__') + if class_name and class_name != cls.__name__: + logger.warning(f"Dataset class '{class_name}' doesn't match target class '{cls.__name__}'") - # Get the reference structure from attrs - reference_structure = dict(ds.attrs) + # Get the reference structure from attrs + reference_structure = dict(ds.attrs) - # Remove the class name since it's not a constructor parameter - reference_structure.pop('__class__', None) + # Remove the class name since it's not a constructor parameter + reference_structure.pop('__class__', None) - # Create arrays dictionary from dataset variables - arrays_dict = {name: array for name, array in ds.data_vars.items()} + # Create arrays dictionary from dataset variables + arrays_dict = {name: array for name, array in ds.data_vars.items()} - # Resolve all references using the centralized method - resolved_params = cls._resolve_reference_structure(reference_structure, arrays_dict) + # Resolve all references using the centralized method + resolved_params = cls._resolve_reference_structure(reference_structure, arrays_dict) - return cls(**resolved_params) + return cls(**resolved_params) + except Exception as e: + raise ValueError(f'Failed to create {cls.__name__} from dataset: {e}') from e @classmethod def from_netcdf(cls, path: Union[str, pathlib.Path]) -> 'Interface': @@ -361,18 +471,27 @@ def from_netcdf(cls, path: Union[str, pathlib.Path]) -> 'Interface': Returns: Interface instance + + Raises: + IOError: If file cannot be read + ValueError: If file format is invalid """ - ds = fx_io.load_dataset_from_netcdf(path) - return cls.from_dataset(ds) + try: + ds = fx_io.load_dataset_from_netcdf(path) + return cls.from_dataset(ds) + except Exception as e: + raise IOError(f'Failed to load {cls.__name__} from NetCDF file {path}: {e}') from e def get_structure(self, clean: bool = False) -> Dict: """ - Get FlowSystem structure. + Get object structure as a dictionary. Args: clean: If True, remove None and empty dicts and lists. - """ + Returns: + Dictionary representation of the object structure + """ reference_structure, _ = self._create_reference_structure() if clean: return fx_io.remove_none_and_empty(reference_structure) @@ -380,28 +499,55 @@ def get_structure(self, clean: bool = False) -> Dict: def to_json(self, path: Union[str, pathlib.Path]): """ - Save the Element to a JSON file using the Interface pattern. + Save the object to a JSON file. This is meant for documentation and comparison, not for reloading. Args: path: The path to the JSON file. + + Raises: + IOError: If file cannot be written """ - # Use the stats mode for JSON export (cleaner output) - data = get_compact_representation(self.get_structure(clean=True)) - with open(path, 'w', encoding='utf-8') as f: - json.dump(data, f, indent=4, ensure_ascii=False) + try: + # Use the stats mode for JSON export (cleaner output) + data = get_compact_representation(self.get_structure(clean=True)) + with open(path, 'w', encoding='utf-8') as f: + json.dump(data, f, indent=4, ensure_ascii=False) + except Exception as e: + raise IOError(f'Failed to save {self.__class__.__name__} to JSON file {path}: {e}') from e def __repr__(self): - # Get the constructor arguments and their current values - init_signature = inspect.signature(self.__init__) - init_args = init_signature.parameters - - # Create a dictionary with argument names and their values - args_str = ', '.join(f'{name}={repr(getattr(self, name, None))}' for name in init_args if name != 'self') - return f'{self.__class__.__name__}({args_str})' + """Return a detailed string representation for debugging.""" + try: + # Get the constructor arguments and their current values + init_signature = inspect.signature(self.__init__) + init_args = init_signature.parameters + + # Create a dictionary with argument names and their values, with better formatting + args_parts = [] + for name in init_args: + if name == 'self': + continue + value = getattr(self, name, None) + # Truncate long representations + value_repr = repr(value) + if len(value_repr) > 50: + value_repr = value_repr[:47] + '...' + args_parts.append(f'{name}={value_repr}') + + args_str = ', '.join(args_parts) + return f'{self.__class__.__name__}({args_str})' + except Exception: + # Fallback if introspection fails + return f'{self.__class__.__name__}()' def __str__(self): - return get_str_representation(self.get_structure(clean=True)) + """Return a user-friendly string representation.""" + try: + return get_str_representation(self.get_structure(clean=True)) + except Exception: + # Fallback if structure generation fails + return f'{self.__class__.__name__} instance' class Element(Interface): From b87d979bb797c0e02c7b547e62dff51375c90def Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 24 Jun 2025 21:50:31 +0200 Subject: [PATCH 082/336] Add option to copy Interfaces (And the FlowSystem) --- flixopt/structure.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/flixopt/structure.py b/flixopt/structure.py index 36f723ad1..9cb830ff0 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -549,6 +549,28 @@ def __str__(self): # Fallback if structure generation fails return f'{self.__class__.__name__} instance' + def copy(self) -> 'Interface': + """ + Create a copy of the Interface object. + + Uses the existing serialization infrastructure to ensure proper copying + of all DataArrays and nested objects. + + Returns: + A new instance of the same class with copied data. + """ + # Convert to dataset, copy it, and convert back + dataset = self.to_dataset().copy(deep=True) + return self.__class__.from_dataset(dataset) + + def __copy__(self): + """Support for copy.copy().""" + return self.copy() + + def __deepcopy__(self, memo): + """Support for copy.deepcopy().""" + return self.copy() + class Element(Interface): """This class is the basic Element of flixopt. Every Element has a label""" From 8ec265ec35d9f43de951884accd74c1ddf4de945 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 25 Jun 2025 11:01:46 +0200 Subject: [PATCH 083/336] Make a copy of a FLowSytsem that gets reused in a second Calculation --- flixopt/calculation.py | 6 ++++++ flixopt/flow_system.py | 13 +++++++++---- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/flixopt/calculation.py b/flixopt/calculation.py index e477f6c11..f52c1ca19 100644 --- a/flixopt/calculation.py +++ b/flixopt/calculation.py @@ -54,7 +54,13 @@ def __init__( folder: folder where results should be saved. If None, then the current working directory is used. """ self.name = name + if flow_system.used_in_calculation: + logging.warning(f'FlowSystem {flow_system.name} is already used in a calculation. ' + f'Creating a copy for Calculation "{self.name}".') + flow_system = flow_system.copy() + self.flow_system = flow_system + self.flow_system._used_in_calculation = True self.model: Optional[SystemModel] = None self.active_timesteps = active_timesteps diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index ff99725a5..386f54a72 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -44,10 +44,10 @@ class FlowSystem(Interface): """ def __init__( - self, - timesteps: pd.DatetimeIndex, - hours_of_last_timestep: Optional[float] = None, - hours_of_previous_timesteps: Optional[Union[int, float, np.ndarray]] = None, + self, + timesteps: pd.DatetimeIndex, + hours_of_last_timestep: Optional[float] = None, + hours_of_previous_timesteps: Optional[Union[int, float, np.ndarray]] = None, ): """ Args: @@ -73,6 +73,7 @@ def __init__( self.model: Optional[SystemModel] = None self._connected_and_transformed = False + self._used_in_calculation = False @staticmethod def _validate_timesteps(timesteps: pd.DatetimeIndex) -> pd.DatetimeIndex: @@ -542,3 +543,7 @@ def flows(self) -> Dict[str, Flow]: @property def all_elements(self) -> Dict[str, Element]: return {**self.components, **self.effects.effects, **self.flows, **self.buses} + + @property + def used_in_calculation(self) -> bool: + return self._used_in_calculation From a46fe648af7c8c279449327b28ebb3832dc19c40 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 25 Jun 2025 11:02:35 +0200 Subject: [PATCH 084/336] Remove test_timeseries.py --- tests/test_timeseries.py | 605 --------------------------------------- 1 file changed, 605 deletions(-) delete mode 100644 tests/test_timeseries.py diff --git a/tests/test_timeseries.py b/tests/test_timeseries.py deleted file mode 100644 index 8702a57fe..000000000 --- a/tests/test_timeseries.py +++ /dev/null @@ -1,605 +0,0 @@ -import json -import tempfile -from pathlib import Path -from typing import Dict, List, Tuple - -import numpy as np -import pandas as pd -import pytest -import xarray as xr - -from flixopt.core import ConversionError, DataConverter, TimeSeriesCollection, TimeSeriesData - - -@pytest.fixture -def sample_timesteps(): - """Create a sample time index with the required 'time' name.""" - return pd.date_range('2023-01-01', periods=5, freq='D', name='time') - - -@pytest.fixture -def simple_dataarray(sample_timesteps): - """Create a simple DataArray with time dimension.""" - return xr.DataArray([10, 20, 30, 40, 50], coords={'time': sample_timesteps}, dims=['time']) - - -@pytest.fixture -def sample_timeseries(simple_dataarray): - """Create a sample TimeSeries object.""" - return TimeSeries(simple_dataarray, name='Test Series') - - -class TestTimeSeries: - """Test suite for TimeSeries class.""" - - def test_initialization(self, simple_dataarray): - """Test basic initialization of TimeSeries.""" - ts = TimeSeries(simple_dataarray, name='Test Series') - - # Check basic properties - assert ts.name == 'Test Series' - assert ts.aggregation_weight is None - assert ts.aggregation_group is None - - # Check data initialization - assert isinstance(ts.stored_data, xr.DataArray) - assert ts.stored_data.equals(simple_dataarray) - assert ts.equals(simple_dataarray) - - # Check backup was created - assert ts._backup.equals(simple_dataarray) - - # Check active timesteps - assert ts.active_timesteps.equals(simple_dataarray.indexes['time']) - - def test_initialization_with_aggregation_params(self, simple_dataarray): - """Test initialization with aggregation parameters.""" - ts = TimeSeries( - simple_dataarray, name='Weighted Series', aggregation_weight=0.5, aggregation_group='test_group' - ) - - assert ts.name == 'Weighted Series' - assert ts.aggregation_weight == 0.5 - assert ts.aggregation_group == 'test_group' - - def test_initialization_validation(self, sample_timesteps): - """Test validation during initialization.""" - # Test missing time dimension - invalid_data = xr.DataArray([1, 2, 3], dims=['invalid_dim']) - with pytest.raises(ValueError, match='must have a "time" index'): - TimeSeries(invalid_data, name='Invalid Series') - - # Test multi-dimensional data - multi_dim_data = xr.DataArray( - [[1, 2, 3], [4, 5, 6]], coords={'dim1': [0, 1], 'time': sample_timesteps[:3]}, dims=['dim1', 'time'] - ) - with pytest.raises(ValueError, match='dimensions of DataArray must be 1'): - TimeSeries(multi_dim_data, name='Multi-dim Series') - - def test_active_timesteps_getter_setter(self, sample_timeseries, sample_timesteps): - """Test active_timesteps getter and setter.""" - # Initial state should use all timesteps - assert sample_timeseries.active_timesteps.equals(sample_timesteps) - - # Set to a subset - subset_index = sample_timesteps[1:3] - sample_timeseries.active_timesteps = subset_index - assert sample_timeseries.active_timesteps.equals(subset_index) - - # Active data should reflect the subset - assert sample_timeseries.equals(sample_timeseries.stored_data.sel(time=subset_index)) - - # Reset to full index - sample_timeseries.active_timesteps = None - assert sample_timeseries.active_timesteps.equals(sample_timesteps) - - # Test invalid type - with pytest.raises(TypeError, match='must be a pandas DatetimeIndex'): - sample_timeseries.active_timesteps = 'invalid' - - def test_reset(self, sample_timeseries, sample_timesteps): - """Test reset method.""" - # Set to subset first - subset_index = sample_timesteps[1:3] - sample_timeseries.active_timesteps = subset_index - - # Reset - sample_timeseries.reset() - - # Should be back to full index - assert sample_timeseries.active_timesteps.equals(sample_timesteps) - assert sample_timeseries.equals(sample_timeseries.stored_data) - - def test_restore_data(self, sample_timeseries, simple_dataarray): - """Test restore_data method.""" - # Modify the stored data - new_data = xr.DataArray([1, 2, 3, 4, 5], coords={'time': sample_timeseries.active_timesteps}, dims=['time']) - - # Store original data for comparison - original_data = sample_timeseries.stored_data - - # Set new data - sample_timeseries.stored_data = new_data - assert sample_timeseries.stored_data.equals(new_data) - - # Restore from backup - sample_timeseries.restore_data() - - # Should be back to original data - assert sample_timeseries.stored_data.equals(original_data) - assert sample_timeseries.equals(original_data) - - def test_stored_data_setter(self, sample_timeseries, sample_timesteps): - """Test stored_data setter with different data types.""" - # Test with a Series - series_data = pd.Series([5, 6, 7, 8, 9], index=sample_timesteps) - sample_timeseries.stored_data = series_data - assert np.array_equal(sample_timeseries.stored_data.values, series_data.values) - - # Test with a single-column DataFrame - df_data = pd.DataFrame({'col1': [15, 16, 17, 18, 19]}, index=sample_timesteps) - sample_timeseries.stored_data = df_data - assert np.array_equal(sample_timeseries.stored_data.values, df_data['col1'].values) - - # Test with a NumPy array - array_data = np.array([25, 26, 27, 28, 29]) - sample_timeseries.stored_data = array_data - assert np.array_equal(sample_timeseries.stored_data.values, array_data) - - # Test with a scalar - sample_timeseries.stored_data = 42 - assert np.all(sample_timeseries.stored_data.values == 42) - - # Test with another DataArray - another_dataarray = xr.DataArray([30, 31, 32, 33, 34], coords={'time': sample_timesteps}, dims=['time']) - sample_timeseries.stored_data = another_dataarray - assert sample_timeseries.stored_data.equals(another_dataarray) - - def test_stored_data_setter_no_change(self, sample_timeseries): - """Test stored_data setter when data doesn't change.""" - # Get current data - current_data = sample_timeseries.stored_data - current_backup = sample_timeseries._backup - - # Set the same data - sample_timeseries.stored_data = current_data - - # Backup shouldn't change - assert sample_timeseries._backup is current_backup # Should be the same object - - def test_from_datasource(self, sample_timesteps): - """Test from_datasource class method.""" - # Test with scalar - ts_scalar = TimeSeries.from_datasource(42, 'Scalar Series', sample_timesteps) - assert np.all(ts_scalar.stored_data.values == 42) - - # Test with Series - series_data = pd.Series([1, 2, 3, 4, 5], index=sample_timesteps) - ts_series = TimeSeries.from_datasource(series_data, 'Series Data', sample_timesteps) - assert np.array_equal(ts_series.stored_data.values, series_data.values) - - # Test with aggregation parameters - ts_with_agg = TimeSeries.from_datasource( - series_data, 'Aggregated Series', sample_timesteps, aggregation_weight=0.7, aggregation_group='group1' - ) - assert ts_with_agg.aggregation_weight == 0.7 - assert ts_with_agg.aggregation_group == 'group1' - - def test_to_json_from_json(self, sample_timeseries): - """Test to_json and from_json methods.""" - # Test to_json (dictionary only) - json_dict = sample_timeseries.to_json() - assert json_dict['name'] == sample_timeseries.name - assert 'data' in json_dict - assert 'coords' in json_dict['data'] - assert 'time' in json_dict['data']['coords'] - - # Test to_json with file saving - with tempfile.TemporaryDirectory() as tmpdirname: - filepath = Path(tmpdirname) / 'timeseries.json' - sample_timeseries.to_json(filepath) - assert filepath.exists() - - # Test from_json with file loading - loaded_ts = TimeSeries.from_json(path=filepath) - assert loaded_ts.name == sample_timeseries.name - assert np.array_equal(loaded_ts.stored_data.values, sample_timeseries.stored_data.values) - - # Test from_json with dictionary - loaded_ts_dict = TimeSeries.from_json(data=json_dict) - assert loaded_ts_dict.name == sample_timeseries.name - assert np.array_equal(loaded_ts_dict.stored_data.values, sample_timeseries.stored_data.values) - - # Test validation in from_json - with pytest.raises(ValueError, match="one of 'path' or 'data'"): - TimeSeries.from_json(data=json_dict, path='dummy.json') - - def test_all_equal(self, sample_timesteps): - """Test all_equal property.""" - # All equal values - equal_data = xr.DataArray([5, 5, 5, 5, 5], coords={'time': sample_timesteps}, dims=['time']) - ts_equal = TimeSeries(equal_data, 'Equal Series') - assert ts_equal.all_equal is True - - # Not all equal - unequal_data = xr.DataArray([5, 5, 6, 5, 5], coords={'time': sample_timesteps}, dims=['time']) - ts_unequal = TimeSeries(unequal_data, 'Unequal Series') - assert ts_unequal.all_equal is False - - def test_arithmetic_operations(self, sample_timeseries): - """Test arithmetic operations.""" - # Create a second TimeSeries for testing - data2 = xr.DataArray([1, 2, 3, 4, 5], coords={'time': sample_timeseries.active_timesteps}, dims=['time']) - ts2 = TimeSeries(data2, 'Second Series') - - # Test operations between two TimeSeries objects - assert np.array_equal( - (sample_timeseries + ts2).values, sample_timeseries.values + ts2.values - ) - assert np.array_equal( - (sample_timeseries - ts2).values, sample_timeseries.values - ts2.values - ) - assert np.array_equal( - (sample_timeseries * ts2).values, sample_timeseries.values * ts2.values - ) - assert np.array_equal( - (sample_timeseries / ts2).values, sample_timeseries.values / ts2.values - ) - - # Test operations with DataArrays - assert np.array_equal((sample_timeseries + data2).values, sample_timeseries.values + data2.values) - assert np.array_equal((data2 + sample_timeseries).values, data2.values + sample_timeseries.values) - - # Test operations with scalars - assert np.array_equal((sample_timeseries + 5).values, sample_timeseries.values + 5) - assert np.array_equal((5 + sample_timeseries).values, 5 + sample_timeseries.values) - - # Test unary operations - assert np.array_equal((-sample_timeseries).values, -sample_timeseries.values) - assert np.array_equal((+sample_timeseries).values, +sample_timeseries.values) - assert np.array_equal((abs(sample_timeseries)).values, abs(sample_timeseries.values)) - - def test_comparison_operations(self, sample_timesteps): - """Test comparison operations.""" - data1 = xr.DataArray([10, 20, 30, 40, 50], coords={'time': sample_timesteps}, dims=['time']) - data2 = xr.DataArray([5, 10, 15, 20, 25], coords={'time': sample_timesteps}, dims=['time']) - - ts1 = TimeSeries(data1, 'Series 1') - ts2 = TimeSeries(data2, 'Series 2') - - # Test __gt__ method - assert (ts1 > ts2).all().item() - - # Test with mixed values - data3 = xr.DataArray([5, 25, 15, 45, 25], coords={'time': sample_timesteps}, dims=['time']) - ts3 = TimeSeries(data3, 'Series 3') - - assert not (ts1 > ts3).all().item() # Not all values in ts1 are greater than ts3 - - def test_numpy_ufunc(self, sample_timeseries): - """Test numpy ufunc compatibility.""" - # Test basic numpy functions - assert np.array_equal(np.add(sample_timeseries, 5).values, np.add(sample_timeseries, 5).values) - - assert np.array_equal( - np.multiply(sample_timeseries, 2).values, np.multiply(sample_timeseries, 2).values - ) - - # Test with two TimeSeries objects - data2 = xr.DataArray([1, 2, 3, 4, 5], coords={'time': sample_timeseries.active_timesteps}, dims=['time']) - ts2 = TimeSeries(data2, 'Second Series') - - assert np.array_equal( - np.add(sample_timeseries, ts2).values, np.add(sample_timeseries, ts2).values - ) - - def test_sel_and_isel_properties(self, sample_timeseries): - """Test sel and isel properties.""" - # Test that sel property works - selected = sample_timeseries.sel(time=sample_timeseries.active_timesteps[0]) - assert selected.item() == sample_timeseries.values[0] - - # Test that isel property works - indexed = sample_timeseries.isel(time=0) - assert indexed.item() == sample_timeseries.values[0] - - -@pytest.fixture -def sample_collection(sample_timesteps): - """Create a sample TimeSeriesCollection.""" - return TimeSeriesCollection(sample_timesteps) - - -@pytest.fixture -def populated_collection(sample_collection): - """Create a TimeSeriesCollection with test data.""" - # Add a constant time series - sample_collection.create_time_series(42, 'constant_series') - - # Add a varying time series - varying_data = np.array([10, 20, 30, 40, 50]) - sample_collection.create_time_series(varying_data, 'varying_series') - - # Add a time series with extra timestep - sample_collection.create_time_series( - np.array([1, 2, 3, 4, 5, 6]), 'extra_timestep_series', needs_extra_timestep=True - ) - - # Add series with aggregation settings - sample_collection.create_time_series( - TimeSeriesData(np.array([5, 5, 5, 5, 5]), agg_group='group1'), 'group1_series1' - ) - sample_collection.create_time_series( - TimeSeriesData(np.array([6, 6, 6, 6, 6]), agg_group='group1'), 'group1_series2' - ) - sample_collection.create_time_series( - TimeSeriesData(np.array([10, 10, 10, 10, 10]), agg_weight=0.5), 'weighted_series' - ) - - return sample_collection - - -class TestTimeSeriesCollection: - """Test suite for TimeSeriesCollection.""" - - def test_initialization(self, sample_timesteps): - """Test basic initialization.""" - collection = TimeSeriesCollection(sample_timesteps) - - assert collection.all_timesteps.equals(sample_timesteps) - assert len(collection.all_timesteps_extra) == len(sample_timesteps) + 1 - assert isinstance(collection.all_hours_per_timestep, xr.DataArray) - assert len(collection) == 0 - - def test_initialization_with_custom_hours(self, sample_timesteps): - """Test initialization with custom hour settings.""" - # Test with last timestep duration - last_timestep_hours = 12 - collection = TimeSeriesCollection(sample_timesteps, hours_of_last_timestep=last_timestep_hours) - - # Verify the last timestep duration - extra_step_delta = collection.all_timesteps_extra[-1] - collection.all_timesteps_extra[-2] - assert extra_step_delta == pd.Timedelta(hours=last_timestep_hours) - - # Test with previous timestep duration - hours_per_step = 8 - collection2 = TimeSeriesCollection(sample_timesteps, hours_of_previous_timesteps=hours_per_step) - - assert collection2.hours_of_previous_timesteps == hours_per_step - - def test_create_time_series(self, sample_collection): - """Test creating time series.""" - # Test scalar - ts1 = sample_collection.create_time_series(42, 'scalar_series') - assert ts1.name == 'scalar_series' - assert np.all(ts1.values == 42) - - # Test numpy array - data = np.array([1, 2, 3, 4, 5]) - ts2 = sample_collection.create_time_series(data, 'array_series') - assert np.array_equal(ts2.values, data) - - # Test with TimeSeriesData - ts3 = sample_collection.create_time_series(TimeSeriesData(10, agg_weight=0.7), 'weighted_series') - assert ts3.aggregation_weight == 0.7 - - # Test with extra timestep - ts4 = sample_collection.create_time_series(5, 'extra_series', needs_extra_timestep=True) - assert ts4.needs_extra_timestep - assert len(ts4) == len(sample_collection.timesteps_extra) - - # Test duplicate name - with pytest.raises(ValueError, match='already exists'): - sample_collection.create_time_series(1, 'scalar_series') - - def test_access_time_series(self, populated_collection): - """Test accessing time series.""" - # Test __getitem__ - ts = populated_collection['varying_series'] - assert ts.name == 'varying_series' - - # Test __contains__ with string - assert 'constant_series' in populated_collection - assert 'nonexistent_series' not in populated_collection - - # Test __contains__ with TimeSeries object - assert populated_collection['varying_series'] in populated_collection - - # Test __iter__ - names = [ts.name for ts in populated_collection] - assert len(names) == 6 - assert 'varying_series' in names - - # Test access to non-existent series - with pytest.raises(KeyError): - populated_collection['nonexistent_series'] - - def test_constants_and_non_constants(self, populated_collection): - """Test constants and non_constants properties.""" - # Test constants - constants = populated_collection.constants - assert len(constants) == 4 # constant_series, group1_series1, group1_series2, weighted_series - assert all(ts.all_equal for ts in constants) - - # Test non_constants - non_constants = populated_collection.non_constants - assert len(non_constants) == 2 # varying_series, extra_timestep_series - assert all(not ts.all_equal for ts in non_constants) - - # Test modifying a series changes the results - populated_collection['constant_series'].stored_data = np.array([1, 2, 3, 4, 5]) - updated_constants = populated_collection.constants - assert len(updated_constants) == 3 # One less constant - assert 'constant_series' not in [ts.name for ts in updated_constants] - - def test_timesteps_properties(self, populated_collection, sample_timesteps): - """Test timestep-related properties.""" - # Test default (all) timesteps - assert populated_collection.timesteps.equals(sample_timesteps) - assert len(populated_collection.timesteps_extra) == len(sample_timesteps) + 1 - - # Test activating a subset - subset = sample_timesteps[1:3] - populated_collection.activate_timesteps(subset) - - assert populated_collection.timesteps.equals(subset) - assert len(populated_collection.timesteps_extra) == len(subset) + 1 - - # Check that time series were updated - assert populated_collection['varying_series'].active_timesteps.equals(subset) - assert populated_collection['extra_timestep_series'].active_timesteps.equals( - populated_collection.timesteps_extra - ) - - # Test reset - populated_collection.reset() - assert populated_collection.timesteps.equals(sample_timesteps) - - def test_to_dataframe_and_dataset(self, populated_collection): - """Test conversion to DataFrame and Dataset.""" - # Test to_dataset - ds = populated_collection.to_dataset() - assert isinstance(ds, xr.Dataset) - assert len(ds.data_vars) == 6 - - # Test to_dataframe with different filters - df_all = populated_collection.to_dataframe(filtered='all') - assert len(df_all.columns) == 6 - - df_constant = populated_collection.to_dataframe(filtered='constant') - assert len(df_constant.columns) == 4 - - df_non_constant = populated_collection.to_dataframe(filtered='non_constant') - assert len(df_non_constant.columns) == 2 - - # Test invalid filter - with pytest.raises(ValueError): - populated_collection.to_dataframe(filtered='invalid') - - def test_calculate_aggregation_weights(self, populated_collection): - """Test aggregation weight calculation.""" - weights = populated_collection.calculate_aggregation_weights() - - # Group weights should be 0.5 each (1/2) - assert populated_collection.group_weights['group1'] == 0.5 - - # Series in group1 should have weight 0.5 - assert weights['group1_series1'] == 0.5 - assert weights['group1_series2'] == 0.5 - - # Series with explicit weight should have that weight - assert weights['weighted_series'] == 0.5 - - # Series without group or weight should have weight 1 - assert weights['constant_series'] == 1 - - def test_insert_new_data(self, populated_collection, sample_timesteps): - """Test inserting new data.""" - # Create new data - new_data = pd.DataFrame( - { - 'constant_series': [100, 100, 100, 100, 100], - 'varying_series': [5, 10, 15, 20, 25], - # extra_timestep_series is omitted to test partial updates - }, - index=sample_timesteps, - ) - - # Insert data - populated_collection.insert_new_data(new_data) - - # Verify updates - assert np.all(populated_collection['constant_series'].values == 100) - assert np.array_equal(populated_collection['varying_series'].values, np.array([5, 10, 15, 20, 25])) - - # Series not in the DataFrame should be unchanged - assert np.array_equal( - populated_collection['extra_timestep_series'].values[:-1], np.array([1, 2, 3, 4, 5]) - ) - - # Test with mismatched index - bad_index = pd.date_range('2023-02-01', periods=5, freq='D', name='time') - bad_data = pd.DataFrame({'constant_series': [1, 1, 1, 1, 1]}, index=bad_index) - - with pytest.raises(ValueError, match='must match collection timesteps'): - populated_collection.insert_new_data(bad_data) - - def test_restore_data(self, populated_collection): - """Test restoring original data.""" - # Capture original data - original_values = {name: ts.stored_data.copy() for name, ts in populated_collection.time_series_data.items()} - - # Modify data - new_data = pd.DataFrame( - { - name: np.ones(len(populated_collection.timesteps)) * 999 - for name in populated_collection.time_series_data - if not populated_collection[name].needs_extra_timestep - }, - index=populated_collection.timesteps, - ) - - populated_collection.insert_new_data(new_data) - - # Verify data was changed - assert np.all(populated_collection['constant_series'].values == 999) - - # Restore data - populated_collection.restore_data() - - # Verify data was restored - for name, original in original_values.items(): - restored = populated_collection[name].stored_data - assert np.array_equal(restored.values, original.values) - - def test_class_method_with_uniform_timesteps(self): - """Test the with_uniform_timesteps class method.""" - collection = TimeSeriesCollection.with_uniform_timesteps( - start_time=pd.Timestamp('2023-01-01'), periods=24, freq='h', hours_per_step=1 - ) - - assert len(collection.timesteps) == 24 - assert collection.hours_of_previous_timesteps == 1 - assert (collection.timesteps[1] - collection.timesteps[0]) == pd.Timedelta(hours=1) - - def test_hours_per_timestep(self, populated_collection): - """Test hours_per_timestep calculation.""" - # Standard case - uniform timesteps - hours = populated_collection.hours_per_timestep.values - assert np.allclose(hours, 24) # Default is daily timesteps - - # Create non-uniform timesteps - non_uniform_times = pd.DatetimeIndex( - [ - pd.Timestamp('2023-01-01'), - pd.Timestamp('2023-01-02'), - pd.Timestamp('2023-01-03 12:00:00'), # 1.5 days from previous - pd.Timestamp('2023-01-04'), # 0.5 days from previous - pd.Timestamp('2023-01-06'), # 2 days from previous - ], - name='time', - ) - - collection = TimeSeriesCollection(non_uniform_times) - hours = collection.hours_per_timestep.values - - # Expected hours between timestamps - expected = np.array([24, 36, 12, 48, 48]) - assert np.allclose(hours, expected) - - def test_validation_and_errors(self, sample_timesteps): - """Test validation and error handling.""" - # Test non-DatetimeIndex - with pytest.raises(TypeError, match='must be a pandas DatetimeIndex'): - TimeSeriesCollection(pd.Index([1, 2, 3, 4, 5])) - - # Test too few timesteps - with pytest.raises(ValueError, match='must contain at least 2 timestamps'): - TimeSeriesCollection(pd.DatetimeIndex([pd.Timestamp('2023-01-01')], name='time')) - - # Test invalid active_timesteps - collection = TimeSeriesCollection(sample_timesteps) - invalid_timesteps = pd.date_range('2024-01-01', periods=3, freq='D', name='time') - - with pytest.raises(ValueError, match='must be a subset'): - collection.activate_timesteps(invalid_timesteps) From 201d0667356e174f5f7f87effec54013bf14a767 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 25 Jun 2025 11:15:40 +0200 Subject: [PATCH 085/336] Reorganizing Datatypes --- flixopt/commons.py | 2 +- flixopt/components.py | 48 ++++++++++++++++++------------------ flixopt/core.py | 38 +++++++--------------------- flixopt/effects.py | 29 +++++++++++----------- flixopt/elements.py | 32 ++++++++++++------------ flixopt/features.py | 32 ++++++++++++------------ flixopt/flow_system.py | 48 ++++++++++++++++++++---------------- flixopt/interface.py | 36 +++++++++++++-------------- flixopt/linear_converters.py | 22 ++++++++--------- flixopt/structure.py | 6 ++--- 10 files changed, 139 insertions(+), 154 deletions(-) diff --git a/flixopt/commons.py b/flixopt/commons.py index 222c07324..68412d6fe 100644 --- a/flixopt/commons.py +++ b/flixopt/commons.py @@ -14,11 +14,11 @@ Transmission, ) from .config import CONFIG, change_logging_level +from .core import TimeSeriesData from .effects import Effect from .elements import Bus, Flow from .flow_system import FlowSystem from .interface import InvestParameters, OnOffParameters, Piece, Piecewise, PiecewiseConversion, PiecewiseEffects -from .core import TimeSeriesData __all__ = [ 'TimeSeriesData', diff --git a/flixopt/components.py b/flixopt/components.py index 81baaeea5..8e172d573 100644 --- a/flixopt/components.py +++ b/flixopt/components.py @@ -9,7 +9,7 @@ import numpy as np from . import utils -from .core import NumericData, NumericDataTS, PlausibilityError, Scalar, TimeSeries +from .core import NumericDataUser, PlausibilityError, Scalar, TimeSeries from .elements import Component, ComponentModel, Flow from .features import InvestmentModel, OnOffModel, PiecewiseModel from .interface import InvestParameters, OnOffParameters, PiecewiseConversion @@ -34,7 +34,7 @@ def __init__( inputs: List[Flow], outputs: List[Flow], on_off_parameters: OnOffParameters = None, - conversion_factors: List[Dict[str, NumericDataTS]] = None, + conversion_factors: List[Dict[str, NumericDataUser]] = None, piecewise_conversion: Optional[PiecewiseConversion] = None, meta_data: Optional[Dict] = None, ): @@ -105,7 +105,7 @@ def _transform_conversion_factors(self, flow_system: 'FlowSystem') -> List[Dict[ transformed_dict = {} for flow, values in conversion_factor.items(): # TODO: Might be better to use the label of the component instead of the flow - transformed_dict[flow] = flow_system.create_time_series( + transformed_dict[flow] = flow_system.fit_to_model_coords( f'{self.flows[flow].label_full}|conversion_factor{idx}', values ) list_of_conversion_factors.append(transformed_dict) @@ -128,14 +128,14 @@ def __init__( charging: Flow, discharging: Flow, capacity_in_flow_hours: Union[Scalar, InvestParameters], - relative_minimum_charge_state: NumericData = 0, - relative_maximum_charge_state: NumericData = 1, + relative_minimum_charge_state: NumericDataUser = 0, + relative_maximum_charge_state: NumericDataUser = 1, initial_charge_state: Union[Scalar, Literal['lastValueOfSim']] = 0, minimal_final_charge_state: Optional[Scalar] = None, maximal_final_charge_state: Optional[Scalar] = None, - eta_charge: NumericData = 1, - eta_discharge: NumericData = 1, - relative_loss_per_hour: NumericData = 0, + eta_charge: NumericDataUser = 1, + eta_discharge: NumericDataUser = 1, + relative_loss_per_hour: NumericDataUser = 0, prevent_simultaneous_charge_and_discharge: bool = True, meta_data: Optional[Dict] = None, ): @@ -176,16 +176,16 @@ def __init__( self.charging = charging self.discharging = discharging self.capacity_in_flow_hours = capacity_in_flow_hours - self.relative_minimum_charge_state: NumericDataTS = relative_minimum_charge_state - self.relative_maximum_charge_state: NumericDataTS = relative_maximum_charge_state + self.relative_minimum_charge_state: NumericDataUser = relative_minimum_charge_state + self.relative_maximum_charge_state: NumericDataUser = relative_maximum_charge_state self.initial_charge_state = initial_charge_state self.minimal_final_charge_state = minimal_final_charge_state self.maximal_final_charge_state = maximal_final_charge_state - self.eta_charge: NumericDataTS = eta_charge - self.eta_discharge: NumericDataTS = eta_discharge - self.relative_loss_per_hour: NumericDataTS = relative_loss_per_hour + self.eta_charge: NumericDataUser = eta_charge + self.eta_discharge: NumericDataUser = eta_discharge + self.relative_loss_per_hour: NumericDataUser = relative_loss_per_hour self.prevent_simultaneous_charge_and_discharge = prevent_simultaneous_charge_and_discharge def create_model(self, model: SystemModel) -> 'StorageModel': @@ -195,19 +195,19 @@ def create_model(self, model: SystemModel) -> 'StorageModel': def transform_data(self, flow_system: 'FlowSystem') -> None: super().transform_data(flow_system) - self.relative_minimum_charge_state = flow_system.create_time_series( + self.relative_minimum_charge_state = flow_system.fit_to_model_coords( f'{self.label_full}|relative_minimum_charge_state', self.relative_minimum_charge_state, needs_extra_timestep=True, ) - self.relative_maximum_charge_state = flow_system.create_time_series( + self.relative_maximum_charge_state = flow_system.fit_to_model_coords( f'{self.label_full}|relative_maximum_charge_state', self.relative_maximum_charge_state, needs_extra_timestep=True, ) - self.eta_charge = flow_system.create_time_series(f'{self.label_full}|eta_charge', self.eta_charge) - self.eta_discharge = flow_system.create_time_series(f'{self.label_full}|eta_discharge', self.eta_discharge) - self.relative_loss_per_hour = flow_system.create_time_series( + self.eta_charge = flow_system.fit_to_model_coords(f'{self.label_full}|eta_charge', self.eta_charge) + self.eta_discharge = flow_system.fit_to_model_coords(f'{self.label_full}|eta_discharge', self.eta_discharge) + self.relative_loss_per_hour = flow_system.fit_to_model_coords( f'{self.label_full}|relative_loss_per_hour', self.relative_loss_per_hour ) if isinstance(self.capacity_in_flow_hours, InvestParameters): @@ -264,8 +264,8 @@ def __init__( out1: Flow, in2: Optional[Flow] = None, out2: Optional[Flow] = None, - relative_losses: Optional[NumericDataTS] = None, - absolute_losses: Optional[NumericDataTS] = None, + relative_losses: Optional[NumericDataUser] = None, + absolute_losses: Optional[NumericDataUser] = None, on_off_parameters: OnOffParameters = None, prevent_simultaneous_flows_in_both_directions: bool = True, meta_data: Optional[Dict] = None, @@ -331,10 +331,10 @@ def create_model(self, model) -> 'TransmissionModel': def transform_data(self, flow_system: 'FlowSystem') -> None: super().transform_data(flow_system) - self.relative_losses = flow_system.create_time_series( + self.relative_losses = flow_system.fit_to_model_coords( f'{self.label_full}|relative_losses', self.relative_losses ) - self.absolute_losses = flow_system.create_time_series( + self.absolute_losses = flow_system.fit_to_model_coords( f'{self.label_full}|absolute_losses', self.absolute_losses ) @@ -556,7 +556,7 @@ def _initial_and_final_charge_state(self): ) @property - def absolute_charge_state_bounds(self) -> Tuple[NumericData, NumericData]: + def absolute_charge_state_bounds(self) -> Tuple[NumericDataUser, NumericDataUser]: relative_lower_bound, relative_upper_bound = self.relative_charge_state_bounds if not isinstance(self.element.capacity_in_flow_hours, InvestParameters): return ( @@ -570,7 +570,7 @@ def absolute_charge_state_bounds(self) -> Tuple[NumericData, NumericData]: ) @property - def relative_charge_state_bounds(self) -> Tuple[NumericData, NumericData]: + def relative_charge_state_bounds(self) -> Tuple[NumericDataUser, NumericDataUser]: return ( self.element.relative_minimum_charge_state, self.element.relative_maximum_charge_state, diff --git a/flixopt/core.py b/flixopt/core.py index 31738f6c7..4ab97b219 100644 --- a/flixopt/core.py +++ b/flixopt/core.py @@ -17,13 +17,13 @@ logger = logging.getLogger('flixopt') Scalar = Union[int, float] -"""A type representing a single number, either integer or float.""" +"""A single number, either integer or float.""" -NumericData = Union[int, float, np.integer, np.floating, np.ndarray, pd.Series, pd.DataFrame, xr.DataArray] -"""Represents any form of numeric data, from simple scalars to complex data structures.""" +NumericDataUser = Union[int, float, np.integer, np.floating, np.ndarray, pd.Series, pd.DataFrame, xr.DataArray, 'TimeSeriesData'] +"""Numeric data accepted in varios types. Will be converted to an xr.DataArray or Scalar internally.""" -NumericDataTS = Union[NumericData, 'TimeSeriesData'] -"""Represents either standard numeric data or TimeSeriesData.""" +NumericDataInternal = Union[int, float, xr.DataArray, 'TimeSeriesData'] +"""Internally used datatypes for numeric data.""" class PlausibilityError(Exception): @@ -37,6 +37,7 @@ class ConversionError(Exception): pass + class TimeSeriesData(xr.DataArray): """Minimal TimeSeriesData that inherits from xr.DataArray with aggregation metadata.""" @@ -153,7 +154,7 @@ def _fix_timeseries_data_indexing( # Check if time coordinates are identical elif not data.coords['time'].equals(timesteps): logger.warning( - f'TimeSeriesData has different time coordinates than expected. Replacing with provided timesteps.' + 'TimeSeriesData has different time coordinates than expected. Replacing with provided timesteps.' ) # Replace time coordinates while preserving data and metadata recoordinated_data = xr.DataArray( @@ -166,7 +167,7 @@ def _fix_timeseries_data_indexing( return data.copy(deep=True) @staticmethod - def to_dataarray(data: NumericData, timesteps: pd.DatetimeIndex) -> xr.DataArray: + def to_dataarray(data: NumericDataUser, timesteps: pd.DatetimeIndex) -> xr.DataArray: """Convert data to xarray.DataArray with specified timesteps index.""" if not isinstance(timesteps, pd.DatetimeIndex) or len(timesteps) == 0: raise ValueError(f'Timesteps must be a non-empty DatetimeIndex, got {type(timesteps).__name__}') @@ -182,10 +183,6 @@ def to_dataarray(data: NumericData, timesteps: pd.DatetimeIndex) -> xr.DataArray if isinstance(data, TimeSeriesData): return DataConverter._fix_timeseries_data_indexing(data, timesteps, dims, coords) - elif isinstance(data, TimeSeries): - # Handle TimeSeries objects (your existing logic) - pass # Add your TimeSeries handling here - elif isinstance(data, (int, float, np.integer, np.floating)): # Scalar: broadcast to all timesteps scalar_data = np.full(expected_shape, data) @@ -220,7 +217,7 @@ def to_dataarray(data: NumericData, timesteps: pd.DatetimeIndex) -> xr.DataArray return data.copy(deep=True) elif isinstance(data, list): - logger.warning(f'Converting list to DataArray. This is not recommended.') + logger.warning('Converting list to DataArray. This is not recommended.') if len(data) != expected_shape[0]: raise ConversionError(f"List length {len(data)} doesn't match expected {expected_shape[0]}") return xr.DataArray(data, coords=coords, dims=dims) @@ -234,23 +231,6 @@ def to_dataarray(data: NumericData, timesteps: pd.DatetimeIndex) -> xr.DataArray raise ConversionError(f'Converting data {type(data)} to xarray.DataArray raised an error: {str(e)}') from e -class TimeSeries: - def __init__(self): - raise NotImplementedError('TimeSeries was removed') - - -class TimeSeriesCollection: - """ - Collection of TimeSeries objects with shared timestep management. - - TimeSeriesCollection handles multiple TimeSeries objects with synchronized - timesteps, provides operations on collections, and manages extra timesteps. - """ - - def __init__(self): - raise NotImplementedError('TimeSeriesCollection was removed') - - def get_numeric_stats(data: xr.DataArray, decimals: int = 2, padd: int = 10) -> str: """Calculates the mean, median, min, max, and standard deviation of a numeric DataArray.""" format_spec = f'>{padd}.{decimals}f' if padd else f'.{decimals}f' diff --git a/flixopt/effects.py b/flixopt/effects.py index b043f4492..7fa136f5b 100644 --- a/flixopt/effects.py +++ b/flixopt/effects.py @@ -13,7 +13,7 @@ import numpy as np import pandas as pd -from .core import NumericData, NumericDataTS, Scalar, TimeSeriesCollection, TimeSeries +from .core import NumericDataInternal, NumericDataUser, Scalar from .features import ShareAllocationModel from .structure import Element, ElementModel, Interface, Model, SystemModel, register_class_for_io @@ -44,8 +44,8 @@ def __init__( maximum_operation: Optional[Scalar] = None, minimum_invest: Optional[Scalar] = None, maximum_invest: Optional[Scalar] = None, - minimum_operation_per_hour: Optional[NumericDataTS] = None, - maximum_operation_per_hour: Optional[NumericDataTS] = None, + minimum_operation_per_hour: Optional[NumericDataUser] = None, + maximum_operation_per_hour: Optional[NumericDataUser] = None, minimum_total: Optional[Scalar] = None, maximum_total: Optional[Scalar] = None, ): @@ -82,22 +82,22 @@ def __init__( self.specific_share_to_other_effects_invest: EffectValuesUser = specific_share_to_other_effects_invest or {} self.minimum_operation = minimum_operation self.maximum_operation = maximum_operation - self.minimum_operation_per_hour: NumericDataTS = minimum_operation_per_hour - self.maximum_operation_per_hour: NumericDataTS = maximum_operation_per_hour + self.minimum_operation_per_hour: NumericDataUser = minimum_operation_per_hour + self.maximum_operation_per_hour: NumericDataUser = maximum_operation_per_hour self.minimum_invest = minimum_invest self.maximum_invest = maximum_invest self.minimum_total = minimum_total self.maximum_total = maximum_total def transform_data(self, flow_system: 'FlowSystem'): - self.minimum_operation_per_hour = flow_system.create_time_series( + self.minimum_operation_per_hour = flow_system.fit_to_model_coords( f'{self.label_full}|minimum_operation_per_hour', self.minimum_operation_per_hour ) - self.maximum_operation_per_hour = flow_system.create_time_series( + self.maximum_operation_per_hour = flow_system.fit_to_model_coords( f'{self.label_full}|maximum_operation_per_hour', self.maximum_operation_per_hour, flow_system ) - self.specific_share_to_other_effects_operation = flow_system.create_effect_time_series( + self.specific_share_to_other_effects_operation = flow_system.fit_effects_to_model_coords( f'{self.label_full}|operation->', self.specific_share_to_other_effects_operation, 'operation' ) @@ -168,10 +168,9 @@ def do_modeling(self): ) -EffectValuesExpr = Dict[str, linopy.LinearExpression] # Used to create Shares -EffectTimeSeries = Dict[str, TimeSeries] # Used internally to index values -EffectValuesDict = Dict[str, NumericDataTS] # How effect values are stored -EffectValuesUser = Union[NumericDataTS, Dict[str, NumericDataTS]] # User-specified Shares to Effects +EffectExpr = Dict[str, linopy.LinearExpression] # Used to create Shares +EffectValuesInternal = Dict[str, NumericDataInternal] # Used internally to index values +EffectValuesUser = Union[NumericDataUser, Dict[str, NumericDataUser]] # User-specified Shares to Effects """ This datatype is used to define the share to an effect by a certain attribute. """ EffectValuesUserScalar = Union[Scalar, Dict[str, Scalar]] # User-specified Shares to Effects @@ -207,7 +206,7 @@ def add_effects(self, *effects: Effect) -> None: self._effects[effect.label] = effect logger.info(f'Registered new Effect: {effect.label}') - def create_effect_values_dict(self, effect_values_user: EffectValuesUser) -> Optional[EffectValuesDict]: + def create_effect_values_dict(self, effect_values_user: EffectValuesUser) -> Optional[Dict[str, NumericDataUser]]: """ Converts effect values into a dictionary. If a scalar is provided, it is associated with a default effect type. @@ -233,6 +232,8 @@ def get_effect_label(eff: Union[Effect, str]) -> str: stacklevel=2, ) return eff.label_full + elif eff is None: + return self.standard_effect.label_full else: return eff @@ -341,7 +342,7 @@ def __init__(self, model: SystemModel, effects: EffectCollection): def add_share_to_effects( self, name: str, - expressions: EffectValuesExpr, + expressions: EffectExpr, target: Literal['operation', 'invest'], ) -> None: for effect, expression in expressions.items(): diff --git a/flixopt/elements.py b/flixopt/elements.py index 48e73ef76..a2ba8f7c1 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -10,7 +10,7 @@ import numpy as np from .config import CONFIG -from .core import NumericData, NumericDataTS, PlausibilityError, Scalar, TimeSeriesCollection +from .core import NumericDataUser, PlausibilityError, Scalar, TimeSeriesCollection from .effects import EffectValuesUser from .features import InvestmentModel, OnOffModel, PreventSimultaneousUsageModel from .interface import InvestParameters, OnOffParameters @@ -90,7 +90,7 @@ class Bus(Element): """ def __init__( - self, label: str, excess_penalty_per_flow_hour: Optional[NumericDataTS] = 1e5, meta_data: Optional[Dict] = None + self, label: str, excess_penalty_per_flow_hour: Optional[NumericDataUser] = 1e5, meta_data: Optional[Dict] = None ): """ Args: @@ -111,7 +111,7 @@ def create_model(self, model: SystemModel) -> 'BusModel': return self.model def transform_data(self, flow_system: 'FlowSystem'): - self.excess_penalty_per_flow_hour = flow_system.create_time_series( + self.excess_penalty_per_flow_hour = flow_system.fit_to_model_coords( f'{self.label_full}|excess_penalty_per_flow_hour', self.excess_penalty_per_flow_hour ) @@ -149,16 +149,16 @@ def __init__( label: str, bus: str, size: Union[Scalar, InvestParameters] = None, - fixed_relative_profile: Optional[NumericDataTS] = None, - relative_minimum: NumericDataTS = 0, - relative_maximum: NumericDataTS = 1, + fixed_relative_profile: Optional[NumericDataUser] = None, + relative_minimum: NumericDataUser = 0, + relative_maximum: NumericDataUser = 1, effects_per_flow_hour: Optional[EffectValuesUser] = None, on_off_parameters: Optional[OnOffParameters] = None, flow_hours_total_max: Optional[Scalar] = None, flow_hours_total_min: Optional[Scalar] = None, load_factor_min: Optional[Scalar] = None, load_factor_max: Optional[Scalar] = None, - previous_flow_rate: Optional[NumericData] = None, + previous_flow_rate: Optional[NumericDataUser] = None, meta_data: Optional[Dict] = None, ): r""" @@ -230,16 +230,16 @@ def create_model(self, model: SystemModel) -> 'FlowModel': return self.model def transform_data(self, flow_system: 'FlowSystem'): - self.relative_minimum = flow_system.create_time_series( + self.relative_minimum = flow_system.fit_to_model_coords( f'{self.label_full}|relative_minimum', self.relative_minimum ) - self.relative_maximum = flow_system.create_time_series( + self.relative_maximum = flow_system.fit_to_model_coords( f'{self.label_full}|relative_maximum', self.relative_maximum ) - self.fixed_relative_profile = flow_system.create_time_series( + self.fixed_relative_profile = flow_system.fit_to_model_coords( f'{self.label_full}|fixed_relative_profile', self.fixed_relative_profile ) - self.effects_per_flow_hour = flow_system.create_effect_time_series( + self.effects_per_flow_hour = flow_system.fit_effects_to_model_coords( self.label_full, self.effects_per_flow_hour, 'per_flow_hour' ) if self.on_off_parameters is not None: @@ -411,7 +411,7 @@ def _create_bounds_for_load_factor(self): ) @property - def flow_rate_bounds_on(self) -> Tuple[NumericData, NumericData]: + def flow_rate_bounds_on(self) -> Tuple[NumericDataUser, NumericDataUser]: """Returns absolute flow rate bounds. Important for OnOffModel""" relative_minimum, relative_maximum = self.flow_rate_lower_bound_relative, self.flow_rate_upper_bound_relative size = self.element.size @@ -422,7 +422,7 @@ def flow_rate_bounds_on(self) -> Tuple[NumericData, NumericData]: return relative_minimum * size.minimum_size, relative_maximum * size.maximum_size @property - def flow_rate_lower_bound_relative(self) -> NumericData: + def flow_rate_lower_bound_relative(self) -> NumericDataUser: """Returns the lower bound of the flow_rate relative to its size""" fixed_profile = self.element.fixed_relative_profile if fixed_profile is None: @@ -430,7 +430,7 @@ def flow_rate_lower_bound_relative(self) -> NumericData: return fixed_profile @property - def flow_rate_upper_bound_relative(self) -> NumericData: + def flow_rate_upper_bound_relative(self) -> NumericDataUser: """ Returns the upper bound of the flow_rate relative to its size""" fixed_profile = self.element.fixed_relative_profile if fixed_profile is None: @@ -438,7 +438,7 @@ def flow_rate_upper_bound_relative(self) -> NumericData: return fixed_profile @property - def flow_rate_lower_bound(self) -> NumericData: + def flow_rate_lower_bound(self) -> NumericDataUser: """ Returns the minimum bound the flow_rate can reach. Further constraining might be done in OnOffModel and InvestmentModel @@ -452,7 +452,7 @@ def flow_rate_lower_bound(self) -> NumericData: return self.flow_rate_lower_bound_relative * self.element.size @property - def flow_rate_upper_bound(self) -> NumericData: + def flow_rate_upper_bound(self) -> NumericDataUser: """ Returns the maximum bound the flow_rate can reach. Further constraining might be done in OnOffModel and InvestmentModel diff --git a/flixopt/features.py b/flixopt/features.py index dc719a2a6..20412ed46 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -11,7 +11,7 @@ from . import utils from .config import CONFIG -from .core import NumericData, Scalar, TimeSeries +from .core import NumericDataUser, Scalar, TimeSeries from .interface import InvestParameters, OnOffParameters, Piecewise from .structure import Model, SystemModel @@ -27,7 +27,7 @@ def __init__( label_of_element: str, parameters: InvestParameters, defining_variable: [linopy.Variable], - relative_bounds_of_defining_variable: Tuple[NumericData, NumericData], + relative_bounds_of_defining_variable: Tuple[NumericDataUser, NumericDataUser], label: Optional[str] = None, on_variable: Optional[linopy.Variable] = None, ): @@ -203,12 +203,12 @@ def __init__( model: SystemModel, label_of_element: str, defining_variables: List[linopy.Variable], - defining_bounds: List[Tuple[NumericData, NumericData]], - previous_values: List[Optional[NumericData]] = None, + defining_bounds: List[Tuple[NumericDataUser, NumericDataUser]], + previous_values: List[Optional[NumericDataUser]] = None, use_off: bool = True, - on_hours_total_min: Optional[NumericData] = 0, - on_hours_total_max: Optional[NumericData] = None, - effects_per_running_hour: Dict[str, NumericData] = None, + on_hours_total_min: Optional[NumericDataUser] = 0, + on_hours_total_max: Optional[NumericDataUser] = None, + effects_per_running_hour: Dict[str, NumericDataUser] = None, label: Optional[str] = None, ): """ @@ -344,7 +344,7 @@ def previous_off_states(self): return 1 - self.previous_states @staticmethod - def compute_previous_states(previous_values: List[NumericData], epsilon: float = 1e-5) -> np.ndarray: + def compute_previous_states(previous_values: List[NumericDataUser], epsilon: float = 1e-5) -> np.ndarray: """Computes the previous states {0, 1} of defining variables as a binary array from their previous values.""" if not previous_values or all([val is None for val in previous_values]): return np.array([0]) @@ -451,9 +451,9 @@ def __init__( model: SystemModel, label_of_element: str, state_variable: linopy.Variable, - minimum_duration: Optional[NumericData] = None, - maximum_duration: Optional[NumericData] = None, - previous_states: Optional[NumericData] = None, + minimum_duration: Optional[NumericDataUser] = None, + maximum_duration: Optional[NumericDataUser] = None, + previous_states: Optional[NumericDataUser] = None, label: Optional[str] = None, ): """ @@ -570,7 +570,7 @@ def previous_duration(self) -> Scalar: @staticmethod def compute_consecutive_hours_in_state( - binary_values: NumericData, hours_per_timestep: Union[int, float, np.ndarray] + binary_values: NumericDataUser, hours_per_timestep: Union[int, float, np.ndarray] ) -> Scalar: """ Computes the final consecutive duration in state 'on' (=1) in hours, from a binary array. @@ -629,8 +629,8 @@ def __init__( on_off_parameters: OnOffParameters, label_of_element: str, defining_variables: List[linopy.Variable], - defining_bounds: List[Tuple[NumericData, NumericData]], - previous_values: List[Optional[NumericData]], + defining_bounds: List[Tuple[NumericDataUser, NumericDataUser]], + previous_values: List[Optional[NumericDataUser]], label: Optional[str] = None, ): """ @@ -918,8 +918,8 @@ def __init__( label_full: Optional[str] = None, total_max: Optional[Scalar] = None, total_min: Optional[Scalar] = None, - max_per_hour: Optional[NumericData] = None, - min_per_hour: Optional[NumericData] = None, + max_per_hour: Optional[NumericDataUser] = None, + min_per_hour: Optional[NumericDataUser] = None, ): super().__init__(model, label_of_element=label_of_element, label=label, label_full=label_full) if not shares_are_time_series: # If the condition is True diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index 386f54a72..024d8b3c5 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -16,10 +16,17 @@ from rich.pretty import Pretty from . import io as fx_io -from .core import NumericData, NumericDataTS, TimeSeriesCollection, TimeSeries, DataConverter, ConversionError, TimeSeriesData -from .effects import Effect, EffectCollection, EffectTimeSeries, EffectValuesDict, EffectValuesUser +from .core import ConversionError, DataConverter, NumericDataInternal, NumericDataUser, TimeSeriesData +from .effects import Effect, EffectCollection, EffectValuesInternal, EffectValuesUser from .elements import Bus, Component, Flow -from .structure import CLASS_REGISTRY, Element, SystemModel, get_compact_representation, get_str_representation, Interface +from .structure import ( + CLASS_REGISTRY, + Element, + Interface, + SystemModel, + get_compact_representation, + get_str_representation, +) if TYPE_CHECKING: import pyvis @@ -280,23 +287,22 @@ def to_json(self, path: Union[str, pathlib.Path]): super().to_json(path) - def create_time_series( + def fit_to_model_coords( self, name: str, - data: Optional[Union[NumericData, TimeSeriesData, TimeSeries]], + data: Optional[NumericDataUser], needs_extra_timestep: bool = False, - ) -> Optional[TimeSeries]: + ) -> Optional[NumericDataInternal]: """ - Create a TimeSeries-like object (now just an xr.DataArray with proper coordinates). - This method is kept for API compatibility but simplified. + Fit data to model coordinate system (currently time, but extensible). Args: - name: Name of the time series - data: Data to convert - needs_extra_timestep: Whether to use timesteps_extra + name: Name of the data + data: Data to fit to model coordinates + needs_extra_timestep: Whether to use extended time coordinates Returns: - xr.DataArray with proper time coordinates + xr.DataArray aligned to model coordinate system """ if data is None: return None @@ -316,22 +322,22 @@ def create_time_series( else: return DataConverter.to_dataarray(data, timesteps=target_timesteps).rename(name) - def create_effect_time_series( + def fit_effects_to_model_coords( self, label_prefix: Optional[str], - effect_values: EffectValuesUser, + effect_values: Optional[EffectValuesUser], label_suffix: Optional[str] = None, - ) -> Optional[Dict[str, xr.DataArray]]: + ) -> Optional[EffectValuesInternal]: """ - Transform EffectValues to effect DataArrays. - Simplified version that returns DataArrays directly. + Transform EffectValues from the user to Internal Datatypes aligned with model coordinates. """ - effect_values_dict: Optional[EffectValuesDict] = self.effects.create_effect_values_dict(effect_values) - if effect_values_dict is None: + if effect_values is None: return None + effect_values_dict = self.effects.create_effect_values_dict(effect_values) + return { - effect: self.create_time_series('|'.join(filter(None, [label_prefix, effect, label_suffix])), value) + effect: self.fit_to_model_coords('|'.join(filter(None, [label_prefix, effect, label_suffix])), value) for effect, value in effect_values_dict.items() } @@ -505,7 +511,7 @@ def format_elements(element_names: list, label: str, alignment: int = 12): freq_str = str(self.timesteps.freq).replace('<', '').replace('>', '') if self.timesteps.freq else 'irregular' lines = [ - f'FlowSystem Overview:', + 'FlowSystem Overview:', f'{"─" * 50}', time_period, f'Timesteps: {len(self.timesteps)} ({freq_str})', diff --git a/flixopt/interface.py b/flixopt/interface.py index c38d6c619..e5ee962ed 100644 --- a/flixopt/interface.py +++ b/flixopt/interface.py @@ -7,7 +7,7 @@ from typing import TYPE_CHECKING, Dict, Iterator, List, Optional, Union from .config import CONFIG -from .core import NumericData, NumericDataTS, Scalar +from .core import NumericDataUser, Scalar from .structure import Interface, register_class_for_io if TYPE_CHECKING: # for type checking and preventing circular imports @@ -20,7 +20,7 @@ @register_class_for_io class Piece(Interface): - def __init__(self, start: NumericData, end: NumericData): + def __init__(self, start: NumericDataUser, end: NumericDataUser): """ Define a Piece, which is part of a Piecewise object. @@ -32,8 +32,8 @@ def __init__(self, start: NumericData, end: NumericData): self.end = end def transform_data(self, flow_system: 'FlowSystem', name_prefix: str): - self.start = flow_system.create_time_series(f'{name_prefix}|start', self.start) - self.end = flow_system.create_time_series(f'{name_prefix}|end', self.end) + self.start = flow_system.fit_to_model_coords(f'{name_prefix}|start', self.start) + self.end = flow_system.fit_to_model_coords(f'{name_prefix}|end', self.end) @register_class_for_io @@ -175,10 +175,10 @@ def __init__( effects_per_running_hour: Optional['EffectValuesUser'] = None, on_hours_total_min: Optional[int] = None, on_hours_total_max: Optional[int] = None, - consecutive_on_hours_min: Optional[NumericData] = None, - consecutive_on_hours_max: Optional[NumericData] = None, - consecutive_off_hours_min: Optional[NumericData] = None, - consecutive_off_hours_max: Optional[NumericData] = None, + consecutive_on_hours_min: Optional[NumericDataUser] = None, + consecutive_on_hours_max: Optional[NumericDataUser] = None, + consecutive_off_hours_min: Optional[NumericDataUser] = None, + consecutive_off_hours_max: Optional[NumericDataUser] = None, switch_on_total_max: Optional[int] = None, force_switch_on: bool = False, ): @@ -206,30 +206,30 @@ def __init__( self.effects_per_running_hour: EffectValuesUser = effects_per_running_hour or {} self.on_hours_total_min: Scalar = on_hours_total_min self.on_hours_total_max: Scalar = on_hours_total_max - self.consecutive_on_hours_min: NumericDataTS = consecutive_on_hours_min - self.consecutive_on_hours_max: NumericDataTS = consecutive_on_hours_max - self.consecutive_off_hours_min: NumericDataTS = consecutive_off_hours_min - self.consecutive_off_hours_max: NumericDataTS = consecutive_off_hours_max + self.consecutive_on_hours_min: NumericDataUser = consecutive_on_hours_min + self.consecutive_on_hours_max: NumericDataUser = consecutive_on_hours_max + self.consecutive_off_hours_min: NumericDataUser = consecutive_off_hours_min + self.consecutive_off_hours_max: NumericDataUser = consecutive_off_hours_max self.switch_on_total_max: Scalar = switch_on_total_max self.force_switch_on: bool = force_switch_on def transform_data(self, flow_system: 'FlowSystem', name_prefix: str): - self.effects_per_switch_on = flow_system.create_effect_time_series( + self.effects_per_switch_on = flow_system.fit_effects_to_model_coords( name_prefix, self.effects_per_switch_on, 'per_switch_on' ) - self.effects_per_running_hour = flow_system.create_effect_time_series( + self.effects_per_running_hour = flow_system.fit_effects_to_model_coords( name_prefix, self.effects_per_running_hour, 'per_running_hour' ) - self.consecutive_on_hours_min = flow_system.create_time_series( + self.consecutive_on_hours_min = flow_system.fit_to_model_coords( f'{name_prefix}|consecutive_on_hours_min', self.consecutive_on_hours_min ) - self.consecutive_on_hours_max = flow_system.create_time_series( + self.consecutive_on_hours_max = flow_system.fit_to_model_coords( f'{name_prefix}|consecutive_on_hours_max', self.consecutive_on_hours_max ) - self.consecutive_off_hours_min = flow_system.create_time_series( + self.consecutive_off_hours_min = flow_system.fit_to_model_coords( f'{name_prefix}|consecutive_off_hours_min', self.consecutive_off_hours_min ) - self.consecutive_off_hours_max = flow_system.create_time_series( + self.consecutive_off_hours_max = flow_system.fit_to_model_coords( f'{name_prefix}|consecutive_off_hours_max', self.consecutive_off_hours_max ) diff --git a/flixopt/linear_converters.py b/flixopt/linear_converters.py index 3fd032632..94463c492 100644 --- a/flixopt/linear_converters.py +++ b/flixopt/linear_converters.py @@ -8,7 +8,7 @@ import numpy as np from .components import LinearConverter -from .core import NumericDataTS, TimeSeriesData +from .core import NumericDataUser, TimeSeriesData from .elements import Flow from .interface import OnOffParameters from .structure import register_class_for_io @@ -21,7 +21,7 @@ class Boiler(LinearConverter): def __init__( self, label: str, - eta: NumericDataTS, + eta: NumericDataUser, Q_fu: Flow, Q_th: Flow, on_off_parameters: OnOffParameters = None, @@ -62,7 +62,7 @@ class Power2Heat(LinearConverter): def __init__( self, label: str, - eta: NumericDataTS, + eta: NumericDataUser, P_el: Flow, Q_th: Flow, on_off_parameters: OnOffParameters = None, @@ -104,7 +104,7 @@ class HeatPump(LinearConverter): def __init__( self, label: str, - COP: NumericDataTS, + COP: NumericDataUser, P_el: Flow, Q_th: Flow, on_off_parameters: OnOffParameters = None, @@ -146,7 +146,7 @@ class CoolingTower(LinearConverter): def __init__( self, label: str, - specific_electricity_demand: NumericDataTS, + specific_electricity_demand: NumericDataUser, P_el: Flow, Q_th: Flow, on_off_parameters: OnOffParameters = None, @@ -190,8 +190,8 @@ class CHP(LinearConverter): def __init__( self, label: str, - eta_th: NumericDataTS, - eta_el: NumericDataTS, + eta_th: NumericDataUser, + eta_el: NumericDataUser, Q_fu: Flow, P_el: Flow, Q_th: Flow, @@ -251,7 +251,7 @@ class HeatPumpWithSource(LinearConverter): def __init__( self, label: str, - COP: NumericDataTS, + COP: NumericDataUser, P_el: Flow, Q_ab: Flow, Q_th: Flow, @@ -297,11 +297,11 @@ def COP(self, value): # noqa: N802 def check_bounds( - value: NumericDataTS, + value: NumericDataUser, parameter_label: str, element_label: str, - lower_bound: NumericDataTS, - upper_bound: NumericDataTS, + lower_bound: NumericDataUser, + upper_bound: NumericDataUser, ) -> None: """ Check if the value is within the bounds. The bounds are exclusive. diff --git a/flixopt/structure.py b/flixopt/structure.py index 9cb830ff0..1e3d2849e 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -18,9 +18,9 @@ from rich.console import Console from rich.pretty import Pretty -from .config import CONFIG -from .core import NumericData, Scalar, TimeSeriesCollection, TimeSeries, TimeSeriesData from . import io as fx_io +from .config import CONFIG +from .core import NumericDataUser, Scalar, TimeSeriesData if TYPE_CHECKING: # for type checking and preventing circular imports from .effects import EffectCollectionModel @@ -851,8 +851,6 @@ def copy_and_convert_datatypes(data: Any, use_numpy: bool = True, use_element_la ) return copy_and_convert_datatypes(data.tolist(), use_numpy, use_element_label) - elif isinstance(data, TimeSeries): - return copy_and_convert_datatypes(data, use_numpy, use_element_label) elif isinstance(data, TimeSeriesData): return copy_and_convert_datatypes(data.data, use_numpy, use_element_label) From 10d2925cec8639df06505829889f33b83cc99d4e Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 25 Jun 2025 11:50:23 +0200 Subject: [PATCH 086/336] Remove TImeSeries and TimeSeriesCollection entirely --- flixopt/components.py | 7 ++++--- flixopt/elements.py | 2 +- flixopt/features.py | 2 +- flixopt/io.py | 37 ------------------------------------- flixopt/structure.py | 6 +++--- 5 files changed, 9 insertions(+), 45 deletions(-) diff --git a/flixopt/components.py b/flixopt/components.py index 8e172d573..3f41783a8 100644 --- a/flixopt/components.py +++ b/flixopt/components.py @@ -7,9 +7,10 @@ import linopy import numpy as np +import xarray as xr from . import utils -from .core import NumericDataUser, PlausibilityError, Scalar, TimeSeries +from .core import NumericDataUser, PlausibilityError, Scalar from .elements import Component, ComponentModel, Flow from .features import InvestmentModel, OnOffModel, PiecewiseModel from .interface import InvestParameters, OnOffParameters, PiecewiseConversion @@ -98,8 +99,8 @@ def transform_data(self, flow_system: 'FlowSystem'): if self.piecewise_conversion: self.piecewise_conversion.transform_data(flow_system, f'{self.label_full}|PiecewiseConversion') - def _transform_conversion_factors(self, flow_system: 'FlowSystem') -> List[Dict[str, TimeSeries]]: - """macht alle Faktoren, die nicht TimeSeries sind, zu TimeSeries""" + def _transform_conversion_factors(self, flow_system: 'FlowSystem') -> List[Dict[str, xr.DataArray]]: + """Converts all conversion factors to internal datatypes""" list_of_conversion_factors = [] for idx, conversion_factor in enumerate(self.conversion_factors): transformed_dict = {} diff --git a/flixopt/elements.py b/flixopt/elements.py index a2ba8f7c1..061a00b65 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -10,7 +10,7 @@ import numpy as np from .config import CONFIG -from .core import NumericDataUser, PlausibilityError, Scalar, TimeSeriesCollection +from .core import NumericDataUser, PlausibilityError, Scalar from .effects import EffectValuesUser from .features import InvestmentModel, OnOffModel, PreventSimultaneousUsageModel from .interface import InvestParameters, OnOffParameters diff --git a/flixopt/features.py b/flixopt/features.py index 20412ed46..5bc8f7922 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -11,7 +11,7 @@ from . import utils from .config import CONFIG -from .core import NumericDataUser, Scalar, TimeSeries +from .core import NumericDataUser, Scalar from .interface import InvestParameters, OnOffParameters, Piecewise from .structure import Model, SystemModel diff --git a/flixopt/io.py b/flixopt/io.py index 23b06cacd..b01844f3a 100644 --- a/flixopt/io.py +++ b/flixopt/io.py @@ -10,47 +10,10 @@ import xarray as xr import yaml -from .core import TimeSeries logger = logging.getLogger('flixopt') -def replace_timeseries(obj, mode: Literal['name', 'stats', 'data'] = 'name'): - """Recursively replaces TimeSeries objects with their names prefixed by '::::'.""" - if isinstance(obj, dict): - return {k: replace_timeseries(v, mode) for k, v in obj.items()} - elif isinstance(obj, list): - return [replace_timeseries(v, mode) for v in obj] - elif isinstance(obj, TimeSeries): # Adjust this based on the actual class - if obj.all_equal: - return obj.values[0].item() - elif mode == 'name': - return f'::::{obj.name}' - elif mode == 'stats': - return obj.stats - elif mode == 'data': - return obj - else: - raise ValueError(f'Invalid mode {mode}') - else: - return obj - - -def insert_dataarray(obj, ds: xr.Dataset): - """Recursively inserts TimeSeries objects into a dataset.""" - if isinstance(obj, dict): - return {k: insert_dataarray(v, ds) for k, v in obj.items()} - elif isinstance(obj, list): - return [insert_dataarray(v, ds) for v in obj] - elif isinstance(obj, str) and obj.startswith('::::'): - da = ds[obj[4:]] - if da.isel(time=-1).isnull(): - return da.isel(time=slice(0, -1)) - return da - else: - return obj - - def remove_none_and_empty(obj): """Recursively removes None and empty dicts and lists values from a dictionary or list.""" diff --git a/flixopt/structure.py b/flixopt/structure.py index 1e3d2849e..cc7b166eb 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -152,7 +152,7 @@ def transform_data(self, flow_system: 'FlowSystem'): def _create_reference_structure(self) -> Tuple[Dict, Dict[str, xr.DataArray]]: """ - Convert all DataArrays/TimeSeries to references and extract them. + Convert all DataArrays to references and extract them. This is the core method that both to_dict() and to_dataset() build upon. Returns: @@ -204,7 +204,7 @@ def _is_empty_container(obj) -> bool: def _extract_dataarrays_recursive(self, obj, context_name: str = '') -> Tuple[Any, Dict[str, xr.DataArray]]: """ - Recursively extract DataArrays/TimeSeries from nested structures. + Recursively extract DataArrays from nested structures. Args: obj: Object to process @@ -392,7 +392,7 @@ def _serialize_to_basic_types(self, obj): def to_dataset(self) -> xr.Dataset: """ Convert the object to an xarray Dataset representation. - All DataArrays and TimeSeries become dataset variables, everything else goes to attrs. + All DataArrays become dataset variables, everything else goes to attrs. Returns: xr.Dataset: Dataset containing all DataArrays with basic objects only in attributes From cf9d17f4d34098985cda4be4ae24bcc7fc093594 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 25 Jun 2025 11:52:07 +0200 Subject: [PATCH 087/336] Remove old method --- flixopt/core.py | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/flixopt/core.py b/flixopt/core.py index 4ab97b219..1b91cc1cc 100644 --- a/flixopt/core.py +++ b/flixopt/core.py @@ -229,16 +229,3 @@ def to_dataarray(data: NumericDataUser, timesteps: pd.DatetimeIndex) -> xr.DataA if isinstance(e, ConversionError): raise raise ConversionError(f'Converting data {type(data)} to xarray.DataArray raised an error: {str(e)}') from e - - -def get_numeric_stats(data: xr.DataArray, decimals: int = 2, padd: int = 10) -> str: - """Calculates the mean, median, min, max, and standard deviation of a numeric DataArray.""" - format_spec = f'>{padd}.{decimals}f' if padd else f'.{decimals}f' - if np.unique(data).size == 1: - return f'{data.max().item():{format_spec}} (constant)' - mean = data.mean().item() - median = data.median().item() - min_val = data.min().item() - max_val = data.max().item() - std = data.std().item() - return f'{mean:{format_spec}} (mean), {median:{format_spec}} (median), {min_val:{format_spec}} (min), {max_val:{format_spec}} (max), {std:{format_spec}} (std)' From bd52e059a6bf7228f5865c2bbf5f75dcaf554103 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 25 Jun 2025 16:00:33 +0200 Subject: [PATCH 088/336] Add option to get structure with stats of dataarrays --- flixopt/core.py | 28 ++++++++++++++++++++++++++++ flixopt/flow_system.py | 5 +++-- flixopt/structure.py | 28 +++++++++++++++++++++++++--- 3 files changed, 56 insertions(+), 5 deletions(-) diff --git a/flixopt/core.py b/flixopt/core.py index 1b91cc1cc..61e951019 100644 --- a/flixopt/core.py +++ b/flixopt/core.py @@ -229,3 +229,31 @@ def to_dataarray(data: NumericDataUser, timesteps: pd.DatetimeIndex) -> xr.DataA if isinstance(e, ConversionError): raise raise ConversionError(f'Converting data {type(data)} to xarray.DataArray raised an error: {str(e)}') from e + + +def get_dataarray_stats(arr: xr.DataArray) -> Dict: + """Generate statistical summary of a DataArray.""" + stats = {} + + if arr.dtype.kind in 'biufc': # bool, int, uint, float, complex + try: + stats.update( + { + 'min': float(arr.min().values), + 'max': float(arr.max().values), + 'mean': float(arr.mean().values), + 'median': float(arr.median().values), + 'std': float(arr.std().values), + 'count': int(arr.count().values), # non-null count + } + ) + + # Add null count only if there are nulls + null_count = int(arr.isnull().sum().values) + if null_count > 0: + stats['nulls'] = null_count + + except Exception: + pass + + return stats diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index 024d8b3c5..64f9b39bd 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -259,19 +259,20 @@ def to_netcdf(self, path: Union[str, pathlib.Path], compression: int = 0): super().to_netcdf(path, compression) logger.info(f'Saved FlowSystem to {path}') - def get_structure(self, clean: bool = False) -> Dict: + def get_structure(self, clean: bool = False, stats: bool = False) -> Dict: """ Get FlowSystem structure. Ensures FlowSystem is connected before getting structure. Args: clean: If True, remove None and empty dicts and lists. + stats: If True, replace DataArray references with statistics """ if not self._connected_and_transformed: logger.warning('FlowSystem is not connected. Calling connect_and_transform() now.') self.connect_and_transform() - return super().get_structure(clean) + return super().get_structure(clean, stats) def to_json(self, path: Union[str, pathlib.Path]): """ diff --git a/flixopt/structure.py b/flixopt/structure.py index cc7b166eb..651aa765a 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -20,7 +20,7 @@ from . import io as fx_io from .config import CONFIG -from .core import NumericDataUser, Scalar, TimeSeriesData +from .core import NumericDataUser, Scalar, TimeSeriesData, get_dataarray_stats if TYPE_CHECKING: # for type checking and preventing circular imports from .effects import EffectCollectionModel @@ -482,21 +482,43 @@ def from_netcdf(cls, path: Union[str, pathlib.Path]) -> 'Interface': except Exception as e: raise IOError(f'Failed to load {cls.__name__} from NetCDF file {path}: {e}') from e - def get_structure(self, clean: bool = False) -> Dict: + def get_structure(self, clean: bool = False, stats: bool = False) -> Dict: """ Get object structure as a dictionary. Args: clean: If True, remove None and empty dicts and lists. + stats: If True, replace DataArray references with statistics Returns: Dictionary representation of the object structure """ - reference_structure, _ = self._create_reference_structure() + reference_structure, extracted_arrays = self._create_reference_structure() + + if stats: + # Replace references with statistics + reference_structure = self._replace_references_with_stats(reference_structure, extracted_arrays) + if clean: return fx_io.remove_none_and_empty(reference_structure) return reference_structure + def _replace_references_with_stats(self, structure, arrays_dict: Dict[str, xr.DataArray]): + """Replace DataArray references with statistical summaries.""" + if isinstance(structure, str) and structure.startswith(':::'): + array_name = structure[3:] + if array_name in arrays_dict: + return get_dataarray_stats(arrays_dict[array_name]) + return structure + + elif isinstance(structure, dict): + return {k: self._replace_references_with_stats(v, arrays_dict) for k, v in structure.items()} + + elif isinstance(structure, list): + return [self._replace_references_with_stats(item, arrays_dict) for item in structure] + + return structure + def to_json(self, path: Union[str, pathlib.Path]): """ Save the object to a JSON file. From aa366892ae3ebbdf844932f9d442c5378edeba03 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 25 Jun 2025 16:22:49 +0200 Subject: [PATCH 089/336] Change __str__ method --- flixopt/structure.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/flixopt/structure.py b/flixopt/structure.py index 651aa765a..33817ec4f 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -532,7 +532,7 @@ def to_json(self, path: Union[str, pathlib.Path]): """ try: # Use the stats mode for JSON export (cleaner output) - data = get_compact_representation(self.get_structure(clean=True)) + data = self.get_structure(clean=True, stats=True) with open(path, 'w', encoding='utf-8') as f: json.dump(data, f, indent=4, ensure_ascii=False) except Exception as e: @@ -566,7 +566,11 @@ def __repr__(self): def __str__(self): """Return a user-friendly string representation.""" try: - return get_str_representation(self.get_structure(clean=True)) + data = self.get_structure(clean=True, stats=True) + with StringIO() as output_buffer: + console = Console(file=output_buffer, width=1000) # Adjust width as needed + console.print(Pretty(data, expand_all=True, indent_guides=True)) + return output_buffer.getvalue() except Exception: # Fallback if structure generation fails return f'{self.__class__.__name__} instance' From 63b1c926ea42b6cc9e374967237c4c6ee1ebc363 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 25 Jun 2025 16:23:14 +0200 Subject: [PATCH 090/336] Remove old methods --- flixopt/io.py | 1 - flixopt/structure.py | 186 ------------------------------------------- 2 files changed, 187 deletions(-) diff --git a/flixopt/io.py b/flixopt/io.py index b01844f3a..9527eb66a 100644 --- a/flixopt/io.py +++ b/flixopt/io.py @@ -10,7 +10,6 @@ import xarray as xr import yaml - logger = logging.getLogger('flixopt') diff --git a/flixopt/structure.py b/flixopt/structure.py index 33817ec4f..b4fcf7d38 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -123,20 +123,6 @@ class Interface: Subclasses must implement: transform_data(flow_system): Transform data to match FlowSystem dimensions - - Example: - >>> class MyComponent(Interface): - ... def __init__(self, name: str, power_data: xr.DataArray): - ... self.name = name - ... self.power_data = power_data - ... - ... def transform_data(self, flow_system): - ... # Transform power_data to match flow_system timesteps - ... pass - >>> - >>> component = MyComponent('gen1', power_array) - >>> component.to_netcdf('component.nc') # Save to file - >>> restored = MyComponent.from_netcdf('component.nc') # Load from file """ def transform_data(self, flow_system: 'FlowSystem'): @@ -798,175 +784,3 @@ def results_structure(self): 'variables': list(self.variables), 'constraints': list(self.constraints), } - - -def copy_and_convert_datatypes(data: Any, use_numpy: bool = True, use_element_label: bool = False) -> Any: - """ - Converts values in a nested data structure into JSON-compatible types while preserving or transforming numpy arrays - and custom `Element` objects based on the specified options. - - The function handles various data types and transforms them into a consistent, readable format: - - Primitive types (`int`, `float`, `str`, `bool`, `None`) are returned as-is. - - Numpy scalars are converted to their corresponding Python scalar types. - - Collections (`list`, `tuple`, `set`, `dict`) are recursively processed to ensure all elements are compatible. - - Numpy arrays are preserved or converted to lists, depending on `use_numpy`. - - Custom `Element` objects can be represented either by their `label` or their initialization parameters as a dictionary. - - Timestamps (`datetime`) are converted to ISO 8601 strings. - - Args: - data: The input data to process, which may be deeply nested and contain a mix of types. - use_numpy: If `True`, numeric numpy arrays (`np.ndarray`) are preserved as-is. If `False`, they are converted to lists. - Default is `True`. - use_element_label: If `True`, `Element` objects are represented by their `label`. If `False`, they are converted into a dictionary - based on their initialization parameters. Default is `False`. - - Returns: - A transformed version of the input data, containing only JSON-compatible types: - - `int`, `float`, `str`, `bool`, `None` - - `list`, `dict` - - `np.ndarray` (if `use_numpy=True`. This is NOT JSON-compatible) - - Raises: - TypeError: If the data cannot be converted to the specified types. - - Examples: - >>> copy_and_convert_datatypes({'a': np.array([1, 2, 3]), 'b': Element(label='example')}) - {'a': array([1, 2, 3]), 'b': {'class': 'Element', 'label': 'example'}} - - >>> copy_and_convert_datatypes({'a': np.array([1, 2, 3]), 'b': Element(label='example')}, use_numpy=False) - {'a': [1, 2, 3], 'b': {'class': 'Element', 'label': 'example'}} - - Notes: - - The function gracefully handles unexpected types by issuing a warning and returning a deep copy of the data. - - Empty collections (lists, dictionaries) and default parameter values in `Element` objects are omitted from the output. - - Numpy arrays with non-numeric data types are automatically converted to lists. - """ - if isinstance(data, np.integer): # This must be checked before checking for regular int and float! - return int(data) - elif isinstance(data, np.floating): - return float(data) - - elif isinstance(data, (int, float, str, bool, type(None))): - return data - elif isinstance(data, datetime): - return data.isoformat() - - elif isinstance(data, (tuple, set)): - return copy_and_convert_datatypes([item for item in data], use_numpy, use_element_label) - elif isinstance(data, dict): - return { - copy_and_convert_datatypes(key, use_numpy, use_element_label=True): copy_and_convert_datatypes( - value, use_numpy, use_element_label - ) - for key, value in data.items() - } - elif isinstance(data, list): # Shorten arrays/lists to be readable - if use_numpy and all([isinstance(value, (int, float)) for value in data]): - return np.array([item for item in data]) - else: - return [copy_and_convert_datatypes(item, use_numpy, use_element_label) for item in data] - - elif isinstance(data, np.ndarray): - if not use_numpy: - return copy_and_convert_datatypes(data.tolist(), use_numpy, use_element_label) - elif use_numpy and np.issubdtype(data.dtype, np.number): - return data - else: - logger.critical( - f'An np.array with non-numeric content was found: {data=}.It will be converted to a list instead' - ) - return copy_and_convert_datatypes(data.tolist(), use_numpy, use_element_label) - - elif isinstance(data, TimeSeriesData): - return copy_and_convert_datatypes(data.data, use_numpy, use_element_label) - - elif isinstance(data, Interface): - if use_element_label and isinstance(data, Element): - return data.label - return data.infos(use_numpy, use_element_label) - elif isinstance(data, xr.DataArray): - # TODO: This is a temporary basic work around - return copy_and_convert_datatypes(data.values, use_numpy, use_element_label) - else: - raise TypeError(f'copy_and_convert_datatypes() did get unexpected data of type "{type(data)}": {data=}') - - -def get_compact_representation(data: Any, array_threshold: int = 50, decimals: int = 2) -> Dict: - """ - Generate a compact json serializable representation of deeply nested data. - Numpy arrays are statistically described if they exceed a threshold and converted to lists. - - Args: - data (Any): The data to format and represent. - array_threshold (int): Maximum length of NumPy arrays to display. Longer arrays are statistically described. - decimals (int): Number of decimal places in which to describe the arrays. - - Returns: - Dict: A dictionary representation of the data - """ - - def format_np_array_if_found(value: Any) -> Any: - """Recursively processes the data, formatting NumPy arrays.""" - if isinstance(value, (int, float, str, bool, type(None))): - return value - elif isinstance(value, np.ndarray): - return describe_numpy_arrays(value) - elif isinstance(value, dict): - return {format_np_array_if_found(k): format_np_array_if_found(v) for k, v in value.items()} - elif isinstance(value, (list, tuple, set)): - return [format_np_array_if_found(v) for v in value] - else: - logger.warning( - f'Unexpected value found when trying to format numpy array numpy array: {type(value)=}; {value=}' - ) - return value - - def describe_numpy_arrays(arr: np.ndarray) -> Union[str, List]: - """Shortens NumPy arrays if they exceed the specified length.""" - - def normalized_center_of_mass(array: Any) -> float: - # position in array (0 bis 1 normiert) - positions = np.linspace(0, 1, len(array)) # weights w_i - # mass center - if np.sum(array) == 0: - return np.nan - else: - return np.sum(positions * array) / np.sum(array) - - if arr.size > array_threshold: # Calculate basic statistics - fmt = f'.{decimals}f' - return ( - f'Array (min={np.min(arr):{fmt}}, max={np.max(arr):{fmt}}, mean={np.mean(arr):{fmt}}, ' - f'median={np.median(arr):{fmt}}, std={np.std(arr):{fmt}}, len={len(arr)}, ' - f'center={normalized_center_of_mass(arr):{fmt}})' - ) - else: - return np.around(arr, decimals=decimals).tolist() - - # Process the data to handle NumPy arrays - formatted_data = format_np_array_if_found(copy_and_convert_datatypes(data, use_numpy=True)) - - return formatted_data - - -def get_str_representation(data: Any, array_threshold: int = 50, decimals: int = 2) -> str: - """ - Generate a string representation of deeply nested data using `rich.print`. - NumPy arrays are shortened to the specified length and converted to strings. - - Args: - data (Any): The data to format and represent. - array_threshold (int): Maximum length of NumPy arrays to display. Longer arrays are statistically described. - decimals (int): Number of decimal places in which to describe the arrays. - - Returns: - str: The formatted string representation of the data. - """ - - formatted_data = get_compact_representation(data, array_threshold, decimals) - - # Use Rich to format and print the data - with StringIO() as output_buffer: - console = Console(file=output_buffer, width=1000) # Adjust width as needed - console.print(Pretty(formatted_data, expand_all=True, indent_guides=True)) - return output_buffer.getvalue() From 29062fac6df49614955b33244e95ad55bee05225 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 25 Jun 2025 16:24:57 +0200 Subject: [PATCH 091/336] remove old imports --- flixopt/calculation.py | 2 +- flixopt/flow_system.py | 9 +-------- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/flixopt/calculation.py b/flixopt/calculation.py index f52c1ca19..251a50075 100644 --- a/flixopt/calculation.py +++ b/flixopt/calculation.py @@ -29,7 +29,7 @@ from .flow_system import FlowSystem from .results import CalculationResults, SegmentedCalculationResults from .solvers import _Solver -from .structure import SystemModel, copy_and_convert_datatypes, get_compact_representation +from .structure import SystemModel logger = logging.getLogger('flixopt') diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index 64f9b39bd..7724a9e61 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -19,14 +19,7 @@ from .core import ConversionError, DataConverter, NumericDataInternal, NumericDataUser, TimeSeriesData from .effects import Effect, EffectCollection, EffectValuesInternal, EffectValuesUser from .elements import Bus, Component, Flow -from .structure import ( - CLASS_REGISTRY, - Element, - Interface, - SystemModel, - get_compact_representation, - get_str_representation, -) +from .structure import Element, Interface, SystemModel if TYPE_CHECKING: import pyvis From 18c43e49d5e5adc2286354c35823031202ce555d Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 26 Jun 2025 08:59:32 +0200 Subject: [PATCH 092/336] Add isel, sel and resample methods to FlowSystem --- flixopt/core.py | 2 +- flixopt/flow_system.py | 140 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 140 insertions(+), 2 deletions(-) diff --git a/flixopt/core.py b/flixopt/core.py index 61e951019..831b90b37 100644 --- a/flixopt/core.py +++ b/flixopt/core.py @@ -212,7 +212,7 @@ def to_dataarray(data: NumericDataUser, timesteps: pd.DatetimeIndex) -> xr.DataA raise ConversionError(f"DataArray dimensions {data.dims} don't match expected {dims}") if data.sizes[dims[0]] != len(coords[0]): raise ConversionError( - f"DataArray length {data.sizes[dims[0]]} doesn't match expected {len(coords[0])}" + f"DataArray length {data.sizes[dims[0]]} doesn't match expected {len(coords[0])}: {data}" ) return data.copy(deep=True) diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index 7724a9e61..2bdfd0bbc 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -194,7 +194,7 @@ def from_dataset(cls, ds: xr.Dataset) -> 'FlowSystem': reference_structure = dict(ds.attrs) # Extract FlowSystem constructor parameters - timesteps_extra = pd.DatetimeIndex(reference_structure['timesteps_extra'], name='time') + timesteps_extra = ds.indexes['time'] hours_of_previous_timesteps = reference_structure['hours_of_previous_timesteps'] # Calculate hours_of_last_timestep from the timesteps @@ -547,3 +547,141 @@ def all_elements(self) -> Dict[str, Element]: @property def used_in_calculation(self) -> bool: return self._used_in_calculation + + def sel(self, **indexers) -> 'FlowSystem': + """Select a subset of the flowsystem like dataset.sel(time=slice('2023-01', '2023-06'))""" + if not self._connected_and_transformed: + self.connect_and_transform() + + # Convert to dataset, select, then convert back + dataset = self.to_dataset() + + # Extend time selection and handle NaN preservation + if 'time' in indexers: + indexers = self._extend_time_selection(indexers, dataset) + selected_dataset = dataset.sel(**indexers) + selected_dataset = self._preserve_nan_pattern(selected_dataset, dataset) + else: + selected_dataset = dataset.sel(**indexers) + + return self.__class__.from_dataset(selected_dataset) + + def isel(self, **indexers) -> 'FlowSystem': + """Select by integer index like dataset.isel(time=slice(0, 100))""" + if not self._connected_and_transformed: + self.connect_and_transform() + + # Convert to dataset, select, then convert back + dataset = self.to_dataset() + + # Extend time selection and handle NaN preservation + if 'time' in indexers: + indexers = self._extend_time_iselection(indexers, dataset) + selected_dataset = dataset.isel(**indexers) + selected_dataset = self._preserve_nan_pattern(selected_dataset, dataset) + else: + selected_dataset = dataset.isel(**indexers) + + return self.__class__.from_dataset(selected_dataset) + + def _preserve_nan_pattern(self, processed_dataset: xr.Dataset, original_dataset: xr.Dataset) -> xr.Dataset: + """ + Preserve NaN pattern at the last timestep for arrays that originally had NaN at the end. + Works for both selection and resampling operations. + """ + for var_name, processed_array in processed_dataset.data_vars.items(): + if var_name in original_dataset.data_vars: + original_array = original_dataset.data_vars[var_name] + + # Check if original array had NaN at the last timestep + if len(original_array.time) > 0 and len(processed_array.time) > 0: + last_original = original_array.isel(time=-1) + + if last_original.isnull().all(): # All values at last timestep are NaN + # Set all values at last timestep to NaN + processed_array = processed_array.copy() + processed_array.values[..., -1] = np.nan + processed_dataset[var_name] = processed_array + elif last_original.isnull().any(): # Some values at last timestep are NaN + # Preserve the specific NaN pattern (if dimensions allow) + processed_array = processed_array.copy() + try: + nan_mask = last_original.isnull().values + processed_array.values[..., -1][nan_mask] = np.nan + except (IndexError, ValueError): + # Fallback: set entire last timestep to NaN if dimensions don't match + processed_array.values[..., -1] = np.nan + processed_dataset[var_name] = processed_array + + return processed_dataset + + def _extend_time_selection(self, indexers: dict, dataset: xr.Dataset) -> dict: + """Extend time selection to include the next timestep for proper boundaries.""" + new_indexers = indexers.copy() + time_sel = indexers['time'] + + if isinstance(time_sel, slice): + # For slice, extend the stop point + if time_sel.stop is not None: + time_coord = dataset.coords['time'] + try: + # Find the index of the stop time and add 1 + stop_idx = time_coord.get_index('time').get_indexer([time_sel.stop], method='nearest')[0] + if stop_idx < len(time_coord) - 1: # Don't go beyond bounds + next_time = time_coord.isel(time=stop_idx + 1).values + new_indexers['time'] = slice(time_sel.start, next_time, time_sel.step) + except Exception: + pass # Keep original if extension fails + + return new_indexers + + def _extend_time_iselection(self, indexers: dict, dataset: xr.Dataset) -> dict: + """Extend integer time selection to include the next timestep.""" + new_indexers = indexers.copy() + time_sel = indexers['time'] + + if isinstance(time_sel, slice): + # For slice, extend the stop point by 1 + stop = time_sel.stop + if stop is not None and stop < len(dataset.coords['time']) - 1: + new_indexers['time'] = slice(time_sel.start, stop + 1, time_sel.step) + elif isinstance(time_sel, int): + # For single index, convert to slice including next + if time_sel < len(dataset.coords['time']) - 1: + new_indexers['time'] = slice(time_sel, time_sel + 2) + elif isinstance(time_sel, (list, np.ndarray)): + # For list/array of indices, add next indices + extended_indices = list(time_sel) + max_idx = len(dataset.coords['time']) - 1 + for idx in time_sel: + if isinstance(idx, int) and idx < max_idx and (idx + 1) not in extended_indices: + extended_indices.append(idx + 1) + new_indexers['time'] = sorted(extended_indices) + + return new_indexers + + def resample(self, time, method: str = 'mean', **kwargs) -> 'FlowSystem': + """ + Resample time dimension like dataset.resample(). + + Args: + time: Resampling frequency (e.g., '1H', '1D') + method: Resampling method ('mean', 'sum', 'max', 'min', 'first', 'last') + **kwargs: Additional arguments passed to xarray.resample() + """ + if not self._connected_and_transformed: + self.connect_and_transform() + + dataset = self.to_dataset() + resampler = dataset.resample(time=time, **kwargs) + + # Apply the specified method + if hasattr(resampler, method): + resampled_dataset = getattr(resampler, method)() + else: + raise ValueError(f'Unsupported resampling method: {method}') + + # Preserve NaN pattern at the last timestep + resampled_dataset = self._preserve_nan_pattern(resampled_dataset, dataset) + + return self.__class__.from_dataset(resampled_dataset) From 1f9ef072abb56a73efe04d57f27dd44287238412 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 26 Jun 2025 09:28:06 +0200 Subject: [PATCH 093/336] Remove need for timeseries with extra timestep --- flixopt/components.py | 25 ++++++++++++++++++++----- flixopt/effects.py | 2 +- flixopt/flow_system.py | 9 ++------- 3 files changed, 23 insertions(+), 13 deletions(-) diff --git a/flixopt/components.py b/flixopt/components.py index 3f41783a8..ae8cdfbf0 100644 --- a/flixopt/components.py +++ b/flixopt/components.py @@ -134,6 +134,8 @@ def __init__( initial_charge_state: Union[Scalar, Literal['lastValueOfSim']] = 0, minimal_final_charge_state: Optional[Scalar] = None, maximal_final_charge_state: Optional[Scalar] = None, + relative_minimum_final_charge_state: Optional[Scalar] = None, + relative_maximum_final_charge_state: Optional[Scalar] = None, eta_charge: NumericDataUser = 1, eta_discharge: NumericDataUser = 1, relative_loss_per_hour: NumericDataUser = 0, @@ -158,6 +160,8 @@ def __init__( initial_charge_state: storage charge_state at the beginning. The default is 0. minimal_final_charge_state: minimal value of chargeState at the end of timeseries. maximal_final_charge_state: maximal value of chargeState at the end of timeseries. + minimal_final_charge_state: relative minimal value of chargeState at the end of timeseries. + maximal_final_charge_state: relative maximal value of chargeState at the end of timeseries. eta_charge: efficiency factor of charging/loading. The default is 1. eta_discharge: efficiency factor of uncharging/unloading. The default is 1. relative_loss_per_hour: loss per chargeState-Unit per hour. The default is 0. @@ -180,6 +184,9 @@ def __init__( self.relative_minimum_charge_state: NumericDataUser = relative_minimum_charge_state self.relative_maximum_charge_state: NumericDataUser = relative_maximum_charge_state + self.relative_minimum_final_charge_state: NumericDataUser = relative_minimum_final_charge_state + self.relative_maximum_final_charge_state: NumericDataUser = relative_maximum_final_charge_state + self.initial_charge_state = initial_charge_state self.minimal_final_charge_state = minimal_final_charge_state self.maximal_final_charge_state = maximal_final_charge_state @@ -199,12 +206,10 @@ def transform_data(self, flow_system: 'FlowSystem') -> None: self.relative_minimum_charge_state = flow_system.fit_to_model_coords( f'{self.label_full}|relative_minimum_charge_state', self.relative_minimum_charge_state, - needs_extra_timestep=True, ) self.relative_maximum_charge_state = flow_system.fit_to_model_coords( f'{self.label_full}|relative_maximum_charge_state', self.relative_maximum_charge_state, - needs_extra_timestep=True, ) self.eta_charge = flow_system.fit_to_model_coords(f'{self.label_full}|eta_charge', self.eta_charge) self.eta_discharge = flow_system.fit_to_model_coords(f'{self.label_full}|eta_discharge', self.eta_discharge) @@ -571,10 +576,20 @@ def absolute_charge_state_bounds(self) -> Tuple[NumericDataUser, NumericDataUser ) @property - def relative_charge_state_bounds(self) -> Tuple[NumericDataUser, NumericDataUser]: + def relative_charge_state_bounds(self) -> Tuple[xr.DataArray, xr.DataArray]: + relative_minimum_final_charge_state = ( + xr.DataArray([np.min(self.element.relative_minimum_charge_state)], coords={'time': [self._model.flow_system.timesteps_extra[-1]]}, dims=['time'] + ) if self.element.relative_minimum_final_charge_state is None else + self.element.relative_minimum_final_charge_state + ) + relative_maximum_final_charge_state = ( + xr.DataArray([np.max(self.element.relative_maximum_charge_state)], coords={'time': [self._model.flow_system.timesteps_extra[-1]]}, dims=['time'] + ) if self.element.relative_maximum_final_charge_state is None else + self.element.relative_maximum_final_charge_state + ) return ( - self.element.relative_minimum_charge_state, - self.element.relative_maximum_charge_state, + xr.concat([self.element.relative_minimum_charge_state, relative_minimum_final_charge_state], dim='time'), + xr.concat([self.element.relative_maximum_charge_state, relative_maximum_final_charge_state], dim='time'), ) diff --git a/flixopt/effects.py b/flixopt/effects.py index 7fa136f5b..89bc009bf 100644 --- a/flixopt/effects.py +++ b/flixopt/effects.py @@ -94,7 +94,7 @@ def transform_data(self, flow_system: 'FlowSystem'): f'{self.label_full}|minimum_operation_per_hour', self.minimum_operation_per_hour ) self.maximum_operation_per_hour = flow_system.fit_to_model_coords( - f'{self.label_full}|maximum_operation_per_hour', self.maximum_operation_per_hour, flow_system + f'{self.label_full}|maximum_operation_per_hour', self.maximum_operation_per_hour ) self.specific_share_to_other_effects_operation = flow_system.fit_effects_to_model_coords( diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index 2bdfd0bbc..8b412cd07 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -285,7 +285,6 @@ def fit_to_model_coords( self, name: str, data: Optional[NumericDataUser], - needs_extra_timestep: bool = False, ) -> Optional[NumericDataInternal]: """ Fit data to model coordinate system (currently time, but extensible). @@ -293,7 +292,6 @@ def fit_to_model_coords( Args: name: Name of the data data: Data to fit to model coordinates - needs_extra_timestep: Whether to use extended time coordinates Returns: xr.DataArray aligned to model coordinate system @@ -301,20 +299,17 @@ def fit_to_model_coords( if data is None: return None - # Choose appropriate timesteps - target_timesteps = self.timesteps_extra if needs_extra_timestep else self.timesteps - if isinstance(data, TimeSeriesData): try: return TimeSeriesData( - DataConverter.to_dataarray(data, timesteps=target_timesteps), + DataConverter.to_dataarray(data, timesteps=self.timesteps), agg_group=data.agg_group, agg_weight=data.agg_weight ).rename(name) except ConversionError as e: logger.critical(f'Could not convert time series data "{name}" to DataArray: {e}. \n' f'Take care to use the correct (time) index.') else: - return DataConverter.to_dataarray(data, timesteps=target_timesteps).rename(name) + return DataConverter.to_dataarray(data, timesteps=self.timesteps).rename(name) def fit_effects_to_model_coords( self, From 5d88fde2a2a6d9c8b6e007e7f4be8a5a64f3d868 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 26 Jun 2025 10:09:48 +0200 Subject: [PATCH 094/336] Simplify IO of FLowSystem --- flixopt/flow_system.py | 120 +++-------------------------------------- 1 file changed, 6 insertions(+), 114 deletions(-) diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index 8b412cd07..aa2e261eb 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -129,15 +129,9 @@ def _create_reference_structure(self) -> Tuple[Dict, Dict[str, xr.DataArray]]: # Start with Interface base functionality for constructor parameters reference_structure, all_extracted_arrays = super()._create_reference_structure() - # Override timesteps serialization (we need timesteps_extra instead of timesteps) - reference_structure['timesteps_extra'] = [date.isoformat() for date in self.timesteps_extra] - - # Remove timesteps from structure since we're using timesteps_extra + # Remove timesteps, as it's directly stored in dataset index reference_structure.pop('timesteps', None) - # Add timing arrays directly (not handled by Interface introspection) - all_extracted_arrays['hours_per_timestep'] = self.hours_per_timestep - # Extract from components components_structure = {} for comp_label, component in self.components.items(): @@ -193,18 +187,11 @@ def from_dataset(cls, ds: xr.Dataset) -> 'FlowSystem': # Get the reference structure from attrs reference_structure = dict(ds.attrs) - # Extract FlowSystem constructor parameters - timesteps_extra = ds.indexes['time'] - hours_of_previous_timesteps = reference_structure['hours_of_previous_timesteps'] - - # Calculate hours_of_last_timestep from the timesteps - hours_of_last_timestep = float((timesteps_extra[-1] - timesteps_extra[-2]) / pd.Timedelta(hours=1)) - # Create FlowSystem instance with constructor parameters flow_system = cls( - timesteps=timesteps_extra[:-1], - hours_of_last_timestep=hours_of_last_timestep, - hours_of_previous_timesteps=hours_of_previous_timesteps, + timesteps=ds.indexes['time'], + hours_of_last_timestep=reference_structure.get('hours_of_last_timestep'), + hours_of_previous_timesteps=reference_structure.get('hours_of_previous_timesteps'), ) # Create arrays dictionary from dataset variables @@ -549,15 +536,7 @@ def sel(self, **indexers) -> 'FlowSystem': self.connect_and_transform() # Convert to dataset, select, then convert back - dataset = self.to_dataset() - - # Extend time selection and handle NaN preservation - if 'time' in indexers: - indexers = self._extend_time_selection(indexers, dataset) - selected_dataset = dataset.sel(**indexers) - selected_dataset = self._preserve_nan_pattern(selected_dataset, dataset) - else: - selected_dataset = dataset.sel(**indexers) + selected_dataset = self.to_dataset().sel(**indexers) return self.__class__.from_dataset(selected_dataset) @@ -567,94 +546,10 @@ def isel(self, **indexers) -> 'FlowSystem': self.connect_and_transform() # Convert to dataset, select, then convert back - dataset = self.to_dataset() - - # Extend time selection and handle NaN preservation - if 'time' in indexers: - indexers = self._extend_time_iselection(indexers, dataset) - selected_dataset = dataset.isel(**indexers) - selected_dataset = self._preserve_nan_pattern(selected_dataset, dataset) - else: - selected_dataset = dataset.isel(**indexers) + selected_dataset = self.to_dataset().isel(**indexers) return self.__class__.from_dataset(selected_dataset) - def _preserve_nan_pattern(self, processed_dataset: xr.Dataset, original_dataset: xr.Dataset) -> xr.Dataset: - """ - Preserve NaN pattern at the last timestep for arrays that originally had NaN at the end. - Works for both selection and resampling operations. - """ - for var_name, processed_array in processed_dataset.data_vars.items(): - if var_name in original_dataset.data_vars: - original_array = original_dataset.data_vars[var_name] - - # Check if original array had NaN at the last timestep - if len(original_array.time) > 0 and len(processed_array.time) > 0: - last_original = original_array.isel(time=-1) - - if last_original.isnull().all(): # All values at last timestep are NaN - # Set all values at last timestep to NaN - processed_array = processed_array.copy() - processed_array.values[..., -1] = np.nan - processed_dataset[var_name] = processed_array - elif last_original.isnull().any(): # Some values at last timestep are NaN - # Preserve the specific NaN pattern (if dimensions allow) - processed_array = processed_array.copy() - try: - nan_mask = last_original.isnull().values - processed_array.values[..., -1][nan_mask] = np.nan - except (IndexError, ValueError): - # Fallback: set entire last timestep to NaN if dimensions don't match - processed_array.values[..., -1] = np.nan - processed_dataset[var_name] = processed_array - - return processed_dataset - - def _extend_time_selection(self, indexers: dict, dataset: xr.Dataset) -> dict: - """Extend time selection to include the next timestep for proper boundaries.""" - new_indexers = indexers.copy() - time_sel = indexers['time'] - - if isinstance(time_sel, slice): - # For slice, extend the stop point - if time_sel.stop is not None: - time_coord = dataset.coords['time'] - try: - # Find the index of the stop time and add 1 - stop_idx = time_coord.get_index('time').get_indexer([time_sel.stop], method='nearest')[0] - if stop_idx < len(time_coord) - 1: # Don't go beyond bounds - next_time = time_coord.isel(time=stop_idx + 1).values - new_indexers['time'] = slice(time_sel.start, next_time, time_sel.step) - except Exception: - pass # Keep original if extension fails - - return new_indexers - - def _extend_time_iselection(self, indexers: dict, dataset: xr.Dataset) -> dict: - """Extend integer time selection to include the next timestep.""" - new_indexers = indexers.copy() - time_sel = indexers['time'] - - if isinstance(time_sel, slice): - # For slice, extend the stop point by 1 - stop = time_sel.stop - if stop is not None and stop < len(dataset.coords['time']) - 1: - new_indexers['time'] = slice(time_sel.start, stop + 1, time_sel.step) - elif isinstance(time_sel, int): - # For single index, convert to slice including next - if time_sel < len(dataset.coords['time']) - 1: - new_indexers['time'] = slice(time_sel, time_sel + 2) - elif isinstance(time_sel, (list, np.ndarray)): - # For list/array of indices, add next indices - extended_indices = list(time_sel) - max_idx = len(dataset.coords['time']) - 1 - for idx in time_sel: - if isinstance(idx, int) and idx < max_idx and (idx + 1) not in extended_indices: - extended_indices.append(idx + 1) - new_indexers['time'] = sorted(extended_indices) - - return new_indexers - def resample(self, time, method: str = 'mean', **kwargs) -> 'FlowSystem': """ Resample time dimension like dataset.resample(). @@ -676,7 +571,4 @@ def resample(self, time, method: str = 'mean', **kwargs) -> 'FlowSystem': else: raise ValueError(f'Unsupported resampling method: {method}') - # Preserve NaN pattern at the last timestep - resampled_dataset = self._preserve_nan_pattern(resampled_dataset, dataset) - return self.__class__.from_dataset(resampled_dataset) From 1e94de392b57e57b8b3600c4598fdad9a46014a7 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 26 Jun 2025 10:12:53 +0200 Subject: [PATCH 095/336] Remove parameter timesteps from IO --- flixopt/structure.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flixopt/structure.py b/flixopt/structure.py index b4fcf7d38..5a95b0a94 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -156,7 +156,7 @@ def _create_reference_structure(self) -> Tuple[Dict, Dict[str, xr.DataArray]]: all_extracted_arrays = {} for name in self._cached_init_params: - if name == 'self': + if name == 'self' or name == 'timesteps': # Skip self and timesteps. Timesteps are directly stored in Datasets continue value = getattr(self, name, None) From e5828ad78f813aff3d116e3696fb832d19b7bbe4 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 26 Jun 2025 10:26:54 +0200 Subject: [PATCH 096/336] Improve Exceptions and Docstrings --- flixopt/flow_system.py | 2 +- flixopt/structure.py | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index aa2e261eb..1d3bc4aa8 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -167,7 +167,7 @@ def to_dataset(self) -> xr.Dataset: xr.Dataset: Dataset containing all DataArrays with structure in attributes """ if not self._connected_and_transformed: - logger.warning('FlowSystem is not connected_and_transformed..') + logger.warning('FlowSystem is not connected_and_transformed. Connecting and transforming data now.') self.connect_and_transform() return super().to_dataset() diff --git a/flixopt/structure.py b/flixopt/structure.py index 5a95b0a94..3fb0be066 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -380,6 +380,9 @@ def to_dataset(self) -> xr.Dataset: Convert the object to an xarray Dataset representation. All DataArrays become dataset variables, everything else goes to attrs. + Its recommended to only call this method on Interfaces with all numeric data stored as xr.DataArrays. + Interfaces inside a FlowSystem are automatically converted this form after connecting and transforming the FlowSystem. + Returns: xr.Dataset: Dataset containing all DataArrays with basic objects only in attributes @@ -391,7 +394,10 @@ def to_dataset(self) -> xr.Dataset: # Create the dataset with extracted arrays as variables and structure as attrs return xr.Dataset(extracted_arrays, attrs=reference_structure) except Exception as e: - raise ValueError(f'Failed to convert {self.__class__.__name__} to dataset: {e}') from e + raise ValueError( + f'Failed to convert {self.__class__.__name__} to dataset. Its recommended to only call this method on ' + f'a fully connected and transformed FlowSystem, or Interfaces inside such a FlowSystem.' + f'Original Error: {e}') from e def to_netcdf(self, path: Union[str, pathlib.Path], compression: int = 0): """ From 870efeee484f39e63cf4b7adca6bc30000b613ac Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 26 Jun 2025 10:35:56 +0200 Subject: [PATCH 097/336] Improve isel sel and resample methods --- flixopt/flow_system.py | 66 ++++++++++++++++++++++++++++++++---------- 1 file changed, 51 insertions(+), 15 deletions(-) diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index 1d3bc4aa8..b146ef06a 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -7,7 +7,7 @@ import pathlib import warnings from io import StringIO -from typing import TYPE_CHECKING, Dict, List, Literal, Optional, Tuple, Union +from typing import TYPE_CHECKING, Dict, List, Literal, Optional, Tuple, Union, Any import numpy as np import pandas as pd @@ -530,34 +530,70 @@ def all_elements(self) -> Dict[str, Element]: def used_in_calculation(self) -> bool: return self._used_in_calculation - def sel(self, **indexers) -> 'FlowSystem': - """Select a subset of the flowsystem like dataset.sel(time=slice('2023-01', '2023-06'))""" + def sel(self, time: Optional[Union[str, slice, List[str], pd.Timestamp]] = None) -> 'FlowSystem': + """ + Select a subset of the flowsystem by the time coordinate. + + Args: + time: Time selection (e.g., slice('2023-01-01', '2023-12-31'), '2023-06-15', or list of times) + + Returns: + FlowSystem: New FlowSystem with selected data + """ if not self._connected_and_transformed: self.connect_and_transform() - # Convert to dataset, select, then convert back - selected_dataset = self.to_dataset().sel(**indexers) + # Build indexers dict from non-None parameters + indexers = {} + if time is not None: + indexers['time'] = time + + if not indexers: + return self.copy() # Return a copy when no selection + selected_dataset = self.to_dataset().sel(**indexers) return self.__class__.from_dataset(selected_dataset) - def isel(self, **indexers) -> 'FlowSystem': - """Select by integer index like dataset.isel(time=slice(0, 100))""" + def isel(self, time: Optional[Union[int, slice, List[int]]] = None) -> 'FlowSystem': + """ + Select a subset of the flowsystem by integer indices. + + Args: + time: Time selection by integer index (e.g., slice(0, 100), 50, or [0, 5, 10]) + + Returns: + FlowSystem: New FlowSystem with selected data + """ if not self._connected_and_transformed: self.connect_and_transform() - # Convert to dataset, select, then convert back - selected_dataset = self.to_dataset().isel(**indexers) + # Build indexers dict from non-None parameters + indexers = {} + if time is not None: + indexers['time'] = time + if not indexers: + return self.copy() # Return a copy when no selection + + selected_dataset = self.to_dataset().isel(**indexers) return self.__class__.from_dataset(selected_dataset) - def resample(self, time, method: str = 'mean', **kwargs) -> 'FlowSystem': + def resample( + self, + time: str, + method: Literal['mean', 'sum', 'max', 'min', 'first', 'last', 'std', 'var', 'median', 'count'] = 'mean', + **kwargs: Any + ) -> 'FlowSystem': """ - Resample time dimension like dataset.resample(). + Create a resampled FlowSystem by resampling data along the time dimension (like xr.Dataset.resample()). Args: - time: Resampling frequency (e.g., '1H', '1D') - method: Resampling method ('mean', 'sum', 'max', 'min', 'first', 'last') + time: Resampling frequency (e.g., '3h', '2D', '1M') + method: Resampling method. Recommended: 'mean', 'first', 'last', 'max', 'min' **kwargs: Additional arguments passed to xarray.resample() + + Returns: + FlowSystem: New FlowSystem with resampled data """ if not self._connected_and_transformed: self.connect_and_transform() @@ -565,10 +601,10 @@ def resample(self, time, method: str = 'mean', **kwargs) -> 'FlowSystem': dataset = self.to_dataset() resampler = dataset.resample(time=time, **kwargs) - # Apply the specified method if hasattr(resampler, method): resampled_dataset = getattr(resampler, method)() else: - raise ValueError(f'Unsupported resampling method: {method}') + available_methods = ['mean', 'sum', 'max', 'min', 'first', 'last', 'std', 'var', 'median', 'count'] + raise ValueError(f'Unsupported resampling method: {method}. Available: {available_methods}') return self.__class__.from_dataset(resampled_dataset) From e97ec5fcd7ce5085bd1418b4077fc8f35240fbbf Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 26 Jun 2025 10:48:35 +0200 Subject: [PATCH 098/336] Change test --- tests/conftest.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index 43f9f8bae..b705939cc 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -95,7 +95,8 @@ def simple_flow_system() -> fx.FlowSystem: discharging=fx.Flow('Q_th_unload', bus='Fernwärme', size=1e4), capacity_in_flow_hours=fx.InvestParameters(fix_effects=20, fixed_size=30, optional=False), initial_charge_state=0, - relative_maximum_charge_state=1 / 100 * np.array([80.0, 70.0, 80.0, 80, 80, 80, 80, 80, 80, 80]), + relative_maximum_charge_state=1 / 100 * np.array([80.0, 70.0, 80.0, 80, 80, 80, 80, 80, 80]), + relative_maximum_final_charge_state=0.8, eta_charge=0.9, eta_discharge=1, relative_loss_per_hour=0.08, From f15113efaf16e448a87054741edaf6016eea60dc Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 26 Jun 2025 11:02:58 +0200 Subject: [PATCH 099/336] Bugfix --- flixopt/components.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/flixopt/components.py b/flixopt/components.py index ae8cdfbf0..be86457e6 100644 --- a/flixopt/components.py +++ b/flixopt/components.py @@ -577,16 +577,18 @@ def absolute_charge_state_bounds(self) -> Tuple[NumericDataUser, NumericDataUser @property def relative_charge_state_bounds(self) -> Tuple[xr.DataArray, xr.DataArray]: - relative_minimum_final_charge_state = ( - xr.DataArray([np.min(self.element.relative_minimum_charge_state)], coords={'time': [self._model.flow_system.timesteps_extra[-1]]}, dims=['time'] - ) if self.element.relative_minimum_final_charge_state is None else - self.element.relative_minimum_final_charge_state + relative_minimum_final_charge_state = xr.DataArray( + [self.element.relative_minimum_charge_state.max('time') if self.element.relative_minimum_final_charge_state is None else self.element.relative_minimum_final_charge_state], + coords={'time': [self._model.flow_system.timesteps_extra[-1]]}, + dims=['time'] ) - relative_maximum_final_charge_state = ( - xr.DataArray([np.max(self.element.relative_maximum_charge_state)], coords={'time': [self._model.flow_system.timesteps_extra[-1]]}, dims=['time'] - ) if self.element.relative_maximum_final_charge_state is None else - self.element.relative_maximum_final_charge_state + relative_maximum_final_charge_state = xr.DataArray( + [self.element.relative_maximum_charge_state.max('time') if self.element.relative_maximum_final_charge_state is None else + self.element.relative_maximum_final_charge_state], + coords={'time': [self._model.flow_system.timesteps_extra[-1]]}, + dims=['time'] ) + return ( xr.concat([self.element.relative_minimum_charge_state, relative_minimum_final_charge_state], dim='time'), xr.concat([self.element.relative_maximum_charge_state, relative_maximum_final_charge_state], dim='time'), From 284072e5680f1a2b09c03a53c71fa40e1164aa22 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 26 Jun 2025 11:24:32 +0200 Subject: [PATCH 100/336] Improve --- flixopt/components.py | 37 ++++++++++++++++++++++++------------- 1 file changed, 24 insertions(+), 13 deletions(-) diff --git a/flixopt/components.py b/flixopt/components.py index be86457e6..fe509c59d 100644 --- a/flixopt/components.py +++ b/flixopt/components.py @@ -237,9 +237,9 @@ def _plausibility_checks(self) -> None: minimum_capacity = self.capacity_in_flow_hours # initial capacity >= allowed min for maximum_size: - minimum_inital_capacity = maximum_capacity * self.relative_minimum_charge_state.isel(time=1) + minimum_inital_capacity = maximum_capacity * self.relative_minimum_charge_state.isel(time=0) # initial capacity <= allowed max for minimum_size: - maximum_inital_capacity = minimum_capacity * self.relative_maximum_charge_state.isel(time=1) + maximum_inital_capacity = minimum_capacity * self.relative_maximum_charge_state.isel(time=0) if self.initial_charge_state > maximum_inital_capacity: raise ValueError( @@ -577,17 +577,28 @@ def absolute_charge_state_bounds(self) -> Tuple[NumericDataUser, NumericDataUser @property def relative_charge_state_bounds(self) -> Tuple[xr.DataArray, xr.DataArray]: - relative_minimum_final_charge_state = xr.DataArray( - [self.element.relative_minimum_charge_state.max('time') if self.element.relative_minimum_final_charge_state is None else self.element.relative_minimum_final_charge_state], - coords={'time': [self._model.flow_system.timesteps_extra[-1]]}, - dims=['time'] - ) - relative_maximum_final_charge_state = xr.DataArray( - [self.element.relative_maximum_charge_state.max('time') if self.element.relative_maximum_final_charge_state is None else - self.element.relative_maximum_final_charge_state], - coords={'time': [self._model.flow_system.timesteps_extra[-1]]}, - dims=['time'] - ) + coords = {'time': self._model.flow_system.timesteps_extra[-1]} + if self.element.relative_minimum_final_charge_state is None: + relative_minimum_final_charge_state = self.element.relative_minimum_charge_state.isel( + time=-1 + ).assign_coords(time=self._model.flow_system.timesteps_extra[-1]) + else: + relative_minimum_final_charge_state = xr.DataArray( + [self.element.relative_minimum_final_charge_state], + coords=coords, + dims=['time'] + ) + + if self.element.relative_maximum_final_charge_state is None: + relative_maximum_final_charge_state = self.element.relative_maximum_charge_state.isel( + time=-1 + ).assign_coords(coords) + else: + relative_maximum_final_charge_state = xr.DataArray( + [self.element.relative_maximum_final_charge_state], + coords=coords, + dims=['time'] + ) return ( xr.concat([self.element.relative_minimum_charge_state, relative_minimum_final_charge_state], dim='time'), From ebbb5dd61140e5198299c7af1683ef1dddf345f2 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 26 Jun 2025 11:25:47 +0200 Subject: [PATCH 101/336] Improve --- flixopt/components.py | 41 ++++++++++++++++++++++------------------- 1 file changed, 22 insertions(+), 19 deletions(-) diff --git a/flixopt/components.py b/flixopt/components.py index fe509c59d..5e59b8bc5 100644 --- a/flixopt/components.py +++ b/flixopt/components.py @@ -577,33 +577,36 @@ def absolute_charge_state_bounds(self) -> Tuple[NumericDataUser, NumericDataUser @property def relative_charge_state_bounds(self) -> Tuple[xr.DataArray, xr.DataArray]: - coords = {'time': self._model.flow_system.timesteps_extra[-1]} + """ + Get relative charge state bounds with final timestep values. + + Returns: + Tuple of (minimum_bounds, maximum_bounds) DataArrays extending to final timestep + """ + final_timestep = self._model.flow_system.timesteps_extra[-1] + final_coords = {'time': final_timestep} + + # Get final minimum charge state if self.element.relative_minimum_final_charge_state is None: - relative_minimum_final_charge_state = self.element.relative_minimum_charge_state.isel( - time=-1 - ).assign_coords(time=self._model.flow_system.timesteps_extra[-1]) + min_final = self.element.relative_minimum_charge_state.isel(time=-1).assign_coords(time=final_timestep) else: - relative_minimum_final_charge_state = xr.DataArray( - [self.element.relative_minimum_final_charge_state], - coords=coords, - dims=['time'] + min_final = xr.DataArray( + [self.element.relative_minimum_final_charge_state], coords=final_coords, dims=['time'] ) + # Get final maximum charge state if self.element.relative_maximum_final_charge_state is None: - relative_maximum_final_charge_state = self.element.relative_maximum_charge_state.isel( - time=-1 - ).assign_coords(coords) + max_final = self.element.relative_maximum_charge_state.isel(time=-1).assign_coords(time=final_timestep) else: - relative_maximum_final_charge_state = xr.DataArray( - [self.element.relative_maximum_final_charge_state], - coords=coords, - dims=['time'] + max_final = xr.DataArray( + [self.element.relative_maximum_final_charge_state], coords=final_coords, dims=['time'] ) - return ( - xr.concat([self.element.relative_minimum_charge_state, relative_minimum_final_charge_state], dim='time'), - xr.concat([self.element.relative_maximum_charge_state, relative_maximum_final_charge_state], dim='time'), - ) + # Concatenate with original bounds + min_bounds = xr.concat([self.element.relative_minimum_charge_state, min_final], dim='time') + max_bounds = xr.concat([self.element.relative_maximum_charge_state, max_final], dim='time') + + return min_bounds, max_bounds @register_class_for_io From a501e05b7b06f8d5a916ade24880109ed5b960e6 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 26 Jun 2025 11:25:58 +0200 Subject: [PATCH 102/336] Add test for Storage Bounds --- tests/test_storage.py | 81 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/tests/test_storage.py b/tests/test_storage.py index 472ba4add..1b9b3b875 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -175,6 +175,87 @@ def test_lossy_storage(self, basic_flow_system_linopy): model.variables['TestStorage|charge_state'].isel(time=0) == 0 ) + def test_charge_state_bounds(self, basic_flow_system_linopy): + """Test that basic storage model variables and constraints are correctly generated.""" + flow_system = basic_flow_system_linopy + timesteps = flow_system.timesteps + timesteps_extra = flow_system.timesteps_extra + + # Create a simple storage + storage = fx.Storage( + 'TestStorage', + charging=fx.Flow('Q_th_in', bus='Fernwärme', size=20), + discharging=fx.Flow('Q_th_out', bus='Fernwärme', size=20), + capacity_in_flow_hours=30, # 30 kWh storage capacity + initial_charge_state=3, + prevent_simultaneous_charge_and_discharge=True, + relative_maximum_charge_state=np.array([0.14, 0.22, 0.3 , 0.38, 0.46, 0.54, 0.62, 0.7 , 0.78, 0.86]), + relative_minimum_charge_state=np.array([0.07, 0.11, 0.15, 0.19, 0.23, 0.27, 0.31, 0.35, 0.39, 0.43]), + ) + + flow_system.add_elements(storage) + model = create_linopy_model(flow_system) + + # Check that all expected variables exist - linopy model variables are accessed by indexing + expected_variables = { + 'TestStorage(Q_th_in)|flow_rate', + 'TestStorage(Q_th_in)|total_flow_hours', + 'TestStorage(Q_th_out)|flow_rate', + 'TestStorage(Q_th_out)|total_flow_hours', + 'TestStorage|charge_state', + 'TestStorage|netto_discharge', + } + for var_name in expected_variables: + assert var_name in model.variables, f"Missing variable: {var_name}" + + # Check that all expected constraints exist - linopy model constraints are accessed by indexing + expected_constraints = { + 'TestStorage(Q_th_in)|total_flow_hours', + 'TestStorage(Q_th_out)|total_flow_hours', + 'TestStorage|netto_discharge', + 'TestStorage|charge_state', + 'TestStorage|initial_charge_state', + } + for con_name in expected_constraints: + assert con_name in model.constraints, f"Missing constraint: {con_name}" + + # Check variable properties + assert_var_equal( + model['TestStorage(Q_th_in)|flow_rate'], + model.add_variables(lower=0, upper=20, coords=(timesteps,)) + ) + assert_var_equal( + model['TestStorage(Q_th_out)|flow_rate'], + model.add_variables(lower=0, upper=20, coords=(timesteps,)) + ) + assert_var_equal( + model['TestStorage|charge_state'], + model.add_variables(lower=np.array([0.07, 0.11, 0.15, 0.19, 0.23, 0.27, 0.31, 0.35, 0.39, 0.43, 0.43]) * 30, + upper=np.array([0.14, 0.22, 0.3 , 0.38, 0.46, 0.54, 0.62, 0.7 , 0.78, 0.86, 0.86]) * 30, + coords=(timesteps_extra,)) + ) + + # Check constraint formulations + assert_conequal( + model.constraints['TestStorage|netto_discharge'], + model.variables['TestStorage|netto_discharge'] == + model.variables['TestStorage(Q_th_out)|flow_rate'] - model.variables['TestStorage(Q_th_in)|flow_rate'] + ) + + charge_state = model.variables['TestStorage|charge_state'] + assert_conequal( + model.constraints['TestStorage|charge_state'], + charge_state.isel(time=slice(1, None)) + == charge_state.isel(time=slice(None, -1)) + + model.variables['TestStorage(Q_th_in)|flow_rate'] * model.hours_per_step + - model.variables['TestStorage(Q_th_out)|flow_rate'] * model.hours_per_step, + ) + # Check initial charge state constraint + assert_conequal( + model.constraints['TestStorage|initial_charge_state'], + model.variables['TestStorage|charge_state'].isel(time=0) == 3 + ) + def test_storage_with_investment(self, basic_flow_system_linopy): """Test storage with investment parameters.""" flow_system = basic_flow_system_linopy From 182508914ab9f8c4e4349e709baebd94847ba279 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 26 Jun 2025 11:34:45 +0200 Subject: [PATCH 103/336] Add test for Storage Bounds --- CHANGELOG.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d692d5e5..545973095 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed +- `relative_minimum_charge_state` and `relative_maximum_charge_state` dont have an extra timestep anymore. The final charge state can be constrainted by parameters `relative_minimum_final_charge_state` and `relative_maximum_final_charge_state` instead. +- FlowSystems can not be shared across multiple Calculations anymore. A copy of the FLowSystem is created instead. THs makes every Calculation independent. +- THe above allowed to remove the intermediate classes `TimeSeries` and `TimeSeriesCollection` classes which orchestratet datahandling. + +### Added +- Added IO for all Interfaces and the FlowSystem +- Added `sel`, `isel` and `resample` methods to FlowSystem, allowing for a flexible data handling. + ## [2.1.2] - 2025-06-14 ### Fixed From 126b07f89dfafb6d53549271f441298e0fc43613 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 26 Jun 2025 11:51:49 +0200 Subject: [PATCH 104/336] CHANGELOG.md --- CHANGELOG.md | 41 ++++++++++++++++++++++++++++++++++++----- 1 file changed, 36 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 545973095..bb95b3756 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,13 +8,44 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Changed -- `relative_minimum_charge_state` and `relative_maximum_charge_state` dont have an extra timestep anymore. The final charge state can be constrainted by parameters `relative_minimum_final_charge_state` and `relative_maximum_final_charge_state` instead. -- FlowSystems can not be shared across multiple Calculations anymore. A copy of the FLowSystem is created instead. THs makes every Calculation independent. -- THe above allowed to remove the intermediate classes `TimeSeries` and `TimeSeriesCollection` classes which orchestratet datahandling. +* **BREAKING**: FlowSystems can not be shared across multiple Calculations anymore. A copy of the FlowSystem is created instead, making every Calculation independent +* **BREAKING**: Type system overhaul - replaced `NumericDataTS` with `NumericDataUser` throughout codebase for better clarity +* **BREAKING**: `relative_minimum_charge_state` and `relative_maximum_charge_state` don't have an extra timestep anymore. The final charge state can now be constrained by parameters `relative_minimum_final_charge_state` and `relative_maximum_final_charge_state` instead +* FlowSystem data management simplified - removed `time_series_collection` pattern in favor of direct timestep properties +* Enhanced FlowSystem interface with improved `__repr__()` and `__str__()` methods +* *Internal*: Removed intermediate `TimeSeries` and `TimeSeriesCollection` classes, replaced directly with `xr.DataArray` or `TimeSeriesData` (inheriting from `xr.DataArray`) ### Added -- Added IO for all Interfaces and the FlowSystem -- Added `sel`, `isel` and `resample` methods to FlowSystem, allowing for a flexible data handling. +* **NEW**: Complete serialization infrastructure through `Interface` base class + * IO for all Interfaces and the FlowSystem with round-trip serialization support + * Automatic DataArray extraction and restoration + * NetCDF export/import capabilities for all Interface objects and FlowSystem + * JSON export for documentation purposes + * Recursive handling of nested Interface objects +* **NEW**: FlowSystem data manipulation methods + * `sel()` and `isel()` methods for temporal data selection + * `resample()` method for temporal resampling + * `copy()` method with deep copying support + * `__eq__()` method for FlowSystem comparison +* **NEW**: Storage component enhancements + * `relative_minimum_final_charge_state` parameter for final state control + * `relative_maximum_final_charge_state` parameter for final state control +* *Internal*: Enhanced data handling methods + * `fit_to_model_coords()` method for data alignment + * `fit_effects_to_model_coords()` method for effect data processing + * `connect_and_transform()` method replacing separate operations +* **NEW**: Core data handling improvements + * `get_dataarray_stats()` function for statistical summaries + * Enhanced `DataConverter` class with better TimeSeriesData support + +### Fixed +* Enhanced NetCDF I/O with proper attribute preservation for DataArrays +* Improved error handling and validation in serialization processes +* Better type consistency across all framework components + +### Know Issues +* Plotly >= 6 may raise errors if "nbformat" is not installed. We pinned plotly to <6, but this may be fixed in the future. +* IO for single Interfaces/Elemenets to Datasets might not work properly if the Interface/Element is not part of a fully transformed and connected FlowSystem. This arrises from Numeric Data not being stored as xr.DataArray by the user. TO avoid this, always use the `to_dataset()` on Elements inside a FlowSystem thats connected and transformed. ## [2.1.2] - 2025-06-14 From 94d841d9f2222b0736d30473d30403b77e5742f4 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 26 Jun 2025 12:00:50 +0200 Subject: [PATCH 105/336] ruff check --- flixopt/flow_system.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index b146ef06a..4ad935dc5 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -7,7 +7,7 @@ import pathlib import warnings from io import StringIO -from typing import TYPE_CHECKING, Dict, List, Literal, Optional, Tuple, Union, Any +from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Tuple, Union import numpy as np import pandas as pd From c19edc8e125e0815aa3c2e5450995e5938970e09 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 26 Jun 2025 14:26:41 +0200 Subject: [PATCH 106/336] Improve types --- .../example_calculation_types.py | 6 +-- flixopt/calculation.py | 42 +++++++++++++++---- flixopt/components.py | 34 +++++++-------- flixopt/core.py | 18 ++++---- flixopt/effects.py | 41 +++++++++++------- flixopt/elements.py | 26 ++++++------ flixopt/features.py | 32 +++++++------- flixopt/flow_system.py | 14 +++---- flixopt/interface.py | 20 ++++----- flixopt/linear_converters.py | 22 +++++----- flixopt/structure.py | 2 +- 11 files changed, 144 insertions(+), 113 deletions(-) diff --git a/examples/03_Calculation_types/example_calculation_types.py b/examples/03_Calculation_types/example_calculation_types.py index 97b18e3c0..b793e26ce 100644 --- a/examples/03_Calculation_types/example_calculation_types.py +++ b/examples/03_Calculation_types/example_calculation_types.py @@ -164,12 +164,12 @@ if full: calculation = fx.FullCalculation('Full', flow_system) calculation.do_modeling() - calculation.solve(fx.solvers.HighsSolver(0, 60)) + calculation.solve(fx.solvers.GurobiSolver(0.001, 60)) calculations.append(calculation) if segmented: calculation = fx.SegmentedCalculation('Segmented', flow_system, segment_length, overlap_length) - calculation.do_modeling_and_solve(fx.solvers.HighsSolver(0, 60)) + calculation.do_modeling_and_solve(fx.solvers.GurobiSolver(0.001, 60)) calculations.append(calculation) if aggregated: @@ -178,7 +178,7 @@ aggregation_parameters.time_series_for_low_peaks = [TS_electricity_demand, TS_heat_demand] calculation = fx.AggregatedCalculation('Aggregated', flow_system, aggregation_parameters) calculation.do_modeling() - calculation.solve(fx.solvers.HighsSolver(0, 60)) + calculation.solve(fx.solvers.GurobiSolver(0.001, 60)) calculations.append(calculation) # Get solutions for plotting for different calculations diff --git a/flixopt/calculation.py b/flixopt/calculation.py index 251a50075..60163b7a2 100644 --- a/flixopt/calculation.py +++ b/flixopt/calculation.py @@ -12,7 +12,8 @@ import math import pathlib import timeit -from typing import Any, Dict, List, Optional, Union +import warnings +from typing import Annotated, Any, Dict, List, Optional, Union import numpy as np import pandas as pd @@ -43,26 +44,39 @@ def __init__( self, name: str, flow_system: FlowSystem, - active_timesteps: Optional[pd.DatetimeIndex] = None, + active_timesteps: Annotated[ + Optional[pd.DatetimeIndex], + "DEPRECATED: Use flow_system.sel(time=...) or flow_system.isel(time=...) instead" + ] = None, folder: Optional[pathlib.Path] = None, ): """ Args: name: name of calculation flow_system: flow_system which should be calculated - active_timesteps: list with indices, which should be used for calculation. If None, then all timesteps are used. folder: folder where results should be saved. If None, then the current working directory is used. """ self.name = name if flow_system.used_in_calculation: - logging.warning(f'FlowSystem {flow_system.name} is already used in a calculation. ' + logging.warning(f'FlowSystem {flow_system} is already used in a calculation. ' f'Creating a copy for Calculation "{self.name}".') flow_system = flow_system.copy() + if active_timesteps is not None: + warnings.warn( + "The 'active_timesteps' parameter is deprecated and will be removed in a future version. " + 'Use flow_system.sel(time=timesteps) or flow_system.isel(time=indices) before passing ' + 'the FlowSystem to the Calculation instead.', + DeprecationWarning, + stacklevel=2, + ) + flow_system = flow_system.sel(time=active_timesteps) + + self.flow_system = flow_system self.flow_system._used_in_calculation = True self.model: Optional[SystemModel] = None - self.active_timesteps = active_timesteps + self._active_timesteps = active_timesteps # deprecated self.durations = {'modeling': 0.0, 'solving': 0.0, 'saving': 0.0} self.folder = pathlib.Path.cwd() / 'results' if folder is None else pathlib.Path(folder) @@ -134,6 +148,15 @@ def summary(self): 'Config': CONFIG.to_dict(), } + @property + def active_timesteps(self) -> pd.DatetimeIndex: + warnings.warn( + "The 'active_timesteps' is deprecated and will be removed in a future version.", + DeprecationWarning, + stacklevel=2, + ) + return self.flow_system.timesteps + class FullCalculation(Calculation): """ @@ -199,7 +222,10 @@ def __init__( flow_system: FlowSystem, aggregation_parameters: AggregationParameters, components_to_clusterize: Optional[List[Component]] = None, - active_timesteps: Optional[pd.DatetimeIndex] = None, + active_timesteps: Annotated[ + Optional[pd.DatetimeIndex], + 'DEPRECATED: Use flow_system.sel(time=...) or flow_system.isel(time=...) instead', + ] = None, folder: Optional[pathlib.Path] = None, ): """ @@ -213,8 +239,6 @@ def __init__( components_to_clusterize: List of Components to perform aggregation on. If None, then all components are aggregated. This means, teh variables in the components are equalized to each other, according to the typical periods computed in the DataAggregation - active_timesteps: pd.DatetimeIndex or None - list with indices, which should be used for calculation. If None, then all timesteps are used. folder: folder where results should be saved. If None, then the current working directory is used. """ super().__init__(name, flow_system, active_timesteps, folder=folder) @@ -370,7 +394,7 @@ def do_modeling_and_solve( ) calculation = FullCalculation( - f'{self.name}-{segment_name}', self.flow_system, active_timesteps=timesteps_of_segment + f'{self.name}-{segment_name}', self.flow_system.sel(timesteps_of_segment), ) self.sub_calculations.append(calculation) calculation.do_modeling() diff --git a/flixopt/components.py b/flixopt/components.py index 5e59b8bc5..49d6f5b31 100644 --- a/flixopt/components.py +++ b/flixopt/components.py @@ -10,7 +10,7 @@ import xarray as xr from . import utils -from .core import NumericDataUser, PlausibilityError, Scalar +from .core import PlausibilityError, Scalar, TemporalData, TemporalDataUser from .elements import Component, ComponentModel, Flow from .features import InvestmentModel, OnOffModel, PiecewiseModel from .interface import InvestParameters, OnOffParameters, PiecewiseConversion @@ -35,7 +35,7 @@ def __init__( inputs: List[Flow], outputs: List[Flow], on_off_parameters: OnOffParameters = None, - conversion_factors: List[Dict[str, NumericDataUser]] = None, + conversion_factors: List[Dict[str, TemporalDataUser]] = None, piecewise_conversion: Optional[PiecewiseConversion] = None, meta_data: Optional[Dict] = None, ): @@ -129,16 +129,16 @@ def __init__( charging: Flow, discharging: Flow, capacity_in_flow_hours: Union[Scalar, InvestParameters], - relative_minimum_charge_state: NumericDataUser = 0, - relative_maximum_charge_state: NumericDataUser = 1, + relative_minimum_charge_state: TemporalDataUser = 0, + relative_maximum_charge_state: TemporalDataUser = 1, initial_charge_state: Union[Scalar, Literal['lastValueOfSim']] = 0, minimal_final_charge_state: Optional[Scalar] = None, maximal_final_charge_state: Optional[Scalar] = None, relative_minimum_final_charge_state: Optional[Scalar] = None, relative_maximum_final_charge_state: Optional[Scalar] = None, - eta_charge: NumericDataUser = 1, - eta_discharge: NumericDataUser = 1, - relative_loss_per_hour: NumericDataUser = 0, + eta_charge: TemporalDataUser = 1, + eta_discharge: TemporalDataUser = 1, + relative_loss_per_hour: TemporalDataUser = 0, prevent_simultaneous_charge_and_discharge: bool = True, meta_data: Optional[Dict] = None, ): @@ -181,19 +181,19 @@ def __init__( self.charging = charging self.discharging = discharging self.capacity_in_flow_hours = capacity_in_flow_hours - self.relative_minimum_charge_state: NumericDataUser = relative_minimum_charge_state - self.relative_maximum_charge_state: NumericDataUser = relative_maximum_charge_state + self.relative_minimum_charge_state: TemporalDataUser = relative_minimum_charge_state + self.relative_maximum_charge_state: TemporalDataUser = relative_maximum_charge_state - self.relative_minimum_final_charge_state: NumericDataUser = relative_minimum_final_charge_state - self.relative_maximum_final_charge_state: NumericDataUser = relative_maximum_final_charge_state + self.relative_minimum_final_charge_state: Scalar = relative_minimum_final_charge_state + self.relative_maximum_final_charge_state: Scalar = relative_maximum_final_charge_state self.initial_charge_state = initial_charge_state self.minimal_final_charge_state = minimal_final_charge_state self.maximal_final_charge_state = maximal_final_charge_state - self.eta_charge: NumericDataUser = eta_charge - self.eta_discharge: NumericDataUser = eta_discharge - self.relative_loss_per_hour: NumericDataUser = relative_loss_per_hour + self.eta_charge: TemporalDataUser = eta_charge + self.eta_discharge: TemporalDataUser = eta_discharge + self.relative_loss_per_hour: TemporalDataUser = relative_loss_per_hour self.prevent_simultaneous_charge_and_discharge = prevent_simultaneous_charge_and_discharge def create_model(self, model: SystemModel) -> 'StorageModel': @@ -270,8 +270,8 @@ def __init__( out1: Flow, in2: Optional[Flow] = None, out2: Optional[Flow] = None, - relative_losses: Optional[NumericDataUser] = None, - absolute_losses: Optional[NumericDataUser] = None, + relative_losses: Optional[TemporalDataUser] = None, + absolute_losses: Optional[TemporalDataUser] = None, on_off_parameters: OnOffParameters = None, prevent_simultaneous_flows_in_both_directions: bool = True, meta_data: Optional[Dict] = None, @@ -562,7 +562,7 @@ def _initial_and_final_charge_state(self): ) @property - def absolute_charge_state_bounds(self) -> Tuple[NumericDataUser, NumericDataUser]: + def absolute_charge_state_bounds(self) -> Tuple[TemporalData, TemporalData]: relative_lower_bound, relative_upper_bound = self.relative_charge_state_bounds if not isinstance(self.element.capacity_in_flow_hours, InvestParameters): return ( diff --git a/flixopt/core.py b/flixopt/core.py index 831b90b37..41ee7b799 100644 --- a/flixopt/core.py +++ b/flixopt/core.py @@ -3,12 +3,8 @@ It provides Datatypes, logging functionality, and some functions to transform data structures. """ -import inspect -import json import logging -import pathlib -from collections import Counter -from typing import Any, Dict, Iterator, List, Literal, Optional, Tuple, Union +from typing import Dict, Optional, Union import numpy as np import pandas as pd @@ -19,11 +15,13 @@ Scalar = Union[int, float] """A single number, either integer or float.""" -NumericDataUser = Union[int, float, np.integer, np.floating, np.ndarray, pd.Series, pd.DataFrame, xr.DataArray, 'TimeSeriesData'] -"""Numeric data accepted in varios types. Will be converted to an xr.DataArray or Scalar internally.""" +TemporalDataUser = Union[ + int, float, np.integer, np.floating, np.ndarray, pd.Series, pd.DataFrame, xr.DataArray, 'TimeSeriesData' +] +"""User data which might have a time dimension. Internally converted to an xr.DataArray with time dimension.""" -NumericDataInternal = Union[int, float, xr.DataArray, 'TimeSeriesData'] -"""Internally used datatypes for numeric data.""" +TemporalData = Union[xr.DataArray, 'TimeSeriesData'] +"""Internally used datatypes for temporal data.""" class PlausibilityError(Exception): @@ -167,7 +165,7 @@ def _fix_timeseries_data_indexing( return data.copy(deep=True) @staticmethod - def to_dataarray(data: NumericDataUser, timesteps: pd.DatetimeIndex) -> xr.DataArray: + def to_dataarray(data: TemporalData, timesteps: pd.DatetimeIndex) -> xr.DataArray: """Convert data to xarray.DataArray with specified timesteps index.""" if not isinstance(timesteps, pd.DatetimeIndex) or len(timesteps) == 0: raise ValueError(f'Timesteps must be a non-empty DatetimeIndex, got {type(timesteps).__name__}') diff --git a/flixopt/effects.py b/flixopt/effects.py index 89bc009bf..1d1a5216c 100644 --- a/flixopt/effects.py +++ b/flixopt/effects.py @@ -13,7 +13,7 @@ import numpy as np import pandas as pd -from .core import NumericDataInternal, NumericDataUser, Scalar +from .core import Scalar, TemporalData, TemporalDataUser from .features import ShareAllocationModel from .structure import Element, ElementModel, Interface, Model, SystemModel, register_class_for_io @@ -38,14 +38,14 @@ def __init__( meta_data: Optional[Dict] = None, is_standard: bool = False, is_objective: bool = False, - specific_share_to_other_effects_operation: Optional['EffectValuesUser'] = None, - specific_share_to_other_effects_invest: Optional['EffectValuesUser'] = None, + specific_share_to_other_effects_operation: Optional['TemporalEffectsUser'] = None, + specific_share_to_other_effects_invest: Optional['ScalarEffectsUser'] = None, minimum_operation: Optional[Scalar] = None, maximum_operation: Optional[Scalar] = None, minimum_invest: Optional[Scalar] = None, maximum_invest: Optional[Scalar] = None, - minimum_operation_per_hour: Optional[NumericDataUser] = None, - maximum_operation_per_hour: Optional[NumericDataUser] = None, + minimum_operation_per_hour: Optional[TemporalDataUser] = None, + maximum_operation_per_hour: Optional[TemporalDataUser] = None, minimum_total: Optional[Scalar] = None, maximum_total: Optional[Scalar] = None, ): @@ -76,14 +76,14 @@ def __init__( self.description = description self.is_standard = is_standard self.is_objective = is_objective - self.specific_share_to_other_effects_operation: EffectValuesUser = ( + self.specific_share_to_other_effects_operation: TemporalEffectsUser = ( specific_share_to_other_effects_operation or {} ) - self.specific_share_to_other_effects_invest: EffectValuesUser = specific_share_to_other_effects_invest or {} + self.specific_share_to_other_effects_invest: ScalarEffectsUser = specific_share_to_other_effects_invest or {} self.minimum_operation = minimum_operation self.maximum_operation = maximum_operation - self.minimum_operation_per_hour: NumericDataUser = minimum_operation_per_hour - self.maximum_operation_per_hour: NumericDataUser = maximum_operation_per_hour + self.minimum_operation_per_hour: TemporalDataUser = minimum_operation_per_hour + self.maximum_operation_per_hour: TemporalDataUser = maximum_operation_per_hour self.minimum_invest = minimum_invest self.maximum_invest = maximum_invest self.minimum_total = minimum_total @@ -168,13 +168,19 @@ def do_modeling(self): ) -EffectExpr = Dict[str, linopy.LinearExpression] # Used to create Shares -EffectValuesInternal = Dict[str, NumericDataInternal] # Used internally to index values -EffectValuesUser = Union[NumericDataUser, Dict[str, NumericDataUser]] # User-specified Shares to Effects -""" This datatype is used to define the share to an effect by a certain attribute. """ +TemporalEffectsUser = Union[TemporalDataUser, Dict[str, TemporalDataUser]] # User-specified Shares to Effects +""" This datatype is used to define a temporal share to an effect by a certain attribute. """ + +ScalarEffectsUser = Union[Scalar, Dict[str, Scalar]] # User-specified Shares to Effects +""" This datatype is used to define a scalar share to an effect by a certain attribute. """ + +TemporalEffects = Dict[str, TemporalData] # User-specified Shares to Effects +""" This datatype is used internally to handle temporal shares to an effect. """ -EffectValuesUserScalar = Union[Scalar, Dict[str, Scalar]] # User-specified Shares to Effects -""" This datatype is used to define the share to an effect by a certain attribute. Only scalars are allowed. """ +ScalarEffects = Dict[str, Scalar] +""" This datatype is used internally to handle scalar shares to an effect. """ + +EffectExpr = Dict[str, linopy.LinearExpression] # Used to create Shares class EffectCollection: @@ -206,7 +212,10 @@ def add_effects(self, *effects: Effect) -> None: self._effects[effect.label] = effect logger.info(f'Registered new Effect: {effect.label}') - def create_effect_values_dict(self, effect_values_user: EffectValuesUser) -> Optional[Dict[str, NumericDataUser]]: + def create_effect_values_dict( + self, + effect_values_user: Union[ScalarEffectsUser, TemporalEffectsUser] + ) -> Optional[Dict[str, Union[Scalar, TemporalDataUser]]]: """ Converts effect values into a dictionary. If a scalar is provided, it is associated with a default effect type. diff --git a/flixopt/elements.py b/flixopt/elements.py index 061a00b65..d596333c3 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -10,8 +10,8 @@ import numpy as np from .config import CONFIG -from .core import NumericDataUser, PlausibilityError, Scalar -from .effects import EffectValuesUser +from .core import PlausibilityError, Scalar, TemporalData, TemporalDataUser +from .effects import TemporalEffectsUser from .features import InvestmentModel, OnOffModel, PreventSimultaneousUsageModel from .interface import InvestParameters, OnOffParameters from .structure import Element, ElementModel, SystemModel, register_class_for_io @@ -90,7 +90,7 @@ class Bus(Element): """ def __init__( - self, label: str, excess_penalty_per_flow_hour: Optional[NumericDataUser] = 1e5, meta_data: Optional[Dict] = None + self, label: str, excess_penalty_per_flow_hour: Optional[TemporalDataUser] = 1e5, meta_data: Optional[Dict] = None ): """ Args: @@ -149,16 +149,16 @@ def __init__( label: str, bus: str, size: Union[Scalar, InvestParameters] = None, - fixed_relative_profile: Optional[NumericDataUser] = None, - relative_minimum: NumericDataUser = 0, - relative_maximum: NumericDataUser = 1, - effects_per_flow_hour: Optional[EffectValuesUser] = None, + fixed_relative_profile: Optional[TemporalDataUser] = None, + relative_minimum: TemporalDataUser = 0, + relative_maximum: TemporalDataUser = 1, + effects_per_flow_hour: Optional[TemporalEffectsUser] = None, on_off_parameters: Optional[OnOffParameters] = None, flow_hours_total_max: Optional[Scalar] = None, flow_hours_total_min: Optional[Scalar] = None, load_factor_min: Optional[Scalar] = None, load_factor_max: Optional[Scalar] = None, - previous_flow_rate: Optional[NumericDataUser] = None, + previous_flow_rate: Optional[TemporalDataUser] = None, meta_data: Optional[Dict] = None, ): r""" @@ -411,7 +411,7 @@ def _create_bounds_for_load_factor(self): ) @property - def flow_rate_bounds_on(self) -> Tuple[NumericDataUser, NumericDataUser]: + def flow_rate_bounds_on(self) -> Tuple[TemporalData, TemporalData]: """Returns absolute flow rate bounds. Important for OnOffModel""" relative_minimum, relative_maximum = self.flow_rate_lower_bound_relative, self.flow_rate_upper_bound_relative size = self.element.size @@ -422,7 +422,7 @@ def flow_rate_bounds_on(self) -> Tuple[NumericDataUser, NumericDataUser]: return relative_minimum * size.minimum_size, relative_maximum * size.maximum_size @property - def flow_rate_lower_bound_relative(self) -> NumericDataUser: + def flow_rate_lower_bound_relative(self) -> TemporalData: """Returns the lower bound of the flow_rate relative to its size""" fixed_profile = self.element.fixed_relative_profile if fixed_profile is None: @@ -430,7 +430,7 @@ def flow_rate_lower_bound_relative(self) -> NumericDataUser: return fixed_profile @property - def flow_rate_upper_bound_relative(self) -> NumericDataUser: + def flow_rate_upper_bound_relative(self) -> TemporalData: """ Returns the upper bound of the flow_rate relative to its size""" fixed_profile = self.element.fixed_relative_profile if fixed_profile is None: @@ -438,7 +438,7 @@ def flow_rate_upper_bound_relative(self) -> NumericDataUser: return fixed_profile @property - def flow_rate_lower_bound(self) -> NumericDataUser: + def flow_rate_lower_bound(self) -> TemporalData: """ Returns the minimum bound the flow_rate can reach. Further constraining might be done in OnOffModel and InvestmentModel @@ -452,7 +452,7 @@ def flow_rate_lower_bound(self) -> NumericDataUser: return self.flow_rate_lower_bound_relative * self.element.size @property - def flow_rate_upper_bound(self) -> NumericDataUser: + def flow_rate_upper_bound(self) -> TemporalData: """ Returns the maximum bound the flow_rate can reach. Further constraining might be done in OnOffModel and InvestmentModel diff --git a/flixopt/features.py b/flixopt/features.py index 5bc8f7922..287f4e933 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -11,7 +11,7 @@ from . import utils from .config import CONFIG -from .core import NumericDataUser, Scalar +from .core import Scalar, TemporalData from .interface import InvestParameters, OnOffParameters, Piecewise from .structure import Model, SystemModel @@ -27,7 +27,7 @@ def __init__( label_of_element: str, parameters: InvestParameters, defining_variable: [linopy.Variable], - relative_bounds_of_defining_variable: Tuple[NumericDataUser, NumericDataUser], + relative_bounds_of_defining_variable: Tuple[TemporalData, TemporalData], label: Optional[str] = None, on_variable: Optional[linopy.Variable] = None, ): @@ -203,12 +203,12 @@ def __init__( model: SystemModel, label_of_element: str, defining_variables: List[linopy.Variable], - defining_bounds: List[Tuple[NumericDataUser, NumericDataUser]], - previous_values: List[Optional[NumericDataUser]] = None, + defining_bounds: List[Tuple[TemporalData, TemporalData]], + previous_values: List[Optional[TemporalData]] = None, use_off: bool = True, - on_hours_total_min: Optional[NumericDataUser] = 0, - on_hours_total_max: Optional[NumericDataUser] = None, - effects_per_running_hour: Dict[str, NumericDataUser] = None, + on_hours_total_min: Optional[TemporalData] = 0, + on_hours_total_max: Optional[TemporalData] = None, + effects_per_running_hour: Dict[str, TemporalData] = None, label: Optional[str] = None, ): """ @@ -344,7 +344,7 @@ def previous_off_states(self): return 1 - self.previous_states @staticmethod - def compute_previous_states(previous_values: List[NumericDataUser], epsilon: float = 1e-5) -> np.ndarray: + def compute_previous_states(previous_values: List[TemporalData], epsilon: float = 1e-5) -> np.ndarray: """Computes the previous states {0, 1} of defining variables as a binary array from their previous values.""" if not previous_values or all([val is None for val in previous_values]): return np.array([0]) @@ -451,9 +451,9 @@ def __init__( model: SystemModel, label_of_element: str, state_variable: linopy.Variable, - minimum_duration: Optional[NumericDataUser] = None, - maximum_duration: Optional[NumericDataUser] = None, - previous_states: Optional[NumericDataUser] = None, + minimum_duration: Optional[TemporalData] = None, + maximum_duration: Optional[TemporalData] = None, + previous_states: Optional[TemporalData] = None, label: Optional[str] = None, ): """ @@ -570,7 +570,7 @@ def previous_duration(self) -> Scalar: @staticmethod def compute_consecutive_hours_in_state( - binary_values: NumericDataUser, hours_per_timestep: Union[int, float, np.ndarray] + binary_values: TemporalData, hours_per_timestep: Union[int, float, np.ndarray] ) -> Scalar: """ Computes the final consecutive duration in state 'on' (=1) in hours, from a binary array. @@ -629,8 +629,8 @@ def __init__( on_off_parameters: OnOffParameters, label_of_element: str, defining_variables: List[linopy.Variable], - defining_bounds: List[Tuple[NumericDataUser, NumericDataUser]], - previous_values: List[Optional[NumericDataUser]], + defining_bounds: List[Tuple[TemporalData, TemporalData]], + previous_values: List[Optional[TemporalData]], label: Optional[str] = None, ): """ @@ -918,8 +918,8 @@ def __init__( label_full: Optional[str] = None, total_max: Optional[Scalar] = None, total_min: Optional[Scalar] = None, - max_per_hour: Optional[NumericDataUser] = None, - min_per_hour: Optional[NumericDataUser] = None, + max_per_hour: Optional[TemporalData] = None, + min_per_hour: Optional[TemporalData] = None, ): super().__init__(model, label_of_element=label_of_element, label=label, label_full=label_full) if not shares_are_time_series: # If the condition is True diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index 4ad935dc5..9c181c8d3 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -16,8 +16,8 @@ from rich.pretty import Pretty from . import io as fx_io -from .core import ConversionError, DataConverter, NumericDataInternal, NumericDataUser, TimeSeriesData -from .effects import Effect, EffectCollection, EffectValuesInternal, EffectValuesUser +from .core import ConversionError, DataConverter, TemporalData, TemporalDataUser, TimeSeriesData +from .effects import Effect, EffectCollection, ScalarEffects, ScalarEffectsUser, TemporalEffects, TemporalEffectsUser from .elements import Bus, Component, Flow from .structure import Element, Interface, SystemModel @@ -271,8 +271,8 @@ def to_json(self, path: Union[str, pathlib.Path]): def fit_to_model_coords( self, name: str, - data: Optional[NumericDataUser], - ) -> Optional[NumericDataInternal]: + data: Optional[TemporalDataUser], + ) -> Optional[TemporalData]: """ Fit data to model coordinate system (currently time, but extensible). @@ -301,9 +301,9 @@ def fit_to_model_coords( def fit_effects_to_model_coords( self, label_prefix: Optional[str], - effect_values: Optional[EffectValuesUser], + effect_values: Optional[TemporalEffectsUser], label_suffix: Optional[str] = None, - ) -> Optional[EffectValuesInternal]: + ) -> Optional[TemporalEffects]: """ Transform EffectValues from the user to Internal Datatypes aligned with model coordinates. """ @@ -530,7 +530,7 @@ def all_elements(self) -> Dict[str, Element]: def used_in_calculation(self) -> bool: return self._used_in_calculation - def sel(self, time: Optional[Union[str, slice, List[str], pd.Timestamp]] = None) -> 'FlowSystem': + def sel(self, time: Optional[Union[str, slice, List[str], pd.Timestamp, pd.DatetimeIndex]] = None) -> 'FlowSystem': """ Select a subset of the flowsystem by the time coordinate. diff --git a/flixopt/interface.py b/flixopt/interface.py index e5ee962ed..ad331b904 100644 --- a/flixopt/interface.py +++ b/flixopt/interface.py @@ -7,7 +7,7 @@ from typing import TYPE_CHECKING, Dict, Iterator, List, Optional, Union from .config import CONFIG -from .core import NumericDataUser, Scalar +from .core import Scalar, TemporalDataUser from .structure import Interface, register_class_for_io if TYPE_CHECKING: # for type checking and preventing circular imports @@ -20,7 +20,7 @@ @register_class_for_io class Piece(Interface): - def __init__(self, start: NumericDataUser, end: NumericDataUser): + def __init__(self, start: TemporalDataUser, end: TemporalDataUser): """ Define a Piece, which is part of a Piecewise object. @@ -175,10 +175,10 @@ def __init__( effects_per_running_hour: Optional['EffectValuesUser'] = None, on_hours_total_min: Optional[int] = None, on_hours_total_max: Optional[int] = None, - consecutive_on_hours_min: Optional[NumericDataUser] = None, - consecutive_on_hours_max: Optional[NumericDataUser] = None, - consecutive_off_hours_min: Optional[NumericDataUser] = None, - consecutive_off_hours_max: Optional[NumericDataUser] = None, + consecutive_on_hours_min: Optional[TemporalDataUser] = None, + consecutive_on_hours_max: Optional[TemporalDataUser] = None, + consecutive_off_hours_min: Optional[TemporalDataUser] = None, + consecutive_off_hours_max: Optional[TemporalDataUser] = None, switch_on_total_max: Optional[int] = None, force_switch_on: bool = False, ): @@ -206,10 +206,10 @@ def __init__( self.effects_per_running_hour: EffectValuesUser = effects_per_running_hour or {} self.on_hours_total_min: Scalar = on_hours_total_min self.on_hours_total_max: Scalar = on_hours_total_max - self.consecutive_on_hours_min: NumericDataUser = consecutive_on_hours_min - self.consecutive_on_hours_max: NumericDataUser = consecutive_on_hours_max - self.consecutive_off_hours_min: NumericDataUser = consecutive_off_hours_min - self.consecutive_off_hours_max: NumericDataUser = consecutive_off_hours_max + self.consecutive_on_hours_min: TemporalDataUser = consecutive_on_hours_min + self.consecutive_on_hours_max: TemporalDataUser = consecutive_on_hours_max + self.consecutive_off_hours_min: TemporalDataUser = consecutive_off_hours_min + self.consecutive_off_hours_max: TemporalDataUser = consecutive_off_hours_max self.switch_on_total_max: Scalar = switch_on_total_max self.force_switch_on: bool = force_switch_on diff --git a/flixopt/linear_converters.py b/flixopt/linear_converters.py index 94463c492..b137ad89a 100644 --- a/flixopt/linear_converters.py +++ b/flixopt/linear_converters.py @@ -8,7 +8,7 @@ import numpy as np from .components import LinearConverter -from .core import NumericDataUser, TimeSeriesData +from .core import TemporalDataUser, TimeSeriesData from .elements import Flow from .interface import OnOffParameters from .structure import register_class_for_io @@ -21,7 +21,7 @@ class Boiler(LinearConverter): def __init__( self, label: str, - eta: NumericDataUser, + eta: TemporalDataUser, Q_fu: Flow, Q_th: Flow, on_off_parameters: OnOffParameters = None, @@ -62,7 +62,7 @@ class Power2Heat(LinearConverter): def __init__( self, label: str, - eta: NumericDataUser, + eta: TemporalDataUser, P_el: Flow, Q_th: Flow, on_off_parameters: OnOffParameters = None, @@ -104,7 +104,7 @@ class HeatPump(LinearConverter): def __init__( self, label: str, - COP: NumericDataUser, + COP: TemporalDataUser, P_el: Flow, Q_th: Flow, on_off_parameters: OnOffParameters = None, @@ -146,7 +146,7 @@ class CoolingTower(LinearConverter): def __init__( self, label: str, - specific_electricity_demand: NumericDataUser, + specific_electricity_demand: TemporalDataUser, P_el: Flow, Q_th: Flow, on_off_parameters: OnOffParameters = None, @@ -190,8 +190,8 @@ class CHP(LinearConverter): def __init__( self, label: str, - eta_th: NumericDataUser, - eta_el: NumericDataUser, + eta_th: TemporalDataUser, + eta_el: TemporalDataUser, Q_fu: Flow, P_el: Flow, Q_th: Flow, @@ -251,7 +251,7 @@ class HeatPumpWithSource(LinearConverter): def __init__( self, label: str, - COP: NumericDataUser, + COP: TemporalDataUser, P_el: Flow, Q_ab: Flow, Q_th: Flow, @@ -297,11 +297,11 @@ def COP(self, value): # noqa: N802 def check_bounds( - value: NumericDataUser, + value: TemporalDataUser, parameter_label: str, element_label: str, - lower_bound: NumericDataUser, - upper_bound: NumericDataUser, + lower_bound: TemporalDataUser, + upper_bound: TemporalDataUser, ) -> None: """ Check if the value is within the bounds. The bounds are exclusive. diff --git a/flixopt/structure.py b/flixopt/structure.py index 3fb0be066..cc307a1e8 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -20,7 +20,7 @@ from . import io as fx_io from .config import CONFIG -from .core import NumericDataUser, Scalar, TimeSeriesData, get_dataarray_stats +from .core import Scalar, TemporalDataUser, TimeSeriesData, get_dataarray_stats if TYPE_CHECKING: # for type checking and preventing circular imports from .effects import EffectCollectionModel From 36cf47d5485c4921d5e9209e56312482aadd50ca Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 26 Jun 2025 14:27:42 +0200 Subject: [PATCH 107/336] CHANGELOG.md --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bb95b3756..1871de91a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed * **BREAKING**: FlowSystems can not be shared across multiple Calculations anymore. A copy of the FlowSystem is created instead, making every Calculation independent -* **BREAKING**: Type system overhaul - replaced `NumericDataTS` with `NumericDataUser` throughout codebase for better clarity +* **BREAKING**: Type system overhaul - added clear separation between temporal and non-temporal data throughout codebase for better clarity * **BREAKING**: `relative_minimum_charge_state` and `relative_maximum_charge_state` don't have an extra timestep anymore. The final charge state can now be constrained by parameters `relative_minimum_final_charge_state` and `relative_maximum_final_charge_state` instead * FlowSystem data management simplified - removed `time_series_collection` pattern in favor of direct timestep properties * Enhanced FlowSystem interface with improved `__repr__()` and `__str__()` methods From 8f1261db381706ce9270fdf571e7a92fdee39a8a Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 26 Jun 2025 14:36:09 +0200 Subject: [PATCH 108/336] Bugfix in Storage --- flixopt/components.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/flixopt/components.py b/flixopt/components.py index 49d6f5b31..639046cfc 100644 --- a/flixopt/components.py +++ b/flixopt/components.py @@ -584,11 +584,13 @@ def relative_charge_state_bounds(self) -> Tuple[xr.DataArray, xr.DataArray]: Tuple of (minimum_bounds, maximum_bounds) DataArrays extending to final timestep """ final_timestep = self._model.flow_system.timesteps_extra[-1] - final_coords = {'time': final_timestep} + final_coords = {'time': [final_timestep]} # Get final minimum charge state if self.element.relative_minimum_final_charge_state is None: - min_final = self.element.relative_minimum_charge_state.isel(time=-1).assign_coords(time=final_timestep) + min_final = self.element.relative_minimum_charge_state.isel( + time=-1, drop=True + ).assign_coords(time=final_timestep) else: min_final = xr.DataArray( [self.element.relative_minimum_final_charge_state], coords=final_coords, dims=['time'] @@ -596,7 +598,9 @@ def relative_charge_state_bounds(self) -> Tuple[xr.DataArray, xr.DataArray]: # Get final maximum charge state if self.element.relative_maximum_final_charge_state is None: - max_final = self.element.relative_maximum_charge_state.isel(time=-1).assign_coords(time=final_timestep) + max_final = self.element.relative_maximum_charge_state.isel( + time=-1, drop=True + ).assign_coords(time=final_timestep) else: max_final = xr.DataArray( [self.element.relative_maximum_final_charge_state], coords=final_coords, dims=['time'] From 89d69f0280e78220cbf7833dc177ddcfef04bad5 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 26 Jun 2025 14:38:29 +0200 Subject: [PATCH 109/336] Revert changes in example_calculation_types.py --- examples/03_Calculation_types/example_calculation_types.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/examples/03_Calculation_types/example_calculation_types.py b/examples/03_Calculation_types/example_calculation_types.py index b793e26ce..ee61d6628 100644 --- a/examples/03_Calculation_types/example_calculation_types.py +++ b/examples/03_Calculation_types/example_calculation_types.py @@ -164,12 +164,12 @@ if full: calculation = fx.FullCalculation('Full', flow_system) calculation.do_modeling() - calculation.solve(fx.solvers.GurobiSolver(0.001, 60)) + calculation.solve(fx.solvers.HighsSolver(0.01/100, 60)) calculations.append(calculation) if segmented: calculation = fx.SegmentedCalculation('Segmented', flow_system, segment_length, overlap_length) - calculation.do_modeling_and_solve(fx.solvers.GurobiSolver(0.001, 60)) + calculation.do_modeling_and_solve(fx.solvers.HighsSolver(0.01/100, 60)) calculations.append(calculation) if aggregated: @@ -178,7 +178,7 @@ aggregation_parameters.time_series_for_low_peaks = [TS_electricity_demand, TS_heat_demand] calculation = fx.AggregatedCalculation('Aggregated', flow_system, aggregation_parameters) calculation.do_modeling() - calculation.solve(fx.solvers.GurobiSolver(0.001, 60)) + calculation.solve(fx.solvers.HighsSolver(0.01/100, 60)) calculations.append(calculation) # Get solutions for plotting for different calculations From 76f51a890f19c9d7de072e34bcc0a0736a468e1d Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 26 Jun 2025 14:39:22 +0200 Subject: [PATCH 110/336] Revert changes in simple_example.py --- examples/01_Simple/simple_example.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/examples/01_Simple/simple_example.py b/examples/01_Simple/simple_example.py index da10aed62..45550c9cc 100644 --- a/examples/01_Simple/simple_example.py +++ b/examples/01_Simple/simple_example.py @@ -103,14 +103,9 @@ calculation = fx.FullCalculation(name='Sim1', flow_system=flow_system) calculation.do_modeling() # Translate the model to a solvable form, creating equations and Variables - calculation2 = fx.FullCalculation(name='Sim2', flow_system=flow_system) - calculation2.do_modeling() # Translate the model to a solvable form, creating equations and Variables - # --- Solve the Calculation and Save Results --- calculation.solve(fx.solvers.HighsSolver(mip_gap=0, time_limit_seconds=30)) - calculation2.solve(fx.solvers.HighsSolver(mip_gap=0, time_limit_seconds=30)) - # --- Analyze Results --- calculation.results['Fernwärme'].plot_node_balance_pie() calculation.results['Fernwärme'].plot_node_balance() From 0ff4d29fd2ac41cdddc7caabd28fe315eb85fa82 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 26 Jun 2025 14:47:34 +0200 Subject: [PATCH 111/336] Add convenient access to Elements in FlowSystem --- flixopt/flow_system.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index 9c181c8d3..49321ba82 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -517,6 +517,30 @@ def __eq__(self, other: 'FlowSystem'): return True + def __getitem__(self, item) -> Element: + """Get element by exact label with helpful error messages.""" + if item in self.all_elements: + return self.all_elements[item] + + # Provide helpful error with suggestions + from difflib import get_close_matches + + suggestions = get_close_matches(item, self.all_elements.keys(), n=3, cutoff=0.6) + + if suggestions: + suggestion_str = ', '.join(f"'{s}'" for s in suggestions) + raise KeyError(f"Element '{item}' not found. Did you mean: {suggestion_str}?") + else: + raise KeyError(f"Element '{item}' not found in FlowSystem") + + def __contains__(self, item: str) -> bool: + """Check if element exists in the FlowSystem.""" + return item in self.all_elements + + def __iter__(self): + """Iterate over element labels.""" + return iter(self.all_elements.keys()) + @property def flows(self) -> Dict[str, Flow]: set_of_flows = {flow for comp in self.components.values() for flow in comp.inputs + comp.outputs} From 84c850b5f0f0b9d849becde622b5ba10507d5961 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 27 Jun 2025 09:23:37 +0200 Subject: [PATCH 112/336] Get Aggregated Calculation Working --- .../example_calculation_types.py | 6 +- flixopt/aggregation.py | 4 +- flixopt/calculation.py | 50 +++++++++++++--- flixopt/core.py | 58 ++++++++++++------- flixopt/flow_system.py | 3 +- tests/conftest.py | 6 +- 6 files changed, 89 insertions(+), 38 deletions(-) diff --git a/examples/03_Calculation_types/example_calculation_types.py b/examples/03_Calculation_types/example_calculation_types.py index ee61d6628..cac628042 100644 --- a/examples/03_Calculation_types/example_calculation_types.py +++ b/examples/03_Calculation_types/example_calculation_types.py @@ -48,9 +48,9 @@ # TimeSeriesData objects TS_heat_demand = fx.TimeSeriesData(heat_demand) - TS_electricity_demand = fx.TimeSeriesData(electricity_demand, agg_weight=0.7) - TS_electricity_price_sell = fx.TimeSeriesData(-(electricity_demand - 0.5), agg_group='p_el') - TS_electricity_price_buy = fx.TimeSeriesData(electricity_price + 0.5, agg_group='p_el') + TS_electricity_demand = fx.TimeSeriesData(electricity_demand, aggregation_weight=0.7) + TS_electricity_price_sell = fx.TimeSeriesData(-(electricity_demand - 0.5), aggregation_group='p_el') + TS_electricity_price_buy = fx.TimeSeriesData(electricity_price + 0.5, aggregation_group='p_el') flow_system = fx.FlowSystem(timesteps) flow_system.add_elements( diff --git a/flixopt/aggregation.py b/flixopt/aggregation.py index f149d5f20..d47a42997 100644 --- a/flixopt/aggregation.py +++ b/flixopt/aggregation.py @@ -274,11 +274,11 @@ def use_extreme_periods(self): @property def labels_for_high_peaks(self) -> List[str]: - return [ts.label for ts in self.time_series_for_high_peaks] + return [ts.name for ts in self.time_series_for_high_peaks] @property def labels_for_low_peaks(self) -> List[str]: - return [ts.label for ts in self.time_series_for_low_peaks] + return [ts.name for ts in self.time_series_for_low_peaks] @property def use_low_peaks(self): diff --git a/flixopt/calculation.py b/flixopt/calculation.py index 60163b7a2..43884632f 100644 --- a/flixopt/calculation.py +++ b/flixopt/calculation.py @@ -14,17 +14,19 @@ import timeit import warnings from typing import Annotated, Any, Dict, List, Optional, Union +from collections import Counter import numpy as np import pandas as pd import yaml +import xarray as xr from . import io as fx_io from . import utils as utils from .aggregation import AggregationModel, AggregationParameters from .components import Storage from .config import CONFIG -from .core import Scalar +from .core import Scalar, DataConverter, drop_constant_arrays, TimeSeriesData from .elements import Component from .features import InvestmentModel from .flow_system import FlowSystem @@ -294,15 +296,17 @@ def _perform_aggregation(self): logger.info(f'{"":#^80}') logger.info(f'{" Aggregating TimeSeries Data ":#^80}') + ds = self.flow_system.to_dataset() + + temporaly_changing_ds = drop_constant_arrays(ds, dim='time') + # Aggregation - creation of aggregated timeseries: self.aggregation = Aggregation( - original_data=self.flow_system.to_dataframe( - include_extra_timestep=False - ), # Exclude last row (NaN) + original_data=temporaly_changing_ds.to_dataframe(), hours_per_time_step=float(dt_min), hours_per_period=self.aggregation_parameters.hours_per_period, nr_of_periods=self.aggregation_parameters.nr_of_periods, - weights=self.flow_system.calculate_aggregation_weights(), + weights=self.calculate_aggregation_weights(temporaly_changing_ds), time_series_for_high_peaks=self.aggregation_parameters.labels_for_high_peaks, time_series_for_low_peaks=self.aggregation_parameters.labels_for_low_peaks, ) @@ -310,11 +314,41 @@ def _perform_aggregation(self): self.aggregation.cluster() self.aggregation.plot(show=True, save=self.folder / 'aggregation.html') if self.aggregation_parameters.aggregate_data_and_fix_non_binary_vars: - self.flow_system.insert_new_data( - self.aggregation.aggregated_data, include_extra_timestep=False - ) + ds = self.flow_system.to_dataset() + for name, series in self.aggregation.aggregated_data.items(): + da = DataConverter.to_dataarray(series, timesteps=self.flow_system.timesteps).rename(name).assign_attrs(ds[name].attrs) + if TimeSeriesData.is_timeseries_data(da): + da = TimeSeriesData.from_dataarray(da) + + ds[name] = da + + self.flow_system = FlowSystem.from_dataset(ds) + self.flow_system.connect_and_transform() self.durations['aggregation'] = round(timeit.default_timer() - t_start_agg, 2) + @classmethod + def calculate_aggregation_weights(cls, ds: xr.Dataset) -> Dict[str, float]: + """Calculate weights for all datavars in the dataset. Weights are pulled from the attrs of the datavars.""" + + groups = [da.attrs['aggregation_group'] for da in ds.values() if 'aggregation_group' in da.attrs] + group_counts = Counter(groups) + + # Calculate weight for each group (1/count) + group_weights = {group: 1 / count for group, count in group_counts.items()} + + weights = {} + for name, da in ds.data_vars.items(): + group_weight = group_weights.get(da.attrs.get('aggregation_group')) + if group_weight is not None: + weights[name] = group_weight + else: + weights[name] = da.attrs.get('aggregation_weight', 1) + + if np.all(np.isclose(list(weights.values()), 1, atol=1e-6)): + logger.info('All Aggregation weights were set to 1') + + return weights + class SegmentedCalculation(Calculation): def __init__( diff --git a/flixopt/core.py b/flixopt/core.py index 41ee7b799..5bba418be 100644 --- a/flixopt/core.py +++ b/flixopt/core.py @@ -41,45 +41,45 @@ class TimeSeriesData(xr.DataArray): __slots__ = () # No additional instance attributes - everything goes in attrs - def __init__(self, *args, agg_group: Optional[str] = None, agg_weight: Optional[float] = None, **kwargs): + def __init__(self, *args, aggregation_group: Optional[str] = None, aggregation_weight: Optional[float] = None, **kwargs): """ Args: *args: Arguments passed to DataArray - agg_group: Aggregation group name - agg_weight: Aggregation weight (0-1) + aggregation_group: Aggregation group name + aggregation_weight: Aggregation weight (0-1) **kwargs: Additional arguments passed to DataArray """ - if (agg_group is not None) and (agg_weight is not None): - raise ValueError('Use either agg_group or agg_weight, not both') + if (aggregation_group is not None) and (aggregation_weight is not None): + raise ValueError('Use either aggregation_group or aggregation_weight, not both') # Let xarray handle all the initialization complexity super().__init__(*args, **kwargs) # Add our metadata to attrs after initialization - if agg_group is not None: - self.attrs['agg_group'] = agg_group - if agg_weight is not None: - self.attrs['agg_weight'] = agg_weight + if aggregation_group is not None: + self.attrs['aggregation_group'] = aggregation_group + if aggregation_weight is not None: + self.attrs['aggregation_weight'] = aggregation_weight # Always mark as TimeSeriesData self.attrs['__timeseries_data__'] = True @property - def agg_group(self) -> Optional[str]: - return self.attrs.get('agg_group') + def aggregation_group(self) -> Optional[str]: + return self.attrs.get('aggregation_group') @property - def agg_weight(self) -> Optional[float]: - return self.attrs.get('agg_weight') + def aggregation_weight(self) -> Optional[float]: + return self.attrs.get('aggregation_weight') @classmethod - def from_dataarray(cls, da: xr.DataArray, agg_group: Optional[str] = None, agg_weight: Optional[float] = None): + def from_dataarray(cls, da: xr.DataArray, aggregation_group: Optional[str] = None, aggregation_weight: Optional[float] = None): """Create TimeSeriesData from DataArray, extracting metadata from attrs.""" # Get aggregation metadata from attrs or parameters - final_agg_group = agg_group if agg_group is not None else da.attrs.get('agg_group') - final_agg_weight = agg_weight if agg_weight is not None else da.attrs.get('agg_weight') + final_aggregation_group = aggregation_group if aggregation_group is not None else da.attrs.get('aggregation_group') + final_aggregation_weight = aggregation_weight if aggregation_weight is not None else da.attrs.get('aggregation_weight') - return cls(da, agg_group=final_agg_group, agg_weight=final_agg_weight) + return cls(da, aggregation_group=final_aggregation_group, aggregation_weight=final_aggregation_weight) @classmethod def is_timeseries_data(cls, obj) -> bool: @@ -88,10 +88,10 @@ def is_timeseries_data(cls, obj) -> bool: def __repr__(self): agg_info = [] - if self.agg_group: - agg_info.append(f"agg_group='{self.agg_group}'") - if self.agg_weight is not None: - agg_info.append(f'agg_weight={self.agg_weight}') + if self.aggregation_group: + agg_info.append(f"aggregation_group='{self.aggregation_group}'") + if self.aggregation_weight is not None: + agg_info.append(f'aggregation_weight={self.aggregation_weight}') info_str = f'TimeSeriesData({", ".join(agg_info)})' if agg_info else 'TimeSeriesData' return f'{info_str}\n{super().__repr__()}' @@ -255,3 +255,19 @@ def get_dataarray_stats(arr: xr.DataArray) -> Dict: pass return stats + + +def drop_constant_arrays(ds: xr.Dataset, dim='time', drop_arrays_without_dim: bool = True): + """Drop variables with very low variance (near-constant).""" + drop_vars = [] + + for name, da in ds.data_vars.items(): + if dim in da.dims: + if da.max(dim) == da.min(dim): + drop_vars.append(name) + continue + elif drop_arrays_without_dim: + drop_vars.append(name) + + logger.debug(f'Dropping {len(drop_vars)} arrays with constant values') + return ds.drop_vars(drop_vars) diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index 49321ba82..560d740bd 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -288,9 +288,10 @@ def fit_to_model_coords( if isinstance(data, TimeSeriesData): try: + data.name = name # Set name of previous object! return TimeSeriesData( DataConverter.to_dataarray(data, timesteps=self.timesteps), - agg_group=data.agg_group, agg_weight=data.agg_weight + aggregation_group=data.aggregation_group, aggregation_weight=data.aggregation_weight ).rename(name) except ConversionError as e: logger.critical(f'Could not convert time series data "{name}" to DataArray: {e}. \n' diff --git a/tests/conftest.py b/tests/conftest.py index b705939cc..074c56efe 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -327,11 +327,11 @@ def flow_system_long(): thermal_load_ts, electrical_load_ts = ( fx.TimeSeriesData(thermal_load), - fx.TimeSeriesData(electrical_load, agg_weight=0.7), + fx.TimeSeriesData(electrical_load, aggregation_weight=0.7), ) p_feed_in, p_sell = ( - fx.TimeSeriesData(-(p_el - 0.5), agg_group='p_el'), - fx.TimeSeriesData(p_el + 0.5, agg_group='p_el'), + fx.TimeSeriesData(-(p_el - 0.5), aggregation_group='p_el'), + fx.TimeSeriesData(p_el + 0.5, aggregation_group='p_el'), ) flow_system = fx.FlowSystem(pd.DatetimeIndex(data.index)) From 8b9dabb7f917b8e06f2d30deffe740ab16df32de Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 27 Jun 2025 09:46:23 +0200 Subject: [PATCH 113/336] Segmented running with wrong results --- flixopt/calculation.py | 47 +++++++++++++++++++----------------------- 1 file changed, 21 insertions(+), 26 deletions(-) diff --git a/flixopt/calculation.py b/flixopt/calculation.py index 43884632f..5a1437ba9 100644 --- a/flixopt/calculation.py +++ b/flixopt/calculation.py @@ -385,8 +385,6 @@ def __init__( self.nr_of_previous_values = nr_of_previous_values self.sub_calculations: List[FullCalculation] = [] - self.all_timesteps = self.flow_system.all_timesteps - self.all_timesteps_extra = self.flow_system.all_timesteps_extra self.segment_names = [ f'Segment_{i + 1}' for i in range(math.ceil(len(self.all_timesteps) / self.timesteps_per_segment)) @@ -419,22 +417,22 @@ def do_modeling_and_solve( for i, (segment_name, timesteps_of_segment) in enumerate( zip(self.segment_names, self.active_timesteps_per_segment, strict=False) ): - if self.sub_calculations: - self._transfer_start_values(i) + calculation = FullCalculation( + f'{self.name}-{segment_name}', self.flow_system.sel(timesteps_of_segment), + ) + self.sub_calculations.append(calculation) logger.info( f'{segment_name} [{i + 1:>2}/{len(self.segment_names):<2}] ' f'({timesteps_of_segment[0]} -> {timesteps_of_segment[-1]}):' ) + if len(self.sub_calculations) >= 2: + self._transfer_start_values(i) - calculation = FullCalculation( - f'{self.name}-{segment_name}', self.flow_system.sel(timesteps_of_segment), - ) - self.sub_calculations.append(calculation) calculation.do_modeling() invest_elements = [ model.label_full - for component in self.flow_system.components.values() + for component in calculation.flow_system.components.values() for model in component.model.all_sub_models if isinstance(model, InvestmentModel) ] @@ -449,8 +447,6 @@ def do_modeling_and_solve( log_main_results=log_main_results, ) - self._reset_start_values() - for calc in self.sub_calculations: for key, value in calc.durations.items(): self.durations[key] += value @@ -471,27 +467,22 @@ def _transfer_start_values(self, segment_index: int): logger.debug( f'start of next segment: {start}. indices of previous values: {start_previous_values}:{end_previous_values}' ) + current_flow_system = self.sub_calculations[segment_index -1].flow_system + next_flow_system = self.sub_calculations[segment_index].flow_system + start_values_of_this_segment = {} - for flow in self.flow_system.flows.values(): - flow.previous_flow_rate = flow.model.flow_rate.solution.sel( + for current_flow, next_flow in zip(current_flow_system.flows.values(), next_flow_system.flows.values()): + next_flow.previous_flow_rate = current_flow.model.flow_rate.solution.sel( time=slice(start_previous_values, end_previous_values) ).values - start_values_of_this_segment[flow.label_full] = flow.previous_flow_rate - for comp in self.flow_system.components.values(): - if isinstance(comp, Storage): - comp.initial_charge_state = comp.model.charge_state.solution.sel(time=start).item() - start_values_of_this_segment[comp.label_full] = comp.initial_charge_state + start_values_of_this_segment[current_flow.label_full] = next_flow.previous_flow_rate + for current_comp, next_comp in zip(current_flow_system.components.values(), next_flow_system.components.values()): + if isinstance(next_comp, Storage): + next_comp.initial_charge_state = current_comp.model.charge_state.solution.sel(time=start).item() + start_values_of_this_segment[current_comp.label_full] = next_comp.initial_charge_state self._transfered_start_values.append(start_values_of_this_segment) - def _reset_start_values(self): - """This resets the start values of all Elements to its original state""" - for flow in self.flow_system.flows.values(): - flow.previous_flow_rate = self._original_start_values[flow.label_full] - for comp in self.flow_system.components.values(): - if isinstance(comp, Storage): - comp.initial_charge_state = self._original_start_values[comp.label_full] - def _calculate_timesteps_of_segment(self) -> List[pd.DatetimeIndex]: active_timesteps_per_segment = [] for i, _ in enumerate(self.segment_names): @@ -511,3 +502,7 @@ def start_values_of_segments(self) -> Dict[int, Dict[str, Any]]: 0: {element.label_full: value for element, value in self._original_start_values.items()}, **{i: start_values for i, start_values in enumerate(self._transfered_start_values, 1)}, } + + @property + def all_timesteps(self) -> pd.DatetimeIndex: + return self.flow_system.timesteps \ No newline at end of file From 7e72ab56d6f66b422212bb4c9468af52dcb86e85 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 27 Jun 2025 09:58:23 +0200 Subject: [PATCH 114/336] Use new persistent FLowSystem to create Calculations upfront --- flixopt/calculation.py | 59 ++++++++++++++++++++++++++---------------- 1 file changed, 36 insertions(+), 23 deletions(-) diff --git a/flixopt/calculation.py b/flixopt/calculation.py index 5a1437ba9..fb5686e15 100644 --- a/flixopt/calculation.py +++ b/flixopt/calculation.py @@ -396,7 +396,7 @@ def __init__( f'{self.timesteps_per_segment_with_overlap=} cant be greater than the total length {len(self.all_timesteps)}' ) - self.flow_system._connect_network() # Connect network to ensure that all FLows know their Component + self.flow_system._connect_network() # Connect network to ensure that all Flows know their Component # Storing all original start values self._original_start_values = { **{flow.label_full: flow.previous_flow_rate for flow in self.flow_system.flows.values()}, @@ -408,39 +408,52 @@ def __init__( } self._transfered_start_values: List[Dict[str, Any]] = [] - def do_modeling_and_solve( - self, solver: _Solver, log_file: Optional[pathlib.Path] = None, log_main_results: bool = False - ): - logger.info(f'{"":#^80}') - logger.info(f'{" Segmented Solving ":#^80}') - + def _create_sub_calculations(self): for i, (segment_name, timesteps_of_segment) in enumerate( zip(self.segment_names, self.active_timesteps_per_segment, strict=False) ): - calculation = FullCalculation( - f'{self.name}-{segment_name}', self.flow_system.sel(timesteps_of_segment), + self.sub_calculations.append( + FullCalculation( + f'{self.name}-{segment_name}', self.flow_system.sel(timesteps_of_segment), + folder=self.folder / segment_name + ) ) - self.sub_calculations.append(calculation) - logger.info( f'{segment_name} [{i + 1:>2}/{len(self.segment_names):<2}] ' f'({timesteps_of_segment[0]} -> {timesteps_of_segment[-1]}):' ) + + def do_modeling_and_solve( + self, solver: _Solver, log_file: Optional[pathlib.Path] = None, log_main_results: bool = False + ): + logger.info(f'{"":#^80}') + logger.info(f'{" Segmented Solving ":#^80}') + + for i, calculation in enumerate(self.sub_calculations): + logger.info( + f'{self.segment_names[i]} [{i + 1:>2}/{len(self.segment_names):<2}] ' + f'({calculation.flow_system.timesteps[0]} -> {calculation.flow_system.timesteps[-1]}):' + ) + if len(self.sub_calculations) >= 2: self._transfer_start_values(i) calculation.do_modeling() - invest_elements = [ - model.label_full - for component in calculation.flow_system.components.values() - for model in component.model.all_sub_models - if isinstance(model, InvestmentModel) - ] - if invest_elements: - logger.critical( - f'Investments are not supported in Segmented Calculation! ' - f'Following InvestmentModels were found: {invest_elements}' - ) + + # Warn about Investments, but only in fist run + if i == 0: + invest_elements = [ + model.label_full + for component in calculation.flow_system.components.values() + for model in component.model.all_sub_models + if isinstance(model, InvestmentModel) + ] + if invest_elements: + logger.critical( + f'Investments are not supported in Segmented Calculation! ' + f'Following InvestmentModels were found: {invest_elements}' + ) + calculation.solve( solver, log_file=pathlib.Path(log_file) if log_file is not None else self.folder / f'{self.name}.log', @@ -458,7 +471,7 @@ def _transfer_start_values(self, segment_index: int): This function gets the last values of the previous solved segment and inserts them as start values for the next segment """ - timesteps_of_prior_segment = self.active_timesteps_per_segment[segment_index - 1] + timesteps_of_prior_segment = self.sub_calculations[segment_index - 1].flow_system.timesteps_extra start = self.active_timesteps_per_segment[segment_index][0] start_previous_values = timesteps_of_prior_segment[self.timesteps_per_segment - self.nr_of_previous_values] From 17632f36895e82f471b4f1562e543431838b6006 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 27 Jun 2025 10:22:52 +0200 Subject: [PATCH 115/336] Improve SegmentedCalcualtion --- flixopt/calculation.py | 35 +++++++++++++++++------------------ 1 file changed, 17 insertions(+), 18 deletions(-) diff --git a/flixopt/calculation.py b/flixopt/calculation.py index fb5686e15..b0f71a40e 100644 --- a/flixopt/calculation.py +++ b/flixopt/calculation.py @@ -389,7 +389,7 @@ def __init__( self.segment_names = [ f'Segment_{i + 1}' for i in range(math.ceil(len(self.all_timesteps) / self.timesteps_per_segment)) ] - self.active_timesteps_per_segment = self._calculate_timesteps_of_segment() + self._timesteps_per_segment = self._calculate_timesteps_per_segment() assert timesteps_per_segment > 2, 'The Segment length must be greater 2, due to unwanted internal side effects' assert self.timesteps_per_segment_with_overlap <= len(self.all_timesteps), ( @@ -410,12 +410,11 @@ def __init__( def _create_sub_calculations(self): for i, (segment_name, timesteps_of_segment) in enumerate( - zip(self.segment_names, self.active_timesteps_per_segment, strict=False) + zip(self.segment_names, self._timesteps_per_segment, strict=False) ): self.sub_calculations.append( FullCalculation( f'{self.name}-{segment_name}', self.flow_system.sel(timesteps_of_segment), - folder=self.folder / segment_name ) ) logger.info( @@ -428,6 +427,7 @@ def do_modeling_and_solve( ): logger.info(f'{"":#^80}') logger.info(f'{" Segmented Solving ":#^80}') + self._create_sub_calculations() for i, calculation in enumerate(self.sub_calculations): logger.info( @@ -435,7 +435,7 @@ def do_modeling_and_solve( f'({calculation.flow_system.timesteps[0]} -> {calculation.flow_system.timesteps[-1]}):' ) - if len(self.sub_calculations) >= 2: + if i > 0: self._transfer_start_values(i) calculation.do_modeling() @@ -466,22 +466,22 @@ def do_modeling_and_solve( self.results = SegmentedCalculationResults.from_calculation(self) - def _transfer_start_values(self, segment_index: int): + def _transfer_start_values(self, i: int): """ This function gets the last values of the previous solved segment and inserts them as start values for the next segment """ - timesteps_of_prior_segment = self.sub_calculations[segment_index - 1].flow_system.timesteps_extra + timesteps_of_prior_segment = self.sub_calculations[i - 1].flow_system.timesteps_extra - start = self.active_timesteps_per_segment[segment_index][0] + start = self.sub_calculations[i].flow_system.timesteps[0] start_previous_values = timesteps_of_prior_segment[self.timesteps_per_segment - self.nr_of_previous_values] end_previous_values = timesteps_of_prior_segment[self.timesteps_per_segment - 1] logger.debug( f'start of next segment: {start}. indices of previous values: {start_previous_values}:{end_previous_values}' ) - current_flow_system = self.sub_calculations[segment_index -1].flow_system - next_flow_system = self.sub_calculations[segment_index].flow_system + current_flow_system = self.sub_calculations[i -1].flow_system + next_flow_system = self.sub_calculations[i].flow_system start_values_of_this_segment = {} for current_flow, next_flow in zip(current_flow_system.flows.values(), next_flow_system.flows.values()): @@ -496,25 +496,24 @@ def _transfer_start_values(self, segment_index: int): self._transfered_start_values.append(start_values_of_this_segment) - def _calculate_timesteps_of_segment(self) -> List[pd.DatetimeIndex]: - active_timesteps_per_segment = [] + def _calculate_timesteps_per_segment(self) -> List[pd.DatetimeIndex]: + timesteps_per_segment = [] for i, _ in enumerate(self.segment_names): start = self.timesteps_per_segment * i end = min(start + self.timesteps_per_segment_with_overlap, len(self.all_timesteps)) - active_timesteps_per_segment.append(self.all_timesteps[start:end]) - return active_timesteps_per_segment + timesteps_per_segment.append(self.all_timesteps[start:end]) + return timesteps_per_segment @property def timesteps_per_segment_with_overlap(self): return self.timesteps_per_segment + self.overlap_timesteps @property - def start_values_of_segments(self) -> Dict[int, Dict[str, Any]]: + def start_values_of_segments(self) -> List[Dict[str, Any]]: """Gives an overview of the start values of all Segments""" - return { - 0: {element.label_full: value for element, value in self._original_start_values.items()}, - **{i: start_values for i, start_values in enumerate(self._transfered_start_values, 1)}, - } + return [ + {name: value for name, value in self._original_start_values.items()} + ] + [start_values for start_values in self._transfered_start_values] @property def all_timesteps(self) -> pd.DatetimeIndex: From 3c355c9c70a2e67ff7c1208f6854c75dddf7d23f Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 27 Jun 2025 11:32:47 +0200 Subject: [PATCH 116/336] Improve SegmentedCalcualtion --- flixopt/calculation.py | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/flixopt/calculation.py b/flixopt/calculation.py index b0f71a40e..8e4a57100 100644 --- a/flixopt/calculation.py +++ b/flixopt/calculation.py @@ -410,13 +410,12 @@ def __init__( def _create_sub_calculations(self): for i, (segment_name, timesteps_of_segment) in enumerate( - zip(self.segment_names, self._timesteps_per_segment, strict=False) + zip(self.segment_names, self._timesteps_per_segment, strict=True) ): - self.sub_calculations.append( - FullCalculation( - f'{self.name}-{segment_name}', self.flow_system.sel(timesteps_of_segment), - ) - ) + calc = FullCalculation(f'{self.name}-{segment_name}', self.flow_system.sel(timesteps_of_segment)) + calc.flow_system._connect_network() # Connect to have Correct names of Flows! + + self.sub_calculations.append(calc) logger.info( f'{segment_name} [{i + 1:>2}/{len(self.segment_names):<2}] ' f'({timesteps_of_segment[0]} -> {timesteps_of_segment[-1]}):' @@ -435,7 +434,7 @@ def do_modeling_and_solve( f'({calculation.flow_system.timesteps[0]} -> {calculation.flow_system.timesteps[-1]}):' ) - if i > 0: + if i > 0 and self.nr_of_previous_values > 0: self._transfer_start_values(i) calculation.do_modeling() @@ -478,18 +477,22 @@ def _transfer_start_values(self, i: int): end_previous_values = timesteps_of_prior_segment[self.timesteps_per_segment - 1] logger.debug( - f'start of next segment: {start}. indices of previous values: {start_previous_values}:{end_previous_values}' + f'Start of next segment: {start}. Indices of previous values: {start_previous_values} -> {end_previous_values}' ) current_flow_system = self.sub_calculations[i -1].flow_system next_flow_system = self.sub_calculations[i].flow_system start_values_of_this_segment = {} - for current_flow, next_flow in zip(current_flow_system.flows.values(), next_flow_system.flows.values()): + + for current_flow in current_flow_system.flows.values(): + next_flow = next_flow_system.flows[current_flow.label_full] next_flow.previous_flow_rate = current_flow.model.flow_rate.solution.sel( time=slice(start_previous_values, end_previous_values) ).values start_values_of_this_segment[current_flow.label_full] = next_flow.previous_flow_rate - for current_comp, next_comp in zip(current_flow_system.components.values(), next_flow_system.components.values()): + + for current_comp in current_flow_system.components.values(): + next_comp = next_flow_system.components[current_comp.label_full] if isinstance(next_comp, Storage): next_comp.initial_charge_state = current_comp.model.charge_state.solution.sel(time=start).item() start_values_of_this_segment[current_comp.label_full] = next_comp.initial_charge_state From f473ce523adac4813648a8da11e52acbeacca0a0 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 27 Jun 2025 11:34:36 +0200 Subject: [PATCH 117/336] Fix SegmentedResults IO --- flixopt/results.py | 4 ++-- tests/test_integration.py | 6 ++++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/flixopt/results.py b/flixopt/results.py index e13cb0785..1dee9ac02 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -659,7 +659,7 @@ def from_file(cls, folder: Union[str, pathlib.Path], name: str): with open(path.with_suffix('.json'), 'r', encoding='utf-8') as f: meta_data = json.load(f) return cls( - [CalculationResults.from_file(folder, name) for name in meta_data['sub_calculations']], + [CalculationResults.from_file(folder, sub_name) for sub_name in meta_data['sub_calculations']], all_timesteps=pd.DatetimeIndex( [datetime.datetime.fromisoformat(date) for date in meta_data['all_timesteps']], name='time' ), @@ -756,7 +756,7 @@ def to_file( f'Folder {folder} and its parent do not exist. Please create them first.' ) from e for segment in self.segment_results: - segment.to_file(folder=folder, name=f'{name}-{segment.name}', compression=compression) + segment.to_file(folder=folder, name=segment.name, compression=compression) with open(path.with_suffix('.json'), 'w', encoding='utf-8') as f: json.dump(self.meta_data, f, indent=4, ensure_ascii=False) diff --git a/tests/test_integration.py b/tests/test_integration.py index dc203c33e..da473b4e6 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -420,6 +420,12 @@ def test_modeling_types_costs(self, modeling_calculation): f'Costs do not match for {modeling_type} modeling type', ) + def test_segmented_io(self, modeling_calculation): + calc, modeling_type = modeling_calculation + if modeling_type == 'segmented': + calc.results.to_file() + _ = fx.results.SegmentedCalculationResults.from_file(calc.folder, calc.name) + if __name__ == '__main__': pytest.main(['-v']) From 7869a7249617c686ccc39ad0794fc2cebe218d34 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 27 Jun 2025 11:47:30 +0200 Subject: [PATCH 118/336] ruff check --- flixopt/calculation.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/flixopt/calculation.py b/flixopt/calculation.py index 8e4a57100..0c844f78f 100644 --- a/flixopt/calculation.py +++ b/flixopt/calculation.py @@ -13,20 +13,20 @@ import pathlib import timeit import warnings -from typing import Annotated, Any, Dict, List, Optional, Union from collections import Counter +from typing import Annotated, Any, Dict, List, Optional, Union import numpy as np import pandas as pd -import yaml import xarray as xr +import yaml from . import io as fx_io from . import utils as utils from .aggregation import AggregationModel, AggregationParameters from .components import Storage from .config import CONFIG -from .core import Scalar, DataConverter, drop_constant_arrays, TimeSeriesData +from .core import DataConverter, Scalar, TimeSeriesData, drop_constant_arrays from .elements import Component from .features import InvestmentModel from .flow_system import FlowSystem @@ -520,4 +520,4 @@ def start_values_of_segments(self) -> List[Dict[str, Any]]: @property def all_timesteps(self) -> pd.DatetimeIndex: - return self.flow_system.timesteps \ No newline at end of file + return self.flow_system.timesteps From bb29ef254a6c410d43ddd72011060f7488d1ee35 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 27 Jun 2025 12:08:27 +0200 Subject: [PATCH 119/336] Update example --- examples/01_Simple/simple_example.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/examples/01_Simple/simple_example.py b/examples/01_Simple/simple_example.py index 45550c9cc..963f2fbe1 100644 --- a/examples/01_Simple/simple_example.py +++ b/examples/01_Simple/simple_example.py @@ -67,7 +67,8 @@ discharging=fx.Flow('Q_th_unload', bus='Fernwärme', size=1000), capacity_in_flow_hours=fx.InvestParameters(fix_effects=20, fixed_size=30, optional=False), initial_charge_state=0, # Initial storage state: empty - relative_maximum_charge_state=1 / 100 * np.array([80, 70, 80, 80, 80, 80, 80, 80, 80, 80]), + relative_maximum_charge_state=1 / 100 * np.array([80, 70, 80, 80, 80, 80, 80, 80, 80]), + relative_maximum_final_charge_state=0.8, eta_charge=0.9, eta_discharge=1, # Efficiency factors for charging/discharging relative_loss_per_hour=0.08, # 8% loss per hour. Absolute loss depends on current charge state From 8d96a49b3a259ceb28540d526e0eb7c4c0f69613 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 27 Jun 2025 13:46:05 +0200 Subject: [PATCH 120/336] Updated logger essages to use .label_full instead of .label --- flixopt/elements.py | 8 ++++---- flixopt/flow_system.py | 14 +++++++------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/flixopt/elements.py b/flixopt/elements.py index d596333c3..a49a12f0d 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -117,7 +117,7 @@ def transform_data(self, flow_system: 'FlowSystem'): def _plausibility_checks(self) -> None: if self.excess_penalty_per_flow_hour is not None and (self.excess_penalty_per_flow_hour == 0).all(): - logger.warning(f'In Bus {self.label}, the excess_penalty_per_flow_hour is 0. Use "None" or a value > 0.') + logger.warning(f'In Bus {self.label_full}, the excess_penalty_per_flow_hour is 0. Use "None" or a value > 0.') @property def with_excess(self) -> bool: @@ -256,21 +256,21 @@ def _plausibility_checks(self) -> None: self.size == CONFIG.modeling.BIG and self.fixed_relative_profile is not None ): # Default Size --> Most likely by accident logger.warning( - f'Flow "{self.label}" has no size assigned, but a "fixed_relative_profile". ' + f'Flow "{self.label_full}" has no size assigned, but a "fixed_relative_profile". ' f'The default size is {CONFIG.modeling.BIG}. As "flow_rate = size * fixed_relative_profile", ' f'the resulting flow_rate will be very high. To fix this, assign a size to the Flow {self}.' ) if self.fixed_relative_profile is not None and self.on_off_parameters is not None: raise ValueError( - f'Flow {self.label} has both a fixed_relative_profile and an on_off_parameters. This is not supported. ' + f'Flow {self.label_full} has both a fixed_relative_profile and an on_off_parameters. This is not supported. ' f'Use relative_minimum and relative_maximum instead, ' f'if you want to allow flows to be switched on and off.' ) if (self.relative_minimum > 0).any() and self.on_off_parameters is None: logger.warning( - f'Flow {self.label} has a relative_minimum of {self.relative_minimum} and no on_off_parameters. ' + f'Flow {self.label_full} has a relative_minimum of {self.relative_minimum} and no on_off_parameters. ' f'This prevents the flow_rate from switching off (flow_rate = 0). ' f'Consider using on_off_parameters to allow the flow to be switched on and off.' ) diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index 560d740bd..306872674 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -409,25 +409,25 @@ def _check_if_element_is_unique(self, element: Element) -> None: element: new element to check """ if element in self.all_elements.values(): - raise ValueError(f'Element {element.label} already added to FlowSystem!') + raise ValueError(f'Element {element.label_full} already added to FlowSystem!') # check if name is already used: if element.label_full in self.all_elements: - raise ValueError(f'Label of Element {element.label} already used in another element!') + raise ValueError(f'Label of Element {element.label_full} already used in another element!') def _add_effects(self, *args: Effect) -> None: self.effects.add_effects(*args) def _add_components(self, *components: Component) -> None: for new_component in list(components): - logger.info(f'Registered new Component: {new_component.label}') + logger.info(f'Registered new Component: {new_component.label_full}') self._check_if_element_is_unique(new_component) # check if already exists: - self.components[new_component.label] = new_component # Add to existing components + self.components[new_component.label_full] = new_component # Add to existing components def _add_buses(self, *buses: Bus): for new_bus in list(buses): - logger.info(f'Registered new Bus: {new_bus.label}') + logger.info(f'Registered new Bus: {new_bus.label_full}') self._check_if_element_is_unique(new_bus) # check if already exists: - self.buses[new_bus.label] = new_bus # Add to existing components + self.buses[new_bus.label_full] = new_bus # Add to existing components def _connect_network(self): """Connects the network of components and buses. Can be rerun without changes if no elements were added""" @@ -440,7 +440,7 @@ def _connect_network(self): if flow._bus_object is not None and flow._bus_object not in self.buses.values(): self._add_buses(flow._bus_object) warnings.warn( - f'The Bus {flow._bus_object.label} was added to the FlowSystem from {flow.label_full}.' + f'The Bus {flow._bus_object.label_full} was added to the FlowSystem from {flow.label_full}.' f'This is deprecated and will be removed in the future. ' f'Please pass the Bus.label to the Flow and the Bus to the FlowSystem instead.', UserWarning, From 8240da1046b979e0925a8e14ebd1fe7850b9ac1a Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 27 Jun 2025 13:59:02 +0200 Subject: [PATCH 121/336] Re-add parameters. Use deprecation warning instead --- flixopt/calculation.py | 2 +- flixopt/core.py | 23 ++++++++++++++++++++++- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/flixopt/calculation.py b/flixopt/calculation.py index 0c844f78f..66a33497b 100644 --- a/flixopt/calculation.py +++ b/flixopt/calculation.py @@ -74,9 +74,9 @@ def __init__( ) flow_system = flow_system.sel(time=active_timesteps) + flow_system._used_in_calculation = True self.flow_system = flow_system - self.flow_system._used_in_calculation = True self.model: Optional[SystemModel] = None self._active_timesteps = active_timesteps # deprecated diff --git a/flixopt/core.py b/flixopt/core.py index 5bba418be..1aa175ed0 100644 --- a/flixopt/core.py +++ b/flixopt/core.py @@ -4,6 +4,7 @@ """ import logging +import warnings from typing import Dict, Optional, Union import numpy as np @@ -41,14 +42,24 @@ class TimeSeriesData(xr.DataArray): __slots__ = () # No additional instance attributes - everything goes in attrs - def __init__(self, *args, aggregation_group: Optional[str] = None, aggregation_weight: Optional[float] = None, **kwargs): + def __init__(self, *args, aggregation_group: Optional[str] = None, aggregation_weight: Optional[float] = None, + agg_group: Optional[str] = None, agg_weight: Optional[float] = None, **kwargs): """ Args: *args: Arguments passed to DataArray aggregation_group: Aggregation group name aggregation_weight: Aggregation weight (0-1) + agg_group: Deprecated, use aggregation_group instead + agg_weight: Deprecated, use aggregation_weight instead **kwargs: Additional arguments passed to DataArray """ + if agg_group is not None: + warnings.warn('agg_group is deprecated, use aggregation_group instead', DeprecationWarning, stacklevel=2) + aggregation_group = agg_group + if agg_weight is not None: + warnings.warn('agg_weight is deprecated, use aggregation_weight instead', DeprecationWarning, stacklevel=2) + aggregation_weight = agg_weight + if (aggregation_group is not None) and (aggregation_weight is not None): raise ValueError('Use either aggregation_group or aggregation_weight, not both') @@ -96,6 +107,16 @@ def __repr__(self): info_str = f'TimeSeriesData({", ".join(agg_info)})' if agg_info else 'TimeSeriesData' return f'{info_str}\n{super().__repr__()}' + @property + def agg_group(self): + warnings.warn('agg_group is deprecated, use aggregation_group instead', DeprecationWarning, stacklevel=2) + return self._aggregation_group + + @property + def agg_weight(self): + warnings.warn('agg_weight is deprecated, use aggregation_weight instead', DeprecationWarning, stacklevel=2) + return self._aggregation_weight + class DataConverter: """ From 8ac2664ce99b1a507f2314db55ab12b8bd88c331 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 27 Jun 2025 14:05:58 +0200 Subject: [PATCH 122/336] Update changelog --- CHANGELOG.md | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1871de91a..45121534f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,12 +8,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Changed -* **BREAKING**: FlowSystems can not be shared across multiple Calculations anymore. A copy of the FlowSystem is created instead, making every Calculation independent -* **BREAKING**: Type system overhaul - added clear separation between temporal and non-temporal data throughout codebase for better clarity * **BREAKING**: `relative_minimum_charge_state` and `relative_maximum_charge_state` don't have an extra timestep anymore. The final charge state can now be constrained by parameters `relative_minimum_final_charge_state` and `relative_maximum_final_charge_state` instead +* FlowSystems can not be shared across multiple Calculations anymore. A copy of the FlowSystem is created instead, making every Calculation independent +* Type system overhaul - added clear separation between temporal and non-temporal data throughout codebase for better clarity * FlowSystem data management simplified - removed `time_series_collection` pattern in favor of direct timestep properties * Enhanced FlowSystem interface with improved `__repr__()` and `__str__()` methods -* *Internal*: Removed intermediate `TimeSeries` and `TimeSeriesCollection` classes, replaced directly with `xr.DataArray` or `TimeSeriesData` (inheriting from `xr.DataArray`) ### Added * **NEW**: Complete serialization infrastructure through `Interface` base class @@ -25,7 +24,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * **NEW**: FlowSystem data manipulation methods * `sel()` and `isel()` methods for temporal data selection * `resample()` method for temporal resampling - * `copy()` method with deep copying support + * `copy()` method to create a copy of a FlowSystem, including all underlying Elements and their data * `__eq__()` method for FlowSystem comparison * **NEW**: Storage component enhancements * `relative_minimum_final_charge_state` parameter for final state control @@ -47,6 +46,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * Plotly >= 6 may raise errors if "nbformat" is not installed. We pinned plotly to <6, but this may be fixed in the future. * IO for single Interfaces/Elemenets to Datasets might not work properly if the Interface/Element is not part of a fully transformed and connected FlowSystem. This arrises from Numeric Data not being stored as xr.DataArray by the user. TO avoid this, always use the `to_dataset()` on Elements inside a FlowSystem thats connected and transformed. +### Deprecated +* The `agg_group` and `agg_weight` parameters of `TimeSeriesData` are deprecated and will be removed in a future version. Use `aggregation_group` and `aggregation_weight` instead. +* The `active_timesteps` parameter of `Calculation` is deprecated and will be removed in a future version. Use the new `sel(time=...)` method on the FlowSystem instead. +* The assignment of Bus Objects to Flow.bus is deprecated and will be removed in a future version. Use the label of the Bus instead. +* The usage of Effects objects in Dicts to assign shares to Effects is deprecated and will be removed in a future version. Use the label of the Effect instead. + ## [2.1.2] - 2025-06-14 ### Fixed From 43a64eaf3f04f531217c2d560fc9813352a8a754 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 27 Jun 2025 14:17:02 +0200 Subject: [PATCH 123/336] Improve warning message --- flixopt/core.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/flixopt/core.py b/flixopt/core.py index 1aa175ed0..121c7fb12 100644 --- a/flixopt/core.py +++ b/flixopt/core.py @@ -148,7 +148,10 @@ def _fix_timeseries_data_indexing( # Check if dimensions match if data.dims != tuple(dims): - logger.warning(f'TimeSeriesData has dimensions {data.dims}, expected {dims}. Reshaping to match timesteps.') + logger.warning( + f'TimeSeriesData has dimensions {data.dims}, expected {dims}. Reshaping to match timesteps. To avoid ' + f'this warning, create a correctly shaped DataArray with the correct dimensions in the first place.' + ) # Try to reshape the data to match expected dimensions if data.size != len(timesteps): raise ConversionError( From b3fe443ea576db57ab452ab667cffa58a6e0eea9 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 27 Jun 2025 15:17:24 +0200 Subject: [PATCH 124/336] Merge --- flixopt/components.py | 2 +- flixopt/core.py | 12 ++++++------ flixopt/effects.py | 18 +++++++++--------- flixopt/features.py | 20 ++++++++++---------- flixopt/flow_system.py | 23 ++++++++++++----------- flixopt/interface.py | 39 ++++++++++++++++++--------------------- flixopt/structure.py | 19 ++++++++----------- tests/conftest.py | 2 +- 8 files changed, 65 insertions(+), 70 deletions(-) diff --git a/flixopt/components.py b/flixopt/components.py index db92067f5..c2787ef9a 100644 --- a/flixopt/components.py +++ b/flixopt/components.py @@ -10,7 +10,7 @@ import xarray as xr from . import utils -from .core import PlausibilityError, Scalar, TemporalData, TemporalDataUser, ScenarioData +from .core import PlausibilityError, Scalar, TemporalData, TemporalDataUser, NonTemporalDataUser from .elements import Component, ComponentModel, Flow from .features import InvestmentModel, OnOffModel, PiecewiseModel from .interface import InvestParameters, OnOffParameters, PiecewiseConversion diff --git a/flixopt/core.py b/flixopt/core.py index 1c58ed290..9c9d6f891 100644 --- a/flixopt/core.py +++ b/flixopt/core.py @@ -5,7 +5,7 @@ import logging import warnings -from typing import Dict, Optional, Union +from typing import Dict, Optional, Union, Tuple import numpy as np import pandas as pd @@ -24,11 +24,11 @@ TemporalData = Union[xr.DataArray, 'TimeSeriesData'] """Internally used datatypes for temporal data.""" -TimestepData = NumericData -"""Represents any form of numeric data that corresponds to timesteps.""" +NonTemporalDataUser = Union[int, float, np.integer, np.floating, np.ndarray, pd.Series, pd.DataFrame, xr.DataArray] +"""User data which has no time dimension. Internally converted to an xr.DataArray without a time dimension.""" -ScenarioData = NumericData -"""Represents any form of numeric data that corresponds to scenarios.""" +NonTemporalData = Union[Scalar, xr.DataArray] +"""Internally used datatypes for non-temporal data.""" class PlausibilityError(Exception): @@ -196,7 +196,7 @@ def _fix_timeseries_data_indexing( @staticmethod def to_dataarray( - data: TimestepData, timesteps: Optional[pd.DatetimeIndex] = None, scenarios: Optional[pd.Index] = None + data: Union[TemporalData, NonTemporalData], timesteps: Optional[pd.DatetimeIndex] = None, scenarios: Optional[pd.Index] = None ) -> xr.DataArray: """ Convert data to xarray.DataArray with specified dimensions. diff --git a/flixopt/effects.py b/flixopt/effects.py index 5270bf7fd..dc1d7d07f 100644 --- a/flixopt/effects.py +++ b/flixopt/effects.py @@ -39,7 +39,7 @@ def __init__( is_standard: bool = False, is_objective: bool = False, specific_share_to_other_effects_operation: Optional['TemporalEffectsUser'] = None, - specific_share_to_other_effects_invest: Optional['ScalarEffectsUser'] = None, + specific_share_to_other_effects_invest: Optional['NonTemporalEffectsUser'] = None, minimum_operation: Optional[Scalar] = None, maximum_operation: Optional[Scalar] = None, minimum_invest: Optional[Scalar] = None, @@ -79,7 +79,7 @@ def __init__( self.specific_share_to_other_effects_operation: TemporalEffectsUser = ( specific_share_to_other_effects_operation or {} ) - self.specific_share_to_other_effects_invest: ScalarEffectsUser = specific_share_to_other_effects_invest or {} + self.specific_share_to_other_effects_invest: NonTemporalEffectsUser = specific_share_to_other_effects_invest or {} self.minimum_operation = minimum_operation self.maximum_operation = maximum_operation self.minimum_operation_per_hour: TemporalDataUser = minimum_operation_per_hour @@ -148,8 +148,8 @@ def __init__(self, model: SystemModel, element: Effect): label_of_element=self.label_of_element, label='invest', label_full=f'{self.label_full}(invest)', - total_max=extract_data(self.element.maximum_invest), - total_min=extract_data(self.element.minimum_invest), + total_max=self.element.maximum_invest, + total_min=self.element.minimum_invest, ) ) @@ -197,13 +197,13 @@ def do_modeling(self): TemporalEffectsUser = Union[TemporalDataUser, Dict[str, TemporalDataUser]] # User-specified Shares to Effects """ This datatype is used to define a temporal share to an effect by a certain attribute. """ -ScalarEffectsUser = Union[Scalar, Dict[str, Scalar]] # User-specified Shares to Effects +NonTemporalEffectsUser = Union[Scalar, Dict[str, Scalar]] # User-specified Shares to Effects """ This datatype is used to define a scalar share to an effect by a certain attribute. """ TemporalEffects = Dict[str, TemporalData] # User-specified Shares to Effects """ This datatype is used internally to handle temporal shares to an effect. """ -ScalarEffects = Dict[str, Scalar] +NonTemporalEffects = Dict[str, Scalar] """ This datatype is used internally to handle scalar shares to an effect. """ EffectExpr = Dict[str, linopy.LinearExpression] # Used to create Shares @@ -240,7 +240,7 @@ def add_effects(self, *effects: Effect) -> None: def create_effect_values_dict( self, - effect_values_user: Union[ScalarEffectsUser, TemporalEffectsUser] + effect_values_user: Union[NonTemporalEffectsUser, TemporalEffectsUser] ) -> Optional[Dict[str, Union[Scalar, TemporalDataUser]]]: """ Converts effect values into a dictionary. If a scalar is provided, it is associated with a default effect type. @@ -366,7 +366,7 @@ def calculate_effect_share_factors(self) -> Tuple[ for name, effect in self.effects.items(): if effect.specific_share_to_other_effects_invest: shares_invest[name] = { - target: extract_data(data) + target: data for target, data in effect.specific_share_to_other_effects_invest.items() } shares_invest = calculate_all_conversion_paths(shares_invest) @@ -375,7 +375,7 @@ def calculate_effect_share_factors(self) -> Tuple[ for name, effect in self.effects.items(): if effect.specific_share_to_other_effects_operation: shares_operation[name] = { - target: extract_data(data) + target: data for target, data in effect.specific_share_to_other_effects_operation.items() } shares_operation = calculate_all_conversion_paths(shares_operation) diff --git a/flixopt/features.py b/flixopt/features.py index 8b80e94d4..3a2744dee 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -45,8 +45,8 @@ def __init__( def do_modeling(self): self.size = self.add( self._model.add_variables( - lower=0 if self.parameters.optional else extract_data(self.parameters.minimum_size), - upper=extract_data(self.parameters.maximum_size), + lower=0 if self.parameters.optional else self.parameters.minimum_size, + upper=self.parameters.maximum_size, name=f'{self.label_full}|size', coords=self._model.get_coords(time_dim=False), ), @@ -292,8 +292,8 @@ def do_modeling(self): self.total_on_hours = self.add( self._model.add_variables( - lower=extract_data(self._on_hours_total_min), - upper=extract_data(self._on_hours_total_max), + lower=self._on_hours_total_min, + upper=self._on_hours_total_max, coords=self._model.get_coords(time_dim=False), name=f'{self.label_full}|on_hours_total', ), @@ -437,7 +437,7 @@ def do_modeling(self): # Create count variable for number of switches self.switch_on_nr = self.add( self._model.add_variables( - upper=extract_data(self._switch_on_max), + upper=self._switch_on_max, lower=0, name=f'{self.label_full}|switch_on_nr', ), @@ -526,7 +526,7 @@ def do_modeling(self): self.duration = self.add( self._model.add_variables( lower=0, - upper=extract_data(self._maximum_duration, mega), + upper=self._maximum_duration if self._maximum_duration is not None else mega, coords=self._model.get_coords(), name=f'{self.label_full}|hours', ), @@ -707,8 +707,8 @@ def do_modeling(self): defining_bounds=self._defining_bounds, previous_values=self._previous_values, use_off=self.parameters.use_off, - on_hours_total_min=extract_data(self.parameters.on_hours_total_min), - on_hours_total_max=extract_data(self.parameters.on_hours_total_max), + on_hours_total_min=self.parameters.on_hours_total_min, + on_hours_total_max=self.parameters.on_hours_total_max, effects_per_running_hour=self.parameters.effects_per_running_hour, ) self.add(self.state_model) @@ -1001,8 +1001,8 @@ def do_modeling(self): if self._has_time_dim: self.total_per_timestep = self.add( self._model.add_variables( - lower=-np.inf if (self._min_per_hour is None) else extract_data(self._min_per_hour) * self._model.hours_per_step, - upper=np.inf if (self._max_per_hour is None) else extract_data(self._max_per_hour) * self._model.hours_per_step, + lower=-np.inf if (self._min_per_hour is None) else self._min_per_hour * self._model.hours_per_step, + upper=np.inf if (self._max_per_hour is None) else self._max_per_hour * self._model.hours_per_step, coords=self._model.get_coords(time_dim=True, scenario_dim=self._has_scenario_dim), name=f'{self.label_full}|total_per_timestep', ), diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index 5734c6a1d..52371c9cb 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -16,8 +16,8 @@ from rich.pretty import Pretty from . import io as fx_io -from .core import ConversionError, DataConverter, TemporalData, TemporalDataUser, TimeSeriesData -from .effects import Effect, EffectCollection, ScalarEffects, ScalarEffectsUser, TemporalEffects, TemporalEffectsUser +from .core import ConversionError, DataConverter, TemporalData, TemporalDataUser, TimeSeriesData, NonTemporalDataUser +from .effects import Effect, EffectCollection, NonTemporalEffects, NonTemporalEffectsUser, TemporalEffects, TemporalEffectsUser from .elements import Bus, Component, Flow from .structure import Element, Interface, SystemModel @@ -49,7 +49,7 @@ def __init__( scenarios: Optional[pd.Index] = None, hours_of_last_timestep: Optional[float] = None, hours_of_previous_timesteps: Optional[Union[int, float, np.ndarray]] = None, - scenario_weights: Optional[ScenarioData] = None, + scenario_weights: Optional[NonTemporalDataUser] = None, ): """ Args: @@ -69,9 +69,8 @@ def __init__( self.hours_of_previous_timesteps = self._calculate_hours_of_previous_timesteps( timesteps, hours_of_previous_timesteps ) - self.scenario_weights = self.create_time_series( - 'scenario_weights', scenario_weights, has_time_dim=False, has_scenario_dim=True - ) + self.scenarios = scenarios + self.scenario_weights = self.fit_to_model_coords('scenario_weights', scenario_weights, has_time_dim=False) # Element collections self.components: Dict[str, Component] = {} @@ -279,6 +278,7 @@ def fit_to_model_coords( self, name: str, data: Optional[TemporalDataUser], + has_time_dim: bool = True, ) -> Optional[TemporalData]: """ Fit data to model coordinate system (currently time, but extensible). @@ -297,21 +297,22 @@ def fit_to_model_coords( try: data.name = name # Set name of previous object! return TimeSeriesData( - DataConverter.to_dataarray(data, timesteps=self.timesteps), + DataConverter.to_dataarray(data, timesteps=self.timesteps, scenarios=self.scenarios), aggregation_group=data.aggregation_group, aggregation_weight=data.aggregation_weight ).rename(name) except ConversionError as e: logger.critical(f'Could not convert time series data "{name}" to DataArray: {e}. \n' f'Take care to use the correct (time) index.') else: - return DataConverter.to_dataarray(data, timesteps=self.timesteps).rename(name) + return DataConverter.to_dataarray(data, timesteps=self.timesteps if has_time_dim else None, scenarios=self.scenarios).rename(name) def fit_effects_to_model_coords( self, label_prefix: Optional[str], effect_values: Optional[TemporalEffectsUser], label_suffix: Optional[str] = None, - ) -> Optional[TemporalEffects]: + has_time_dim: bool = True, + ) -> Optional[Union[TemporalEffects, NonTemporalEffects]]: """ Transform EffectValues from the user to Internal Datatypes aligned with model coordinates. """ @@ -321,14 +322,14 @@ def fit_effects_to_model_coords( effect_values_dict = self.effects.create_effect_values_dict(effect_values) return { - effect: self.fit_to_model_coords('|'.join(filter(None, [label_prefix, effect, label_suffix])), value) + effect: self.fit_to_model_coords('|'.join(filter(None, [label_prefix, effect, label_suffix])), value, has_time_dim=has_time_dim) for effect, value in effect_values_dict.items() } def connect_and_transform(self): """Transform data for all elements using the new simplified approach.""" self.scenario_weights = self.fit_to_model_coords( - 'scenario_weights', self.scenario_weights, has_time_dim=False, has_scenario_dim=True + 'scenario_weights', self.scenario_weights, has_time_dim=False ) if not self._connected_and_transformed: self._connect_network() diff --git a/flixopt/interface.py b/flixopt/interface.py index a8f209c8f..eb84a45e3 100644 --- a/flixopt/interface.py +++ b/flixopt/interface.py @@ -7,7 +7,7 @@ from typing import TYPE_CHECKING, Dict, Iterator, List, Literal, Optional, Union from .config import CONFIG -from .core import Scalar, TemporalDataUser +from .core import Scalar, TemporalDataUser, NonTemporalDataUser from .structure import Interface, register_class_for_io if TYPE_CHECKING: # for type checking and preventing circular imports @@ -33,8 +33,8 @@ def __init__(self, start: TemporalDataUser, end: TemporalDataUser): self.has_time_dim = False def transform_data(self, flow_system: 'FlowSystem', name_prefix: str): - self.start = flow_system.fit_to_model_coords(f'{name_prefix}|start', self.start, has_time_dim=self.has_time_dim, has_scenario_dim=True) - self.end = flow_system.fit_to_model_coords(f'{name_prefix}|end', self.end, has_time_dim=self.has_time_dim, has_scenario_dim=True) + self.start = flow_system.fit_to_model_coords(f'{name_prefix}|start', self.start, has_time_dim=self.has_time_dim) + self.end = flow_system.fit_to_model_coords(f'{name_prefix}|end', self.end, has_time_dim=self.has_time_dim) @register_class_for_io @@ -146,9 +146,9 @@ class InvestParameters(Interface): def __init__( self, - fixed_size: Optional[ScenarioData] = None, - minimum_size: Optional[ScenarioData] = None, - maximum_size: Optional[ScenarioData] = None, + fixed_size: Optional[NonTemporalDataUser] = None, + minimum_size: Optional[NonTemporalDataUser] = None, + maximum_size: Optional[NonTemporalDataUser] = None, optional: bool = True, # Investition ist weglassbar fix_effects: Optional['EffectValuesUserScenario'] = None, specific_effects: Optional['EffectValuesUserScenario'] = None, # costs per Flow-Unit/Storage-Size/... @@ -185,40 +185,37 @@ def __init__( def transform_data(self, flow_system: 'FlowSystem', name_prefix: str): self._plausibility_checks(flow_system) - self.fix_effects = flow_system.create_effect_time_series( + self.fix_effects = flow_system.fit_effects_to_model_coords( label_prefix=name_prefix, effect_values=self.fix_effects, label_suffix='fix_effects', has_time_dim=False, - has_scenario_dim=True, ) - self.divest_effects = flow_system.create_effect_time_series( + self.divest_effects = flow_system.fit_effects_to_model_coords( label_prefix=name_prefix, effect_values=self.divest_effects, label_suffix='divest_effects', has_time_dim=False, - has_scenario_dim=True, ) - self.specific_effects = flow_system.create_effect_time_series( + self.specific_effects = flow_system.fit_effects_to_model_coords( label_prefix=name_prefix, effect_values=self.specific_effects, label_suffix='specific_effects', has_time_dim=False, - has_scenario_dim=True, ) if self.piecewise_effects is not None: self.piecewise_effects.has_time_dim = False self.piecewise_effects.transform_data(flow_system, f'{name_prefix}|PiecewiseEffects') - self._minimum_size = flow_system.create_time_series( - f'{name_prefix}|minimum_size', self.minimum_size, has_time_dim=False, has_scenario_dim=True + self._minimum_size = flow_system.fit_to_model_coords( + f'{name_prefix}|minimum_size', self.minimum_size, has_time_dim=False ) - self._maximum_size = flow_system.create_time_series( - f'{name_prefix}|maximum_size', self.maximum_size, has_time_dim=False, has_scenario_dim=True + self._maximum_size = flow_system.fit_to_model_coords( + f'{name_prefix}|maximum_size', self.maximum_size, has_time_dim=False ) if self.fixed_size is not None: - self.fixed_size = flow_system.create_time_series( - f'{name_prefix}|fixed_size', self.fixed_size, has_time_dim=False, has_scenario_dim=True + self.fixed_size = flow_system.fit_to_model_coords( + f'{name_prefix}|fixed_size', self.fixed_size, has_time_dim=False ) def _plausibility_checks(self, flow_system): @@ -310,13 +307,13 @@ def transform_data(self, flow_system: 'FlowSystem', name_prefix: str): self.consecutive_off_hours_max = flow_system.fit_to_model_coords( f'{name_prefix}|consecutive_off_hours_max', self.consecutive_off_hours_max ) - self.on_hours_total_max = flow_system.create_time_series( + self.on_hours_total_max = flow_system.fit_to_model_coords( f'{name_prefix}|on_hours_total_max', self.on_hours_total_max, has_time_dim=False ) - self.on_hours_total_min = flow_system.create_time_series( + self.on_hours_total_min = flow_system.fit_to_model_coords( f'{name_prefix}|on_hours_total_min', self.on_hours_total_min, has_time_dim=False ) - self.switch_on_total_max = flow_system.create_time_series( + self.switch_on_total_max = flow_system.fit_to_model_coords( f'{name_prefix}|switch_on_total_max', self.switch_on_total_max, has_time_dim=False ) diff --git a/flixopt/structure.py b/flixopt/structure.py index 9fc12bcd6..d02a8d4e9 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -7,7 +7,6 @@ import json import logging import pathlib -from datetime import datetime from io import StringIO from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Tuple, Union @@ -20,7 +19,7 @@ from . import io as fx_io from .config import CONFIG -from .core import Scalar, TemporalDataUser, TimeSeriesData, get_dataarray_stats +from .core import Scalar, TemporalDataUser, TimeSeriesData, get_dataarray_stats, NonTemporalData if TYPE_CHECKING: # for type checking and preventing circular imports from .effects import EffectCollectionModel @@ -70,21 +69,19 @@ def do_modeling(self): for bus_model in bus_models: # Buses after Components, because FlowModels are created in ComponentModels bus_model.do_modeling() - def _calculate_scenario_weights(self, weights: Optional[TimeSeries] = None) -> xr.DataArray: + def _calculate_scenario_weights(self, weights: Optional[NonTemporalData] = None) -> xr.DataArray: """Calculates the weights of the scenarios. If None, all scenarios have the same weight. All weights are normalized to 1. If no scenarios are present, s single weight of 1 is returned. """ - if weights is not None and not isinstance(weights, TimeSeries): - raise TypeError(f'Weights must be a TimeSeries or None, got {type(weights)}') - if self.time_series_collection.scenarios is None: + if weights is not None and not isinstance(weights, xr.DataArray): + raise TypeError(f'Weights must be a xr.DataArray or None, got {type(weights)}') + if self.flow_system.scenarios is None: return xr.DataArray(1) if weights is None: weights = xr.DataArray( - np.ones(len(self.time_series_collection.scenarios)), - coords={'scenario': self.time_series_collection.scenarios} + np.ones(len(self.flow_system.scenarios)), + coords={'scenario': self.flow_system.scenarios} ) - elif isinstance(weights, TimeSeries): - weights = weights.selected_data return weights / weights.sum() @@ -137,7 +134,7 @@ def get_coords( """ if not scenario_dim and not time_dim: return None - scenarios = self.time_series_collection.scenarios + scenarios = self.flow_system.scenarios timesteps = ( self.flow_system.timesteps if not extra_timestep else self.flow_system.timesteps_extra ) diff --git a/tests/conftest.py b/tests/conftest.py index 198b9a92b..ae2cb8c25 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -16,7 +16,7 @@ from flixopt.structure import SystemModel -@pytest.fixture() +@pytest.fixture( def highs_solver(): return fx.solvers.HighsSolver(mip_gap=0, time_limit_seconds=300) From 483ba12c9099113b17000042e9f6f886992d520c Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 28 Jun 2025 21:40:06 +0200 Subject: [PATCH 125/336] Merge --- flixopt/components.py | 16 ++++++++-------- flixopt/results.py | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/flixopt/components.py b/flixopt/components.py index c2787ef9a..5b902b65b 100644 --- a/flixopt/components.py +++ b/flixopt/components.py @@ -257,20 +257,20 @@ def _plausibility_checks(self) -> None: maximum_capacity = self.capacity_in_flow_hours minimum_capacity = self.capacity_in_flow_hours - # initial capacity >= allowed min for maximum_size: - minimum_inital_capacity = maximum_capacity * self.relative_minimum_charge_state.isel(time=0) - # initial capacity <= allowed max for minimum_size: - maximum_inital_capacity = minimum_capacity * self.relative_maximum_charge_state.isel(time=0) + # initial capacity >= allowed min for maximum_size: + minimum_initial_capacity = maximum_capacity * self.relative_minimum_charge_state.isel(time=0) + # initial capacity <= allowed max for minimum_size: + maximum_initial_capacity = minimum_capacity * self.relative_maximum_charge_state.isel(time=0) - if (self.initial_charge_state > maximum_inital_capacity).any(): + if (self.initial_charge_state > maximum_initial_capacity).any(): raise ValueError( f'{self.label_full}: {self.initial_charge_state=} ' - f'is above allowed maximum charge_state {maximum_inital_capacity}' + f'is above allowed maximum charge_state {maximum_initial_capacity}' ) - if (self.initial_charge_state < minimum_inital_capacity).any(): + if (self.initial_charge_state < minimum_initial_capacity).any(): raise ValueError( f'{self.label_full}: {self.initial_charge_state=} ' - f'is below allowed minimum charge_state {minimum_inital_capacity}' + f'is below allowed minimum charge_state {minimum_initial_capacity}' ) if self.balanced: diff --git a/flixopt/results.py b/flixopt/results.py index 3eba91c8a..7a6f7926e 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -124,7 +124,7 @@ def from_calculation(cls, calculation: 'Calculation'): """ return cls( solution=calculation.model.solution, - flow_system=calculation.flow_system.to_dataset(), + flow_system_data=calculation.flow_system.to_dataset(), summary=calculation.summary, model=calculation.model, name=calculation.name, From dee1de4ebb0418f30cfcc54ba634f21c31fba0c6 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 28 Jun 2025 21:40:48 +0200 Subject: [PATCH 126/336] Fit scenario weights to model coords when transforming --- flixopt/flow_system.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index 52371c9cb..dea0a40e6 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -70,7 +70,7 @@ def __init__( timesteps, hours_of_previous_timesteps ) self.scenarios = scenarios - self.scenario_weights = self.fit_to_model_coords('scenario_weights', scenario_weights, has_time_dim=False) + self.scenario_weights = scenario_weights # Element collections self.components: Dict[str, Component] = {} From 4ec39149705b5fb60886db33901bd47f05ba9ea9 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 28 Jun 2025 21:40:57 +0200 Subject: [PATCH 127/336] Merge --- flixopt/features.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flixopt/features.py b/flixopt/features.py index 3a2744dee..e9d936b74 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -66,7 +66,7 @@ def do_modeling(self): self._create_bounds_for_optional_investment() - if self._model.time_series_collection.scenarios is not None: + if self._model.flow_system.scenarios is not None: self._create_bounds_for_scenarios() # Bounds for defining variable From 2554d8a79f38eef6b1c89983137453f6a51eb3b8 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 28 Jun 2025 21:41:32 +0200 Subject: [PATCH 128/336] Removing logic between minimum, maximum and fixed size from InvestParameters --- flixopt/interface.py | 20 ++++++-------------- 1 file changed, 6 insertions(+), 14 deletions(-) diff --git a/flixopt/interface.py b/flixopt/interface.py index eb84a45e3..f8bf0b1bf 100644 --- a/flixopt/interface.py +++ b/flixopt/interface.py @@ -179,8 +179,8 @@ def __init__( self.optional = optional self.specific_effects: EffectValuesUserScenario = specific_effects if specific_effects is not None else {} self.piecewise_effects = piecewise_effects - self._minimum_size = minimum_size if minimum_size is not None else CONFIG.modeling.EPSILON - self._maximum_size = maximum_size if maximum_size is not None else CONFIG.modeling.BIG # default maximum + self.minimum_size = minimum_size if minimum_size is not None else CONFIG.modeling.EPSILON + self.maximum_size = maximum_size if maximum_size is not None else CONFIG.modeling.BIG # default maximum self.investment_scenarios = investment_scenarios def transform_data(self, flow_system: 'FlowSystem', name_prefix: str): @@ -207,10 +207,10 @@ def transform_data(self, flow_system: 'FlowSystem', name_prefix: str): self.piecewise_effects.has_time_dim = False self.piecewise_effects.transform_data(flow_system, f'{name_prefix}|PiecewiseEffects') - self._minimum_size = flow_system.fit_to_model_coords( + self.minimum_size = flow_system.fit_to_model_coords( f'{name_prefix}|minimum_size', self.minimum_size, has_time_dim=False ) - self._maximum_size = flow_system.fit_to_model_coords( + self.maximum_size = flow_system.fit_to_model_coords( f'{name_prefix}|maximum_size', self.maximum_size, has_time_dim=False ) if self.fixed_size is not None: @@ -220,10 +220,10 @@ def transform_data(self, flow_system: 'FlowSystem', name_prefix: str): def _plausibility_checks(self, flow_system): if isinstance(self.investment_scenarios, list): - if not set(self.investment_scenarios).issubset(flow_system.time_series_collection.scenarios): + if not set(self.investment_scenarios).issubset(flow_system.scenarios): raise ValueError( f'Some scenarios in investment_scenarios are not present in the time_series_collection: ' - f'{set(self.investment_scenarios) - set(flow_system.time_series_collection.scenarios)}' + f'{set(self.investment_scenarios) - set(flow_system.scenarios)}' ) if self.investment_scenarios is not None: if not self.optional: @@ -233,14 +233,6 @@ def _plausibility_checks(self, flow_system): 'Otherwise the investment cannot be 0 incertain scenarios while being non-zero in others.' ) - @property - def minimum_size(self): - return self.fixed_size if self.fixed_size is not None else self._minimum_size - - @property - def maximum_size(self): - return self.fixed_size if self.fixed_size is not None else self._maximum_size - @register_class_for_io class OnOffParameters(Interface): From d062727476015049e0b36b274316acf7312c8062 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 28 Jun 2025 21:45:11 +0200 Subject: [PATCH 129/336] Remove selected_timesteps --- flixopt/calculation.py | 28 +++++++--------------------- 1 file changed, 7 insertions(+), 21 deletions(-) diff --git a/flixopt/calculation.py b/flixopt/calculation.py index 5796cd70e..88e509438 100644 --- a/flixopt/calculation.py +++ b/flixopt/calculation.py @@ -46,30 +46,19 @@ def __init__( self, name: str, flow_system: FlowSystem, - selected_timesteps: Annotated[ + active_timesteps: Annotated[ Optional[pd.DatetimeIndex], 'DEPRECATED: Use flow_system.sel(time=...) or flow_system.isel(time=...) instead', ] = None, - selected_scenarios: Optional[pd.Index] = None, folder: Optional[pathlib.Path] = None, - active_timesteps: Optional[pd.DatetimeIndex] = None, ): """ Args: name: name of calculation flow_system: flow_system which should be calculated - selected_timesteps: timesteps which should be used for calculation. If None, then all timesteps are used. - selected_scenarios: scenarios which should be used for calculation. If None, then all scenarios are used. folder: folder where results should be saved. If None, then the current working directory is used. - active_timesteps: Deprecated. Use selected_timesteps instead. + active_timesteps: Deprecated. Use FLowSystem.sel(time=...) or FlowSystem.isel(time=...) instead. """ - if active_timesteps is not None: - warnings.warn( - 'active_timesteps is deprecated. Use selected_timesteps instead.', - DeprecationWarning, - stacklevel=2, - ) - selected_timesteps = active_timesteps self.name = name if flow_system.used_in_calculation: logging.warning(f'FlowSystem {flow_system} is already used in a calculation. ' @@ -85,14 +74,12 @@ def __init__( stacklevel=2, ) flow_system = flow_system.sel(time=active_timesteps) + self._active_timesteps = active_timesteps # deprecated flow_system._used_in_calculation = True self.flow_system = flow_system self.model: Optional[SystemModel] = None - self.selected_timesteps = selected_timesteps - self.selected_scenarios = selected_scenarios - self._active_timesteps = active_timesteps # deprecated self.durations = {'modeling': 0.0, 'solving': 0.0, 'saving': 0.0} self.folder = pathlib.Path.cwd() / 'results' if folder is None else pathlib.Path(folder) @@ -169,11 +156,11 @@ def summary(self): @property def active_timesteps(self) -> pd.DatetimeIndex: warnings.warn( - 'active_timesteps is deprecated. Use selected_timesteps instead.', + 'active_timesteps is deprecated. Use active_timesteps instead.', DeprecationWarning, stacklevel=2, ) - return self.selected_timesteps + return self._active_timesteps class FullCalculation(Calculation): @@ -240,7 +227,6 @@ def __init__( flow_system: FlowSystem, aggregation_parameters: AggregationParameters, components_to_clusterize: Optional[List[Component]] = None, - selected_timesteps: Optional[pd.DatetimeIndex] = None, active_timesteps: Annotated[ Optional[pd.DatetimeIndex], 'DEPRECATED: Use flow_system.sel(time=...) or flow_system.isel(time=...) instead', @@ -258,13 +244,13 @@ def __init__( components_to_clusterize: List of Components to perform aggregation on. If None, then all components are aggregated. This means, teh variables in the components are equalized to each other, according to the typical periods computed in the DataAggregation - selected_timesteps: pd.DatetimeIndex or None + active_timesteps: pd.DatetimeIndex or None list with indices, which should be used for calculation. If None, then all timesteps are used. folder: folder where results should be saved. If None, then the current working directory is used. """ if flow_system.scenarios is not None: raise ValueError('Aggregation is not supported for scenarios yet. Please use FullCalculation instead.') - super().__init__(name, flow_system, selected_timesteps, folder=folder, active_timesteps=active_timesteps) + super().__init__(name, flow_system, folder=folder, active_timesteps=active_timesteps) self.aggregation_parameters = aggregation_parameters self.components_to_clusterize = components_to_clusterize self.aggregation = None From 6dc23f587f4ff7517ffb0c551f0270214cd85190 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 28 Jun 2025 21:49:01 +0200 Subject: [PATCH 130/336] Improve TypeHints --- flixopt/core.py | 6 +++--- flixopt/flow_system.py | 9 +++++---- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/flixopt/core.py b/flixopt/core.py index 9c9d6f891..648344098 100644 --- a/flixopt/core.py +++ b/flixopt/core.py @@ -22,13 +22,13 @@ """User data which might have a time dimension. Internally converted to an xr.DataArray with time dimension.""" TemporalData = Union[xr.DataArray, 'TimeSeriesData'] -"""Internally used datatypes for temporal data.""" +"""Internally used datatypes for temporal data (data with a time dimension).""" NonTemporalDataUser = Union[int, float, np.integer, np.floating, np.ndarray, pd.Series, pd.DataFrame, xr.DataArray] -"""User data which has no time dimension. Internally converted to an xr.DataArray without a time dimension.""" +"""User data which has no time dimension. Internally converted to a Scalar or an xr.DataArray without a time dimension.""" NonTemporalData = Union[Scalar, xr.DataArray] -"""Internally used datatypes for non-temporal data.""" +"""Internally used datatypes for non-temporal data. Can be a Scalar or an xr.DataArray.""" class PlausibilityError(Exception): diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index dea0a40e6..7849cdcea 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -16,7 +16,7 @@ from rich.pretty import Pretty from . import io as fx_io -from .core import ConversionError, DataConverter, TemporalData, TemporalDataUser, TimeSeriesData, NonTemporalDataUser +from .core import ConversionError, DataConverter, TemporalData, TemporalDataUser, TimeSeriesData, NonTemporalDataUser, NonTemporalData from .effects import Effect, EffectCollection, NonTemporalEffects, NonTemporalEffectsUser, TemporalEffects, TemporalEffectsUser from .elements import Bus, Component, Flow from .structure import Element, Interface, SystemModel @@ -277,15 +277,16 @@ def to_json(self, path: Union[str, pathlib.Path]): def fit_to_model_coords( self, name: str, - data: Optional[TemporalDataUser], + data: Optional[Union[TemporalDataUser, NonTemporalDataUser]], has_time_dim: bool = True, - ) -> Optional[TemporalData]: + ) -> Optional[Union[TemporalData, NonTemporalData]]: """ Fit data to model coordinate system (currently time, but extensible). Args: name: Name of the data data: Data to fit to model coordinates + has_time_dim: Whether the data has a time dimension Returns: xr.DataArray aligned to model coordinate system @@ -309,7 +310,7 @@ def fit_to_model_coords( def fit_effects_to_model_coords( self, label_prefix: Optional[str], - effect_values: Optional[TemporalEffectsUser], + effect_values: Optional[Union[TemporalEffectsUser, NonTemporalEffectsUser]], label_suffix: Optional[str] = None, has_time_dim: bool = True, ) -> Optional[Union[TemporalEffects, NonTemporalEffects]]: From e6100d66d64533bddc29de98e84b2a7650807c68 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 28 Jun 2025 22:05:42 +0200 Subject: [PATCH 131/336] New property on InvestParameters for min/max/fixed size --- flixopt/elements.py | 6 +++--- flixopt/features.py | 8 ++++---- flixopt/interface.py | 10 +++++++++- tests/conftest.py | 2 +- 4 files changed, 17 insertions(+), 9 deletions(-) diff --git a/flixopt/elements.py b/flixopt/elements.py index 9d0f91885..721c8d50d 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -443,7 +443,7 @@ def flow_rate_bounds_on(self) -> Tuple[TemporalData, TemporalData]: if size.fixed_size is not None: return relative_minimum * size.fixed_size, relative_maximum * size.fixed_size - return relative_minimum * size.minimum_size, relative_maximum * size.maximum_size + return relative_minimum * size.minimum_or_fixed_size, relative_maximum * size.maximum_or_fixed_size @property def flow_rate_lower_bound_relative(self) -> TemporalData: @@ -472,7 +472,7 @@ def flow_rate_lower_bound(self) -> TemporalData: if isinstance(self.element.size, InvestParameters): if self.element.size.optional: return 0 - return self.flow_rate_lower_bound_relative * self.element.size.minimum_size + return self.flow_rate_lower_bound_relative * self.element.size.minimum_or_fixed_size return self.flow_rate_lower_bound_relative * self.element.size @property @@ -482,7 +482,7 @@ def flow_rate_upper_bound(self) -> TemporalData: Further constraining might be done in OnOffModel and InvestmentModel """ if isinstance(self.element.size, InvestParameters): - return self.flow_rate_upper_bound_relative * self.element.size.maximum_size + return self.flow_rate_upper_bound_relative * self.element.size.maximum_or_fixed_size return self.flow_rate_upper_bound_relative * self.element.size diff --git a/flixopt/features.py b/flixopt/features.py index e9d936b74..5e0ed0e80 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -10,7 +10,7 @@ import numpy as np from .config import CONFIG -from .core import Scalar, TemporalData +from .core import Scalar, TemporalData, NonTemporalData from .interface import InvestParameters, OnOffParameters, Piecewise from .structure import Model, SystemModel @@ -45,8 +45,8 @@ def __init__( def do_modeling(self): self.size = self.add( self._model.add_variables( - lower=0 if self.parameters.optional else self.parameters.minimum_size, - upper=self.parameters.maximum_size, + lower=0 if self.parameters.optional else self.parameters.minimum_or_fixed_size, + upper=self.parameters.maximum_or_fixed_size, name=f'{self.label_full}|size', coords=self._model.get_coords(time_dim=False), ), @@ -138,7 +138,7 @@ def _create_bounds_for_optional_investment(self): # eq2: P_invest >= isInvested * max(epsilon, investSize_min) self.add( self._model.add_constraints( - self.size >= self.is_invested * np.maximum(CONFIG.modeling.EPSILON, self.parameters.minimum_size), + self.size >= self.is_invested * np.maximum(CONFIG.modeling.EPSILON, self.parameters.minimum_or_fixed_size), name=f'{self.label_full}|is_invested_lb', ), 'is_invested_lb', diff --git a/flixopt/interface.py b/flixopt/interface.py index f8bf0b1bf..81f351ebe 100644 --- a/flixopt/interface.py +++ b/flixopt/interface.py @@ -7,7 +7,7 @@ from typing import TYPE_CHECKING, Dict, Iterator, List, Literal, Optional, Union from .config import CONFIG -from .core import Scalar, TemporalDataUser, NonTemporalDataUser +from .core import Scalar, TemporalDataUser, NonTemporalDataUser, NonTemporalData from .structure import Interface, register_class_for_io if TYPE_CHECKING: # for type checking and preventing circular imports @@ -233,6 +233,14 @@ def _plausibility_checks(self, flow_system): 'Otherwise the investment cannot be 0 incertain scenarios while being non-zero in others.' ) + @property + def minimum_or_fixed_size(self) -> NonTemporalData: + return self.fixed_size if self.fixed_size is not None else self.minimum_size + + @property + def maximum_or_fixed_size(self) -> NonTemporalData: + return self.fixed_size if self.fixed_size is not None else self.maximum_size + @register_class_for_io class OnOffParameters(Interface): diff --git a/tests/conftest.py b/tests/conftest.py index ae2cb8c25..198b9a92b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -16,7 +16,7 @@ from flixopt.structure import SystemModel -@pytest.fixture( +@pytest.fixture() def highs_solver(): return fx.solvers.HighsSolver(mip_gap=0, time_limit_seconds=300) From 46f20358e61236317fe95631a95c20bfee0b1c86 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 28 Jun 2025 22:29:06 +0200 Subject: [PATCH 132/336] Move logic for InvestParameters in Transmission to from Model to Interface --- flixopt/components.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/flixopt/components.py b/flixopt/components.py index 5b902b65b..2f566963b 100644 --- a/flixopt/components.py +++ b/flixopt/components.py @@ -358,7 +358,17 @@ def _plausibility_checks(self): if flow is not None and isinstance(flow.size, InvestParameters): raise ValueError( 'Transmission currently does not support separate InvestParameters for Flows. ' - 'Please use Flow in1. The size of in2 is equal to in1. THis is handled internally' + 'Please use Flow in1. The size of in2 is equal to in1. This is handled internally' + ) + + # Make sure either None or both in Flows have InvestParameters + if self.in2 is not None: + if isinstance(self.in1.size, InvestParameters) and not isinstance( + self.in2.size, InvestParameters + ): + array_name = self.in1.size.maximum_size.name.replace(self.in1, self.in2) + self.in2.size = InvestParameters( + maximum_size=self.in1.size.maximum_size.rename(array_name) ) def create_model(self, model) -> 'TransmissionModel': @@ -390,13 +400,6 @@ def do_modeling(self): if flow.on_off_parameters is None: flow.on_off_parameters = OnOffParameters() - # Make sure either None or both in Flows have InvestParameters - if self.element.in2 is not None: - if isinstance(self.element.in1.size, InvestParameters) and not isinstance( - self.element.in2.size, InvestParameters - ): - self.element.in2.size = InvestParameters(maximum_size=self.element.in1.size.maximum_size) - super().do_modeling() # first direction From 6baeb8e5c0837bcea51add5914594a68347b3a98 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 28 Jun 2025 22:29:46 +0200 Subject: [PATCH 133/336] Make transformation of data more hierarchical (Flows after Components) --- flixopt/elements.py | 3 +++ flixopt/flow_system.py | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/flixopt/elements.py b/flixopt/elements.py index 721c8d50d..9df367eec 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -72,6 +72,9 @@ def transform_data(self, flow_system: 'FlowSystem') -> None: if self.on_off_parameters is not None: self.on_off_parameters.transform_data(flow_system, self.label_full) + for flow in self.inputs + self.outputs: + flow.transform_data(flow_system) + def _check_unique_flow_labels(self): all_flow_labels = [flow.label for flow in self.inputs + self.outputs] diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index 7849cdcea..200e8eb60 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -334,7 +334,7 @@ def connect_and_transform(self): ) if not self._connected_and_transformed: self._connect_network() - for element in self.all_elements.values(): + for element in list(self.components.values()) + list(self.effects.effects.values()) + list(self.buses.values()): element.transform_data(self) self._connected_and_transformed = True From aeaaa8356e60cabfccd06b27725c60660f6845e3 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 28 Jun 2025 22:47:23 +0200 Subject: [PATCH 134/336] Add scenario validation --- flixopt/flow_system.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index 200e8eb60..b513372bf 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -69,7 +69,7 @@ def __init__( self.hours_of_previous_timesteps = self._calculate_hours_of_previous_timesteps( timesteps, hours_of_previous_timesteps ) - self.scenarios = scenarios + self.scenarios = None if scenarios is None else self._validate_scenarios(scenarios) self.scenario_weights = scenario_weights # Element collections @@ -94,6 +94,22 @@ def _validate_timesteps(timesteps: pd.DatetimeIndex) -> pd.DatetimeIndex: raise ValueError('timesteps must be sorted') return timesteps + @staticmethod + def _validate_scenarios(scenarios: pd.Index) -> pd.Index: + """ + Validate and prepare scenario index. + + Args: + scenarios: The scenario index to validate + """ + if not isinstance(scenarios, pd.Index) or len(scenarios) == 0: + raise ConversionError('Scenarios must be a non-empty Index') + + if not scenarios.name == 'scenario': + raise ConversionError(f'Scenarios must be named "scenario", got "{scenarios.name}"') + + return scenarios + @staticmethod def _create_timesteps_with_extra( timesteps: pd.DatetimeIndex, hours_of_last_timestep: Optional[float] From 15fd1241f2dc0fb93880b077b5272ce4eb837571 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 28 Jun 2025 22:57:10 +0200 Subject: [PATCH 135/336] Change Transmission to have a "balanced" attribute. Change Tests accordingly --- flixopt/components.py | 32 +++---- tests/test_component.py | 175 +++++++++++++++++++++++++++++++++++++- tests/test_integration.py | 104 +--------------------- 3 files changed, 191 insertions(+), 120 deletions(-) diff --git a/flixopt/components.py b/flixopt/components.py index 2f566963b..cf05af0ed 100644 --- a/flixopt/components.py +++ b/flixopt/components.py @@ -305,6 +305,7 @@ def __init__( absolute_losses: Optional[TemporalDataUser] = None, on_off_parameters: OnOffParameters = None, prevent_simultaneous_flows_in_both_directions: bool = True, + balanced: bool = False, meta_data: Optional[Dict] = None, ): """ @@ -322,6 +323,7 @@ def __init__( absolute_losses: The absolute loss, occur only when the Flow is on. Induces the creation of the ON-Variable on_off_parameters: Parameters defining the on/off behavior of the component. prevent_simultaneous_flows_in_both_directions: If True, inflow and outflow are not allowed to be both non-zero at same timestep. + balanced: Wether to equate the size of the in1 and in2 Flow. Needs InvestParameters in both Flows. meta_data: used to store more information about the Element. Is not used internally, but saved in the results. Only use python native types. """ super().__init__( @@ -341,6 +343,7 @@ def __init__( self.relative_losses = relative_losses self.absolute_losses = absolute_losses + self.balanced = balanced def _plausibility_checks(self): super()._plausibility_checks() @@ -353,23 +356,20 @@ def _plausibility_checks(self): assert self.out2.bus == self.in1.bus, ( f'Input 1 and Output 2 do not start/end at the same Bus: {self.in1.bus=}, {self.out2.bus=}' ) - # Check Investments - for flow in [self.out1, self.in2, self.out2]: - if flow is not None and isinstance(flow.size, InvestParameters): - raise ValueError( - 'Transmission currently does not support separate InvestParameters for Flows. ' - 'Please use Flow in1. The size of in2 is equal to in1. This is handled internally' - ) - # Make sure either None or both in Flows have InvestParameters - if self.in2 is not None: - if isinstance(self.in1.size, InvestParameters) and not isinstance( - self.in2.size, InvestParameters + if self.balanced: + if self.in2 is None: + raise ValueError('Balanced Transmission needs InvestParameters in both in-Flows') + if not isinstance(self.in1.size, InvestParameters) or not isinstance(self.in2.size, InvestParameters): + raise ValueError('Balanced Transmission needs InvestParameters in both in-Flows') + if ( + (self.in1.size.minimum_or_fixed_size > self.in2.size.maximum_or_fixed_size).any() or + (self.in1.size.maximum_or_fixed_size < self.in2.size.minimum_or_fixed_size).any() ): - array_name = self.in1.size.maximum_size.name.replace(self.in1, self.in2) - self.in2.size = InvestParameters( - maximum_size=self.in1.size.maximum_size.rename(array_name) - ) + raise ValueError( + f'Balanced Transmission needs compatible minimum and maximum sizes.' + f'Got: {self.in1.size.minimum_size=}, {self.in1.size.maximum_size=}, {self.in1.size.fixed_size=} and ' + f'{self.in2.size.minimum_size=}, {self.in2.size.maximum_size=}, {self.in2.size.fixed_size=}.') def create_model(self, model) -> 'TransmissionModel': self._plausibility_checks() @@ -410,7 +410,7 @@ def do_modeling(self): self.create_transmission_equation('dir2', self.element.in2, self.element.out2) # equate size of both directions - if isinstance(self.element.in1.size, InvestParameters) and self.element.in2 is not None: + if self.element.balanced: # eq: in1.size = in2.size self.add( self._model.add_constraints( diff --git a/tests/test_component.py b/tests/test_component.py index 18ceb717a..8a99b5d5b 100644 --- a/tests/test_component.py +++ b/tests/test_component.py @@ -6,7 +6,7 @@ import flixopt as fx import flixopt.elements -from .conftest import assert_conequal, assert_var_equal, create_linopy_model +from .conftest import assert_conequal, assert_var_equal, create_linopy_model, create_calculation_and_solve, assert_almost_equal_numeric class TestComponentModel: @@ -182,3 +182,176 @@ def test_on_with_single_flow(self, basic_flow_system_linopy): model.variables['TestComponent|on'] * 100 >= model.variables['TestComponent(In1)|flow_rate'], ) + +class TestTransmissionModel: + def test_transmission_basic(self, basic_flow_system, highs_solver): + """Test basic transmission functionality""" + flow_system = basic_flow_system + flow_system.add_elements(fx.Bus('Wärme lokal')) + + boiler = fx.linear_converters.Boiler( + 'Boiler', eta=0.5, Q_th=fx.Flow('Q_th', bus='Wärme lokal'), Q_fu=fx.Flow('Q_fu', bus='Gas') + ) + + transmission = fx.Transmission( + 'Rohr', + relative_losses=0.2, + absolute_losses=20, + in1=fx.Flow('Rohr1', 'Wärme lokal', size=fx.InvestParameters(specific_effects=5, maximum_size=1e6)), + out1=fx.Flow('Rohr2', 'Fernwärme', size=1000), + ) + + flow_system.add_elements(transmission, boiler) + + _ = create_calculation_and_solve(flow_system, highs_solver, 'test_transmission_basic') + + # Assertions + assert_almost_equal_numeric( + transmission.in1.model.on_off.on.solution.values, + np.array([1, 1, 1, 1, 1, 1, 1, 1, 1, 1]), + 'On does not work properly', + ) + + assert_almost_equal_numeric( + transmission.in1.model.flow_rate.solution.values * 0.8 - 20, + transmission.out1.model.flow_rate.solution.values, + 'Losses are not computed correctly', + ) + + def test_transmission_balanced(self, basic_flow_system, highs_solver): + """Test advanced transmission functionality""" + flow_system = basic_flow_system + flow_system.add_elements(fx.Bus('Wärme lokal')) + + boiler = fx.linear_converters.Boiler( + 'Boiler_Standard', + eta=0.9, + Q_th=fx.Flow('Q_th', bus='Fernwärme', relative_maximum=np.array([0, 0, 0, 1, 1, 1, 1, 1, 1, 1])), + Q_fu=fx.Flow('Q_fu', bus='Gas'), + ) + + boiler2 = fx.linear_converters.Boiler( + 'Boiler_backup', eta=0.4, Q_th=fx.Flow('Q_th', bus='Wärme lokal'), Q_fu=fx.Flow('Q_fu', bus='Gas') + ) + + last2 = fx.Sink( + 'Wärmelast2', + sink=fx.Flow( + 'Q_th_Last', + bus='Wärme lokal', + size=1, + fixed_relative_profile=flow_system.components['Wärmelast'].sink.fixed_relative_profile + * np.array([0, 0, 0, 0, 0, 1, 1, 1, 1, 1]), + ), + ) + + transmission = fx.Transmission( + 'Rohr', + relative_losses=0.2, + absolute_losses=20, + in1=fx.Flow('Rohr1a', bus='Wärme lokal', size=fx.InvestParameters(specific_effects=5, maximum_size=1000)), + out1=fx.Flow('Rohr1b', 'Fernwärme', size=1000), + in2=fx.Flow('Rohr2a', 'Fernwärme', size=fx.InvestParameters()), + out2=fx.Flow('Rohr2b', bus='Wärme lokal', size=1000), + balanced=True, + ) + + flow_system.add_elements(transmission, boiler, boiler2, last2) + + calculation = create_calculation_and_solve(flow_system, highs_solver, 'test_transmission_advanced') + + # Assertions + assert_almost_equal_numeric( + transmission.in1.model.on_off.on.solution.values, + np.array([1, 1, 1, 0, 0, 0, 0, 0, 0, 0]), + 'On does not work properly', + ) + + assert_almost_equal_numeric( + calculation.results.model.variables['Rohr(Rohr1b)|flow_rate'].solution.values, + transmission.out1.model.flow_rate.solution.values, + 'Flow rate of Rohr__Rohr1b is not correct', + ) + + assert_almost_equal_numeric( + transmission.in1.model.flow_rate.solution.values * 0.8 + - np.array([20 if val > 0.1 else 0 for val in transmission.in1.model.flow_rate.solution.values]), + transmission.out1.model.flow_rate.solution.values, + 'Losses are not computed correctly', + ) + + assert_almost_equal_numeric( + transmission.in1.model._investment.size.solution.item(), + transmission.in2.model._investment.size.solution.item(), + 'The Investments are not equated correctly', + ) + + def test_transmission_unbalanced(self, basic_flow_system, highs_solver): + """Test advanced transmission functionality""" + flow_system = basic_flow_system + flow_system.add_elements(fx.Bus('Wärme lokal')) + + boiler = fx.linear_converters.Boiler( + 'Boiler_Standard', + eta=0.9, + Q_th=fx.Flow('Q_th', bus='Fernwärme', relative_maximum=np.array([0, 0, 0, 1, 1, 1, 1, 1, 1, 1])), + Q_fu=fx.Flow('Q_fu', bus='Gas'), + ) + + boiler2 = fx.linear_converters.Boiler( + 'Boiler_backup', eta=0.4, Q_th=fx.Flow('Q_th', bus='Wärme lokal'), Q_fu=fx.Flow('Q_fu', bus='Gas') + ) + + last2 = fx.Sink( + 'Wärmelast2', + sink=fx.Flow( + 'Q_th_Last', + bus='Wärme lokal', + size=1, + fixed_relative_profile=flow_system.components['Wärmelast'].sink.fixed_relative_profile + * np.array([0, 0, 0, 0, 0, 1, 1, 1, 1, 1]), + ), + ) + + transmission = fx.Transmission( + 'Rohr', + relative_losses=0.2, + absolute_losses=20, + in1=fx.Flow('Rohr1a', bus='Wärme lokal', size=fx.InvestParameters(specific_effects=50, maximum_size=1000)), + out1=fx.Flow('Rohr1b', 'Fernwärme', size=1000), + in2=fx.Flow('Rohr2a', 'Fernwärme', size=fx.InvestParameters(specific_effects=100, minimum_size=10, optional=False)), + out2=fx.Flow('Rohr2b', bus='Wärme lokal', size=1000), + balanced=False, + ) + + flow_system.add_elements(transmission, boiler, boiler2, last2) + + calculation = create_calculation_and_solve(flow_system, highs_solver, 'test_transmission_advanced') + + # Assertions + assert_almost_equal_numeric( + transmission.in1.model.on_off.on.solution.values, + np.array([1, 1, 1, 0, 0, 0, 0, 0, 0, 0]), + 'On does not work properly', + ) + + assert_almost_equal_numeric( + calculation.results.model.variables['Rohr(Rohr1b)|flow_rate'].solution.values, + transmission.out1.model.flow_rate.solution.values, + 'Flow rate of Rohr__Rohr1b is not correct', + ) + + assert_almost_equal_numeric( + transmission.in1.model.flow_rate.solution.values * 0.8 + - np.array([20 if val > 0.1 else 0 for val in transmission.in1.model.flow_rate.solution.values]), + transmission.out1.model.flow_rate.solution.values, + 'Losses are not computed correctly', + ) + + assert transmission.in1.model._investment.size.solution.item() > 11 + + assert_almost_equal_numeric( + transmission.in2.model._investment.size.solution.item(), + 10, + 'Sizing does not work properly', + ) diff --git a/tests/test_integration.py b/tests/test_integration.py index da473b4e6..e3d44d764 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -73,109 +73,6 @@ def test_results_persistence(self, simple_flow_system, highs_solver): assert_almost_equal_numeric(results.solution['CO2|total'].values, 255.09184, 'CO2 doesnt match expected value') -class TestComponents: - def test_transmission_basic(self, basic_flow_system, highs_solver): - """Test basic transmission functionality""" - flow_system = basic_flow_system - flow_system.add_elements(fx.Bus('Wärme lokal')) - - boiler = fx.linear_converters.Boiler( - 'Boiler', eta=0.5, Q_th=fx.Flow('Q_th', bus='Wärme lokal'), Q_fu=fx.Flow('Q_fu', bus='Gas') - ) - - transmission = fx.Transmission( - 'Rohr', - relative_losses=0.2, - absolute_losses=20, - in1=fx.Flow('Rohr1', 'Wärme lokal', size=fx.InvestParameters(specific_effects=5, maximum_size=1e6)), - out1=fx.Flow('Rohr2', 'Fernwärme', size=1000), - ) - - flow_system.add_elements(transmission, boiler) - - _ = create_calculation_and_solve(flow_system, highs_solver, 'test_transmission_basic') - - # Assertions - assert_almost_equal_numeric( - transmission.in1.model.on_off.on.solution.values, - np.array([1, 1, 1, 1, 1, 1, 1, 1, 1, 1]), - 'On does not work properly', - ) - - assert_almost_equal_numeric( - transmission.in1.model.flow_rate.solution.values * 0.8 - 20, - transmission.out1.model.flow_rate.solution.values, - 'Losses are not computed correctly', - ) - - def test_transmission_advanced(self, basic_flow_system, highs_solver): - """Test advanced transmission functionality""" - flow_system = basic_flow_system - flow_system.add_elements(fx.Bus('Wärme lokal')) - - boiler = fx.linear_converters.Boiler( - 'Boiler_Standard', - eta=0.9, - Q_th=fx.Flow('Q_th', bus='Fernwärme', relative_maximum=np.array([0, 0, 0, 1, 1, 1, 1, 1, 1, 1])), - Q_fu=fx.Flow('Q_fu', bus='Gas'), - ) - - boiler2 = fx.linear_converters.Boiler( - 'Boiler_backup', eta=0.4, Q_th=fx.Flow('Q_th', bus='Wärme lokal'), Q_fu=fx.Flow('Q_fu', bus='Gas') - ) - - last2 = fx.Sink( - 'Wärmelast2', - sink=fx.Flow( - 'Q_th_Last', - bus='Wärme lokal', - size=1, - fixed_relative_profile=flow_system.components['Wärmelast'].sink.fixed_relative_profile - * np.array([0, 0, 0, 0, 0, 1, 1, 1, 1, 1]), - ), - ) - - transmission = fx.Transmission( - 'Rohr', - relative_losses=0.2, - absolute_losses=20, - in1=fx.Flow('Rohr1a', bus='Wärme lokal', size=fx.InvestParameters(specific_effects=5, maximum_size=1000)), - out1=fx.Flow('Rohr1b', 'Fernwärme', size=1000), - in2=fx.Flow('Rohr2a', 'Fernwärme', size=1000), - out2=fx.Flow('Rohr2b', bus='Wärme lokal', size=1000), - ) - - flow_system.add_elements(transmission, boiler, boiler2, last2) - - calculation = create_calculation_and_solve(flow_system, highs_solver, 'test_transmission_advanced') - - # Assertions - assert_almost_equal_numeric( - transmission.in1.model.on_off.on.solution.values, - np.array([1, 1, 1, 0, 0, 0, 0, 0, 0, 0]), - 'On does not work properly', - ) - - assert_almost_equal_numeric( - calculation.results.model.variables['Rohr(Rohr1b)|flow_rate'].solution.values, - transmission.out1.model.flow_rate.solution.values, - 'Flow rate of Rohr__Rohr1b is not correct', - ) - - assert_almost_equal_numeric( - transmission.in1.model.flow_rate.solution.values * 0.8 - - np.array([20 if val > 0.1 else 0 for val in transmission.in1.model.flow_rate.solution.values]), - transmission.out1.model.flow_rate.solution.values, - 'Losses are not computed correctly', - ) - - assert_almost_equal_numeric( - transmission.in1.model._investment.size.solution.item(), - transmission.in2.model._investment.size.solution.item(), - 'The Investments are not equated correctly', - ) - - class TestComplex: def test_basic_flow_system(self, flow_system_base, highs_solver): calculation = create_calculation_and_solve(flow_system_base, highs_solver, 'test_basic_flow_system') @@ -354,6 +251,7 @@ def test_piecewise_conversion(self, flow_system_piecewise_conversion, highs_solv 'Speicher investCosts_segmented_costs doesnt match expected value', ) + @pytest.mark.slow class TestModelingTypes: @pytest.fixture(params=['full', 'segmented', 'aggregated']) From d0b231dfba896b9b57045ce0d4b3fec0fe596fac Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 28 Jun 2025 22:57:23 +0200 Subject: [PATCH 136/336] Improve index validations --- flixopt/core.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/flixopt/core.py b/flixopt/core.py index 648344098..3b320276d 100644 --- a/flixopt/core.py +++ b/flixopt/core.py @@ -246,7 +246,8 @@ def _validate_timesteps(timesteps: pd.DatetimeIndex) -> pd.DatetimeIndex: raise ConversionError('Timesteps must be a non-empty DatetimeIndex') if not timesteps.name == 'time': - raise ConversionError(f'Scenarios must be named "time", got "{timesteps.name}"') + logger.warning(f'Timesteps must be named "time", got "{timesteps.name}". Renaming to "time".') + timesteps = timesteps.rename('time') return timesteps @@ -262,7 +263,8 @@ def _validate_scenarios(scenarios: pd.Index) -> pd.Index: raise ConversionError('Scenarios must be a non-empty Index') if not scenarios.name == 'scenario': - raise ConversionError(f'Scenarios must be named "scenario", got "{scenarios.name}"') + logger.warning(f'Scenarios must be named "scenario", got "{scenarios.name}". Renaming to "scenario".') + scenarios = scenarios.rename('scenario') return scenarios From 4ebe6a5343f8a303a9286a31399e568b437239a2 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 29 Jun 2025 20:50:58 +0200 Subject: [PATCH 137/336] rename method in tests --- tests/test_dataconverter.py | 151 ++++++++++++++++++------------------ 1 file changed, 75 insertions(+), 76 deletions(-) diff --git a/tests/test_dataconverter.py b/tests/test_dataconverter.py index 0484d4aac..1ad41a0d2 100644 --- a/tests/test_dataconverter.py +++ b/tests/test_dataconverter.py @@ -6,7 +6,6 @@ from flixopt.core import ( # Adjust this import to match your project structure ConversionError, DataConverter, - TimeSeries, ) @@ -32,39 +31,39 @@ class TestSingleDimensionConversion: def test_scalar_conversion(self, sample_time_index): """Test converting a scalar value.""" # Test with integer - result = DataConverter.as_dataarray(42, sample_time_index) + result = DataConverter.to_dataarray(42, sample_time_index) assert isinstance(result, xr.DataArray) assert result.shape == (len(sample_time_index),) assert result.dims == ('time',) assert np.all(result.values == 42) # Test with float - result = DataConverter.as_dataarray(42.5, sample_time_index) + result = DataConverter.to_dataarray(42.5, sample_time_index) assert np.all(result.values == 42.5) # Test with numpy scalar types - result = DataConverter.as_dataarray(np.int64(42), sample_time_index) + result = DataConverter.to_dataarray(np.int64(42), sample_time_index) assert np.all(result.values == 42) - result = DataConverter.as_dataarray(np.float32(42.5), sample_time_index) + result = DataConverter.to_dataarray(np.float32(42.5), sample_time_index) assert np.all(result.values == 42.5) def test_ndarray_conversion(self, sample_time_index): """Test converting a numpy ndarray.""" # Test with integer 1D array arr_1d = np.array([1, 2, 3, 4, 5]) - result = DataConverter.as_dataarray(arr_1d, sample_time_index) + result = DataConverter.to_dataarray(arr_1d, sample_time_index) assert result.shape == (5,) assert result.dims == ('time',) assert np.array_equal(result.values, arr_1d) # Test with float 1D array arr_1d = np.array([1.1, 2.2, 3.3, 4.4, 5.5]) - result = DataConverter.as_dataarray(arr_1d, sample_time_index) + result = DataConverter.to_dataarray(arr_1d, sample_time_index) assert np.array_equal(result.values, arr_1d) # Test with array containing NaN arr_1d = np.array([1, np.nan, 3, np.nan, 5]) - result = DataConverter.as_dataarray(arr_1d, sample_time_index) + result = DataConverter.to_dataarray(arr_1d, sample_time_index) assert np.array_equal(np.isnan(result.values), np.isnan(arr_1d)) assert np.array_equal(result.values[~np.isnan(result.values)], arr_1d[~np.isnan(arr_1d)]) @@ -74,7 +73,7 @@ def test_dataarray_conversion(self, sample_time_index): original = xr.DataArray(data=np.array([1, 2, 3, 4, 5]), coords={'time': sample_time_index}, dims=['time']) # Convert and check - result = DataConverter.as_dataarray(original, sample_time_index) + result = DataConverter.to_dataarray(original, sample_time_index) assert result.shape == (5,) assert result.dims == ('time',) assert np.array_equal(result.values, original.values) @@ -89,7 +88,7 @@ def test_dataarray_conversion(self, sample_time_index): # Should raise an error for mismatched time coordinates with pytest.raises(ConversionError): - DataConverter.as_dataarray(original, sample_time_index) + DataConverter.to_dataarray(original, sample_time_index) class TestMultiDimensionConversion: @@ -98,7 +97,7 @@ class TestMultiDimensionConversion: def test_scalar_with_scenarios(self, sample_time_index, sample_scenario_index): """Test converting scalar values with scenario dimension.""" # Test with integer - result = DataConverter.as_dataarray(42, sample_time_index, sample_scenario_index) + result = DataConverter.to_dataarray(42, sample_time_index, sample_scenario_index) assert isinstance(result, xr.DataArray) assert result.shape == (len(sample_time_index), len(sample_scenario_index)) @@ -108,7 +107,7 @@ def test_scalar_with_scenarios(self, sample_time_index, sample_scenario_index): assert set(result.coords['time'].values) == set(sample_time_index.values) # Test with float - result = DataConverter.as_dataarray(42.5, sample_time_index, sample_scenario_index) + result = DataConverter.to_dataarray(42.5, sample_time_index, sample_scenario_index) assert np.all(result.values == 42.5) def test_1d_array_with_scenarios(self, sample_time_index, sample_scenario_index): @@ -117,7 +116,7 @@ def test_1d_array_with_scenarios(self, sample_time_index, sample_scenario_index) arr_1d = np.array([1, 2, 3, 4, 5]) # Convert with scenarios - result = DataConverter.as_dataarray(arr_1d, sample_time_index, sample_scenario_index) + result = DataConverter.to_dataarray(arr_1d, sample_time_index, sample_scenario_index) assert result.shape == (len(sample_time_index), len(sample_scenario_index)) assert result.dims == ('time', 'scenario') @@ -139,7 +138,7 @@ def test_2d_array_with_scenarios(self, sample_time_index, sample_scenario_index) ) # Convert to DataArray - result = DataConverter.as_dataarray(arr_2d.T, sample_time_index, sample_scenario_index) + result = DataConverter.to_dataarray(arr_2d.T, sample_time_index, sample_scenario_index) assert result.shape == (5, 3) assert result.dims == ('time', 'scenario') @@ -159,7 +158,7 @@ def test_dataarray_with_scenarios(self, sample_time_index, sample_scenario_index ) # Test conversion - result = DataConverter.as_dataarray(original, sample_time_index, sample_scenario_index) + result = DataConverter.to_dataarray(original, sample_time_index, sample_scenario_index) assert result.shape == (5, 3) assert result.dims == ('time', 'scenario') @@ -179,7 +178,7 @@ def test_series_single_dimension(self, sample_time_index): series = pd.Series([10, 20, 30, 40, 50], index=sample_time_index) # Convert and check - result = DataConverter.as_dataarray(series, sample_time_index) + result = DataConverter.to_dataarray(series, sample_time_index) assert isinstance(result, xr.DataArray) assert result.shape == (5,) assert result.dims == ('time',) @@ -190,7 +189,7 @@ def test_series_single_dimension(self, sample_time_index): scenario_index = pd.Index(['baseline', 'high_demand', 'low_price'], name='scenario') series = pd.Series([100, 200, 300], index=scenario_index) - result = DataConverter.as_dataarray(series, scenarios=scenario_index) + result = DataConverter.to_dataarray(series, scenarios=scenario_index) assert result.shape == (3,) assert result.dims == ('scenario',) assert np.array_equal(result.values, series.values) @@ -204,7 +203,7 @@ def test_series_mismatched_index(self, sample_time_index): # Should raise error for mismatched index with pytest.raises(ConversionError): - DataConverter.as_dataarray(series, sample_time_index) + DataConverter.to_dataarray(series, sample_time_index) def test_series_broadcast_to_scenarios(self, sample_time_index, sample_scenario_index): """Test broadcasting a time-indexed Series across scenarios.""" @@ -212,7 +211,7 @@ def test_series_broadcast_to_scenarios(self, sample_time_index, sample_scenario_ series = pd.Series([10, 20, 30, 40, 50], index=sample_time_index) # Convert with scenarios - result = DataConverter.as_dataarray(series, sample_time_index, sample_scenario_index) + result = DataConverter.to_dataarray(series, sample_time_index, sample_scenario_index) assert result.shape == (5, 3) assert result.dims == ('time', 'scenario') @@ -228,7 +227,7 @@ def test_series_broadcast_to_time(self, sample_time_index, sample_scenario_index series = pd.Series([100, 200, 300], index=sample_scenario_index) # Convert with time - result = DataConverter.as_dataarray(series, sample_time_index, sample_scenario_index) + result = DataConverter.to_dataarray(series, sample_time_index, sample_scenario_index) assert result.shape == (5, 3) assert result.dims == ('time', 'scenario') @@ -264,7 +263,7 @@ def test_dataframe_single_column(self, sample_time_index): df = pd.DataFrame({'value': [10, 20, 30, 40, 50]}, index=sample_time_index) # Convert and check - result = DataConverter.as_dataarray(df, sample_time_index) + result = DataConverter.to_dataarray(df, sample_time_index) assert isinstance(result, xr.DataArray) assert result.shape == (5,) assert result.dims == ('time',) @@ -277,7 +276,7 @@ def test_dataframe_multi_column_fails(self, sample_time_index): # Should raise error with pytest.raises(ConversionError): - DataConverter.as_dataarray(df, sample_time_index) + DataConverter.to_dataarray(df, sample_time_index) def test_dataframe_time_scenario(self, sample_time_index, sample_scenario_index): """Test converting a DataFrame with time index and scenario columns.""" @@ -289,7 +288,7 @@ def test_dataframe_time_scenario(self, sample_time_index, sample_scenario_index) df.columns.name = 'scenario' # Convert and check - result = DataConverter.as_dataarray(df, sample_time_index, sample_scenario_index) + result = DataConverter.to_dataarray(df, sample_time_index, sample_scenario_index) assert result.shape == (5, 3) assert result.dims == ('time', 'scenario') @@ -309,7 +308,7 @@ def test_dataframe_mismatched_coordinates(self, sample_time_index, sample_scenar # Should raise error with pytest.raises(ConversionError): - DataConverter.as_dataarray(df, sample_time_index, sample_scenario_index) + DataConverter.to_dataarray(df, sample_time_index, sample_scenario_index) # Create DataFrame with different scenario columns different_scenarios = pd.Index(['scenario1', 'scenario2', 'scenario3'], name='scenario') @@ -319,7 +318,7 @@ def test_dataframe_mismatched_coordinates(self, sample_time_index, sample_scenar # Should raise error with pytest.raises(ConversionError): - DataConverter.as_dataarray(df, sample_time_index, sample_scenario_index) + DataConverter.to_dataarray(df, sample_time_index, sample_scenario_index) def test_ensure_copy(self, sample_time_index, sample_scenario_index): """Test that the returned DataArray is a copy.""" @@ -329,7 +328,7 @@ def test_ensure_copy(self, sample_time_index, sample_scenario_index): df.columns = sample_scenario_index # Convert - result = DataConverter.as_dataarray(df, sample_time_index, sample_scenario_index) + result = DataConverter.to_dataarray(df, sample_time_index, sample_scenario_index) # Modify the result result.loc[dict(time=sample_time_index[0], scenario='baseline')] = 999 @@ -346,51 +345,51 @@ def test_time_index_validation(self): # Test with unnamed index unnamed_index = pd.date_range('2024-01-01', periods=5, freq='D') with pytest.raises(ConversionError): - DataConverter.as_dataarray(42, unnamed_index) + DataConverter.to_dataarray(42, unnamed_index) # Test with empty index empty_index = pd.DatetimeIndex([], name='time') with pytest.raises(ConversionError): - DataConverter.as_dataarray(42, empty_index) + DataConverter.to_dataarray(42, empty_index) # Test with non-DatetimeIndex wrong_type_index = pd.Index([1, 2, 3, 4, 5], name='time') with pytest.raises(ConversionError): - DataConverter.as_dataarray(42, wrong_type_index) + DataConverter.to_dataarray(42, wrong_type_index) def test_scenario_index_validation(self, sample_time_index): """Test validation of scenario index.""" # Test with unnamed scenario index unnamed_index = pd.Index(['baseline', 'high_demand']) with pytest.raises(ConversionError): - DataConverter.as_dataarray(42, sample_time_index, unnamed_index) + DataConverter.to_dataarray(42, sample_time_index, unnamed_index) # Test with empty scenario index empty_index = pd.Index([], name='scenario') with pytest.raises(ConversionError): - DataConverter.as_dataarray(42, sample_time_index, empty_index) + DataConverter.to_dataarray(42, sample_time_index, empty_index) # Test with non-Index scenario with pytest.raises(ConversionError): - DataConverter.as_dataarray(42, sample_time_index, ['baseline', 'high_demand']) + DataConverter.to_dataarray(42, sample_time_index, ['baseline', 'high_demand']) def test_invalid_data_types(self, sample_time_index, sample_scenario_index): """Test handling of invalid data types.""" # Test invalid input type (string) with pytest.raises(ConversionError): - DataConverter.as_dataarray('invalid_string', sample_time_index) + DataConverter.to_dataarray('invalid_string', sample_time_index) # Test invalid input type with scenarios with pytest.raises(ConversionError): - DataConverter.as_dataarray('invalid_string', sample_time_index, sample_scenario_index) + DataConverter.to_dataarray('invalid_string', sample_time_index, sample_scenario_index) # Test unsupported complex object with pytest.raises(ConversionError): - DataConverter.as_dataarray(object(), sample_time_index) + DataConverter.to_dataarray(object(), sample_time_index) # Test None value with pytest.raises(ConversionError): - DataConverter.as_dataarray(None, sample_time_index) + DataConverter.to_dataarray(None, sample_time_index) def test_mismatched_input_dimensions(self, sample_time_index, sample_scenario_index): """Test handling of mismatched input dimensions.""" @@ -399,16 +398,16 @@ def test_mismatched_input_dimensions(self, sample_time_index, sample_scenario_in [1, 2, 3, 4, 5, 6], index=pd.date_range('2025-01-01', periods=6, freq='D', name='time') ) with pytest.raises(ConversionError): - DataConverter.as_dataarray(mismatched_series, sample_time_index) + DataConverter.to_dataarray(mismatched_series, sample_time_index) # Test DataFrame with multiple columns df_multi_col = pd.DataFrame({'A': [1, 2, 3, 4, 5], 'B': [6, 7, 8, 9, 10]}, index=sample_time_index) with pytest.raises(ConversionError): - DataConverter.as_dataarray(df_multi_col, sample_time_index) + DataConverter.to_dataarray(df_multi_col, sample_time_index) # Test mismatched array shape for time-only with pytest.raises(ConversionError): - DataConverter.as_dataarray(np.array([1, 2, 3]), sample_time_index) # Wrong length + DataConverter.to_dataarray(np.array([1, 2, 3]), sample_time_index) # Wrong length # Test mismatched array shape for scenario × time # Array shape should be (n_scenarios, n_timesteps) @@ -420,24 +419,24 @@ def test_mismatched_input_dimensions(self, sample_time_index, sample_scenario_in ] ) with pytest.raises(ConversionError): - DataConverter.as_dataarray(wrong_shape_array, sample_time_index, sample_scenario_index) + DataConverter.to_dataarray(wrong_shape_array, sample_time_index, sample_scenario_index) # Test array with too many dimensions with pytest.raises(ConversionError): # 3D array not allowed - DataConverter.as_dataarray(np.ones((3, 5, 2)), sample_time_index, sample_scenario_index) + DataConverter.to_dataarray(np.ones((3, 5, 2)), sample_time_index, sample_scenario_index) def test_dataarray_dimension_mismatch(self, sample_time_index, sample_scenario_index): """Test handling of mismatched DataArray dimensions.""" # Create DataArray with wrong dimensions wrong_dims = xr.DataArray(data=np.array([1, 2, 3, 4, 5]), coords={'wrong_dim': range(5)}, dims=['wrong_dim']) with pytest.raises(ConversionError): - DataConverter.as_dataarray(wrong_dims, sample_time_index) + DataConverter.to_dataarray(wrong_dims, sample_time_index) # Create DataArray with scenario but no time wrong_dims_2 = xr.DataArray(data=np.array([1, 2, 3]), coords={'scenario': ['a', 'b', 'c']}, dims=['scenario']) with pytest.raises(ConversionError): - DataConverter.as_dataarray(wrong_dims_2, sample_time_index, sample_scenario_index) + DataConverter.to_dataarray(wrong_dims_2, sample_time_index, sample_scenario_index) # Create DataArray with right dims but wrong length wrong_length = xr.DataArray( @@ -449,7 +448,7 @@ def test_dataarray_dimension_mismatch(self, sample_time_index, sample_scenario_i dims=['scenario', 'time'], ) with pytest.raises(ConversionError): - DataConverter.as_dataarray(wrong_length, sample_time_index, sample_scenario_index) + DataConverter.to_dataarray(wrong_length, sample_time_index, sample_scenario_index) class TestDataArrayBroadcasting: """Tests for broadcasting DataArrays.""" @@ -458,7 +457,7 @@ def test_broadcast_1d_array_to_2d(self, sample_time_index, sample_scenario_index arr_1d = np.array([1, 2, 3, 4, 5]) xr.testing.assert_equal( - DataConverter.as_dataarray(arr_1d, sample_time_index, sample_scenario_index), + DataConverter.to_dataarray(arr_1d, sample_time_index, sample_scenario_index), xr.DataArray( np.array([arr_1d] * 3).T, coords=(sample_time_index, sample_scenario_index) @@ -467,7 +466,7 @@ def test_broadcast_1d_array_to_2d(self, sample_time_index, sample_scenario_index arr_1d = np.array([1, 2, 3]) xr.testing.assert_equal( - DataConverter.as_dataarray(arr_1d, sample_time_index, sample_scenario_index), + DataConverter.to_dataarray(arr_1d, sample_time_index, sample_scenario_index), xr.DataArray( np.array([arr_1d] * 5), coords=(sample_time_index, sample_scenario_index) @@ -479,7 +478,7 @@ def test_broadcast_1d_array_to_1d(self, sample_time_index,): arr_1d = np.array([1, 2, 3, 4, 5]) xr.testing.assert_equal( - DataConverter.as_dataarray(arr_1d, sample_time_index), + DataConverter.to_dataarray(arr_1d, sample_time_index), xr.DataArray( arr_1d, coords=(sample_time_index,) @@ -488,7 +487,7 @@ def test_broadcast_1d_array_to_1d(self, sample_time_index,): arr_1d = np.array([1, 2, 3]) with pytest.raises(ConversionError): - DataConverter.as_dataarray(arr_1d, sample_time_index) + DataConverter.to_dataarray(arr_1d, sample_time_index) class TestEdgeCases: @@ -500,12 +499,12 @@ def test_single_timestep(self, sample_scenario_index): single_timestep = pd.DatetimeIndex(['2024-01-01'], name='time') # Scalar conversion - result = DataConverter.as_dataarray(42, single_timestep) + result = DataConverter.to_dataarray(42, single_timestep) assert result.shape == (1,) assert result.dims == ('time',) # With scenarios - result_with_scenarios = DataConverter.as_dataarray(42, single_timestep, sample_scenario_index) + result_with_scenarios = DataConverter.to_dataarray(42, single_timestep, sample_scenario_index) assert result_with_scenarios.shape == (1, len(sample_scenario_index)) assert result_with_scenarios.dims == ('time', 'scenario') @@ -515,19 +514,19 @@ def test_single_scenario(self, sample_time_index): single_scenario = pd.Index(['baseline'], name='scenario') # Scalar conversion with single scenario - result = DataConverter.as_dataarray(42, sample_time_index, single_scenario) + result = DataConverter.to_dataarray(42, sample_time_index, single_scenario) assert result.shape == (len(sample_time_index), 1) assert result.dims == ('time', 'scenario') # Array conversion with single scenario arr = np.array([1, 2, 3, 4, 5]) - result_arr = DataConverter.as_dataarray(arr, sample_time_index, single_scenario) + result_arr = DataConverter.to_dataarray(arr, sample_time_index, single_scenario) assert result_arr.shape == (5, 1) assert np.array_equal(result_arr.sel(scenario='baseline').values, arr) # 2D array with single scenario arr_2d = np.array([[1, 2, 3, 4, 5]]) # Note the extra dimension - result_arr_2d = DataConverter.as_dataarray(arr_2d.T, sample_time_index, single_scenario) + result_arr_2d = DataConverter.to_dataarray(arr_2d.T, sample_time_index, single_scenario) assert result_arr_2d.shape == (5, 1) assert np.array_equal(result_arr_2d.sel(scenario='baseline').values, arr_2d[0]) @@ -546,12 +545,12 @@ def test_different_scenario_order(self, sample_time_index): ] ).T - result1 = DataConverter.as_dataarray(data, sample_time_index, scenarios1) + result1 = DataConverter.to_dataarray(data, sample_time_index, scenarios1) assert np.array_equal(result1.sel(scenario='a').values, [1, 2, 3, 4, 5]) assert np.array_equal(result1.sel(scenario='c').values, [11, 12, 13, 14, 15]) # Create DataArray with second order - result2 = DataConverter.as_dataarray(data, sample_time_index, scenarios2) + result2 = DataConverter.to_dataarray(data, sample_time_index, scenarios2) # First row should match 'c' now assert np.array_equal(result2.sel(scenario='c').values, [1, 2, 3, 4, 5]) # Last row should match 'a' now @@ -561,16 +560,16 @@ def test_all_nan_data(self, sample_time_index, sample_scenario_index): """Test handling of all-NaN data.""" # Create array of all NaNs all_nan_array = np.full(5, np.nan) - result = DataConverter.as_dataarray(all_nan_array, sample_time_index) + result = DataConverter.to_dataarray(all_nan_array, sample_time_index) assert np.all(np.isnan(result.values)) # With scenarios - result = DataConverter.as_dataarray(all_nan_array, sample_time_index, sample_scenario_index) + result = DataConverter.to_dataarray(all_nan_array, sample_time_index, sample_scenario_index) assert result.shape == (len(sample_time_index), len(sample_scenario_index)) assert np.all(np.isnan(result.values)) # Series of all NaNs - result = DataConverter.as_dataarray( + result = DataConverter.to_dataarray( np.array([np.nan, np.nan, np.nan, np.nan, np.nan]), sample_time_index, sample_scenario_index ) assert np.all(np.isnan(result.values)) @@ -579,14 +578,14 @@ def test_mixed_data_types(self, sample_time_index, sample_scenario_index): """Test conversion of mixed integer and float data.""" # Create array with mixed types mixed_array = np.array([1, 2.5, 3, 4.5, 5]) - result = DataConverter.as_dataarray(mixed_array, sample_time_index) + result = DataConverter.to_dataarray(mixed_array, sample_time_index) # Result should be float dtype assert np.issubdtype(result.dtype, np.floating) assert np.array_equal(result.values, mixed_array) # With scenarios - result = DataConverter.as_dataarray(mixed_array, sample_time_index, sample_scenario_index) + result = DataConverter.to_dataarray(mixed_array, sample_time_index, sample_scenario_index) assert np.issubdtype(result.dtype, np.floating) for scenario in sample_scenario_index: assert np.array_equal(result.sel(scenario=scenario).values, mixed_array) @@ -609,7 +608,7 @@ def test_large_dataset(self, sample_scenario_index): large_data = np.random.rand(len(sample_scenario_index), len(large_timesteps)) # Convert and check - result = DataConverter.as_dataarray(large_data.T, large_timesteps, sample_scenario_index) + result = DataConverter.to_dataarray(large_data.T, large_timesteps, sample_scenario_index) assert result.shape == (len(large_timesteps), len(sample_scenario_index)) assert result.dims == ('time', 'scenario') @@ -622,7 +621,7 @@ class TestMultiScenarioArrayConversion: def test_1d_array_broadcasting(self, sample_time_index, sample_scenario_index): """Test that 1D arrays are properly broadcast to all scenarios.""" arr_1d = np.array([1, 2, 3, 4, 5]) - result = DataConverter.as_dataarray(arr_1d, sample_time_index, sample_scenario_index) + result = DataConverter.to_dataarray(arr_1d, sample_time_index, sample_scenario_index) assert result.shape == (len(sample_time_index), len(sample_scenario_index)) @@ -643,14 +642,14 @@ def test_2d_array_different_shapes(self, sample_time_index): single_scenario = pd.Index(['baseline'], name='scenario') arr_1_scenario = np.array([[1, 2, 3, 4, 5]]) - result = DataConverter.as_dataarray(arr_1_scenario.T, sample_time_index, single_scenario) + result = DataConverter.to_dataarray(arr_1_scenario.T, sample_time_index, single_scenario) assert result.shape == (len(sample_time_index), 1) # Test with 2 scenarios two_scenarios = pd.Index(['baseline', 'high_demand'], name='scenario') arr_2_scenarios = np.array([[1, 2, 3, 4, 5], [6, 7, 8, 9, 10]]) - result = DataConverter.as_dataarray(arr_2_scenarios.T, sample_time_index, two_scenarios) + result = DataConverter.to_dataarray(arr_2_scenarios.T, sample_time_index, two_scenarios) assert result.shape == (len(sample_time_index), 2) assert np.array_equal(result.sel(scenario='baseline').values, arr_2_scenarios[0]) assert np.array_equal(result.sel(scenario='high_demand').values, arr_2_scenarios[1]) @@ -658,19 +657,19 @@ def test_2d_array_different_shapes(self, sample_time_index): # Test mismatched scenarios count three_scenarios = pd.Index(['a', 'b', 'c'], name='scenario') with pytest.raises(ConversionError): - DataConverter.as_dataarray(arr_2_scenarios, sample_time_index, three_scenarios) + DataConverter.to_dataarray(arr_2_scenarios, sample_time_index, three_scenarios) def test_array_handling_edge_cases(self, sample_time_index, sample_scenario_index): """Test array edge cases.""" # Test with boolean array bool_array = np.array([True, False, True, False, True]) - result = DataConverter.as_dataarray(bool_array, sample_time_index, sample_scenario_index) + result = DataConverter.to_dataarray(bool_array, sample_time_index, sample_scenario_index) assert result.dtype == bool assert result.shape == (len(sample_time_index), len(sample_scenario_index)) # Test with array containing infinite values inf_array = np.array([1, np.inf, 3, -np.inf, 5]) - result = DataConverter.as_dataarray(inf_array, sample_time_index, sample_scenario_index) + result = DataConverter.to_dataarray(inf_array, sample_time_index, sample_scenario_index) for scenario in sample_scenario_index: scenario_data = result.sel(scenario=scenario) assert np.isinf(scenario_data[1].item()) @@ -696,7 +695,7 @@ def test_preserving_scenario_order(self, sample_time_index): ) # Convert to DataArray - result = DataConverter.as_dataarray(data.T, sample_time_index, scenarios) + result = DataConverter.to_dataarray(data.T, sample_time_index, scenarios) # Verify order of scenarios is preserved assert list(result.coords['scenario'].values) == list(scenarios) @@ -714,42 +713,42 @@ def test_preserving_scenario_order(self, sample_time_index): def test_invalid_inputs(sample_time_index): # Test invalid input type with pytest.raises(ConversionError): - DataConverter.as_dataarray('invalid_string', sample_time_index) + DataConverter.to_dataarray('invalid_string', sample_time_index) # Test mismatched Series index mismatched_series = pd.Series([1, 2, 3, 4, 5, 6], index=pd.date_range('2025-01-01', periods=6, freq='D')) with pytest.raises(ConversionError): - DataConverter.as_dataarray(mismatched_series, sample_time_index) + DataConverter.to_dataarray(mismatched_series, sample_time_index) # Test DataFrame with multiple columns df_multi_col = pd.DataFrame({'A': [1, 2, 3, 4, 5], 'B': [6, 7, 8, 9, 10]}, index=sample_time_index) with pytest.raises(ConversionError): - DataConverter.as_dataarray(df_multi_col, sample_time_index) + DataConverter.to_dataarray(df_multi_col, sample_time_index) # Test mismatched array shape with pytest.raises(ConversionError): - DataConverter.as_dataarray(np.array([1, 2, 3]), sample_time_index) # Wrong length + DataConverter.to_dataarray(np.array([1, 2, 3]), sample_time_index) # Wrong length # Test multi-dimensional array with pytest.raises(ConversionError): - DataConverter.as_dataarray(np.array([[1, 2], [3, 4]]), sample_time_index) # 2D array not allowed + DataConverter.to_dataarray(np.array([[1, 2], [3, 4]]), sample_time_index) # 2D array not allowed def test_time_index_validation(): # Test with unnamed index unnamed_index = pd.date_range('2024-01-01', periods=5, freq='D') with pytest.raises(ConversionError): - DataConverter.as_dataarray(42, unnamed_index) + DataConverter.to_dataarray(42, unnamed_index) # Test with empty index empty_index = pd.DatetimeIndex([], name='time') with pytest.raises(ConversionError): - DataConverter.as_dataarray(42, empty_index) + DataConverter.to_dataarray(42, empty_index) # Test with non-DatetimeIndex wrong_type_index = pd.Index([1, 2, 3, 4, 5], name='time') with pytest.raises(ConversionError): - DataConverter.as_dataarray(42, wrong_type_index) + DataConverter.to_dataarray(42, wrong_type_index) if __name__ == '__main__': From 6b56dac3818771df98e4ace468a79a258d509e71 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 29 Jun 2025 20:52:42 +0200 Subject: [PATCH 138/336] Update DataConverter --- flixopt/core.py | 516 ++++++++--------------------------- tests/test_dataconverter.py | 522 ++++++++++-------------------------- 2 files changed, 255 insertions(+), 783 deletions(-) diff --git a/flixopt/core.py b/flixopt/core.py index 3b320276d..9ab71cab9 100644 --- a/flixopt/core.py +++ b/flixopt/core.py @@ -126,178 +126,77 @@ def agg_weight(self): class DataConverter: """ - Converts various data types into xarray.DataArray with optional time and scenario dimension. + Converts scalars and 1D data into xarray.DataArray with optional time and scenario dimensions. - Supports: scalars, arrays, Series, DataFrames, DataArrays, and TimeSeriesData. + Only handles: + - Scalars (int, float, np.number) + - 1D arrays (np.ndarray, pd.Series) + - xr.DataArray (for broadcasting/checking) """ - @staticmethod - def _fix_timeseries_data_indexing( - data: TimeSeriesData, timesteps: pd.DatetimeIndex, dims: list, coords: list - ) -> TimeSeriesData: - """ - Fix TimeSeriesData indexing issues and return properly indexed TimeSeriesData. - - Args: - data: TimeSeriesData that might have indexing issues - timesteps: Target timesteps - dims: Expected dimensions - coords: Expected coordinates - - Returns: - TimeSeriesData with correct indexing - - Raises: - ConversionError: If data cannot be fixed to match expected indexing - """ - expected_shape = (len(timesteps),) - - # Check if dimensions match - if data.dims != tuple(dims): - logger.warning( - f'TimeSeriesData has dimensions {data.dims}, expected {dims}. Reshaping to match timesteps. To avoid ' - f'this warning, create a correctly shaped DataArray with the correct dimensions in the first place.' - ) - # Try to reshape the data to match expected dimensions - if data.size != len(timesteps): - raise ConversionError( - f'TimeSeriesData has {data.size} elements, cannot reshape to match {len(timesteps)} timesteps' - ) - # Create new DataArray with correct coordinates, preserving metadata - reshaped_data = xr.DataArray( - data.values.reshape(expected_shape), coords=coords, dims=dims, name=data.name, attrs=data.attrs.copy() - ) - return TimeSeriesData(reshaped_data) - - # Check if time coordinate length matches - elif data.sizes[dims[0]] != len(coords[0]): - logger.warning( - f'TimeSeriesData has {data.sizes[dims[0]]} time points, ' - f"expected {len(coords[0])}. Cannot reindex - lengths don't match." - ) - raise ConversionError( - f"TimeSeriesData length {data.sizes[dims[0]]} doesn't match expected {len(coords[0])}" - ) - - # Check if time coordinates are identical - elif not data.coords['time'].equals(timesteps): - logger.warning( - 'TimeSeriesData has different time coordinates than expected. Replacing with provided timesteps.' - ) - # Replace time coordinates while preserving data and metadata - recoordinated_data = xr.DataArray( - data.values, coords=coords, dims=dims, name=data.name, attrs=data.attrs.copy() - ) - return TimeSeriesData(recoordinated_data) - - else: - # Everything matches - return copy to avoid modifying original - return data.copy(deep=True) - @staticmethod def to_dataarray( - data: Union[TemporalData, NonTemporalData], timesteps: Optional[pd.DatetimeIndex] = None, scenarios: Optional[pd.Index] = None + data: Union[Scalar, np.ndarray, pd.Series, xr.DataArray, TimeSeriesData], + timesteps: Optional[pd.DatetimeIndex] = None, + scenarios: Optional[pd.Index] = None, ) -> xr.DataArray: """ Convert data to xarray.DataArray with specified dimensions. Args: - data: The data to convert (scalar, array, or DataArray) + data: Scalar, 1D array/Series, or existing DataArray timesteps: Optional DatetimeIndex for time dimension scenarios: Optional Index for scenario dimension Returns: DataArray with the converted data """ - # Prepare dimensions and coordinates coords, dims = DataConverter._prepare_dimensions(timesteps, scenarios) - # Select appropriate converter based on data type + # Handle scalars if isinstance(data, (int, float, np.integer, np.floating)): return DataConverter._convert_scalar(data, coords, dims) - elif isinstance(data, xr.DataArray): - return DataConverter._convert_dataarray(data, coords, dims) - + # Handle 1D numpy arrays elif isinstance(data, np.ndarray): - return DataConverter._convert_ndarray(data, coords, dims) + if data.ndim != 1: + raise ConversionError(f'Only 1D arrays supported, got {data.ndim}D array') + return DataConverter._convert_1d_array(data, coords, dims) + # Handle pandas Series elif isinstance(data, pd.Series): return DataConverter._convert_series(data, coords, dims) - elif isinstance(data, pd.DataFrame): - return DataConverter._convert_dataframe(data, coords, dims) + # Handle existing DataArrays (including TimeSeriesData) + elif isinstance(data, xr.DataArray): + return DataConverter._handle_dataarray(data, coords, dims) else: - raise ConversionError(f'Unsupported data type: {type(data).__name__}') - - @staticmethod - def _validate_timesteps(timesteps: pd.DatetimeIndex) -> pd.DatetimeIndex: - """ - Validate and prepare time index. - - Args: - timesteps: The time index to validate - - Returns: - Validated time index - """ - if not isinstance(timesteps, pd.DatetimeIndex) or len(timesteps) == 0: - raise ConversionError('Timesteps must be a non-empty DatetimeIndex') - - if not timesteps.name == 'time': - logger.warning(f'Timesteps must be named "time", got "{timesteps.name}". Renaming to "time".') - timesteps = timesteps.rename('time') - - return timesteps - - @staticmethod - def _validate_scenarios(scenarios: pd.Index) -> pd.Index: - """ - Validate and prepare scenario index. - - Args: - scenarios: The scenario index to validate - """ - if not isinstance(scenarios, pd.Index) or len(scenarios) == 0: - raise ConversionError('Scenarios must be a non-empty Index') - - if not scenarios.name == 'scenario': - logger.warning(f'Scenarios must be named "scenario", got "{scenarios.name}". Renaming to "scenario".') - scenarios = scenarios.rename('scenario') - - return scenarios + raise ConversionError( + f'Unsupported data type: {type(data).__name__}. Only scalars, 1D arrays, Series, and DataArrays are supported.' + ) @staticmethod def _prepare_dimensions( timesteps: Optional[pd.DatetimeIndex], scenarios: Optional[pd.Index] ) -> Tuple[Dict[str, pd.Index], Tuple[str, ...]]: - """ - Prepare coordinates and dimensions for the DataArray. - - Args: - timesteps: Optional time index - scenarios: Optional scenario index - - Returns: - Tuple of (coordinates dict, dimensions tuple) - """ - # Validate inputs if provided - if timesteps is not None: - timesteps = DataConverter._validate_timesteps(timesteps) - - if scenarios is not None: - scenarios = DataConverter._validate_scenarios(scenarios) - - # Build coordinates and dimensions + """Prepare coordinates and dimensions.""" coords = {} dims = [] if timesteps is not None: + if not isinstance(timesteps, pd.DatetimeIndex) or len(timesteps) == 0: + raise ConversionError('Timesteps must be a non-empty DatetimeIndex') + if timesteps.name != 'time': + timesteps = timesteps.rename('time') coords['time'] = timesteps dims.append('time') if scenarios is not None: + if not isinstance(scenarios, pd.Index) or len(scenarios) == 0: + raise ConversionError('Scenarios must be a non-empty Index') + if scenarios.name != 'scenario': + scenarios = scenarios.rename('scenario') coords['scenario'] = scenarios dims.append('scenario') @@ -307,322 +206,129 @@ def _prepare_dimensions( def _convert_scalar( data: Union[int, float, np.integer, np.floating], coords: Dict[str, pd.Index], dims: Tuple[str, ...] ) -> xr.DataArray: - """ - Convert a scalar value to a DataArray. - - Args: - data: The scalar value - coords: Coordinate dictionary - dims: Dimension names - - Returns: - DataArray with the scalar value - """ + """Convert scalar to DataArray, broadcasting to all dimensions.""" if isinstance(data, (np.integer, np.floating)): data = data.item() return xr.DataArray(data, coords=coords, dims=dims) @staticmethod - def _convert_dataarray(data: xr.DataArray, coords: Dict[str, pd.Index], dims: Tuple[str, ...]) -> xr.DataArray: - """ - Convert an existing DataArray to desired dimensions. - - Args: - data: The source DataArray - coords: Target coordinates - dims: Target dimensions - - Returns: - DataArray with the target dimensions - """ - # No dimensions case - if len(dims) == 0: - if data.size != 1: - raise ConversionError('When converting to dimensionless DataArray, source must be scalar') - return xr.DataArray(data.values.item()) - - # Check if data already has matching dimensions and coordinates - if set(data.dims) == set(dims): - # Check if coordinates match - is_compatible = True - for dim in dims: - if dim in data.dims and not np.array_equal(data.coords[dim].values, coords[dim].values): - is_compatible = False - break - - if is_compatible: - # Ensure dimensions are in the correct order - if data.dims != dims: - # Transpose to get dimensions in the right order - return data.transpose(*dims).copy(deep=True) - else: - # Return existing DataArray if compatible and order is correct - return data.copy(deep=True) - - # Handle dimension broadcasting - if len(data.dims) == 1 and len(dims) == 2: - # Single dimension to two dimensions - if data.dims[0] == 'time' and 'scenario' in dims: - # Broadcast time dimension to include scenarios - return DataConverter._broadcast_time_to_scenarios(data, coords, dims) - - elif data.dims[0] == 'scenario' and 'time' in dims: - # Broadcast scenario dimension to include time - return DataConverter._broadcast_scenario_to_time(data, coords, dims) - - raise ConversionError( - f'Cannot convert {data.dims} to {dims}. Source coordinates: {data.coords}, Target coordinates: {coords}' - ) - @staticmethod - def _broadcast_time_to_scenarios( - data: xr.DataArray, coords: Dict[str, pd.Index], dims: Tuple[str, ...] - ) -> xr.DataArray: - """ - Broadcast a time-only DataArray to include scenarios. - - Args: - data: The time-indexed DataArray - coords: Target coordinates - dims: Target dimensions - - Returns: - DataArray with time and scenario dimensions - """ - # Check compatibility - if not np.array_equal(data.coords['time'].values, coords['time'].values): - raise ConversionError("Source time coordinates don't match target time coordinates") - - if len(coords['scenario']) <= 1: - return data.copy(deep=True) - - # Broadcast values - values = np.repeat(data.values[:, np.newaxis], len(coords['scenario']), axis=1) - return xr.DataArray(values.copy(), coords=coords, dims=dims) - - @staticmethod - def _broadcast_scenario_to_time( - data: xr.DataArray, coords: Dict[str, pd.Index], dims: Tuple[str, ...] - ) -> xr.DataArray: - """ - Broadcast a scenario-only DataArray to include time. - - Args: - data: The scenario-indexed DataArray - coords: Target coordinates - dims: Target dimensions - - Returns: - DataArray with time and scenario dimensions - """ - # Check compatibility - if not np.array_equal(data.coords['scenario'].values, coords['scenario'].values): - raise ConversionError("Source scenario coordinates don't match target scenario coordinates") - - # Broadcast values - values = np.repeat(data.values[:, np.newaxis], len(coords['time']), axis=1).T - return xr.DataArray(values.copy(), coords=coords, dims=dims) - - @staticmethod - def _convert_ndarray(data: np.ndarray, coords: Dict[str, pd.Index], dims: Tuple[str, ...]) -> xr.DataArray: - """ - Convert a NumPy array to a DataArray. - - Args: - data: The NumPy array - coords: Target coordinates - dims: Target dimensions - - Returns: - DataArray from the NumPy array - """ - # Handle dimensionless case + def _convert_1d_array(data: np.ndarray, coords: Dict[str, pd.Index], dims: Tuple[str, ...]) -> xr.DataArray: + """Convert 1D array to DataArray.""" if len(dims) == 0: - if data.size != 1: - raise ConversionError('Without dimensions, can only convert scalar arrays') - return xr.DataArray(data.item()) + if len(data) != 1: + raise ConversionError('Cannot convert multi-element array without dimensions') + return xr.DataArray(data[0]) - # Handle single dimension elif len(dims) == 1: - return DataConverter._convert_ndarray_single_dim(data, coords, dims) - - # Handle two dimensions - elif len(dims) == 2: - return DataConverter._convert_ndarray_two_dims(data, coords, dims) - - else: - raise ConversionError('Maximum 2 dimensions supported') - - @staticmethod - def _convert_ndarray_single_dim( - data: np.ndarray, coords: Dict[str, pd.Index], dims: Tuple[str, ...] - ) -> xr.DataArray: - """ - Convert a NumPy array to a single-dimension DataArray. - - Args: - data: The NumPy array - coords: Target coordinates - dims: Target dimensions (length 1) - - Returns: - DataArray with single dimension - """ - dim_name = dims[0] - dim_length = len(coords[dim_name]) - - if data.ndim == 1: - # 1D array must match dimension length - if data.shape[0] != dim_length: - raise ConversionError(f"Array length {data.shape[0]} doesn't match {dim_name} length {dim_length}") + dim_name = dims[0] + if len(data) != len(coords[dim_name]): + raise ConversionError( + f'Array length {len(data)} does not match {dim_name} length {len(coords[dim_name])}' + ) return xr.DataArray(data, coords=coords, dims=dims) - else: - raise ConversionError(f'Expected 1D array for single dimension, got {data.ndim}D') - - @staticmethod - def _convert_ndarray_two_dims(data: np.ndarray, coords: Dict[str, pd.Index], dims: Tuple[str, ...]) -> xr.DataArray: - """ - Convert a NumPy array to a two-dimension DataArray. - Args: - data: The NumPy array - coords: Target coordinates - dims: Target dimensions (length 2) - - Returns: - DataArray with two dimensions - """ - scenario_length = len(coords['scenario']) - time_length = len(coords['time']) + elif len(dims) == 2: + # Broadcast 1D array to 2D based on which dimension it matches + time_len = len(coords['time']) + scenario_len = len(coords['scenario']) - if data.ndim == 1: - # For 1D array, create 2D array based on which dimension it matches - if data.shape[0] == time_length: + if len(data) == time_len: # Broadcast across scenarios - values = np.repeat(data[:, np.newaxis], scenario_length, axis=1) + values = np.repeat(data[:, np.newaxis], scenario_len, axis=1) return xr.DataArray(values, coords=coords, dims=dims) - elif data.shape[0] == scenario_length: + elif len(data) == scenario_len: # Broadcast across time - values = np.repeat(data[np.newaxis, :], time_length, axis=0) + values = np.repeat(data[np.newaxis, :], time_len, axis=0) return xr.DataArray(values, coords=coords, dims=dims) else: - raise ConversionError(f"1D array length {data.shape[0]} doesn't match either dimension") - - elif data.ndim == 2: - # For 2D array, shape must match dimensions - expected_shape = (time_length, scenario_length) - if data.shape != expected_shape: - raise ConversionError(f"2D array shape {data.shape} doesn't match expected shape {expected_shape}") - return xr.DataArray(data, coords=coords, dims=dims) + raise ConversionError( + f'Array length {len(data)} matches neither time ({time_len}) nor scenario ({scenario_len}) dimensions' + ) else: - raise ConversionError(f'Expected 1D or 2D array for two dimensions, got {data.ndim}D') + raise ConversionError('Maximum 2 dimensions supported') @staticmethod def _convert_series(data: pd.Series, coords: Dict[str, pd.Index], dims: Tuple[str, ...]) -> xr.DataArray: - """ - Convert pandas Series to xarray DataArray. - - Args: - data: pandas Series to convert - coords: Target coordinates - dims: Target dimensions + """Convert pandas Series to DataArray.""" + if len(dims) == 0: + if len(data) != 1: + raise ConversionError('Cannot convert multi-element Series without dimensions') + return xr.DataArray(data.iloc[0]) - Returns: - DataArray from the pandas Series - """ - # Handle single dimension case - if len(dims) == 1: + elif len(dims) == 1: dim_name = dims[0] + if not data.index.equals(coords[dim_name]): + raise ConversionError(f'Series index does not match {dim_name} coordinates') + return xr.DataArray(data.values, coords=coords, dims=dims) - # Check if series index matches the dimension - if data.index.equals(coords[dim_name]): - return xr.DataArray(data.values.copy(), coords=coords, dims=dims) - else: - raise ConversionError( - f"Series index doesn't match {dim_name} coordinates.\n" - f'Series index: {data.index}\n' - f'Target {dim_name} coordinates: {coords[dim_name]}' - ) - - # Handle two dimensions case elif len(dims) == 2: - # Check if dimensions are time and scenario - if dims != ('time', 'scenario'): - raise ConversionError( - f'Two-dimensional conversion only supports time and scenario dimensions, got {dims}' - ) - - # Case 1: Series is indexed by time + # Check which dimension the Series index matches if data.index.equals(coords['time']): # Broadcast across scenarios values = np.repeat(data.values[:, np.newaxis], len(coords['scenario']), axis=1) - return xr.DataArray(values.copy(), coords=coords, dims=dims) - - # Case 2: Series is indexed by scenario + return xr.DataArray(values, coords=coords, dims=dims) elif data.index.equals(coords['scenario']): # Broadcast across time values = np.repeat(data.values[np.newaxis, :], len(coords['time']), axis=0) - return xr.DataArray(values.copy(), coords=coords, dims=dims) - + return xr.DataArray(values, coords=coords, dims=dims) else: - raise ConversionError( - "Series index must match either 'time' or 'scenario' coordinates.\n" - f'Series index: {data.index}\n' - f'Target time coordinates: {coords["time"]}\n' - f'Target scenario coordinates: {coords["scenario"]}' - ) + raise ConversionError('Series index must match either time or scenario coordinates') else: - raise ConversionError(f'Maximum 2 dimensions supported, got {len(dims)}') + raise ConversionError('Maximum 2 dimensions supported') @staticmethod - def _convert_dataframe(data: pd.DataFrame, coords: Dict[str, pd.Index], dims: Tuple[str, ...]) -> xr.DataArray: - """ - Convert pandas DataFrame to xarray DataArray. - Only allows time as index and scenarios as columns. + def _handle_dataarray(data: xr.DataArray, coords: Dict[str, pd.Index], dims: Tuple[str, ...]) -> xr.DataArray: + """Handle existing DataArray - check compatibility or broadcast.""" + # If no target dimensions, data must be scalar + if len(dims) == 0: + if data.size != 1: + raise ConversionError('DataArray must be scalar when no dimensions specified') + return xr.DataArray(data.values.item()) - Args: - data: pandas DataFrame to convert - coords: Target coordinates - dims: Target dimensions + # Check if already compatible + if data.dims == dims: + # Check if coordinates match + compatible = True + for dim in dims: + if not np.array_equal(data.coords[dim].values, coords[dim].values): + compatible = False + break + if compatible: + return data.copy() - Returns: - DataArray from the pandas DataFrame - """ - # Single dimension case - if len(dims) == 1: - # If DataFrame has one column, treat it like a Series - if len(data.columns) == 1: - series = data.iloc[:, 0] - return DataConverter._convert_series(series, coords, dims) + # Handle broadcasting from smaller to larger dimensions + if len(data.dims) < len(dims): + return DataConverter._broadcast_dataarray(data, coords, dims) - raise ConversionError( - f'When converting DataFrame to single-dimension DataArray, DataFrame must have exactly one column, got {len(data.columns)}' - ) + # If dimensions don't match and can't broadcast, raise error + raise ConversionError(f'Cannot convert DataArray with dims {data.dims} to target dims {dims}') - # Two dimensions case - elif len(dims) == 2: - # Check if dimensions are time and scenario - if dims != ('time', 'scenario'): - raise ConversionError( - f'Two-dimensional conversion only supports time and scenario dimensions, got {dims}' - ) + @staticmethod + def _broadcast_dataarray(data: xr.DataArray, coords: Dict[str, pd.Index], dims: Tuple[str, ...]) -> xr.DataArray: + """Broadcast DataArray to target dimensions.""" + if len(data.dims) == 0: + # Scalar DataArray - broadcast to all dimensions + return xr.DataArray(data.values.item(), coords=coords, dims=dims) - # DataFrame must have time as index and scenarios as columns - if data.index.equals(coords['time']) and data.columns.equals(coords['scenario']): - # Create DataArray with proper dimension order - return xr.DataArray(data.values.copy(), coords=coords, dims=dims) - else: - raise ConversionError( - 'DataFrame must have time as index and scenarios as columns.\n' - f'DataFrame index: {data.index}\n' - f'DataFrame columns: {data.columns}\n' - f'Target time coordinates: {coords["time"]}\n' - f'Target scenario coordinates: {coords["scenario"]}' - ) + elif len(data.dims) == 1 and len(dims) == 2: + source_dim = data.dims[0] - else: - raise ConversionError(f'Maximum 2 dimensions supported, got {len(dims)}') + # Check coordinate compatibility + if not np.array_equal(data.coords[source_dim].values, coords[source_dim].values): + raise ConversionError(f'Source {source_dim} coordinates do not match target coordinates') + + if source_dim == 'time': + # Broadcast time to include scenarios + values = np.repeat(data.values[:, np.newaxis], len(coords['scenario']), axis=1) + return xr.DataArray(values, coords=coords, dims=dims) + elif source_dim == 'scenario': + # Broadcast scenario to include time + values = np.repeat(data.values[np.newaxis, :], len(coords['time']), axis=0) + return xr.DataArray(values, coords=coords, dims=dims) + + raise ConversionError(f'Cannot broadcast from {data.dims} to {dims}') def get_dataarray_stats(arr: xr.DataArray) -> Dict: diff --git a/tests/test_dataconverter.py b/tests/test_dataconverter.py index 1ad41a0d2..d92077307 100644 --- a/tests/test_dataconverter.py +++ b/tests/test_dataconverter.py @@ -6,6 +6,7 @@ from flixopt.core import ( # Adjust this import to match your project structure ConversionError, DataConverter, + TimeSeriesData, ) @@ -19,12 +20,6 @@ def sample_scenario_index(): return pd.Index(['baseline', 'high_demand', 'low_price'], name='scenario') -@pytest.fixture -def multi_index(sample_time_index, sample_scenario_index): - """Create a sample MultiIndex combining scenarios and times.""" - return pd.MultiIndex.from_product([sample_scenario_index, sample_time_index], names=['scenario', 'time']) - - class TestSingleDimensionConversion: """Tests for converting data without scenarios (1D: time only).""" @@ -110,8 +105,8 @@ def test_scalar_with_scenarios(self, sample_time_index, sample_scenario_index): result = DataConverter.to_dataarray(42.5, sample_time_index, sample_scenario_index) assert np.all(result.values == 42.5) - def test_1d_array_with_scenarios(self, sample_time_index, sample_scenario_index): - """Test converting 1D array with scenario dimension (broadcasting).""" + def test_1d_array_with_scenarios_time_broadcast(self, sample_time_index, sample_scenario_index): + """Test converting 1D array matching time dimension (broadcasting across scenarios).""" # Create 1D array matching timesteps length arr_1d = np.array([1, 2, 3, 4, 5]) @@ -126,35 +121,29 @@ def test_1d_array_with_scenarios(self, sample_time_index, sample_scenario_index) scenario_slice = result.sel(scenario=scenario) assert np.array_equal(scenario_slice.values, arr_1d) - def test_2d_array_with_scenarios(self, sample_time_index, sample_scenario_index): - """Test converting 2D array with scenario dimension.""" - # Create 2D array with different values per scenario - arr_2d = np.array( - [ - [1, 2, 3, 4, 5], # baseline scenario - [6, 7, 8, 9, 10], # high_demand scenario - [11, 12, 13, 14, 15], # low_price scenario - ] - ) + def test_1d_array_with_scenarios_scenario_broadcast(self, sample_time_index, sample_scenario_index): + """Test converting 1D array matching scenario dimension (broadcasting across time).""" + # Create 1D array matching scenario length + arr_1d = np.array([10, 20, 30]) # 3 scenarios - # Convert to DataArray - result = DataConverter.to_dataarray(arr_2d.T, sample_time_index, sample_scenario_index) + # Convert with time and scenarios + result = DataConverter.to_dataarray(arr_1d, sample_time_index, sample_scenario_index) - assert result.shape == (5, 3) + assert result.shape == (len(sample_time_index), len(sample_scenario_index)) assert result.dims == ('time', 'scenario') - # Check that each scenario has correct values - assert np.array_equal(result.sel(scenario='baseline').values, arr_2d[0]) - assert np.array_equal(result.sel(scenario='high_demand').values, arr_2d[1]) - assert np.array_equal(result.sel(scenario='low_price').values, arr_2d[2]) + # Each time step should have the same scenario values (broadcasting) + for time in sample_time_index: + time_slice = result.sel(time=time) + assert np.array_equal(time_slice.values, arr_1d) def test_dataarray_with_scenarios(self, sample_time_index, sample_scenario_index): """Test converting an existing DataArray with scenarios.""" - # Create a multi-scenario DataArray + # Create a multi-scenario DataArray with dims in (time, scenario) order original = xr.DataArray( - data=np.array([[1, 2, 3, 4, 5], [6, 7, 8, 9, 10], [11, 12, 13, 14, 15]]), - coords={'scenario': sample_scenario_index, 'time': sample_time_index}, - dims=['scenario', 'time'], + data=np.array([[1, 6, 11], [2, 7, 12], [3, 8, 13], [4, 9, 14], [5, 10, 15]]), + coords={'time': sample_time_index, 'scenario': sample_scenario_index}, + dims=['time', 'scenario'], ) # Test conversion @@ -162,7 +151,7 @@ def test_dataarray_with_scenarios(self, sample_time_index, sample_scenario_index assert result.shape == (5, 3) assert result.dims == ('time', 'scenario') - assert np.array_equal(result.values, original.values.T) + assert np.array_equal(result.values, original.values) # Ensure it's a copy result.loc[:, 'baseline'] = 999 @@ -172,7 +161,7 @@ def test_dataarray_with_scenarios(self, sample_time_index, sample_scenario_index class TestSeriesConversion: """Tests for converting pandas Series to DataArray.""" - def test_series_single_dimension(self, sample_time_index): + def test_series_single_dimension_time(self, sample_time_index): """Test converting a pandas Series with time index.""" # Create a Series with matching time index series = pd.Series([10, 20, 30, 40, 50], index=sample_time_index) @@ -185,15 +174,16 @@ def test_series_single_dimension(self, sample_time_index): assert np.array_equal(result.values, series.values) assert np.array_equal(result.coords['time'].values, sample_time_index.values) - # Test with scenario index - scenario_index = pd.Index(['baseline', 'high_demand', 'low_price'], name='scenario') - series = pd.Series([100, 200, 300], index=scenario_index) + def test_series_single_dimension_scenario(self, sample_scenario_index): + """Test converting a pandas Series with scenario index.""" + # Create a Series with scenario index + series = pd.Series([100, 200, 300], index=sample_scenario_index) - result = DataConverter.to_dataarray(series, scenarios=scenario_index) + result = DataConverter.to_dataarray(series, scenarios=sample_scenario_index) assert result.shape == (3,) assert result.dims == ('scenario',) assert np.array_equal(result.values, series.values) - assert np.array_equal(result.coords['scenario'].values, scenario_index.values) + assert np.array_equal(result.coords['scenario'].values, sample_scenario_index.values) def test_series_mismatched_index(self, sample_time_index): """Test converting a Series with mismatched index.""" @@ -237,104 +227,39 @@ def test_series_broadcast_to_time(self, sample_time_index, sample_scenario_index time_slice = result.sel(time=time) assert np.array_equal(time_slice.values, series.values) - def test_series_dimension_order(self, sample_time_index, sample_scenario_index): - """Test that dimension order is respected with Series conversions.""" - # Create custom dimensions tuple with reversed order - dims = ('scenario', 'time',) - coords = {'time': sample_time_index, 'scenario': sample_scenario_index} - - # Time-indexed series - series = pd.Series([10, 20, 30, 40, 50], index=sample_time_index) - with pytest.raises(ConversionError, match="only supports time and scenario dimensions"): - _ = DataConverter._convert_series(series, coords, dims) - - # Scenario-indexed series - series = pd.Series([100, 200, 300], index=sample_scenario_index) - with pytest.raises(ConversionError, match="only supports time and scenario dimensions"): - _ = DataConverter._convert_series(series, coords, dims) +class TestTimeSeriesDataConversion: + """Tests for converting TimeSeriesData objects.""" -class TestDataFrameConversion: - """Tests for converting pandas DataFrame to DataArray.""" + def test_timeseries_data_conversion(self, sample_time_index): + """Test converting TimeSeriesData.""" + # Create TimeSeriesData + data_array = xr.DataArray([1, 2, 3, 4, 5], coords={'time': sample_time_index}, dims=['time']) + ts_data = TimeSeriesData(data_array, aggregation_group='test_group') - def test_dataframe_single_column(self, sample_time_index): - """Test converting a DataFrame with a single column.""" - # Create DataFrame with one column - df = pd.DataFrame({'value': [10, 20, 30, 40, 50]}, index=sample_time_index) + # Convert + result = DataConverter.to_dataarray(ts_data, sample_time_index) - # Convert and check - result = DataConverter.to_dataarray(df, sample_time_index) assert isinstance(result, xr.DataArray) assert result.shape == (5,) assert result.dims == ('time',) - assert np.array_equal(result.values, df['value'].values) - - def test_dataframe_multi_column_fails(self, sample_time_index): - """Test that converting a multi-column DataFrame to 1D fails.""" - # Create DataFrame with multiple columns - df = pd.DataFrame({'val1': [10, 20, 30, 40, 50], 'val2': [15, 25, 35, 45, 55]}, index=sample_time_index) + assert np.array_equal(result.values, [1, 2, 3, 4, 5]) - # Should raise error - with pytest.raises(ConversionError): - DataConverter.to_dataarray(df, sample_time_index) - - def test_dataframe_time_scenario(self, sample_time_index, sample_scenario_index): - """Test converting a DataFrame with time index and scenario columns.""" - # Create DataFrame with time as index and scenarios as columns - data = {'baseline': [10, 20, 30, 40, 50], 'high_demand': [15, 25, 35, 45, 55], 'low_price': [5, 15, 25, 35, 45]} - df = pd.DataFrame(data, index=sample_time_index) + def test_timeseries_data_with_scenarios(self, sample_time_index, sample_scenario_index): + """Test converting TimeSeriesData with broadcasting to scenarios.""" + # Create 1D TimeSeriesData + data_array = xr.DataArray([1, 2, 3, 4, 5], coords={'time': sample_time_index}, dims=['time']) + ts_data = TimeSeriesData(data_array) - # Make sure columns are named properly - df.columns.name = 'scenario' - - # Convert and check - result = DataConverter.to_dataarray(df, sample_time_index, sample_scenario_index) + # Convert with scenarios (should broadcast) + result = DataConverter.to_dataarray(ts_data, sample_time_index, sample_scenario_index) assert result.shape == (5, 3) assert result.dims == ('time', 'scenario') - assert np.array_equal(result.values, df.values) - # Check values for specific scenarios - assert np.array_equal(result.sel(scenario='baseline').values, df['baseline'].values) - assert np.array_equal(result.sel(scenario='high_demand').values, df['high_demand'].values) - - def test_dataframe_mismatched_coordinates(self, sample_time_index, sample_scenario_index): - """Test conversion fails with mismatched coordinates.""" - # Create DataFrame with different time index - different_times = pd.date_range('2025-01-01', periods=5, freq='D', name='time') - data = {'baseline': [10, 20, 30, 40, 50], 'high_demand': [15, 25, 35, 45, 55], 'low_price': [5, 15, 25, 35, 45]} - df = pd.DataFrame(data, index=different_times) - df.columns = sample_scenario_index - - # Should raise error - with pytest.raises(ConversionError): - DataConverter.to_dataarray(df, sample_time_index, sample_scenario_index) - - # Create DataFrame with different scenario columns - different_scenarios = pd.Index(['scenario1', 'scenario2', 'scenario3'], name='scenario') - data = {'scenario1': [10, 20, 30, 40, 50], 'scenario2': [15, 25, 35, 45, 55], 'scenario3': [5, 15, 25, 35, 45]} - df = pd.DataFrame(data, index=sample_time_index) - df.columns = different_scenarios - - # Should raise error - with pytest.raises(ConversionError): - DataConverter.to_dataarray(df, sample_time_index, sample_scenario_index) - - def test_ensure_copy(self, sample_time_index, sample_scenario_index): - """Test that the returned DataArray is a copy.""" - # Create DataFrame - data = {'baseline': [10, 20, 30, 40, 50], 'high_demand': [15, 25, 35, 45, 55], 'low_price': [5, 15, 25, 35, 45]} - df = pd.DataFrame(data, index=sample_time_index) - df.columns = sample_scenario_index - - # Convert - result = DataConverter.to_dataarray(df, sample_time_index, sample_scenario_index) - - # Modify the result - result.loc[dict(time=sample_time_index[0], scenario='baseline')] = 999 - - # Original should be unchanged - assert df.loc[sample_time_index[0], 'baseline'] == 10 + # Each scenario should have the same values + for scenario in sample_scenario_index: + assert np.array_equal(result.sel(scenario=scenario).values, [1, 2, 3, 4, 5]) class TestInvalidInputs: @@ -344,8 +269,9 @@ def test_time_index_validation(self): """Test validation of time index.""" # Test with unnamed index unnamed_index = pd.date_range('2024-01-01', periods=5, freq='D') - with pytest.raises(ConversionError): - DataConverter.to_dataarray(42, unnamed_index) + # Should automatically rename to 'time' with a warning, not raise error + result = DataConverter.to_dataarray(42, unnamed_index) + assert result.coords['time'].name == 'time' # Test with empty index empty_index = pd.DatetimeIndex([], name='time') @@ -361,8 +287,9 @@ def test_scenario_index_validation(self, sample_time_index): """Test validation of scenario index.""" # Test with unnamed scenario index unnamed_index = pd.Index(['baseline', 'high_demand']) - with pytest.raises(ConversionError): - DataConverter.to_dataarray(42, sample_time_index, unnamed_index) + # Should automatically rename to 'scenario' with a warning, not raise error + result = DataConverter.to_dataarray(42, sample_time_index, unnamed_index) + assert result.coords['scenario'].name == 'scenario' # Test with empty scenario index empty_index = pd.Index([], name='scenario') @@ -391,6 +318,18 @@ def test_invalid_data_types(self, sample_time_index, sample_scenario_index): with pytest.raises(ConversionError): DataConverter.to_dataarray(None, sample_time_index) + def test_multidimensional_array_rejection(self, sample_time_index, sample_scenario_index): + """Test that multidimensional arrays are rejected.""" + # Test 2D array (not supported in simplified version) + arr_2d = np.array([[1, 2, 3, 4, 5], [6, 7, 8, 9, 10]]) + with pytest.raises(ConversionError, match="Only 1D arrays supported"): + DataConverter.to_dataarray(arr_2d, sample_time_index) + + # Test 3D array + arr_3d = np.ones((2, 3, 4)) + with pytest.raises(ConversionError, match="Only 1D arrays supported"): + DataConverter.to_dataarray(arr_3d, sample_time_index, sample_scenario_index) + def test_mismatched_input_dimensions(self, sample_time_index, sample_scenario_index): """Test handling of mismatched input dimensions.""" # Test mismatched Series index @@ -400,31 +339,14 @@ def test_mismatched_input_dimensions(self, sample_time_index, sample_scenario_in with pytest.raises(ConversionError): DataConverter.to_dataarray(mismatched_series, sample_time_index) - # Test DataFrame with multiple columns - df_multi_col = pd.DataFrame({'A': [1, 2, 3, 4, 5], 'B': [6, 7, 8, 9, 10]}, index=sample_time_index) - with pytest.raises(ConversionError): - DataConverter.to_dataarray(df_multi_col, sample_time_index) - - # Test mismatched array shape for time-only + # Test mismatched array length for time-only with pytest.raises(ConversionError): DataConverter.to_dataarray(np.array([1, 2, 3]), sample_time_index) # Wrong length - # Test mismatched array shape for scenario × time - # Array shape should be (n_scenarios, n_timesteps) - wrong_shape_array = np.array( - [ - [1, 2, 3, 4], # Missing a timestep - [5, 6, 7, 8], - [9, 10, 11, 12], - ] - ) - with pytest.raises(ConversionError): - DataConverter.to_dataarray(wrong_shape_array, sample_time_index, sample_scenario_index) - - # Test array with too many dimensions + # Test array that doesn't match either dimension + wrong_length_array = np.array([1, 2, 3, 4]) # Doesn't match time (5) or scenario (3) with pytest.raises(ConversionError): - # 3D array not allowed - DataConverter.to_dataarray(np.ones((3, 5, 2)), sample_time_index, sample_scenario_index) + DataConverter.to_dataarray(wrong_length_array, sample_time_index, sample_scenario_index) def test_dataarray_dimension_mismatch(self, sample_time_index, sample_scenario_index): """Test handling of mismatched DataArray dimensions.""" @@ -433,61 +355,58 @@ def test_dataarray_dimension_mismatch(self, sample_time_index, sample_scenario_i with pytest.raises(ConversionError): DataConverter.to_dataarray(wrong_dims, sample_time_index) - # Create DataArray with scenario but no time - wrong_dims_2 = xr.DataArray(data=np.array([1, 2, 3]), coords={'scenario': ['a', 'b', 'c']}, dims=['scenario']) - with pytest.raises(ConversionError): - DataConverter.to_dataarray(wrong_dims_2, sample_time_index, sample_scenario_index) - - # Create DataArray with right dims but wrong length - wrong_length = xr.DataArray( - data=np.array([[1, 2, 3], [4, 5, 6], [7, 8, 9]]), - coords={ - 'scenario': sample_scenario_index, - 'time': pd.date_range('2024-01-01', periods=3, freq='D', name='time'), - }, - dims=['scenario', 'time'], + # Create DataArray with right dims but wrong coordinate values + wrong_coords = xr.DataArray( + data=np.array([1, 2, 3, 4, 5]), + coords={'time': pd.date_range('2025-01-01', periods=5, freq='D', name='time')}, + dims=['time'] ) with pytest.raises(ConversionError): - DataConverter.to_dataarray(wrong_length, sample_time_index, sample_scenario_index) + DataConverter.to_dataarray(wrong_coords, sample_time_index) + class TestDataArrayBroadcasting: """Tests for broadcasting DataArrays.""" - def test_broadcast_1d_array_to_2d(self, sample_time_index, sample_scenario_index): - """Test broadcasting a 1D array to all scenarios.""" + + def test_broadcast_1d_array_to_2d_time(self, sample_time_index, sample_scenario_index): + """Test broadcasting a 1D array (time) to 2D.""" arr_1d = np.array([1, 2, 3, 4, 5]) - xr.testing.assert_equal( - DataConverter.to_dataarray(arr_1d, sample_time_index, sample_scenario_index), - xr.DataArray( - np.array([arr_1d] * 3).T, - coords=(sample_time_index, sample_scenario_index) - ) - ) + result = DataConverter.to_dataarray(arr_1d, sample_time_index, sample_scenario_index) - arr_1d = np.array([1, 2, 3]) - xr.testing.assert_equal( - DataConverter.to_dataarray(arr_1d, sample_time_index, sample_scenario_index), - xr.DataArray( - np.array([arr_1d] * 5), - coords=(sample_time_index, sample_scenario_index) - ) - ) + # Should broadcast across scenarios + expected = np.repeat(arr_1d[:, np.newaxis], len(sample_scenario_index), axis=1) + assert np.array_equal(result.values, expected) + assert result.dims == ('time', 'scenario') - def test_broadcast_1d_array_to_1d(self, sample_time_index,): - """Test broadcasting a 1D array to all scenarios.""" + def test_broadcast_1d_array_to_2d_scenario(self, sample_time_index, sample_scenario_index): + """Test broadcasting a 1D array (scenario) to 2D.""" + arr_1d = np.array([1, 2, 3]) # Matches scenario length + + result = DataConverter.to_dataarray(arr_1d, sample_time_index, sample_scenario_index) + + # Should broadcast across time + expected = np.repeat(arr_1d[np.newaxis, :], len(sample_time_index), axis=0) + assert np.array_equal(result.values, expected) + assert result.dims == ('time', 'scenario') + + def test_broadcast_1d_array_to_1d(self, sample_time_index): + """Test that 1D array with matching dimension doesn't change.""" arr_1d = np.array([1, 2, 3, 4, 5]) - xr.testing.assert_equal( - DataConverter.to_dataarray(arr_1d, sample_time_index), - xr.DataArray( - arr_1d, - coords=(sample_time_index,) - ) - ) + result = DataConverter.to_dataarray(arr_1d, sample_time_index) - arr_1d = np.array([1, 2, 3]) - with pytest.raises(ConversionError): - DataConverter.to_dataarray(arr_1d, sample_time_index) + assert np.array_equal(result.values, arr_1d) + assert result.dims == ('time',) + + def test_scalar_dataarray_broadcasting(self, sample_time_index, sample_scenario_index): + """Test broadcasting scalar DataArray.""" + scalar_da = xr.DataArray(42) + + result = DataConverter.to_dataarray(scalar_da, sample_time_index, sample_scenario_index) + + assert result.shape == (len(sample_time_index), len(sample_scenario_index)) + assert np.all(result.values == 42) class TestEdgeCases: @@ -524,38 +443,6 @@ def test_single_scenario(self, sample_time_index): assert result_arr.shape == (5, 1) assert np.array_equal(result_arr.sel(scenario='baseline').values, arr) - # 2D array with single scenario - arr_2d = np.array([[1, 2, 3, 4, 5]]) # Note the extra dimension - result_arr_2d = DataConverter.to_dataarray(arr_2d.T, sample_time_index, single_scenario) - assert result_arr_2d.shape == (5, 1) - assert np.array_equal(result_arr_2d.sel(scenario='baseline').values, arr_2d[0]) - - def test_different_scenario_order(self, sample_time_index): - """Test that scenario order is preserved.""" - # Test with different scenario orders - scenarios1 = pd.Index(['a', 'b', 'c'], name='scenario') - scenarios2 = pd.Index(['c', 'b', 'a'], name='scenario') - - # Create DataArray with first order - data = np.array( - [ - [1, 2, 3, 4, 5], # a - [6, 7, 8, 9, 10], # b - [11, 12, 13, 14, 15], # c - ] - ).T - - result1 = DataConverter.to_dataarray(data, sample_time_index, scenarios1) - assert np.array_equal(result1.sel(scenario='a').values, [1, 2, 3, 4, 5]) - assert np.array_equal(result1.sel(scenario='c').values, [11, 12, 13, 14, 15]) - - # Create DataArray with second order - result2 = DataConverter.to_dataarray(data, sample_time_index, scenarios2) - # First row should match 'c' now - assert np.array_equal(result2.sel(scenario='c').values, [1, 2, 3, 4, 5]) - # Last row should match 'a' now - assert np.array_equal(result2.sel(scenario='a').values, [11, 12, 13, 14, 15]) - def test_all_nan_data(self, sample_time_index, sample_scenario_index): """Test handling of all-NaN data.""" # Create array of all NaNs @@ -568,12 +455,6 @@ def test_all_nan_data(self, sample_time_index, sample_scenario_index): assert result.shape == (len(sample_time_index), len(sample_scenario_index)) assert np.all(np.isnan(result.values)) - # Series of all NaNs - result = DataConverter.to_dataarray( - np.array([np.nan, np.nan, np.nan, np.nan, np.nan]), sample_time_index, sample_scenario_index - ) - assert np.all(np.isnan(result.values)) - def test_mixed_data_types(self, sample_time_index, sample_scenario_index): """Test conversion of mixed integer and float data.""" # Create array with mixed types @@ -590,165 +471,50 @@ def test_mixed_data_types(self, sample_time_index, sample_scenario_index): for scenario in sample_scenario_index: assert np.array_equal(result.sel(scenario=scenario).values, mixed_array) - -class TestFunctionalUseCase: - """Tests for realistic use cases combining multiple features.""" - - def test_large_dataset(self, sample_scenario_index): - """Test with a larger dataset to ensure performance.""" - # Create a larger timestep array (e.g., hourly for a year) - large_timesteps = pd.date_range( - '2024-01-01', - periods=8760, # Hours in a year - freq='H', - name='time', - ) - - # Create large 2D array (3 scenarios × 8760 hours) - large_data = np.random.rand(len(sample_scenario_index), len(large_timesteps)) - - # Convert and check - result = DataConverter.to_dataarray(large_data.T, large_timesteps, sample_scenario_index) - - assert result.shape == (len(large_timesteps), len(sample_scenario_index)) - assert result.dims == ('time', 'scenario') - assert np.array_equal(result.values, large_data.T) - - -class TestMultiScenarioArrayConversion: - """Tests specifically focused on array conversion with scenarios.""" - - def test_1d_array_broadcasting(self, sample_time_index, sample_scenario_index): - """Test that 1D arrays are properly broadcast to all scenarios.""" - arr_1d = np.array([1, 2, 3, 4, 5]) - result = DataConverter.to_dataarray(arr_1d, sample_time_index, sample_scenario_index) - - assert result.shape == (len(sample_time_index), len(sample_scenario_index)) - - # Each scenario should have identical values - for i, scenario in enumerate(sample_scenario_index): - assert np.array_equal(result.sel(scenario=scenario).values, arr_1d) - - # Modify one scenario's values - result.loc[dict(scenario=scenario)] = np.ones(len(sample_time_index)) * i - - # Ensure modifications are isolated to each scenario - for i, scenario in enumerate(sample_scenario_index): - assert np.all(result.sel(scenario=scenario).values == i) - - def test_2d_array_different_shapes(self, sample_time_index): - """Test different scenario shapes with 2D arrays.""" - # Test with 1 scenario - single_scenario = pd.Index(['baseline'], name='scenario') - arr_1_scenario = np.array([[1, 2, 3, 4, 5]]) - - result = DataConverter.to_dataarray(arr_1_scenario.T, sample_time_index, single_scenario) - assert result.shape == (len(sample_time_index), 1) - - # Test with 2 scenarios - two_scenarios = pd.Index(['baseline', 'high_demand'], name='scenario') - arr_2_scenarios = np.array([[1, 2, 3, 4, 5], [6, 7, 8, 9, 10]]) - - result = DataConverter.to_dataarray(arr_2_scenarios.T, sample_time_index, two_scenarios) - assert result.shape == (len(sample_time_index), 2) - assert np.array_equal(result.sel(scenario='baseline').values, arr_2_scenarios[0]) - assert np.array_equal(result.sel(scenario='high_demand').values, arr_2_scenarios[1]) - - # Test mismatched scenarios count - three_scenarios = pd.Index(['a', 'b', 'c'], name='scenario') - with pytest.raises(ConversionError): - DataConverter.to_dataarray(arr_2_scenarios, sample_time_index, three_scenarios) - - def test_array_handling_edge_cases(self, sample_time_index, sample_scenario_index): - """Test array edge cases.""" - # Test with boolean array + def test_boolean_data(self, sample_time_index, sample_scenario_index): + """Test handling of boolean data.""" bool_array = np.array([True, False, True, False, True]) result = DataConverter.to_dataarray(bool_array, sample_time_index, sample_scenario_index) assert result.dtype == bool assert result.shape == (len(sample_time_index), len(sample_scenario_index)) - # Test with array containing infinite values - inf_array = np.array([1, np.inf, 3, -np.inf, 5]) - result = DataConverter.to_dataarray(inf_array, sample_time_index, sample_scenario_index) - for scenario in sample_scenario_index: - scenario_data = result.sel(scenario=scenario) - assert np.isinf(scenario_data[1].item()) - assert np.isinf(scenario_data[3].item()) - assert scenario_data[3].item() < 0 # Negative infinity - - -class TestScenarioReindexing: - """Tests for reindexing and coordinate preservation in DataConverter.""" - - def test_preserving_scenario_order(self, sample_time_index): - """Test that scenario order is preserved in converted DataArrays.""" - # Define scenarios in a specific order - scenarios = pd.Index(['scenario3', 'scenario1', 'scenario2'], name='scenario') - - # Create 2D array - data = np.array( - [ - [1, 2, 3, 4, 5], # scenario3 - [6, 7, 8, 9, 10], # scenario1 - [11, 12, 13, 14, 15], # scenario2 - ] - ) - - # Convert to DataArray - result = DataConverter.to_dataarray(data.T, sample_time_index, scenarios) - - # Verify order of scenarios is preserved - assert list(result.coords['scenario'].values) == list(scenarios) - - # Verify data for each scenario - assert np.array_equal(result.sel(scenario='scenario3').values, data[0]) - assert np.array_equal(result.sel(scenario='scenario1').values, data[1]) - assert np.array_equal(result.sel(scenario='scenario2').values, data[2]) +class TestNoIndexConversion: + """Tests for conversion without any indices (scalar results).""" -if __name__ == '__main__': - pytest.main() - - -def test_invalid_inputs(sample_time_index): - # Test invalid input type - with pytest.raises(ConversionError): - DataConverter.to_dataarray('invalid_string', sample_time_index) - - # Test mismatched Series index - mismatched_series = pd.Series([1, 2, 3, 4, 5, 6], index=pd.date_range('2025-01-01', periods=6, freq='D')) - with pytest.raises(ConversionError): - DataConverter.to_dataarray(mismatched_series, sample_time_index) - - # Test DataFrame with multiple columns - df_multi_col = pd.DataFrame({'A': [1, 2, 3, 4, 5], 'B': [6, 7, 8, 9, 10]}, index=sample_time_index) - with pytest.raises(ConversionError): - DataConverter.to_dataarray(df_multi_col, sample_time_index) - - # Test mismatched array shape - with pytest.raises(ConversionError): - DataConverter.to_dataarray(np.array([1, 2, 3]), sample_time_index) # Wrong length - - # Test multi-dimensional array - with pytest.raises(ConversionError): - DataConverter.to_dataarray(np.array([[1, 2], [3, 4]]), sample_time_index) # 2D array not allowed - - -def test_time_index_validation(): - # Test with unnamed index - unnamed_index = pd.date_range('2024-01-01', periods=5, freq='D') - with pytest.raises(ConversionError): - DataConverter.to_dataarray(42, unnamed_index) - - # Test with empty index - empty_index = pd.DatetimeIndex([], name='time') - with pytest.raises(ConversionError): - DataConverter.to_dataarray(42, empty_index) + def test_scalar_no_dimensions(self): + """Test scalar conversion without any dimensions.""" + result = DataConverter.to_dataarray(42) + assert isinstance(result, xr.DataArray) + assert result.shape == () + assert result.dims == () + assert result.item() == 42 + + def test_single_element_array_no_dimensions(self): + """Test single-element array without dimensions.""" + arr = np.array([42]) + result = DataConverter.to_dataarray(arr) + assert result.shape == () + assert result.item() == 42 + + def test_multi_element_array_no_dimensions_fails(self): + """Test that multi-element array fails without dimensions.""" + arr = np.array([1, 2, 3]) + with pytest.raises(ConversionError): + DataConverter.to_dataarray(arr) - # Test with non-DatetimeIndex - wrong_type_index = pd.Index([1, 2, 3, 4, 5], name='time') - with pytest.raises(ConversionError): - DataConverter.to_dataarray(42, wrong_type_index) + def test_series_no_dimensions_fails(self): + """Test that multi-element Series fails without dimensions.""" + series = pd.Series([1, 2, 3]) + with pytest.raises(ConversionError): + DataConverter.to_dataarray(series) + + def test_single_element_series_no_dimensions(self): + """Test single-element Series without dimensions.""" + series = pd.Series([42]) + result = DataConverter.to_dataarray(series) + assert result.shape == () + assert result.item() == 42 if __name__ == '__main__': From a7ec9943866959234fe1db079dfb421350b2ecf7 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 29 Jun 2025 20:58:22 +0200 Subject: [PATCH 139/336] Add DataFrame Support back --- flixopt/core.py | 21 ++++++- tests/test_dataconverter.py | 111 ++++++++++++++++++++++++++++++++++++ 2 files changed, 129 insertions(+), 3 deletions(-) diff --git a/flixopt/core.py b/flixopt/core.py index 9ab71cab9..687200f21 100644 --- a/flixopt/core.py +++ b/flixopt/core.py @@ -134,9 +134,20 @@ class DataConverter: - xr.DataArray (for broadcasting/checking) """ + @staticmethod + def _convert_dataframe(data: pd.DataFrame, coords: Dict[str, pd.Index], dims: Tuple[str, ...]) -> xr.DataArray: + """Convert single-column pandas DataFrame to DataArray by treating it as a Series.""" + # Check that DataFrame has exactly one column + if len(data.columns) != 1: + raise ConversionError(f'Only single-column DataFrames are supported, got {len(data.columns)} columns') + + # Extract the single column as a Series and convert it + series = data.iloc[:, 0] + return DataConverter._convert_series(series, coords, dims) + @staticmethod def to_dataarray( - data: Union[Scalar, np.ndarray, pd.Series, xr.DataArray, TimeSeriesData], + data: Union[Scalar, np.ndarray, pd.Series, pd.DataFrame, xr.DataArray, TimeSeriesData], timesteps: Optional[pd.DatetimeIndex] = None, scenarios: Optional[pd.Index] = None, ) -> xr.DataArray: @@ -144,7 +155,7 @@ def to_dataarray( Convert data to xarray.DataArray with specified dimensions. Args: - data: Scalar, 1D array/Series, or existing DataArray + data: Scalar, 1D array/Series, single-column DataFrame, or existing DataArray timesteps: Optional DatetimeIndex for time dimension scenarios: Optional Index for scenario dimension @@ -167,13 +178,17 @@ def to_dataarray( elif isinstance(data, pd.Series): return DataConverter._convert_series(data, coords, dims) + # Handle pandas DataFrames (single column only) + elif isinstance(data, pd.DataFrame): + return DataConverter._convert_dataframe(data, coords, dims) + # Handle existing DataArrays (including TimeSeriesData) elif isinstance(data, xr.DataArray): return DataConverter._handle_dataarray(data, coords, dims) else: raise ConversionError( - f'Unsupported data type: {type(data).__name__}. Only scalars, 1D arrays, Series, and DataArrays are supported.' + f'Unsupported data type: {type(data).__name__}. Only scalars, 1D arrays, Series, single-column DataFrames, and DataArrays are supported.' ) @staticmethod diff --git a/tests/test_dataconverter.py b/tests/test_dataconverter.py index d92077307..5536b8cb2 100644 --- a/tests/test_dataconverter.py +++ b/tests/test_dataconverter.py @@ -262,6 +262,117 @@ def test_timeseries_data_with_scenarios(self, sample_time_index, sample_scenario assert np.array_equal(result.sel(scenario=scenario).values, [1, 2, 3, 4, 5]) +class TestDataFrameConversion: + """Tests for converting single-column pandas DataFrames to DataArray.""" + + def test_single_column_dataframe_time(self, sample_time_index): + """Test converting a single-column DataFrame with time index.""" + # Create DataFrame with one column + df = pd.DataFrame({'value': [10, 20, 30, 40, 50]}, index=sample_time_index) + + # Convert and check + result = DataConverter.to_dataarray(df, sample_time_index) + assert isinstance(result, xr.DataArray) + assert result.shape == (5,) + assert result.dims == ('time',) + assert np.array_equal(result.values, df['value'].values) + + def test_single_column_dataframe_scenario(self, sample_scenario_index): + """Test converting a single-column DataFrame with scenario index.""" + # Create DataFrame with one column and scenario index + df = pd.DataFrame({'value': [100, 200, 300]}, index=sample_scenario_index) + + result = DataConverter.to_dataarray(df, scenarios=sample_scenario_index) + assert result.shape == (3,) + assert result.dims == ('scenario',) + assert np.array_equal(result.values, df['value'].values) + + def test_dataframe_broadcast_to_scenarios(self, sample_time_index, sample_scenario_index): + """Test broadcasting a time-indexed DataFrame across scenarios.""" + # Create DataFrame with time index + df = pd.DataFrame({'power': [10, 20, 30, 40, 50]}, index=sample_time_index) + + # Convert with scenarios + result = DataConverter.to_dataarray(df, sample_time_index, sample_scenario_index) + + assert result.shape == (5, 3) + assert result.dims == ('time', 'scenario') + + # Check broadcasting - each scenario should have the same values + for scenario in sample_scenario_index: + scenario_slice = result.sel(scenario=scenario) + assert np.array_equal(scenario_slice.values, df['power'].values) + + def test_dataframe_broadcast_to_time(self, sample_time_index, sample_scenario_index): + """Test broadcasting a scenario-indexed DataFrame across time.""" + # Create DataFrame with scenario index + df = pd.DataFrame({'cost': [100, 200, 300]}, index=sample_scenario_index) + + # Convert with time + result = DataConverter.to_dataarray(df, sample_time_index, sample_scenario_index) + + assert result.shape == (5, 3) + assert result.dims == ('time', 'scenario') + + # Check broadcasting - each time should have the same scenario values + for time in sample_time_index: + time_slice = result.sel(time=time) + assert np.array_equal(time_slice.values, df['cost'].values) + + def test_multi_column_dataframe_fails(self, sample_time_index): + """Test that multi-column DataFrames are rejected.""" + # Create DataFrame with multiple columns + df = pd.DataFrame({ + 'value1': [10, 20, 30, 40, 50], + 'value2': [15, 25, 35, 45, 55] + }, index=sample_time_index) + + # Should raise error + with pytest.raises(ConversionError, match="Only single-column DataFrames are supported"): + DataConverter.to_dataarray(df, sample_time_index) + + def test_dataframe_mismatched_index(self, sample_time_index): + """Test DataFrame with mismatched index.""" + # Create DataFrame with different time index + different_times = pd.date_range('2025-01-01', periods=5, freq='D', name='time') + df = pd.DataFrame({'value': [10, 20, 30, 40, 50]}, index=different_times) + + # Should raise error for mismatched index + with pytest.raises(ConversionError): + DataConverter.to_dataarray(df, sample_time_index) + + def test_dataframe_copy_behavior(self, sample_time_index): + """Test that DataFrame conversion creates a copy.""" + # Create DataFrame + df = pd.DataFrame({'value': [10, 20, 30, 40, 50]}, index=sample_time_index) + + # Convert + result = DataConverter.to_dataarray(df, sample_time_index) + + # Modify the result + result[0] = 999 + + # Original DataFrame should be unchanged + assert df.loc[sample_time_index[0], 'value'] == 10 + + def test_empty_dataframe_fails(self, sample_time_index): + """Test that empty DataFrames are rejected.""" + # DataFrame with no columns + df = pd.DataFrame(index=sample_time_index) + + with pytest.raises(ConversionError, match="Only single-column DataFrames are supported"): + DataConverter.to_dataarray(df, sample_time_index) + + def test_dataframe_with_named_column(self, sample_time_index): + """Test DataFrame with a named column.""" + df = pd.DataFrame(index=sample_time_index) + df['energy_output'] = [100, 150, 200, 175, 125] + + result = DataConverter.to_dataarray(df, sample_time_index) + assert result.shape == (5,) + assert np.array_equal(result.values, [100, 150, 200, 175, 125]) + + class TestInvalidInputs: """Tests for invalid inputs and error handling.""" From 2a75ed3c1c87283ab65d89a8d47cb35e01f8d83e Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 29 Jun 2025 20:59:00 +0200 Subject: [PATCH 140/336] Add copy() to DataConverter --- flixopt/core.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/flixopt/core.py b/flixopt/core.py index 687200f21..9834d8f39 100644 --- a/flixopt/core.py +++ b/flixopt/core.py @@ -240,7 +240,7 @@ def _convert_1d_array(data: np.ndarray, coords: Dict[str, pd.Index], dims: Tuple raise ConversionError( f'Array length {len(data)} does not match {dim_name} length {len(coords[dim_name])}' ) - return xr.DataArray(data, coords=coords, dims=dims) + return xr.DataArray(data.copy(), coords=coords, dims=dims) elif len(dims) == 2: # Broadcast 1D array to 2D based on which dimension it matches @@ -250,11 +250,11 @@ def _convert_1d_array(data: np.ndarray, coords: Dict[str, pd.Index], dims: Tuple if len(data) == time_len: # Broadcast across scenarios values = np.repeat(data[:, np.newaxis], scenario_len, axis=1) - return xr.DataArray(values, coords=coords, dims=dims) + return xr.DataArray(values.copy(), coords=coords, dims=dims) elif len(data) == scenario_len: # Broadcast across time values = np.repeat(data[np.newaxis, :], time_len, axis=0) - return xr.DataArray(values, coords=coords, dims=dims) + return xr.DataArray(values.copy(), coords=coords, dims=dims) else: raise ConversionError( f'Array length {len(data)} matches neither time ({time_len}) nor scenario ({scenario_len}) dimensions' @@ -275,18 +275,18 @@ def _convert_series(data: pd.Series, coords: Dict[str, pd.Index], dims: Tuple[st dim_name = dims[0] if not data.index.equals(coords[dim_name]): raise ConversionError(f'Series index does not match {dim_name} coordinates') - return xr.DataArray(data.values, coords=coords, dims=dims) + return xr.DataArray(data.values.copy(), coords=coords, dims=dims) elif len(dims) == 2: # Check which dimension the Series index matches if data.index.equals(coords['time']): # Broadcast across scenarios values = np.repeat(data.values[:, np.newaxis], len(coords['scenario']), axis=1) - return xr.DataArray(values, coords=coords, dims=dims) + return xr.DataArray(values.copy(), coords=coords, dims=dims) elif data.index.equals(coords['scenario']): # Broadcast across time values = np.repeat(data.values[np.newaxis, :], len(coords['time']), axis=0) - return xr.DataArray(values, coords=coords, dims=dims) + return xr.DataArray(values.copy(), coords=coords, dims=dims) else: raise ConversionError('Series index must match either time or scenario coordinates') @@ -337,11 +337,11 @@ def _broadcast_dataarray(data: xr.DataArray, coords: Dict[str, pd.Index], dims: if source_dim == 'time': # Broadcast time to include scenarios values = np.repeat(data.values[:, np.newaxis], len(coords['scenario']), axis=1) - return xr.DataArray(values, coords=coords, dims=dims) + return xr.DataArray(values.copy(), coords=coords, dims=dims) elif source_dim == 'scenario': # Broadcast scenario to include time values = np.repeat(data.values[np.newaxis, :], len(coords['time']), axis=0) - return xr.DataArray(values, coords=coords, dims=dims) + return xr.DataArray(values.copy(), coords=coords, dims=dims) raise ConversionError(f'Cannot broadcast from {data.dims} to {dims}') From dae9f01baeb9f14ebfa90ae1485f8befb4bdb838 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 29 Jun 2025 21:06:42 +0200 Subject: [PATCH 141/336] Update fit_to_model_coords to take a list of coords --- flixopt/flow_system.py | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index b513372bf..6bf7502e5 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -294,7 +294,7 @@ def fit_to_model_coords( self, name: str, data: Optional[Union[TemporalDataUser, NonTemporalDataUser]], - has_time_dim: bool = True, + dimensions: Union[List[str], str] = 'time', # Default to time only ) -> Optional[Union[TemporalData, NonTemporalData]]: """ Fit data to model coordinate system (currently time, but extensible). @@ -310,18 +310,31 @@ def fit_to_model_coords( if data is None: return None + # Build coords from requested dimensions + if isinstance(dimensions, str): + dimensions = [dimensions] + + coords = {} + for dim in dimensions: + if dim == 'time' and self.timesteps is not None: + coords['time'] = self.timesteps + elif dim == 'scenario' and self.scenarios is not None: + coords['scenario'] = self.scenarios + # Future: elif dim == 'region' and self.regions is not None: ... + + # Rest of your method stays the same, just pass coords if isinstance(data, TimeSeriesData): try: data.name = name # Set name of previous object! return TimeSeriesData( - DataConverter.to_dataarray(data, timesteps=self.timesteps, scenarios=self.scenarios), + DataConverter.to_dataarray(data, coords=coords), aggregation_group=data.aggregation_group, aggregation_weight=data.aggregation_weight ).rename(name) except ConversionError as e: logger.critical(f'Could not convert time series data "{name}" to DataArray: {e}. \n' f'Take care to use the correct (time) index.') else: - return DataConverter.to_dataarray(data, timesteps=self.timesteps if has_time_dim else None, scenarios=self.scenarios).rename(name) + return DataConverter.to_dataarray(data, coords=coords).rename(name) def fit_effects_to_model_coords( self, From ba195ff45a7dc4f40f87dfe9ceb311765d0e5c88 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 29 Jun 2025 21:33:01 +0200 Subject: [PATCH 142/336] Make the DataConverter more universal by accepting a list of coords/dims --- flixopt/core.py | 148 ++++--- tests/test_dataconverter.py | 831 +++++++++++++++--------------------- 2 files changed, 437 insertions(+), 542 deletions(-) diff --git a/flixopt/core.py b/flixopt/core.py index 9834d8f39..c11ba3993 100644 --- a/flixopt/core.py +++ b/flixopt/core.py @@ -148,21 +148,23 @@ def _convert_dataframe(data: pd.DataFrame, coords: Dict[str, pd.Index], dims: Tu @staticmethod def to_dataarray( data: Union[Scalar, np.ndarray, pd.Series, pd.DataFrame, xr.DataArray, TimeSeriesData], - timesteps: Optional[pd.DatetimeIndex] = None, - scenarios: Optional[pd.Index] = None, + coords: Optional[Dict[str, pd.Index]] = None, ) -> xr.DataArray: """ - Convert data to xarray.DataArray with specified dimensions. + Convert data to xarray.DataArray with specified coordinates. Args: data: Scalar, 1D array/Series, single-column DataFrame, or existing DataArray - timesteps: Optional DatetimeIndex for time dimension - scenarios: Optional Index for scenario dimension + coords: Dictionary mapping dimension names to coordinate indices + e.g., {'time': timesteps, 'scenario': scenarios} Returns: DataArray with the converted data """ - coords, dims = DataConverter._prepare_dimensions(timesteps, scenarios) + if coords is None: + coords = {} + + coords, dims = DataConverter._prepare_dimensions(coords) # Handle scalars if isinstance(data, (int, float, np.integer, np.floating)): @@ -192,30 +194,40 @@ def to_dataarray( ) @staticmethod - def _prepare_dimensions( - timesteps: Optional[pd.DatetimeIndex], scenarios: Optional[pd.Index] - ) -> Tuple[Dict[str, pd.Index], Tuple[str, ...]]: - """Prepare coordinates and dimensions.""" - coords = {} + def _prepare_dimensions(coords: Dict[str, pd.Index]) -> Tuple[Dict[str, pd.Index], Tuple[str, ...]]: + """ + Prepare and validate coordinates for the DataArray. + + Args: + coords: Dictionary mapping dimension names to coordinate indices + + Returns: + Tuple of (validated coordinates dict, dimensions tuple) + """ + # Check dimension limit + if len(coords) > 2: + raise ConversionError(f'Maximum 2 dimensions currently supported, got {len(coords)}') + + validated_coords = {} dims = [] - if timesteps is not None: - if not isinstance(timesteps, pd.DatetimeIndex) or len(timesteps) == 0: - raise ConversionError('Timesteps must be a non-empty DatetimeIndex') - if timesteps.name != 'time': - timesteps = timesteps.rename('time') - coords['time'] = timesteps - dims.append('time') + for dim_name, coord_index in coords.items(): + # Validate coordinate index + if not isinstance(coord_index, pd.Index) or len(coord_index) == 0: + raise ConversionError(f'{dim_name} coordinates must be a non-empty pandas Index') + + # Ensure coordinate index has the correct name + if coord_index.name != dim_name: + coord_index = coord_index.rename(dim_name) + + # Special validation for time dimension + if dim_name == 'time' and not isinstance(coord_index, pd.DatetimeIndex): + raise ConversionError('time coordinates must be a DatetimeIndex') - if scenarios is not None: - if not isinstance(scenarios, pd.Index) or len(scenarios) == 0: - raise ConversionError('Scenarios must be a non-empty Index') - if scenarios.name != 'scenario': - scenarios = scenarios.rename('scenario') - coords['scenario'] = scenarios - dims.append('scenario') + validated_coords[dim_name] = coord_index + dims.append(dim_name) - return coords, tuple(dims) + return validated_coords, tuple(dims) @staticmethod def _convert_scalar( @@ -244,24 +256,31 @@ def _convert_1d_array(data: np.ndarray, coords: Dict[str, pd.Index], dims: Tuple elif len(dims) == 2: # Broadcast 1D array to 2D based on which dimension it matches - time_len = len(coords['time']) - scenario_len = len(coords['scenario']) - - if len(data) == time_len: - # Broadcast across scenarios - values = np.repeat(data[:, np.newaxis], scenario_len, axis=1) - return xr.DataArray(values.copy(), coords=coords, dims=dims) - elif len(data) == scenario_len: - # Broadcast across time - values = np.repeat(data[np.newaxis, :], time_len, axis=0) - return xr.DataArray(values.copy(), coords=coords, dims=dims) - else: + dim_lengths = {dim: len(coords[dim]) for dim in dims} + + # Find which dimension the array length matches + matching_dims = [dim for dim, length in dim_lengths.items() if len(data) == length] + + if len(matching_dims) == 0: + raise ConversionError(f'Array length {len(data)} matches none of the dimensions: {dim_lengths}') + elif len(matching_dims) > 1: raise ConversionError( - f'Array length {len(data)} matches neither time ({time_len}) nor scenario ({scenario_len}) dimensions' + f'Array length {len(data)} matches multiple dimensions: {matching_dims}. Cannot determine broadcasting direction.' ) + # Broadcast along the matching dimension + match_dim = matching_dims[0] + other_dim = [d for d in dims if d != match_dim][0] + + if dims.index(match_dim) == 0: # First dimension + values = np.repeat(data[:, np.newaxis], len(coords[other_dim]), axis=1) + else: # Second dimension + values = np.repeat(data[np.newaxis, :], len(coords[other_dim]), axis=0) + + return xr.DataArray(values.copy(), coords=coords, dims=dims) + else: - raise ConversionError('Maximum 2 dimensions supported') + raise ConversionError(f'Maximum 2 dimensions currently supported, got {len(dims)}') @staticmethod def _convert_series(data: pd.Series, coords: Dict[str, pd.Index], dims: Tuple[str, ...]) -> xr.DataArray: @@ -279,16 +298,23 @@ def _convert_series(data: pd.Series, coords: Dict[str, pd.Index], dims: Tuple[st elif len(dims) == 2: # Check which dimension the Series index matches - if data.index.equals(coords['time']): - # Broadcast across scenarios - values = np.repeat(data.values[:, np.newaxis], len(coords['scenario']), axis=1) - return xr.DataArray(values.copy(), coords=coords, dims=dims) - elif data.index.equals(coords['scenario']): - # Broadcast across time - values = np.repeat(data.values[np.newaxis, :], len(coords['time']), axis=0) - return xr.DataArray(values.copy(), coords=coords, dims=dims) - else: - raise ConversionError('Series index must match either time or scenario coordinates') + if 'time' in coords and data.index.equals(coords['time']): + # Broadcast across other dimensions + other_dims = [d for d in dims if d != 'time'] + if len(other_dims) == 1: + other_dim = other_dims[0] + values = np.repeat(data.values[:, np.newaxis], len(coords[other_dim]), axis=1) + return xr.DataArray(values.copy(), coords=coords, dims=dims) + + elif len([d for d in dims if d != 'time']) == 1: + # Check if Series matches the non-time dimension + other_dim = [d for d in dims if d != 'time'][0] + if data.index.equals(coords[other_dim]): + # Broadcast across time + values = np.repeat(data.values[np.newaxis, :], len(coords['time']), axis=0) + return xr.DataArray(values.copy(), coords=coords, dims=dims) + + raise ConversionError(f'Series index must match one of the target dimensions: {list(coords.keys())}') else: raise ConversionError('Maximum 2 dimensions supported') @@ -330,18 +356,24 @@ def _broadcast_dataarray(data: xr.DataArray, coords: Dict[str, pd.Index], dims: elif len(data.dims) == 1 and len(dims) == 2: source_dim = data.dims[0] + # Check if source dimension exists in target + if source_dim not in coords: + raise ConversionError(f'Source dimension "{source_dim}" not found in target coordinates') + # Check coordinate compatibility if not np.array_equal(data.coords[source_dim].values, coords[source_dim].values): raise ConversionError(f'Source {source_dim} coordinates do not match target coordinates') - if source_dim == 'time': - # Broadcast time to include scenarios - values = np.repeat(data.values[:, np.newaxis], len(coords['scenario']), axis=1) - return xr.DataArray(values.copy(), coords=coords, dims=dims) - elif source_dim == 'scenario': - # Broadcast scenario to include time - values = np.repeat(data.values[np.newaxis, :], len(coords['time']), axis=0) - return xr.DataArray(values.copy(), coords=coords, dims=dims) + # Find the other dimension to broadcast to + other_dim = [d for d in dims if d != source_dim][0] + + # Broadcast based on dimension order + if dims.index(source_dim) == 0: # Source is first dimension + values = np.repeat(data.values[:, np.newaxis], len(coords[other_dim]), axis=1) + else: # Source is second dimension + values = np.repeat(data.values[np.newaxis, :], len(coords[other_dim]), axis=0) + + return xr.DataArray(values.copy(), coords=coords, dims=dims) raise ConversionError(f'Cannot broadcast from {data.dims} to {dims}') diff --git a/tests/test_dataconverter.py b/tests/test_dataconverter.py index 5536b8cb2..eed6c1283 100644 --- a/tests/test_dataconverter.py +++ b/tests/test_dataconverter.py @@ -11,621 +11,484 @@ @pytest.fixture -def sample_time_index(): +def time_coords(): return pd.date_range('2024-01-01', periods=5, freq='D', name='time') @pytest.fixture -def sample_scenario_index(): - return pd.Index(['baseline', 'high_demand', 'low_price'], name='scenario') +def scenario_coords(): + return pd.Index(['baseline', 'high', 'low'], name='scenario') -class TestSingleDimensionConversion: - """Tests for converting data without scenarios (1D: time only).""" +@pytest.fixture +def region_coords(): + return pd.Index(['north', 'south', 'east'], name='region') - def test_scalar_conversion(self, sample_time_index): - """Test converting a scalar value.""" - # Test with integer - result = DataConverter.to_dataarray(42, sample_time_index) - assert isinstance(result, xr.DataArray) - assert result.shape == (len(sample_time_index),) - assert result.dims == ('time',) - assert np.all(result.values == 42) - # Test with float - result = DataConverter.to_dataarray(42.5, sample_time_index) - assert np.all(result.values == 42.5) +class TestBasicConversion: + """Test basic data type conversions with different coordinate configurations.""" - # Test with numpy scalar types - result = DataConverter.to_dataarray(np.int64(42), sample_time_index) - assert np.all(result.values == 42) - result = DataConverter.to_dataarray(np.float32(42.5), sample_time_index) - assert np.all(result.values == 42.5) - - def test_ndarray_conversion(self, sample_time_index): - """Test converting a numpy ndarray.""" - # Test with integer 1D array - arr_1d = np.array([1, 2, 3, 4, 5]) - result = DataConverter.to_dataarray(arr_1d, sample_time_index) - assert result.shape == (5,) - assert result.dims == ('time',) - assert np.array_equal(result.values, arr_1d) - - # Test with float 1D array - arr_1d = np.array([1.1, 2.2, 3.3, 4.4, 5.5]) - result = DataConverter.to_dataarray(arr_1d, sample_time_index) - assert np.array_equal(result.values, arr_1d) - - # Test with array containing NaN - arr_1d = np.array([1, np.nan, 3, np.nan, 5]) - result = DataConverter.to_dataarray(arr_1d, sample_time_index) - assert np.array_equal(np.isnan(result.values), np.isnan(arr_1d)) - assert np.array_equal(result.values[~np.isnan(result.values)], arr_1d[~np.isnan(arr_1d)]) - - def test_dataarray_conversion(self, sample_time_index): - """Test converting an existing xarray DataArray.""" - # Create original DataArray - original = xr.DataArray(data=np.array([1, 2, 3, 4, 5]), coords={'time': sample_time_index}, dims=['time']) - - # Convert and check - result = DataConverter.to_dataarray(original, sample_time_index) + def test_scalar_no_coords(self): + """Scalar without coordinates should create 0D DataArray.""" + result = DataConverter.to_dataarray(42) + assert result.shape == () + assert result.dims == () + assert result.item() == 42 + + def test_scalar_single_coord(self, time_coords): + """Scalar with single coordinate should broadcast.""" + result = DataConverter.to_dataarray(42, coords={'time': time_coords}) assert result.shape == (5,) assert result.dims == ('time',) - assert np.array_equal(result.values, original.values) - - # Ensure it's a copy - result[0] = 999 - assert original[0].item() == 1 # Original should be unchanged - - # Test with different time coordinates but same length - different_times = pd.date_range('2025-01-01', periods=5, freq='D', name='time') - original = xr.DataArray(data=np.array([1, 2, 3, 4, 5]), coords={'time': different_times}, dims=['time']) - - # Should raise an error for mismatched time coordinates - with pytest.raises(ConversionError): - DataConverter.to_dataarray(original, sample_time_index) - - -class TestMultiDimensionConversion: - """Tests for converting data with scenarios (2D: scenario × time).""" - - def test_scalar_with_scenarios(self, sample_time_index, sample_scenario_index): - """Test converting scalar values with scenario dimension.""" - # Test with integer - result = DataConverter.to_dataarray(42, sample_time_index, sample_scenario_index) + assert np.all(result.values == 42) - assert isinstance(result, xr.DataArray) - assert result.shape == (len(sample_time_index), len(sample_scenario_index)) + def test_scalar_multiple_coords(self, time_coords, scenario_coords): + """Scalar with multiple coordinates should broadcast to all.""" + result = DataConverter.to_dataarray(42, coords={'time': time_coords, 'scenario': scenario_coords}) + assert result.shape == (5, 3) assert result.dims == ('time', 'scenario') assert np.all(result.values == 42) - assert set(result.coords['scenario'].values) == set(sample_scenario_index.values) - assert set(result.coords['time'].values) == set(sample_time_index.values) - # Test with float - result = DataConverter.to_dataarray(42.5, sample_time_index, sample_scenario_index) - assert np.all(result.values == 42.5) + def test_numpy_scalars(self, time_coords): + """Test numpy scalar types.""" + for scalar in [np.int32(42), np.int64(42), np.float32(42.5), np.float64(42.5)]: + result = DataConverter.to_dataarray(scalar, coords={'time': time_coords}) + assert result.shape == (5,) + assert np.all(result.values == scalar.item()) - def test_1d_array_with_scenarios_time_broadcast(self, sample_time_index, sample_scenario_index): - """Test converting 1D array matching time dimension (broadcasting across scenarios).""" - # Create 1D array matching timesteps length - arr_1d = np.array([1, 2, 3, 4, 5]) - # Convert with scenarios - result = DataConverter.to_dataarray(arr_1d, sample_time_index, sample_scenario_index) +class TestArrayConversion: + """Test numpy array conversions.""" - assert result.shape == (len(sample_time_index), len(sample_scenario_index)) - assert result.dims == ('time', 'scenario') + def test_1d_array_no_coords(self): + """1D array without coords should fail unless single element.""" + # Multi-element fails + with pytest.raises(ConversionError): + DataConverter.to_dataarray(np.array([1, 2, 3])) - # Each scenario should have the same values (broadcasting) - for scenario in sample_scenario_index: - scenario_slice = result.sel(scenario=scenario) - assert np.array_equal(scenario_slice.values, arr_1d) + # Single element succeeds + result = DataConverter.to_dataarray(np.array([42])) + assert result.shape == () + assert result.item() == 42 - def test_1d_array_with_scenarios_scenario_broadcast(self, sample_time_index, sample_scenario_index): - """Test converting 1D array matching scenario dimension (broadcasting across time).""" - # Create 1D array matching scenario length - arr_1d = np.array([10, 20, 30]) # 3 scenarios + def test_1d_array_matching_coord(self, time_coords): + """1D array matching coordinate length should work.""" + arr = np.array([10, 20, 30, 40, 50]) + result = DataConverter.to_dataarray(arr, coords={'time': time_coords}) + assert result.shape == (5,) + assert result.dims == ('time',) + assert np.array_equal(result.values, arr) - # Convert with time and scenarios - result = DataConverter.to_dataarray(arr_1d, sample_time_index, sample_scenario_index) + def test_1d_array_mismatched_coord(self, time_coords): + """1D array not matching coordinate length should fail.""" + arr = np.array([10, 20, 30]) # Length 3, time_coords has length 5 + with pytest.raises(ConversionError): + DataConverter.to_dataarray(arr, coords={'time': time_coords}) - assert result.shape == (len(sample_time_index), len(sample_scenario_index)) + def test_1d_array_broadcast_to_multiple_coords(self, time_coords, scenario_coords): + """1D array should broadcast to matching dimension.""" + # Array matching time dimension + time_arr = np.array([10, 20, 30, 40, 50]) + result = DataConverter.to_dataarray(time_arr, coords={'time': time_coords, 'scenario': scenario_coords}) + assert result.shape == (5, 3) assert result.dims == ('time', 'scenario') - # Each time step should have the same scenario values (broadcasting) - for time in sample_time_index: - time_slice = result.sel(time=time) - assert np.array_equal(time_slice.values, arr_1d) - - def test_dataarray_with_scenarios(self, sample_time_index, sample_scenario_index): - """Test converting an existing DataArray with scenarios.""" - # Create a multi-scenario DataArray with dims in (time, scenario) order - original = xr.DataArray( - data=np.array([[1, 6, 11], [2, 7, 12], [3, 8, 13], [4, 9, 14], [5, 10, 15]]), - coords={'time': sample_time_index, 'scenario': sample_scenario_index}, - dims=['time', 'scenario'], - ) - - # Test conversion - result = DataConverter.to_dataarray(original, sample_time_index, sample_scenario_index) + # Each scenario should have the same time values + for scenario in scenario_coords: + assert np.array_equal(result.sel(scenario=scenario).values, time_arr) + # Array matching scenario dimension + scenario_arr = np.array([100, 200, 300]) + result = DataConverter.to_dataarray(scenario_arr, coords={'time': time_coords, 'scenario': scenario_coords}) assert result.shape == (5, 3) assert result.dims == ('time', 'scenario') - assert np.array_equal(result.values, original.values) - # Ensure it's a copy - result.loc[:, 'baseline'] = 999 - assert original.sel(scenario='baseline')[0].item() == 1 # Original should be unchanged + # Each time should have the same scenario values + for time in time_coords: + assert np.array_equal(result.sel(time=time).values, scenario_arr) + + def test_1d_array_ambiguous_length(self): + """Array length matching multiple dimensions should fail.""" + # Both dimensions have length 3 + coords_3x3 = { + 'time': pd.date_range('2024-01-01', periods=3, freq='D', name='time'), + 'scenario': pd.Index(['A', 'B', 'C'], name='scenario') + } + arr = np.array([1, 2, 3]) + + with pytest.raises(ConversionError, match="matches multiple dimensions"): + DataConverter.to_dataarray(arr, coords=coords_3x3) + + def test_multidimensional_array_rejected(self, time_coords): + """Multidimensional arrays should be rejected.""" + arr_2d = np.array([[1, 2, 3], [4, 5, 6]]) + with pytest.raises(ConversionError, match="Only 1D arrays supported"): + DataConverter.to_dataarray(arr_2d, coords={'time': time_coords}) class TestSeriesConversion: - """Tests for converting pandas Series to DataArray.""" + """Test pandas Series conversions.""" - def test_series_single_dimension_time(self, sample_time_index): - """Test converting a pandas Series with time index.""" - # Create a Series with matching time index - series = pd.Series([10, 20, 30, 40, 50], index=sample_time_index) + def test_series_no_coords(self): + """Series without coords should fail unless single element.""" + # Multi-element fails + series = pd.Series([1, 2, 3]) + with pytest.raises(ConversionError): + DataConverter.to_dataarray(series) - # Convert and check - result = DataConverter.to_dataarray(series, sample_time_index) - assert isinstance(result, xr.DataArray) + # Single element succeeds + single_series = pd.Series([42]) + result = DataConverter.to_dataarray(single_series) + assert result.shape == () + assert result.item() == 42 + + def test_series_matching_index(self, time_coords, scenario_coords): + """Series with matching index should work.""" + # Time-indexed series + time_series = pd.Series([10, 20, 30, 40, 50], index=time_coords) + result = DataConverter.to_dataarray(time_series, coords={'time': time_coords}) assert result.shape == (5,) assert result.dims == ('time',) - assert np.array_equal(result.values, series.values) - assert np.array_equal(result.coords['time'].values, sample_time_index.values) + assert np.array_equal(result.values, time_series.values) - def test_series_single_dimension_scenario(self, sample_scenario_index): - """Test converting a pandas Series with scenario index.""" - # Create a Series with scenario index - series = pd.Series([100, 200, 300], index=sample_scenario_index) - - result = DataConverter.to_dataarray(series, scenarios=sample_scenario_index) + # Scenario-indexed series + scenario_series = pd.Series([100, 200, 300], index=scenario_coords) + result = DataConverter.to_dataarray(scenario_series, coords={'scenario': scenario_coords}) assert result.shape == (3,) assert result.dims == ('scenario',) - assert np.array_equal(result.values, series.values) - assert np.array_equal(result.coords['scenario'].values, sample_scenario_index.values) + assert np.array_equal(result.values, scenario_series.values) - def test_series_mismatched_index(self, sample_time_index): - """Test converting a Series with mismatched index.""" - # Create Series with different time index - different_times = pd.date_range('2025-01-01', periods=5, freq='D', name='time') - series = pd.Series([10, 20, 30, 40, 50], index=different_times) + def test_series_mismatched_index(self, time_coords): + """Series with non-matching index should fail.""" + wrong_times = pd.date_range('2025-01-01', periods=5, freq='D', name='time') + series = pd.Series([10, 20, 30, 40, 50], index=wrong_times) - # Should raise error for mismatched index with pytest.raises(ConversionError): - DataConverter.to_dataarray(series, sample_time_index) + DataConverter.to_dataarray(series, coords={'time': time_coords}) - def test_series_broadcast_to_scenarios(self, sample_time_index, sample_scenario_index): - """Test broadcasting a time-indexed Series across scenarios.""" - # Create a Series with time index - series = pd.Series([10, 20, 30, 40, 50], index=sample_time_index) + def test_series_broadcast_to_multiple_coords(self, time_coords, scenario_coords): + """Series should broadcast to non-matching dimensions.""" + # Time series broadcast to scenarios + time_series = pd.Series([10, 20, 30, 40, 50], index=time_coords) + result = DataConverter.to_dataarray(time_series, coords={'time': time_coords, 'scenario': scenario_coords}) + assert result.shape == (5, 3) - # Convert with scenarios - result = DataConverter.to_dataarray(series, sample_time_index, sample_scenario_index) + for scenario in scenario_coords: + assert np.array_equal(result.sel(scenario=scenario).values, time_series.values) + # Scenario series broadcast to time + scenario_series = pd.Series([100, 200, 300], index=scenario_coords) + result = DataConverter.to_dataarray(scenario_series, coords={'time': time_coords, 'scenario': scenario_coords}) assert result.shape == (5, 3) - assert result.dims == ('time', 'scenario') - # Check broadcasting - each scenario should have the same values - for scenario in sample_scenario_index: - scenario_slice = result.sel(scenario=scenario) - assert np.array_equal(scenario_slice.values, series.values) + for time in time_coords: + assert np.array_equal(result.sel(time=time).values, scenario_series.values) - def test_series_broadcast_to_time(self, sample_time_index, sample_scenario_index): - """Test broadcasting a scenario-indexed Series across time.""" - # Create a Series with scenario index - series = pd.Series([100, 200, 300], index=sample_scenario_index) + def test_series_wrong_dimension(self, time_coords, region_coords): + """Series indexed by dimension not in coords should fail.""" + wrong_series = pd.Series([1, 2, 3], index=region_coords) - # Convert with time - result = DataConverter.to_dataarray(series, sample_time_index, sample_scenario_index) + with pytest.raises(ConversionError): + DataConverter.to_dataarray(wrong_series, coords={'time': time_coords}) - assert result.shape == (5, 3) - assert result.dims == ('time', 'scenario') - # Check broadcasting - each time should have the same scenario values - for time in sample_time_index: - time_slice = result.sel(time=time) - assert np.array_equal(time_slice.values, series.values) +class TestDataFrameConversion: + """Test pandas DataFrame conversions.""" + def test_single_column_dataframe(self, time_coords): + """Single-column DataFrame should work like Series.""" + df = pd.DataFrame({'value': [10, 20, 30, 40, 50]}, index=time_coords) + result = DataConverter.to_dataarray(df, coords={'time': time_coords}) -class TestTimeSeriesDataConversion: - """Tests for converting TimeSeriesData objects.""" + assert result.shape == (5,) + assert result.dims == ('time',) + assert np.array_equal(result.values, df['value'].values) - def test_timeseries_data_conversion(self, sample_time_index): - """Test converting TimeSeriesData.""" - # Create TimeSeriesData - data_array = xr.DataArray([1, 2, 3, 4, 5], coords={'time': sample_time_index}, dims=['time']) - ts_data = TimeSeriesData(data_array, aggregation_group='test_group') + def test_multi_column_dataframe_rejected(self, time_coords): + """Multi-column DataFrame should be rejected.""" + df = pd.DataFrame({ + 'value1': [10, 20, 30, 40, 50], + 'value2': [15, 25, 35, 45, 55] + }, index=time_coords) - # Convert - result = DataConverter.to_dataarray(ts_data, sample_time_index) + with pytest.raises(ConversionError, match="Only single-column DataFrames are supported"): + DataConverter.to_dataarray(df, coords={'time': time_coords}) - assert isinstance(result, xr.DataArray) - assert result.shape == (5,) - assert result.dims == ('time',) - assert np.array_equal(result.values, [1, 2, 3, 4, 5]) + def test_empty_dataframe_rejected(self, time_coords): + """Empty DataFrame should be rejected.""" + df = pd.DataFrame(index=time_coords) # No columns - def test_timeseries_data_with_scenarios(self, sample_time_index, sample_scenario_index): - """Test converting TimeSeriesData with broadcasting to scenarios.""" - # Create 1D TimeSeriesData - data_array = xr.DataArray([1, 2, 3, 4, 5], coords={'time': sample_time_index}, dims=['time']) - ts_data = TimeSeriesData(data_array) + with pytest.raises(ConversionError, match="Only single-column DataFrames are supported"): + DataConverter.to_dataarray(df, coords={'time': time_coords}) - # Convert with scenarios (should broadcast) - result = DataConverter.to_dataarray(ts_data, sample_time_index, sample_scenario_index) + def test_dataframe_broadcast(self, time_coords, scenario_coords): + """Single-column DataFrame should broadcast like Series.""" + df = pd.DataFrame({'power': [10, 20, 30, 40, 50]}, index=time_coords) + result = DataConverter.to_dataarray(df, coords={'time': time_coords, 'scenario': scenario_coords}) assert result.shape == (5, 3) - assert result.dims == ('time', 'scenario') + for scenario in scenario_coords: + assert np.array_equal(result.sel(scenario=scenario).values, df['power'].values) - # Each scenario should have the same values - for scenario in sample_scenario_index: - assert np.array_equal(result.sel(scenario=scenario).values, [1, 2, 3, 4, 5]) +class TestDataArrayConversion: + """Test xarray DataArray conversions.""" -class TestDataFrameConversion: - """Tests for converting single-column pandas DataFrames to DataArray.""" - - def test_single_column_dataframe_time(self, sample_time_index): - """Test converting a single-column DataFrame with time index.""" - # Create DataFrame with one column - df = pd.DataFrame({'value': [10, 20, 30, 40, 50]}, index=sample_time_index) + def test_compatible_dataarray(self, time_coords): + """Compatible DataArray should pass through.""" + original = xr.DataArray([10, 20, 30, 40, 50], coords={'time': time_coords}, dims=['time']) + result = DataConverter.to_dataarray(original, coords={'time': time_coords}) - # Convert and check - result = DataConverter.to_dataarray(df, sample_time_index) - assert isinstance(result, xr.DataArray) assert result.shape == (5,) assert result.dims == ('time',) - assert np.array_equal(result.values, df['value'].values) - - def test_single_column_dataframe_scenario(self, sample_scenario_index): - """Test converting a single-column DataFrame with scenario index.""" - # Create DataFrame with one column and scenario index - df = pd.DataFrame({'value': [100, 200, 300]}, index=sample_scenario_index) - - result = DataConverter.to_dataarray(df, scenarios=sample_scenario_index) - assert result.shape == (3,) - assert result.dims == ('scenario',) - assert np.array_equal(result.values, df['value'].values) + assert np.array_equal(result.values, original.values) - def test_dataframe_broadcast_to_scenarios(self, sample_time_index, sample_scenario_index): - """Test broadcasting a time-indexed DataFrame across scenarios.""" - # Create DataFrame with time index - df = pd.DataFrame({'power': [10, 20, 30, 40, 50]}, index=sample_time_index) + # Should be a copy + result[0] = 999 + assert original[0].item() == 10 - # Convert with scenarios - result = DataConverter.to_dataarray(df, sample_time_index, sample_scenario_index) + def test_incompatible_dataarray_coords(self, time_coords): + """DataArray with wrong coordinates should fail.""" + wrong_times = pd.date_range('2025-01-01', periods=5, freq='D', name='time') + original = xr.DataArray([10, 20, 30, 40, 50], coords={'time': wrong_times}, dims=['time']) - assert result.shape == (5, 3) - assert result.dims == ('time', 'scenario') + with pytest.raises(ConversionError): + DataConverter.to_dataarray(original, coords={'time': time_coords}) - # Check broadcasting - each scenario should have the same values - for scenario in sample_scenario_index: - scenario_slice = result.sel(scenario=scenario) - assert np.array_equal(scenario_slice.values, df['power'].values) + def test_incompatible_dataarray_dims(self, time_coords): + """DataArray with wrong dimensions should fail.""" + original = xr.DataArray([10, 20, 30, 40, 50], coords={'wrong_dim': range(5)}, dims=['wrong_dim']) - def test_dataframe_broadcast_to_time(self, sample_time_index, sample_scenario_index): - """Test broadcasting a scenario-indexed DataFrame across time.""" - # Create DataFrame with scenario index - df = pd.DataFrame({'cost': [100, 200, 300]}, index=sample_scenario_index) + with pytest.raises(ConversionError): + DataConverter.to_dataarray(original, coords={'time': time_coords}) - # Convert with time - result = DataConverter.to_dataarray(df, sample_time_index, sample_scenario_index) + def test_dataarray_broadcast(self, time_coords, scenario_coords): + """DataArray should broadcast to additional dimensions.""" + # 1D time DataArray to 2D time+scenario + original = xr.DataArray([10, 20, 30, 40, 50], coords={'time': time_coords}, dims=['time']) + result = DataConverter.to_dataarray(original, coords={'time': time_coords, 'scenario': scenario_coords}) assert result.shape == (5, 3) assert result.dims == ('time', 'scenario') - # Check broadcasting - each time should have the same scenario values - for time in sample_time_index: - time_slice = result.sel(time=time) - assert np.array_equal(time_slice.values, df['cost'].values) + for scenario in scenario_coords: + assert np.array_equal(result.sel(scenario=scenario).values, original.values) - def test_multi_column_dataframe_fails(self, sample_time_index): - """Test that multi-column DataFrames are rejected.""" - # Create DataFrame with multiple columns - df = pd.DataFrame({ - 'value1': [10, 20, 30, 40, 50], - 'value2': [15, 25, 35, 45, 55] - }, index=sample_time_index) + def test_scalar_dataarray_broadcast(self, time_coords, scenario_coords): + """Scalar DataArray should broadcast to all dimensions.""" + scalar_da = xr.DataArray(42) + result = DataConverter.to_dataarray(scalar_da, coords={'time': time_coords, 'scenario': scenario_coords}) - # Should raise error - with pytest.raises(ConversionError, match="Only single-column DataFrames are supported"): - DataConverter.to_dataarray(df, sample_time_index) + assert result.shape == (5, 3) + assert np.all(result.values == 42) - def test_dataframe_mismatched_index(self, sample_time_index): - """Test DataFrame with mismatched index.""" - # Create DataFrame with different time index - different_times = pd.date_range('2025-01-01', periods=5, freq='D', name='time') - df = pd.DataFrame({'value': [10, 20, 30, 40, 50]}, index=different_times) - # Should raise error for mismatched index - with pytest.raises(ConversionError): - DataConverter.to_dataarray(df, sample_time_index) +class TestTimeSeriesDataConversion: + """Test TimeSeriesData conversions.""" - def test_dataframe_copy_behavior(self, sample_time_index): - """Test that DataFrame conversion creates a copy.""" - # Create DataFrame - df = pd.DataFrame({'value': [10, 20, 30, 40, 50]}, index=sample_time_index) + def test_timeseries_data_basic(self, time_coords): + """TimeSeriesData should work like DataArray.""" + data_array = xr.DataArray([10, 20, 30, 40, 50], coords={'time': time_coords}, dims=['time']) + ts_data = TimeSeriesData(data_array, aggregation_group='test') - # Convert - result = DataConverter.to_dataarray(df, sample_time_index) + result = DataConverter.to_dataarray(ts_data, coords={'time': time_coords}) - # Modify the result - result[0] = 999 + assert result.shape == (5,) + assert result.dims == ('time',) + assert np.array_equal(result.values, [10, 20, 30, 40, 50]) - # Original DataFrame should be unchanged - assert df.loc[sample_time_index[0], 'value'] == 10 + def test_timeseries_data_broadcast(self, time_coords, scenario_coords): + """TimeSeriesData should broadcast to additional dimensions.""" + data_array = xr.DataArray([10, 20, 30, 40, 50], coords={'time': time_coords}, dims=['time']) + ts_data = TimeSeriesData(data_array) - def test_empty_dataframe_fails(self, sample_time_index): - """Test that empty DataFrames are rejected.""" - # DataFrame with no columns - df = pd.DataFrame(index=sample_time_index) + result = DataConverter.to_dataarray(ts_data, coords={'time': time_coords, 'scenario': scenario_coords}) - with pytest.raises(ConversionError, match="Only single-column DataFrames are supported"): - DataConverter.to_dataarray(df, sample_time_index) + assert result.shape == (5, 3) + for scenario in scenario_coords: + assert np.array_equal(result.sel(scenario=scenario).values, [10, 20, 30, 40, 50]) - def test_dataframe_with_named_column(self, sample_time_index): - """Test DataFrame with a named column.""" - df = pd.DataFrame(index=sample_time_index) - df['energy_output'] = [100, 150, 200, 175, 125] - result = DataConverter.to_dataarray(df, sample_time_index) - assert result.shape == (5,) - assert np.array_equal(result.values, [100, 150, 200, 175, 125]) +class TestCustomDimensions: + """Test with custom dimension names beyond time/scenario.""" + def test_custom_single_dimension(self, region_coords): + """Test with custom dimension name.""" + result = DataConverter.to_dataarray(42, coords={'region': region_coords}) + assert result.shape == (3,) + assert result.dims == ('region',) + assert np.all(result.values == 42) -class TestInvalidInputs: - """Tests for invalid inputs and error handling.""" + def test_custom_multiple_dimensions(self): + """Test with multiple custom dimensions.""" + products = pd.Index(['A', 'B'], name='product') + technologies = pd.Index(['solar', 'wind', 'gas'], name='technology') - def test_time_index_validation(self): - """Test validation of time index.""" - # Test with unnamed index - unnamed_index = pd.date_range('2024-01-01', periods=5, freq='D') - # Should automatically rename to 'time' with a warning, not raise error - result = DataConverter.to_dataarray(42, unnamed_index) - assert result.coords['time'].name == 'time' + # Array matching technology dimension + arr = np.array([100, 150, 80]) + result = DataConverter.to_dataarray(arr, coords={'product': products, 'technology': technologies}) - # Test with empty index - empty_index = pd.DatetimeIndex([], name='time') - with pytest.raises(ConversionError): - DataConverter.to_dataarray(42, empty_index) + assert result.shape == (2, 3) + assert result.dims == ('product', 'technology') - # Test with non-DatetimeIndex - wrong_type_index = pd.Index([1, 2, 3, 4, 5], name='time') - with pytest.raises(ConversionError): - DataConverter.to_dataarray(42, wrong_type_index) - - def test_scenario_index_validation(self, sample_time_index): - """Test validation of scenario index.""" - # Test with unnamed scenario index - unnamed_index = pd.Index(['baseline', 'high_demand']) - # Should automatically rename to 'scenario' with a warning, not raise error - result = DataConverter.to_dataarray(42, sample_time_index, unnamed_index) - assert result.coords['scenario'].name == 'scenario' - - # Test with empty scenario index - empty_index = pd.Index([], name='scenario') - with pytest.raises(ConversionError): - DataConverter.to_dataarray(42, sample_time_index, empty_index) + # Should broadcast across products + for product in products: + assert np.array_equal(result.sel(product=product).values, arr) - # Test with non-Index scenario - with pytest.raises(ConversionError): - DataConverter.to_dataarray(42, sample_time_index, ['baseline', 'high_demand']) + def test_mixed_dimension_types(self): + """Test mixing time dimension with custom dimensions.""" + time_coords = pd.date_range('2024-01-01', periods=3, freq='D', name='time') + regions = pd.Index(['north', 'south'], name='region') - def test_invalid_data_types(self, sample_time_index, sample_scenario_index): - """Test handling of invalid data types.""" - # Test invalid input type (string) - with pytest.raises(ConversionError): - DataConverter.to_dataarray('invalid_string', sample_time_index) + # Time series should broadcast to regions + time_series = pd.Series([10, 20, 30], index=time_coords) + result = DataConverter.to_dataarray(time_series, coords={'time': time_coords, 'region': regions}) - # Test invalid input type with scenarios - with pytest.raises(ConversionError): - DataConverter.to_dataarray('invalid_string', sample_time_index, sample_scenario_index) + assert result.shape == (3, 2) + assert result.dims == ('time', 'region') - # Test unsupported complex object - with pytest.raises(ConversionError): - DataConverter.to_dataarray(object(), sample_time_index) - # Test None value - with pytest.raises(ConversionError): - DataConverter.to_dataarray(None, sample_time_index) +class TestValidation: + """Test coordinate validation.""" - def test_multidimensional_array_rejection(self, sample_time_index, sample_scenario_index): - """Test that multidimensional arrays are rejected.""" - # Test 2D array (not supported in simplified version) - arr_2d = np.array([[1, 2, 3, 4, 5], [6, 7, 8, 9, 10]]) - with pytest.raises(ConversionError, match="Only 1D arrays supported"): - DataConverter.to_dataarray(arr_2d, sample_time_index) + def test_empty_coords(self): + """Empty coordinates should work for scalars.""" + result = DataConverter.to_dataarray(42, coords={}) + assert result.shape == () + assert result.item() == 42 - # Test 3D array - arr_3d = np.ones((2, 3, 4)) - with pytest.raises(ConversionError, match="Only 1D arrays supported"): - DataConverter.to_dataarray(arr_3d, sample_time_index, sample_scenario_index) - - def test_mismatched_input_dimensions(self, sample_time_index, sample_scenario_index): - """Test handling of mismatched input dimensions.""" - # Test mismatched Series index - mismatched_series = pd.Series( - [1, 2, 3, 4, 5, 6], index=pd.date_range('2025-01-01', periods=6, freq='D', name='time') - ) + def test_invalid_coord_type(self): + """Non-pandas Index coordinates should fail.""" with pytest.raises(ConversionError): - DataConverter.to_dataarray(mismatched_series, sample_time_index) + DataConverter.to_dataarray(42, coords={'time': [1, 2, 3]}) - # Test mismatched array length for time-only + def test_empty_coord_index(self): + """Empty coordinate index should fail.""" + empty_index = pd.Index([], name='time') with pytest.raises(ConversionError): - DataConverter.to_dataarray(np.array([1, 2, 3]), sample_time_index) # Wrong length + DataConverter.to_dataarray(42, coords={'time': empty_index}) + + def test_time_coord_validation(self): + """Time coordinates must be DatetimeIndex.""" + # Non-datetime index with name 'time' should fail + wrong_time = pd.Index([1, 2, 3], name='time') + with pytest.raises(ConversionError, match="time coordinates must be a DatetimeIndex"): + DataConverter.to_dataarray(42, coords={'time': wrong_time}) + + def test_coord_naming(self, time_coords): + """Coordinates should be auto-renamed to match dimension.""" + # Unnamed time index should be renamed + unnamed_time = time_coords.rename(None) + result = DataConverter.to_dataarray(42, coords={'time': unnamed_time}) + assert result.coords['time'].name == 'time' - # Test array that doesn't match either dimension - wrong_length_array = np.array([1, 2, 3, 4]) # Doesn't match time (5) or scenario (3) - with pytest.raises(ConversionError): - DataConverter.to_dataarray(wrong_length_array, sample_time_index, sample_scenario_index) - def test_dataarray_dimension_mismatch(self, sample_time_index, sample_scenario_index): - """Test handling of mismatched DataArray dimensions.""" - # Create DataArray with wrong dimensions - wrong_dims = xr.DataArray(data=np.array([1, 2, 3, 4, 5]), coords={'wrong_dim': range(5)}, dims=['wrong_dim']) - with pytest.raises(ConversionError): - DataConverter.to_dataarray(wrong_dims, sample_time_index) - - # Create DataArray with right dims but wrong coordinate values - wrong_coords = xr.DataArray( - data=np.array([1, 2, 3, 4, 5]), - coords={'time': pd.date_range('2025-01-01', periods=5, freq='D', name='time')}, - dims=['time'] - ) - with pytest.raises(ConversionError): - DataConverter.to_dataarray(wrong_coords, sample_time_index) +class TestErrorHandling: + """Test error handling and edge cases.""" + def test_unsupported_data_types(self, time_coords): + """Unsupported data types should fail with clear messages.""" + unsupported = [ + 'string', + object(), + None, + {'dict': 'value'}, + [1, 2, 3] + ] -class TestDataArrayBroadcasting: - """Tests for broadcasting DataArrays.""" + for data in unsupported: + with pytest.raises(ConversionError): + DataConverter.to_dataarray(data, coords={'time': time_coords}) - def test_broadcast_1d_array_to_2d_time(self, sample_time_index, sample_scenario_index): - """Test broadcasting a 1D array (time) to 2D.""" - arr_1d = np.array([1, 2, 3, 4, 5]) + def test_dimension_mismatch_messages(self, time_coords, scenario_coords): + """Error messages should be informative.""" + # Array with wrong length + wrong_arr = np.array([1, 2]) # Length 2, but no dimension has length 2 + with pytest.raises(ConversionError, match="matches none of the dimensions"): + DataConverter.to_dataarray(wrong_arr, coords={'time': time_coords, 'scenario': scenario_coords}) - result = DataConverter.to_dataarray(arr_1d, sample_time_index, sample_scenario_index) + def test_maximum_dimensions(self): + """Should handle up to 2 dimensions currently.""" + coords = { + 'dim1': pd.Index(['a', 'b'], name='dim1'), + 'dim2': pd.Index(['x', 'y'], name='dim2'), + 'dim3': pd.Index(['1', '2'], name='dim3') + } - # Should broadcast across scenarios - expected = np.repeat(arr_1d[:, np.newaxis], len(sample_scenario_index), axis=1) - assert np.array_equal(result.values, expected) - assert result.dims == ('time', 'scenario') + with pytest.raises(ConversionError, match="Maximum 2 dimensions currently supported"): + DataConverter.to_dataarray(42, coords=coords) - def test_broadcast_1d_array_to_2d_scenario(self, sample_time_index, sample_scenario_index): - """Test broadcasting a 1D array (scenario) to 2D.""" - arr_1d = np.array([1, 2, 3]) # Matches scenario length - result = DataConverter.to_dataarray(arr_1d, sample_time_index, sample_scenario_index) +class TestDataIntegrity: + """Test data copying and integrity.""" - # Should broadcast across time - expected = np.repeat(arr_1d[np.newaxis, :], len(sample_time_index), axis=0) - assert np.array_equal(result.values, expected) - assert result.dims == ('time', 'scenario') + def test_array_copy_independence(self, time_coords): + """Converted arrays should be independent copies.""" + original_arr = np.array([10, 20, 30, 40, 50]) + result = DataConverter.to_dataarray(original_arr, coords={'time': time_coords}) - def test_broadcast_1d_array_to_1d(self, sample_time_index): - """Test that 1D array with matching dimension doesn't change.""" - arr_1d = np.array([1, 2, 3, 4, 5]) + # Modify result + result[0] = 999 - result = DataConverter.to_dataarray(arr_1d, sample_time_index) + # Original should be unchanged + assert original_arr[0] == 10 - assert np.array_equal(result.values, arr_1d) - assert result.dims == ('time',) + def test_series_copy_independence(self, time_coords): + """Converted Series should be independent copies.""" + original_series = pd.Series([10, 20, 30, 40, 50], index=time_coords) + result = DataConverter.to_dataarray(original_series, coords={'time': time_coords}) - def test_scalar_dataarray_broadcasting(self, sample_time_index, sample_scenario_index): - """Test broadcasting scalar DataArray.""" - scalar_da = xr.DataArray(42) + # Modify result + result[0] = 999 - result = DataConverter.to_dataarray(scalar_da, sample_time_index, sample_scenario_index) + # Original should be unchanged + assert original_series.iloc[0] == 10 - assert result.shape == (len(sample_time_index), len(sample_scenario_index)) - assert np.all(result.values == 42) + def test_dataframe_copy_independence(self, time_coords): + """Converted DataFrames should be independent copies.""" + original_df = pd.DataFrame({'value': [10, 20, 30, 40, 50]}, index=time_coords) + result = DataConverter.to_dataarray(original_df, coords={'time': time_coords}) + # Modify result + result[0] = 999 -class TestEdgeCases: - """Tests for edge cases and special scenarios.""" + # Original should be unchanged + assert original_df.loc[time_coords[0], 'value'] == 10 - def test_single_timestep(self, sample_scenario_index): - """Test with a single timestep.""" - # Test with only one timestep - single_timestep = pd.DatetimeIndex(['2024-01-01'], name='time') - # Scalar conversion - result = DataConverter.to_dataarray(42, single_timestep) - assert result.shape == (1,) - assert result.dims == ('time',) +class TestSpecialValues: + """Test handling of special numeric values.""" - # With scenarios - result_with_scenarios = DataConverter.to_dataarray(42, single_timestep, sample_scenario_index) - assert result_with_scenarios.shape == (1, len(sample_scenario_index)) - assert result_with_scenarios.dims == ('time', 'scenario') + def test_nan_values(self, time_coords): + """NaN values should be preserved.""" + arr_with_nan = np.array([1, np.nan, 3, np.nan, 5]) + result = DataConverter.to_dataarray(arr_with_nan, coords={'time': time_coords}) - def test_single_scenario(self, sample_time_index): - """Test with a single scenario.""" - # Test with only one scenario - single_scenario = pd.Index(['baseline'], name='scenario') + assert np.array_equal(np.isnan(result.values), np.isnan(arr_with_nan)) + assert np.array_equal(result.values[~np.isnan(result.values)], arr_with_nan[~np.isnan(arr_with_nan)]) - # Scalar conversion with single scenario - result = DataConverter.to_dataarray(42, sample_time_index, single_scenario) - assert result.shape == (len(sample_time_index), 1) - assert result.dims == ('time', 'scenario') + def test_infinite_values(self, time_coords): + """Infinite values should be preserved.""" + arr_with_inf = np.array([1, np.inf, 3, -np.inf, 5]) + result = DataConverter.to_dataarray(arr_with_inf, coords={'time': time_coords}) - # Array conversion with single scenario - arr = np.array([1, 2, 3, 4, 5]) - result_arr = DataConverter.to_dataarray(arr, sample_time_index, single_scenario) - assert result_arr.shape == (5, 1) - assert np.array_equal(result_arr.sel(scenario='baseline').values, arr) - - def test_all_nan_data(self, sample_time_index, sample_scenario_index): - """Test handling of all-NaN data.""" - # Create array of all NaNs - all_nan_array = np.full(5, np.nan) - result = DataConverter.to_dataarray(all_nan_array, sample_time_index) - assert np.all(np.isnan(result.values)) - - # With scenarios - result = DataConverter.to_dataarray(all_nan_array, sample_time_index, sample_scenario_index) - assert result.shape == (len(sample_time_index), len(sample_scenario_index)) - assert np.all(np.isnan(result.values)) - - def test_mixed_data_types(self, sample_time_index, sample_scenario_index): - """Test conversion of mixed integer and float data.""" - # Create array with mixed types - mixed_array = np.array([1, 2.5, 3, 4.5, 5]) - result = DataConverter.to_dataarray(mixed_array, sample_time_index) - - # Result should be float dtype - assert np.issubdtype(result.dtype, np.floating) - assert np.array_equal(result.values, mixed_array) + assert np.array_equal(result.values, arr_with_inf) - # With scenarios - result = DataConverter.to_dataarray(mixed_array, sample_time_index, sample_scenario_index) - assert np.issubdtype(result.dtype, np.floating) - for scenario in sample_scenario_index: - assert np.array_equal(result.sel(scenario=scenario).values, mixed_array) + def test_boolean_values(self, time_coords): + """Boolean values should be preserved.""" + bool_arr = np.array([True, False, True, False, True]) + result = DataConverter.to_dataarray(bool_arr, coords={'time': time_coords}) - def test_boolean_data(self, sample_time_index, sample_scenario_index): - """Test handling of boolean data.""" - bool_array = np.array([True, False, True, False, True]) - result = DataConverter.to_dataarray(bool_array, sample_time_index, sample_scenario_index) assert result.dtype == bool - assert result.shape == (len(sample_time_index), len(sample_scenario_index)) + assert np.array_equal(result.values, bool_arr) + def test_mixed_numeric_types(self, time_coords): + """Mixed integer/float should become float.""" + mixed_arr = np.array([1, 2.5, 3, 4.5, 5]) + result = DataConverter.to_dataarray(mixed_arr, coords={'time': time_coords}) -class TestNoIndexConversion: - """Tests for conversion without any indices (scalar results).""" - - def test_scalar_no_dimensions(self): - """Test scalar conversion without any dimensions.""" - result = DataConverter.to_dataarray(42) - assert isinstance(result, xr.DataArray) - assert result.shape == () - assert result.dims == () - assert result.item() == 42 - - def test_single_element_array_no_dimensions(self): - """Test single-element array without dimensions.""" - arr = np.array([42]) - result = DataConverter.to_dataarray(arr) - assert result.shape == () - assert result.item() == 42 - - def test_multi_element_array_no_dimensions_fails(self): - """Test that multi-element array fails without dimensions.""" - arr = np.array([1, 2, 3]) - with pytest.raises(ConversionError): - DataConverter.to_dataarray(arr) - - def test_series_no_dimensions_fails(self): - """Test that multi-element Series fails without dimensions.""" - series = pd.Series([1, 2, 3]) - with pytest.raises(ConversionError): - DataConverter.to_dataarray(series) - - def test_single_element_series_no_dimensions(self): - """Test single-element Series without dimensions.""" - series = pd.Series([42]) - result = DataConverter.to_dataarray(series) - assert result.shape == () - assert result.item() == 42 + assert np.issubdtype(result.dtype, np.floating) + assert np.array_equal(result.values, mixed_arr) if __name__ == '__main__': From 605f03469ecbc202cdf65841f19937566d6d8e41 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 29 Jun 2025 21:49:46 +0200 Subject: [PATCH 143/336] Update DataConverter for n-d arrays --- flixopt/core.py | 187 ++++++++++++++++++++++++++++++------ tests/test_dataconverter.py | 98 +++++++++++++++++++ 2 files changed, 255 insertions(+), 30 deletions(-) diff --git a/flixopt/core.py b/flixopt/core.py index c11ba3993..9278f079c 100644 --- a/flixopt/core.py +++ b/flixopt/core.py @@ -126,24 +126,148 @@ def agg_weight(self): class DataConverter: """ - Converts scalars and 1D data into xarray.DataArray with optional time and scenario dimensions. + Converts data into xarray.DataArray with specified coordinates. - Only handles: - - Scalars (int, float, np.number) - - 1D arrays (np.ndarray, pd.Series) - - xr.DataArray (for broadcasting/checking) + Supports: + - Scalars (broadcast to all dimensions) + - 1D data (np.ndarray, pd.Series, single-column DataFrame) + - xr.DataArray (validated and potentially broadcast) + + Simple 1D data is matched to one dimension and broadcast to others. + DataArrays can have any number of dimensions. """ @staticmethod - def _convert_dataframe(data: pd.DataFrame, coords: Dict[str, pd.Index], dims: Tuple[str, ...]) -> xr.DataArray: - """Convert single-column pandas DataFrame to DataArray by treating it as a Series.""" - # Check that DataFrame has exactly one column - if len(data.columns) != 1: - raise ConversionError(f'Only single-column DataFrames are supported, got {len(data.columns)} columns') + def _convert_1d_data_to_dataarray( + data: Union[np.ndarray, pd.Series], coords: Dict[str, pd.Index], target_dims: Tuple[str, ...] + ) -> xr.DataArray: + """ + Convert 1D data (array or Series) to DataArray by matching to one dimension. + + Args: + data: 1D numpy array or pandas Series + coords: Available coordinates + target_dims: Target dimension names + + Returns: + DataArray with the data matched to appropriate dimension + """ + if len(target_dims) == 0: + # No target dimensions - data must be single element + if len(data) != 1: + raise ConversionError('Cannot convert multi-element data without target dimensions') + return xr.DataArray(data[0] if isinstance(data, np.ndarray) else data.iloc[0]) + + # For Series, try to match index to coordinates + if isinstance(data, pd.Series): + for dim_name in target_dims: + if data.index.equals(coords[dim_name]): + return xr.DataArray(data.values.copy(), coords={dim_name: coords[dim_name]}, dims=[dim_name]) + + # If no index match, fall through to length matching + + # For arrays or unmatched Series, match by length + matching_dims = [] + for dim_name in target_dims: + if len(data) == len(coords[dim_name]): + matching_dims.append(dim_name) + + if len(matching_dims) == 0: + dim_info = {dim: len(coords[dim]) for dim in target_dims} + raise ConversionError(f'Data length {len(data)} matches none of the target dimensions: {dim_info}') + elif len(matching_dims) > 1: + raise ConversionError( + f'Data length {len(data)} matches multiple dimensions: {matching_dims}. Cannot determine which dimension to use.' + ) + + # Match to the single matching dimension + match_dim = matching_dims[0] + values = data.values.copy() if isinstance(data, pd.Series) else data.copy() + return xr.DataArray(values, coords={match_dim: coords[match_dim]}, dims=[match_dim]) + + @staticmethod + def _broadcast_to_target_dims( + data: xr.DataArray, coords: Dict[str, pd.Index], target_dims: Tuple[str, ...] + ) -> xr.DataArray: + """ + Broadcast DataArray to match target dimensions. + + Args: + data: Source DataArray + coords: Target coordinates + target_dims: Target dimension names + + Returns: + DataArray broadcast to target dimensions + """ + if len(target_dims) == 0: + # Target is scalar + if data.size != 1: + raise ConversionError('Cannot convert multi-element DataArray to scalar') + return xr.DataArray(data.values.item()) + + # If data already matches target, validate coordinates and return + if set(data.dims) == set(target_dims) and len(data.dims) == len(target_dims): + # Check coordinate compatibility + for dim in data.dims: + if dim in coords and not np.array_equal(data.coords[dim].values, coords[dim].values): + raise ConversionError(f'DataArray {dim} coordinates do not match target coordinates') + + # Ensure correct dimension order + if data.dims != target_dims: + data = data.transpose(*target_dims) + return data.copy() + + # Handle scalar data (0D) - broadcast to all dimensions + if data.ndim == 0: + return xr.DataArray(data.item(), coords=coords, dims=target_dims) + + # Handle broadcasting from fewer to more dimensions + if len(data.dims) < len(target_dims): + return DataConverter._broadcast_dataarray_to_more_dims(data, coords, target_dims) + + # Cannot handle more dimensions than target + if len(data.dims) > len(target_dims): + raise ConversionError(f'Cannot reduce DataArray from {len(data.dims)} to {len(target_dims)} dimensions') + + raise ConversionError(f'Cannot convert DataArray with dims {data.dims} to target dims {target_dims}') + + @staticmethod + def _broadcast_dataarray_to_more_dims( + data: xr.DataArray, coords: Dict[str, pd.Index], target_dims: Tuple[str, ...] + ) -> xr.DataArray: + """Broadcast DataArray to additional dimensions.""" + # Validate that all source dimensions exist in target + for dim in data.dims: + if dim not in target_dims: + raise ConversionError(f'Source dimension "{dim}" not found in target dimensions {target_dims}') + + # Check coordinate compatibility + if not np.array_equal(data.coords[dim].values, coords[dim].values): + raise ConversionError(f'Source {dim} coordinates do not match target coordinates') + + # Build the full coordinate system + full_coords = {} + for dim in target_dims: + full_coords[dim] = coords[dim] + + # Use xarray's broadcast_to functionality + # Create a template DataArray with target structure + template_data = np.broadcast_to(data.values, [len(coords[dim]) for dim in target_dims]) - # Extract the single column as a Series and convert it - series = data.iloc[:, 0] - return DataConverter._convert_series(series, coords, dims) + # Create mapping for broadcasting + # We need to insert new axes for missing dimensions + expanded_data = data.values + for i, dim in enumerate(target_dims): + if dim not in data.dims: + # Add new axis for this dimension + expanded_data = np.expand_dims(expanded_data, axis=i) + + # Now broadcast to full shape + target_shape = tuple(len(coords[dim]) for dim in target_dims) + broadcasted_data = np.broadcast_to(expanded_data, target_shape) + + return xr.DataArray(broadcasted_data.copy(), coords=full_coords, dims=target_dims) @staticmethod def to_dataarray( @@ -153,10 +277,14 @@ def to_dataarray( """ Convert data to xarray.DataArray with specified coordinates. + Accepts: + - Scalars (broadcast to all dimensions) + - 1D arrays, Series, or single-column DataFrames (matched to one dimension, broadcast to others) + - xr.DataArray (validated and potentially broadcast to additional dimensions) + Args: - data: Scalar, 1D array/Series, single-column DataFrame, or existing DataArray + data: Data to convert coords: Dictionary mapping dimension names to coordinate indices - e.g., {'time': timesteps, 'scenario': scenarios} Returns: DataArray with the converted data @@ -164,35 +292,38 @@ def to_dataarray( if coords is None: coords = {} - coords, dims = DataConverter._prepare_dimensions(coords) + validated_coords, target_dims = DataConverter._prepare_dimensions(coords) - # Handle scalars + # Step 1: Convert to DataArray (with safe 1D/2D logic for simple data) if isinstance(data, (int, float, np.integer, np.floating)): - return DataConverter._convert_scalar(data, coords, dims) + # Scalars: create 0D DataArray, will be broadcast later + intermediate = xr.DataArray(data.item() if hasattr(data, 'item') else data) - # Handle 1D numpy arrays elif isinstance(data, np.ndarray): if data.ndim != 1: raise ConversionError(f'Only 1D arrays supported, got {data.ndim}D array') - return DataConverter._convert_1d_array(data, coords, dims) + intermediate = DataConverter._convert_1d_data_to_dataarray(data, validated_coords, target_dims) - # Handle pandas Series elif isinstance(data, pd.Series): - return DataConverter._convert_series(data, coords, dims) + intermediate = DataConverter._convert_1d_data_to_dataarray(data, validated_coords, target_dims) - # Handle pandas DataFrames (single column only) elif isinstance(data, pd.DataFrame): - return DataConverter._convert_dataframe(data, coords, dims) + if len(data.columns) != 1: + raise ConversionError(f'Only single-column DataFrames are supported, got {len(data.columns)} columns') + series = data.iloc[:, 0] + intermediate = DataConverter._convert_1d_data_to_dataarray(series, validated_coords, target_dims) - # Handle existing DataArrays (including TimeSeriesData) elif isinstance(data, xr.DataArray): - return DataConverter._handle_dataarray(data, coords, dims) + intermediate = data.copy() else: raise ConversionError( f'Unsupported data type: {type(data).__name__}. Only scalars, 1D arrays, Series, single-column DataFrames, and DataArrays are supported.' ) + # Step 2: Broadcast to target dimensions if needed + return DataConverter._broadcast_to_target_dims(intermediate, validated_coords, target_dims) + @staticmethod def _prepare_dimensions(coords: Dict[str, pd.Index]) -> Tuple[Dict[str, pd.Index], Tuple[str, ...]]: """ @@ -204,10 +335,6 @@ def _prepare_dimensions(coords: Dict[str, pd.Index]) -> Tuple[Dict[str, pd.Index Returns: Tuple of (validated coordinates dict, dimensions tuple) """ - # Check dimension limit - if len(coords) > 2: - raise ConversionError(f'Maximum 2 dimensions currently supported, got {len(coords)}') - validated_coords = {} dims = [] diff --git a/tests/test_dataconverter.py b/tests/test_dataconverter.py index eed6c1283..d8e29014f 100644 --- a/tests/test_dataconverter.py +++ b/tests/test_dataconverter.py @@ -309,6 +309,104 @@ def test_timeseries_data_broadcast(self, time_coords, scenario_coords): assert np.array_equal(result.sel(scenario=scenario).values, [10, 20, 30, 40, 50]) +class TestMultipleDimensions: + """Test support for more than 2 dimensions.""" + + def test_scalar_many_dimensions(self): + """Scalar should broadcast to any number of dimensions.""" + coords = { + 'time': pd.date_range('2024-01-01', periods=2, freq='D', name='time'), + 'scenario': pd.Index(['A', 'B'], name='scenario'), + 'region': pd.Index(['north', 'south'], name='region'), + 'technology': pd.Index(['solar', 'wind'], name='technology') + } + + result = DataConverter.to_dataarray(42, coords=coords) + assert result.shape == (2, 2, 2, 2) + assert result.dims == ('time', 'scenario', 'region', 'technology') + assert np.all(result.values == 42) + + def test_1d_array_broadcast_to_many_dimensions(self): + """1D array should broadcast to many dimensions.""" + coords = { + 'time': pd.date_range('2024-01-01', periods=3, freq='D', name='time'), + 'scenario': pd.Index(['A', 'B'], name='scenario'), + 'region': pd.Index(['north', 'south'], name='region') + } + + # Array matching time dimension + time_arr = np.array([10, 20, 30]) + result = DataConverter.to_dataarray(time_arr, coords=coords) + + assert result.shape == (3, 2, 2) + assert result.dims == ('time', 'scenario', 'region') + + # Check broadcasting - all scenarios and regions should have same time values + for scenario in coords['scenario']: + for region in coords['region']: + assert np.array_equal( + result.sel(scenario=scenario, region=region).values, + time_arr + ) + + def test_series_broadcast_to_many_dimensions(self): + """Series should broadcast to many dimensions.""" + time_coords = pd.date_range('2024-01-01', periods=3, freq='D', name='time') + coords = { + 'time': time_coords, + 'scenario': pd.Index(['A', 'B'], name='scenario'), + 'region': pd.Index(['north', 'south'], name='region'), + 'product': pd.Index(['X', 'Y', 'Z'], name='product') + } + + # Time-indexed series + time_series = pd.Series([100, 200, 300], index=time_coords) + result = DataConverter.to_dataarray(time_series, coords=coords) + + assert result.shape == (3, 2, 2, 3) + assert result.dims == ('time', 'scenario', 'region', 'product') + + # Check that all non-time dimensions have the same time series values + for scenario in coords['scenario']: + for region in coords['region']: + for product in coords['product']: + assert np.array_equal( + result.sel(scenario=scenario, region=region, product=product).values, + time_series.values + ) + + def test_dataarray_broadcast_to_more_dimensions(self): + """DataArray should broadcast to additional dimensions.""" + time_coords = pd.date_range('2024-01-01', periods=2, freq='D', name='time') + scenario_coords = pd.Index(['A', 'B'], name='scenario') + + # Start with 2D DataArray + original = xr.DataArray( + [[10, 20], [30, 40]], + coords={'time': time_coords, 'scenario': scenario_coords}, + dims=['time', 'scenario'] + ) + + # Broadcast to 3D + coords = { + 'time': time_coords, + 'scenario': scenario_coords, + 'region': pd.Index(['north', 'south'], name='region') + } + + result = DataConverter.to_dataarray(original, coords=coords) + + assert result.shape == (2, 2, 2) + assert result.dims == ('time', 'scenario', 'region') + + # Check that all regions have the same time+scenario values + for region in coords['region']: + assert np.array_equal( + result.sel(region=region).values, + original.values + ) + + class TestCustomDimensions: """Test with custom dimension names beyond time/scenario.""" From 656000690d0586c18483013ad9024b286061eeb2 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 29 Jun 2025 21:58:06 +0200 Subject: [PATCH 144/336] Update DataConverter for n-d arrays --- flixopt/core.py | 53 +++++++++++++++++++++---------------- tests/test_dataconverter.py | 13 +-------- 2 files changed, 31 insertions(+), 35 deletions(-) diff --git a/flixopt/core.py b/flixopt/core.py index 9278f079c..2b841af0b 100644 --- a/flixopt/core.py +++ b/flixopt/core.py @@ -164,7 +164,8 @@ def _convert_1d_data_to_dataarray( if data.index.equals(coords[dim_name]): return xr.DataArray(data.values.copy(), coords={dim_name: coords[dim_name]}, dims=[dim_name]) - # If no index match, fall through to length matching + # If no index matches, raise error + raise ConversionError(f'Data {data} does not match any of the target dimensions: {target_dims}') # For arrays or unmatched Series, match by length matching_dims = [] @@ -246,28 +247,34 @@ def _broadcast_dataarray_to_more_dims( if not np.array_equal(data.coords[dim].values, coords[dim].values): raise ConversionError(f'Source {dim} coordinates do not match target coordinates') - # Build the full coordinate system - full_coords = {} - for dim in target_dims: - full_coords[dim] = coords[dim] - - # Use xarray's broadcast_to functionality - # Create a template DataArray with target structure - template_data = np.broadcast_to(data.values, [len(coords[dim]) for dim in target_dims]) - - # Create mapping for broadcasting - # We need to insert new axes for missing dimensions - expanded_data = data.values - for i, dim in enumerate(target_dims): - if dim not in data.dims: - # Add new axis for this dimension - expanded_data = np.expand_dims(expanded_data, axis=i) - - # Now broadcast to full shape - target_shape = tuple(len(coords[dim]) for dim in target_dims) - broadcasted_data = np.broadcast_to(expanded_data, target_shape) - - return xr.DataArray(broadcasted_data.copy(), coords=full_coords, dims=target_dims) + # Start with the original data + result_data = data.values + result_dims = list(data.dims) + result_coords = {dim: data.coords[dim] for dim in data.dims} + + # Add missing dimensions one by one + for target_dim in target_dims: + if target_dim not in result_dims: + # Add this dimension at the end + result_data = np.expand_dims(result_data, axis=-1) + result_dims.append(target_dim) + result_coords[target_dim] = coords[target_dim] + + # Broadcast along the new dimension + new_shape = list(result_data.shape) + new_shape[-1] = len(coords[target_dim]) + result_data = np.broadcast_to(result_data, new_shape) + + # Reorder dimensions to match target order + if tuple(result_dims) != target_dims: + # Create mapping from current to target order + dim_indices = [result_dims.index(dim) for dim in target_dims] + result_data = np.transpose(result_data, dim_indices) + + # Build final coordinates dict in target order + final_coords = {dim: coords[dim] for dim in target_dims} + + return xr.DataArray(result_data.copy(), coords=final_coords, dims=target_dims) @staticmethod def to_dataarray( diff --git a/tests/test_dataconverter.py b/tests/test_dataconverter.py index d8e29014f..175866c71 100644 --- a/tests/test_dataconverter.py +++ b/tests/test_dataconverter.py @@ -502,20 +502,9 @@ def test_dimension_mismatch_messages(self, time_coords, scenario_coords): """Error messages should be informative.""" # Array with wrong length wrong_arr = np.array([1, 2]) # Length 2, but no dimension has length 2 - with pytest.raises(ConversionError, match="matches none of the dimensions"): + with pytest.raises(ConversionError, match="matches none of the target dimensions"): DataConverter.to_dataarray(wrong_arr, coords={'time': time_coords, 'scenario': scenario_coords}) - def test_maximum_dimensions(self): - """Should handle up to 2 dimensions currently.""" - coords = { - 'dim1': pd.Index(['a', 'b'], name='dim1'), - 'dim2': pd.Index(['x', 'y'], name='dim2'), - 'dim3': pd.Index(['1', '2'], name='dim3') - } - - with pytest.raises(ConversionError, match="Maximum 2 dimensions currently supported"): - DataConverter.to_dataarray(42, coords=coords) - class TestDataIntegrity: """Test data copying and integrity.""" From 78132ef0e589bd8b627e7f5e0a023482c3b3b422 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 29 Jun 2025 22:04:22 +0200 Subject: [PATCH 145/336] Add extra tests for 3-dims --- flixopt/flow_system.py | 4 +- tests/test_dataconverter.py | 277 ++++++++++++++++++++++++++++++++++++ 2 files changed, 279 insertions(+), 2 deletions(-) diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index 6bf7502e5..42a287876 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -105,8 +105,8 @@ def _validate_scenarios(scenarios: pd.Index) -> pd.Index: if not isinstance(scenarios, pd.Index) or len(scenarios) == 0: raise ConversionError('Scenarios must be a non-empty Index') - if not scenarios.name == 'scenario': - raise ConversionError(f'Scenarios must be named "scenario", got "{scenarios.name}"') + if scenarios.name != 'scenario': + scenarios = scenarios.rename('scenario') return scenarios diff --git a/tests/test_dataconverter.py b/tests/test_dataconverter.py index 175866c71..56f36aabf 100644 --- a/tests/test_dataconverter.py +++ b/tests/test_dataconverter.py @@ -309,6 +309,283 @@ def test_timeseries_data_broadcast(self, time_coords, scenario_coords): assert np.array_equal(result.sel(scenario=scenario).values, [10, 20, 30, 40, 50]) +class TestThreeDimensionConversion: + """Test conversions with exactly 3 dimensions for all data types.""" + + @pytest.fixture + def three_d_coords(self, time_coords, scenario_coords): + """Standard 3D coordinate system with unique lengths.""" + return { + 'time': time_coords, # length 5 + 'scenario': scenario_coords, # length 3 + 'region': pd.Index(['north', 'south'], name='region') # length 2 - unique! + } + + def test_scalar_three_dimensions(self, three_d_coords): + """Scalar should broadcast to 3 dimensions.""" + result = DataConverter.to_dataarray(42, coords=three_d_coords) + + assert result.shape == (5, 3, 2) # time=5, scenario=3, region=2 + assert result.dims == ('time', 'scenario', 'region') + assert np.all(result.values == 42) + + # Verify all coordinates are correct + assert result.indexes['time'].equals(three_d_coords['time']) + assert result.indexes['scenario'].equals(three_d_coords['scenario']) + assert result.indexes['region'].equals(three_d_coords['region']) + + def test_numpy_scalar_three_dimensions(self, three_d_coords): + """Numpy scalars should broadcast to 3 dimensions.""" + for scalar in [np.int32(100), np.float64(3.14)]: + result = DataConverter.to_dataarray(scalar, coords=three_d_coords) + + assert result.shape == (5, 3, 2) + assert result.dims == ('time', 'scenario', 'region') + assert np.all(result.values == scalar.item()) + + def test_1d_array_time_to_three_dimensions(self, three_d_coords): + """1D array matching time should broadcast to 3D.""" + time_arr = np.array([10, 20, 30, 40, 50]) + result = DataConverter.to_dataarray(time_arr, coords=three_d_coords) + + assert result.shape == (5, 3, 2) + assert result.dims == ('time', 'scenario', 'region') + + # Check broadcasting across scenarios and regions + for scenario in three_d_coords['scenario']: + for region in three_d_coords['region']: + slice_data = result.sel(scenario=scenario, region=region) + assert np.array_equal(slice_data.values, time_arr) + + def test_1d_array_scenario_to_three_dimensions(self, three_d_coords): + """1D array matching scenario should broadcast to 3D.""" + scenario_arr = np.array([100, 200, 300]) + result = DataConverter.to_dataarray(scenario_arr, coords=three_d_coords) + + assert result.shape == (5, 3, 2) + assert result.dims == ('time', 'scenario', 'region') + + # Check broadcasting across time and regions + for time in three_d_coords['time']: + for region in three_d_coords['region']: + slice_data = result.sel(time=time, region=region) + assert np.array_equal(slice_data.values, scenario_arr) + + def test_1d_array_region_to_three_dimensions(self, three_d_coords): + """1D array matching region should broadcast to 3D.""" + region_arr = np.array([1000, 2000]) # Length 2 to match region + result = DataConverter.to_dataarray(region_arr, coords=three_d_coords) + + assert result.shape == (5, 3, 2) + assert result.dims == ('time', 'scenario', 'region') + + # Check broadcasting across time and scenarios + for time in three_d_coords['time']: + for scenario in three_d_coords['scenario']: + slice_data = result.sel(time=time, scenario=scenario) + assert np.array_equal(slice_data.values, region_arr) + + def test_series_time_to_three_dimensions(self, three_d_coords): + """Time-indexed Series should broadcast to 3D.""" + time_series = pd.Series([15, 25, 35, 45, 55], index=three_d_coords['time']) + result = DataConverter.to_dataarray(time_series, coords=three_d_coords) + + assert result.shape == (5, 3, 2) + assert result.dims == ('time', 'scenario', 'region') + + # Check broadcasting + for scenario in three_d_coords['scenario']: + for region in three_d_coords['region']: + slice_data = result.sel(scenario=scenario, region=region) + assert np.array_equal(slice_data.values, time_series.values) + + def test_series_scenario_to_three_dimensions(self, three_d_coords): + """Scenario-indexed Series should broadcast to 3D.""" + scenario_series = pd.Series([500, 600, 700], index=three_d_coords['scenario']) + result = DataConverter.to_dataarray(scenario_series, coords=three_d_coords) + + assert result.shape == (5, 3, 2) + assert result.dims == ('time', 'scenario', 'region') + + # Check broadcasting + for time in three_d_coords['time']: + for region in three_d_coords['region']: + slice_data = result.sel(time=time, region=region) + assert np.array_equal(slice_data.values, scenario_series.values) + + def test_series_region_to_three_dimensions(self, three_d_coords): + """Region-indexed Series should broadcast to 3D.""" + region_series = pd.Series([5000, 6000], index=three_d_coords['region']) # Length 2 + result = DataConverter.to_dataarray(region_series, coords=three_d_coords) + + assert result.shape == (5, 3, 2) + assert result.dims == ('time', 'scenario', 'region') + + # Check broadcasting + for time in three_d_coords['time']: + for scenario in three_d_coords['scenario']: + slice_data = result.sel(time=time, scenario=scenario) + assert np.array_equal(slice_data.values, region_series.values) + + def test_dataframe_time_to_three_dimensions(self, three_d_coords): + """Time-indexed DataFrame should broadcast to 3D.""" + df = pd.DataFrame({'power': [11, 22, 33, 44, 55]}, index=three_d_coords['time']) + result = DataConverter.to_dataarray(df, coords=three_d_coords) + + assert result.shape == (5, 3, 2) + assert result.dims == ('time', 'scenario', 'region') + + # Check broadcasting + for scenario in three_d_coords['scenario']: + for region in three_d_coords['region']: + slice_data = result.sel(scenario=scenario, region=region) + assert np.array_equal(slice_data.values, df['power'].values) + + def test_dataframe_scenario_to_three_dimensions(self, three_d_coords): + """Scenario-indexed DataFrame should broadcast to 3D.""" + df = pd.DataFrame({'cost': [1100, 1200, 1300]}, index=three_d_coords['scenario']) + result = DataConverter.to_dataarray(df, coords=three_d_coords) + + assert result.shape == (5, 3, 2) + assert result.dims == ('time', 'scenario', 'region') + + # Check broadcasting + for time in three_d_coords['time']: + for region in three_d_coords['region']: + slice_data = result.sel(time=time, region=region) + assert np.array_equal(slice_data.values, df['cost'].values) + + def test_1d_dataarray_time_to_three_dimensions(self, three_d_coords): + """1D time DataArray should broadcast to 3D.""" + original = xr.DataArray([101, 102, 103, 104, 105], + coords={'time': three_d_coords['time']}, + dims=['time']) + result = DataConverter.to_dataarray(original, coords=three_d_coords) + + assert result.shape == (5, 3, 2) + assert result.dims == ('time', 'scenario', 'region') + + # Check broadcasting + for scenario in three_d_coords['scenario']: + for region in three_d_coords['region']: + slice_data = result.sel(scenario=scenario, region=region) + assert np.array_equal(slice_data.values, original.values) + + def test_1d_dataarray_scenario_to_three_dimensions(self, three_d_coords): + """1D scenario DataArray should broadcast to 3D.""" + original = xr.DataArray([2001, 2002, 2003], + coords={'scenario': three_d_coords['scenario']}, + dims=['scenario']) + result = DataConverter.to_dataarray(original, coords=three_d_coords) + + assert result.shape == (5, 3, 2) + assert result.dims == ('time', 'scenario', 'region') + + # Check broadcasting + for time in three_d_coords['time']: + for region in three_d_coords['region']: + slice_data = result.sel(time=time, region=region) + assert np.array_equal(slice_data.values, original.values) + + def test_2d_dataarray_to_three_dimensions(self, three_d_coords): + """2D DataArray should broadcast to 3D.""" + # Create 2D time x scenario DataArray + data_2d = np.random.rand(5, 3) + original = xr.DataArray(data_2d, + coords={'time': three_d_coords['time'], + 'scenario': three_d_coords['scenario']}, + dims=['time', 'scenario']) + + result = DataConverter.to_dataarray(original, coords=three_d_coords) + + assert result.shape == (5, 3, 2) + assert result.dims == ('time', 'scenario', 'region') + + # Check that all regions have the same time x scenario data + for region in three_d_coords['region']: + slice_data = result.sel(region=region) + assert np.array_equal(slice_data.values, original.values) + + def test_timeseries_data_to_three_dimensions(self, three_d_coords): + """TimeSeriesData should broadcast to 3D.""" + data_array = xr.DataArray([99, 88, 77, 66, 55], + coords={'time': three_d_coords['time']}, + dims=['time']) + ts_data = TimeSeriesData(data_array, aggregation_group='test_3d') + + result = DataConverter.to_dataarray(ts_data, coords=three_d_coords) + + assert result.shape == (5, 3, 2) + assert result.dims == ('time', 'scenario', 'region') + + # Check broadcasting + for scenario in three_d_coords['scenario']: + for region in three_d_coords['region']: + slice_data = result.sel(scenario=scenario, region=region) + assert np.array_equal(slice_data.values, [99, 88, 77, 66, 55]) + + def test_three_d_copy_independence(self, three_d_coords): + """3D results should be independent copies.""" + original_arr = np.array([10, 20, 30, 40, 50]) + result = DataConverter.to_dataarray(original_arr, coords=three_d_coords) + + # Modify result + result[0, 0, 0] = 999 + + # Original should be unchanged + assert original_arr[0] == 10 + + def test_three_d_special_values(self, three_d_coords): + """3D conversion should preserve special values.""" + # Array with NaN and inf + special_arr = np.array([1, np.nan, np.inf, -np.inf, 5]) + result = DataConverter.to_dataarray(special_arr, coords=three_d_coords) + + assert result.shape == (5, 3, 2) + + # Check that special values are preserved in all broadcasts + for scenario in three_d_coords['scenario']: + for region in three_d_coords['region']: + slice_data = result.sel(scenario=scenario, region=region) + assert np.array_equal(np.isnan(slice_data.values), np.isnan(special_arr)) + assert np.array_equal(np.isinf(slice_data.values), np.isinf(special_arr)) + + def test_three_d_ambiguous_length_error(self): + """Should fail when array length matches multiple dimensions in 3D.""" + # All dimensions have length 3 + coords_3x3x3 = { + 'time': pd.date_range('2024-01-01', periods=3, freq='D', name='time'), + 'scenario': pd.Index(['A', 'B', 'C'], name='scenario'), + 'region': pd.Index(['X', 'Y', 'Z'], name='region') + } + + arr = np.array([1, 2, 3]) # Length 3 - matches all dimensions + + with pytest.raises(ConversionError, match="matches multiple dimensions"): + DataConverter.to_dataarray(arr, coords=coords_3x3x3) + + def test_three_d_custom_dimensions(self): + """3D conversion with custom dimension names.""" + coords = { + 'product': pd.Index(['A', 'B'], name='product'), + 'factory': pd.Index(['F1', 'F2', 'F3'], name='factory'), + 'quarter': pd.Index(['Q1', 'Q2', 'Q3', 'Q4'], name='quarter') + } + + # Array matching factory dimension + factory_arr = np.array([100, 200, 300]) + result = DataConverter.to_dataarray(factory_arr, coords=coords) + + assert result.shape == (2, 3, 4) + assert result.dims == ('product', 'factory', 'quarter') + + # Check broadcasting + for product in coords['product']: + for quarter in coords['quarter']: + slice_data = result.sel(product=product, quarter=quarter) + assert np.array_equal(slice_data.values, factory_arr) + + class TestMultipleDimensions: """Test support for more than 2 dimensions.""" From a53c116c8b69127c1849082a256f7e713d83a9cf Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 30 Jun 2025 17:53:13 +0200 Subject: [PATCH 146/336] Add FLowSystemDimension Type --- flixopt/core.py | 5 ++++- flixopt/flow_system.py | 10 ++++++---- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/flixopt/core.py b/flixopt/core.py index 2b841af0b..43b529421 100644 --- a/flixopt/core.py +++ b/flixopt/core.py @@ -5,7 +5,7 @@ import logging import warnings -from typing import Dict, Optional, Union, Tuple +from typing import Dict, Optional, Union, Tuple, Literal import numpy as np import pandas as pd @@ -30,6 +30,9 @@ NonTemporalData = Union[Scalar, xr.DataArray] """Internally used datatypes for non-temporal data. Can be a Scalar or an xr.DataArray.""" +FlowSystemDimensions = Literal['time', 'scenario'] +"""Possible dimensions of a FlowSystem.""" + class PlausibilityError(Exception): """Error for a failing Plausibility check.""" diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index 42a287876..c753b8cc8 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -16,7 +16,7 @@ from rich.pretty import Pretty from . import io as fx_io -from .core import ConversionError, DataConverter, TemporalData, TemporalDataUser, TimeSeriesData, NonTemporalDataUser, NonTemporalData +from .core import ConversionError, DataConverter, TemporalData, TemporalDataUser, TimeSeriesData, NonTemporalDataUser, NonTemporalData, FlowSystemDimensions from .effects import Effect, EffectCollection, NonTemporalEffects, NonTemporalEffectsUser, TemporalEffects, TemporalEffectsUser from .elements import Bus, Component, Flow from .structure import Element, Interface, SystemModel @@ -294,7 +294,7 @@ def fit_to_model_coords( self, name: str, data: Optional[Union[TemporalDataUser, NonTemporalDataUser]], - dimensions: Union[List[str], str] = 'time', # Default to time only + dimensions: Optional[Union[List[FlowSystemDimensions], FlowSystemDimensions]] = None, # Default to time only ) -> Optional[Union[TemporalData, NonTemporalData]]: """ Fit data to model coordinate system (currently time, but extensible). @@ -302,7 +302,7 @@ def fit_to_model_coords( Args: name: Name of the data data: Data to fit to model coordinates - has_time_dim: Whether the data has a time dimension + dimensions: Dimensions to use for the DataArray Returns: xr.DataArray aligned to model coordinate system @@ -316,10 +316,12 @@ def fit_to_model_coords( coords = {} for dim in dimensions: - if dim == 'time' and self.timesteps is not None: + if dim == 'time': coords['time'] = self.timesteps elif dim == 'scenario' and self.scenarios is not None: coords['scenario'] = self.scenarios + else: + raise ValueError(f'Invalid flow system dimension "{dim}"') # Future: elif dim == 'region' and self.regions is not None: ... # Rest of your method stays the same, just pass coords From 2cb551b064a9803fe7c6271561ef3e30111ae060 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 30 Jun 2025 18:09:51 +0200 Subject: [PATCH 147/336] Revert some logic about the fit_to_model coords --- flixopt/flow_system.py | 32 +++++++++++++++++--------------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index c753b8cc8..0e6c2cf6f 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -294,7 +294,7 @@ def fit_to_model_coords( self, name: str, data: Optional[Union[TemporalDataUser, NonTemporalDataUser]], - dimensions: Optional[Union[List[FlowSystemDimensions], FlowSystemDimensions]] = None, # Default to time only + has_time_dim: bool = True, ) -> Optional[Union[TemporalData, NonTemporalData]]: """ Fit data to model coordinate system (currently time, but extensible). @@ -302,7 +302,7 @@ def fit_to_model_coords( Args: name: Name of the data data: Data to fit to model coordinates - dimensions: Dimensions to use for the DataArray + has_time_dim: Wether to use the time dimension or not Returns: xr.DataArray aligned to model coordinate system @@ -310,19 +310,10 @@ def fit_to_model_coords( if data is None: return None - # Build coords from requested dimensions - if isinstance(dimensions, str): - dimensions = [dimensions] + coords = self.coords - coords = {} - for dim in dimensions: - if dim == 'time': - coords['time'] = self.timesteps - elif dim == 'scenario' and self.scenarios is not None: - coords['scenario'] = self.scenarios - else: - raise ValueError(f'Invalid flow system dimension "{dim}"') - # Future: elif dim == 'region' and self.regions is not None: ... + if not has_time_dim: + coords.pop('time') # Rest of your method stays the same, just pass coords if isinstance(data, TimeSeriesData): @@ -354,7 +345,11 @@ def fit_effects_to_model_coords( effect_values_dict = self.effects.create_effect_values_dict(effect_values) return { - effect: self.fit_to_model_coords('|'.join(filter(None, [label_prefix, effect, label_suffix])), value, has_time_dim=has_time_dim) + effect: self.fit_to_model_coords( + '|'.join(filter(None, [label_prefix, effect, label_suffix])), + value, + has_time_dim=has_time_dim + ) for effect, value in effect_values_dict.items() } @@ -594,6 +589,13 @@ def flows(self) -> Dict[str, Flow]: def all_elements(self) -> Dict[str, Element]: return {**self.components, **self.effects.effects, **self.flows, **self.buses} + @property + def coords(self) -> Dict[str, pd.Index]: + active_coords = {'time': self.timesteps} + if self.scenarios is not None: + active_coords['scenario'] = self.scenarios + return active_coords + @property def used_in_calculation(self) -> bool: return self._used_in_calculation From d7be7667e27a42f2bf784eab4631883650b3f8a9 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 30 Jun 2025 18:22:10 +0200 Subject: [PATCH 148/336] Adjust FLowSystem IO for scenarios --- flixopt/flow_system.py | 1 + flixopt/structure.py | 6 +++++- tests/conftest.py | 3 ++- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index 0e6c2cf6f..e2a39508d 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -212,6 +212,7 @@ def from_dataset(cls, ds: xr.Dataset) -> 'FlowSystem': # Create FlowSystem instance with constructor parameters flow_system = cls( timesteps=ds.indexes['time'], + scenarios=ds.indexes.get('scenario'), hours_of_last_timestep=reference_structure.get('hours_of_last_timestep'), hours_of_previous_timesteps=reference_structure.get('hours_of_previous_timesteps'), ) diff --git a/flixopt/structure.py b/flixopt/structure.py index d02a8d4e9..681c4dc0c 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -203,12 +203,16 @@ def _create_reference_structure(self) -> Tuple[Dict, Dict[str, xr.DataArray]]: all_extracted_arrays = {} for name in self._cached_init_params: - if name == 'self' or name == 'timesteps': # Skip self and timesteps. Timesteps are directly stored in Datasets + if name == 'self': # Skip self and timesteps. Timesteps are directly stored in Datasets continue value = getattr(self, name, None) + if value is None: continue + if isinstance(value, pd.Index): + logger.debug(f'Skipping {name=} because it is an Index') + continue # Extract arrays and get reference structure processed_value, extracted_arrays = self._extract_dataarrays_recursive(value, name) diff --git a/tests/conftest.py b/tests/conftest.py index 198b9a92b..55f17057d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -170,7 +170,8 @@ def simple_flow_system_scenarios() -> fx.FlowSystem: discharging=fx.Flow('Q_th_unload', bus='Fernwärme', size=1e4), capacity_in_flow_hours=fx.InvestParameters(fix_effects=20, fixed_size=30, optional=False), initial_charge_state=0, - relative_maximum_charge_state=1 / 100 * np.array([80.0, 70.0, 80.0, 80, 80, 80, 80, 80, 80, 80]), + relative_maximum_charge_state=1 / 100 * np.array([80.0, 70.0, 80.0, 80, 80, 80, 80, 80, 80]), + relative_maximum_final_charge_state=0.8, eta_charge=0.9, eta_discharge=1, relative_loss_per_hour=0.08, From e60dd079884d0784e3e3e87e0e15e1ffb1ef449b Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 30 Jun 2025 18:37:15 +0200 Subject: [PATCH 149/336] BUGFIX: Raise Exception instead of logging --- flixopt/flow_system.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index e2a39508d..1da0184a9 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -325,8 +325,8 @@ def fit_to_model_coords( aggregation_group=data.aggregation_group, aggregation_weight=data.aggregation_weight ).rename(name) except ConversionError as e: - logger.critical(f'Could not convert time series data "{name}" to DataArray: {e}. \n' - f'Take care to use the correct (time) index.') + raise ConversionError( + f'Could not convert time series data "{name}" to DataArray: Original Error: {e}') from e else: return DataConverter.to_dataarray(data, coords=coords).rename(name) From bd2f1b82fd1dbf934a7179726aefb280db2891eb Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 30 Jun 2025 18:40:26 +0200 Subject: [PATCH 150/336] Change usage of TimeSeriesData --- tests/conftest.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 55f17057d..9f247164f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -400,16 +400,17 @@ def flow_system_long(): p_el = data['Strompr.€/MWh'].values gas_price = data['Gaspr.€/MWh'].values + flow_system = fx.FlowSystem(pd.DatetimeIndex(data.index)) + thermal_load_ts, electrical_load_ts = ( - fx.TimeSeriesData(thermal_load), - fx.TimeSeriesData(electrical_load, aggregation_weight=0.7), + fx.TimeSeriesData(thermal_load, coords={'time': flow_system.timesteps}), + fx.TimeSeriesData(electrical_load, aggregation_weight=0.7, coords={'time': flow_system.timesteps}), ) p_feed_in, p_sell = ( - fx.TimeSeriesData(-(p_el - 0.5), aggregation_group='p_el'), - fx.TimeSeriesData(p_el + 0.5, aggregation_group='p_el'), + fx.TimeSeriesData(-(p_el - 0.5), aggregation_group='p_el', coords={'time': flow_system.timesteps}), + fx.TimeSeriesData(p_el + 0.5, aggregation_group='p_el', coords={'time': flow_system.timesteps}), ) - flow_system = fx.FlowSystem(pd.DatetimeIndex(data.index)) flow_system.add_elements( fx.Bus('Strom'), fx.Bus('Fernwärme'), From a7da9d286efd18766ea608d4f8328f54521bf982 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 30 Jun 2025 21:32:57 +0200 Subject: [PATCH 151/336] Adjust logic to handle non scalars --- flixopt/elements.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flixopt/elements.py b/flixopt/elements.py index 9df367eec..1daadeb55 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -195,7 +195,7 @@ def __init__( meta_data: used to store more information about the Element. Is not used internally, but saved in the results. Only use python native types. """ super().__init__(label, meta_data=meta_data) - self.size = size or CONFIG.modeling.BIG # Default size + self.size = size if size is not None else CONFIG.modeling.BIG # Default size self.relative_minimum = relative_minimum self.relative_maximum = relative_maximum self.fixed_relative_profile = fixed_relative_profile From b8f0e226a5f6024489e8e05bb31636f9c788d569 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 30 Jun 2025 21:33:49 +0200 Subject: [PATCH 152/336] Adjust logic to _resolve_dataarray_reference into separate method --- flixopt/structure.py | 51 ++++++++++++++++++++++++++++++-------------- 1 file changed, 35 insertions(+), 16 deletions(-) diff --git a/flixopt/structure.py b/flixopt/structure.py index 681c4dc0c..84b9c6cba 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -316,6 +316,40 @@ def _extract_dataarrays_recursive(self, obj, context_name: str = '') -> Tuple[An else: return self._serialize_to_basic_types(obj), extracted_arrays + @classmethod + def _resolve_dataarray_reference( + cls, reference: str, arrays_dict: Dict[str, xr.DataArray] + ) -> Union[xr.DataArray, TimeSeriesData]: + """ + Resolve a single DataArray reference (:::name) to actual DataArray or TimeSeriesData. + + Args: + reference: Reference string starting with ":::" + arrays_dict: Dictionary of available DataArrays + + Returns: + Resolved DataArray or TimeSeriesData object + + Raises: + ValueError: If referenced array is not found + """ + array_name = reference[3:] # Remove ":::" prefix + if array_name not in arrays_dict: + raise ValueError(f"Referenced DataArray '{array_name}' not found in dataset") + + array = arrays_dict[array_name] + + # Handle null values with warning + if array.isnull().any(): + logger.warning(f"DataArray '{array_name}' contains null values. Dropping them.") + array = array.dropna(dim='time', how='all') + + # Check if this should be restored as TimeSeriesData + if TimeSeriesData.is_timeseries_data(array): + return TimeSeriesData.from_dataarray(array) + + return array + @classmethod def _resolve_reference_structure(cls, structure, arrays_dict: Dict[str, xr.DataArray]): """ @@ -333,22 +367,7 @@ def _resolve_reference_structure(cls, structure, arrays_dict: Dict[str, xr.DataA """ # Handle DataArray references if isinstance(structure, str) and structure.startswith(':::'): - array_name = structure[3:] # Remove ":::" prefix - if array_name not in arrays_dict: - raise ValueError(f"Referenced DataArray '{array_name}' not found in dataset") - - array = arrays_dict[array_name] - - # Handle null values with warning - if array.isnull().any(): - logger.warning(f"DataArray '{array_name}' contains null values. Dropping them.") - array = array.dropna(dim='time', how='all') - - # Check if this should be restored as TimeSeriesData - if TimeSeriesData.is_timeseries_data(array): - return TimeSeriesData.from_dataarray(array) - - return array + return cls._resolve_dataarray_reference(structure, arrays_dict) elif isinstance(structure, list): resolved_list = [] From 7123b6b736cfbd55fbf8fd501bdd48f0ca37470d Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 30 Jun 2025 21:34:50 +0200 Subject: [PATCH 153/336] Update IO of FlowSystem --- flixopt/flow_system.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index 1da0184a9..656aecd77 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -209,17 +209,20 @@ def from_dataset(cls, ds: xr.Dataset) -> 'FlowSystem': # Get the reference structure from attrs reference_structure = dict(ds.attrs) + # Create arrays dictionary from dataset variables + arrays_dict = {name: array for name, array in ds.data_vars.items()} + # Create FlowSystem instance with constructor parameters flow_system = cls( timesteps=ds.indexes['time'], scenarios=ds.indexes.get('scenario'), + scenario_weights=cls._resolve_dataarray_reference( + reference_structure['scenario_weights'], arrays_dict + ) if 'scenario_weights' in reference_structure else None, hours_of_last_timestep=reference_structure.get('hours_of_last_timestep'), hours_of_previous_timesteps=reference_structure.get('hours_of_previous_timesteps'), ) - # Create arrays dictionary from dataset variables - arrays_dict = {name: array for name, array in ds.data_vars.items()} - # Restore components components_structure = reference_structure.get('components', {}) for comp_label, comp_data in components_structure.items(): From fa5475d89982ee7ae9ba374b4d84ba73b75c9979 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 2 Jul 2025 10:03:12 +0200 Subject: [PATCH 154/336] Improve get_coords() --- flixopt/structure.py | 33 ++++++++++++++++----------------- 1 file changed, 16 insertions(+), 17 deletions(-) diff --git a/flixopt/structure.py b/flixopt/structure.py index 84b9c6cba..7732ffeb4 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -132,26 +132,25 @@ def get_coords( Returns: The coordinates of the model. Might also be None if no scenarios are present and time_dim is False """ - if not scenario_dim and not time_dim: - return None - scenarios = self.flow_system.scenarios - timesteps = ( - self.flow_system.timesteps if not extra_timestep else self.flow_system.timesteps_extra - ) + if extra_timestep and not time_dim: + raise ValueError('extra_timestep=True requires time_dim=True') + + coords = self.flow_system.coords - if scenario_dim and time_dim: - if scenarios is None: - return (timesteps,) - return timesteps, scenarios + if not scenario_dim: + coords.pop('scenario', None) + if not time_dim: + coords.pop('time', None) + if extra_timestep: + coords['time'] = self.flow_system.timesteps_extra + + if not coords: + return None - if scenario_dim and not time_dim: - if scenarios is None: - return None - return (scenarios,) - if time_dim and not scenario_dim: - return (timesteps,) + if len(coords) == 1: + return (coords.popitem()[1],) - raise ValueError(f'Cannot get coordinates with both {scenario_dim=} and {time_dim=}') + return tuple(coords.values()) class Interface: From 80cb1610a5550e97149e5d2601c30c9f268d2ee2 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 2 Jul 2025 10:04:04 +0200 Subject: [PATCH 155/336] Adjust FlowSystem init for correct IO --- flixopt/flow_system.py | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index 656aecd77..ecfca4ed2 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -62,15 +62,24 @@ def __init__( If you use an array, take care that its long enough to cover all previous values! scenario_weights: The weights of the scenarios. If None, all scenarios have the same weight. All weights are normalized to 1. """ - # Store timing information directly self.timesteps = self._validate_timesteps(timesteps) + self.timesteps_extra = self._create_timesteps_with_extra(timesteps, hours_of_last_timestep) - self.hours_per_timestep = self.calculate_hours_per_timestep(self.timesteps_extra) - self.hours_of_previous_timesteps = self._calculate_hours_of_previous_timesteps( - timesteps, hours_of_previous_timesteps - ) + self.hours_of_previous_timesteps = self._calculate_hours_of_previous_timesteps(timesteps, hours_of_previous_timesteps) + self.scenarios = None if scenarios is None else self._validate_scenarios(scenarios) - self.scenario_weights = scenario_weights + + hours_per_timestep = self.calculate_hours_per_timestep(self.timesteps_extra) + + self.hours_of_last_timestep = hours_per_timestep[-1].item() + + self.hours_per_timestep = self.fit_to_model_coords('hours_per_timestep', hours_per_timestep) + + self.scenario_weights = self.fit_to_model_coords( + 'scenario_weights', + scenario_weights, + has_time_dim=False, + ) # Element collections self.components: Dict[str, Component] = {} @@ -123,7 +132,7 @@ def _create_timesteps_with_extra( @staticmethod def calculate_hours_per_timestep(timesteps_extra: pd.DatetimeIndex) -> xr.DataArray: - """Calculate duration of each timestep.""" + """Calculate duration of each timestep as a 1D DataArray.""" hours_per_step = np.diff(timesteps_extra) / pd.Timedelta(hours=1) return xr.DataArray( hours_per_step, coords={'time': timesteps_extra[:-1]}, dims=['time'], name='hours_per_timestep' From 81ad3baf19bee65853854a20e706bcee19559aa1 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 2 Jul 2025 11:04:02 +0200 Subject: [PATCH 156/336] Add scenario to sel and isel methods, and dont normalize scenario weights --- flixopt/flow_system.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index ecfca4ed2..0d77d046e 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -60,7 +60,7 @@ def __init__( If None, the first time increment of time_series is used. This is needed to calculate previous durations (for example consecutive_on_hours). If you use an array, take care that its long enough to cover all previous values! - scenario_weights: The weights of the scenarios. If None, all scenarios have the same weight. All weights are normalized to 1. + scenario_weights: The weights of each scenarios. If None, all scenarios have the same weight (normalized to 1). Its recommended to scale the weights to sum up to 1. """ self.timesteps = self._validate_timesteps(timesteps) @@ -318,7 +318,7 @@ def fit_to_model_coords( has_time_dim: Wether to use the time dimension or not Returns: - xr.DataArray aligned to model coordinate system + xr.DataArray aligned to model coordinate system. If data is None, returns None. """ if data is None: return None @@ -613,12 +613,14 @@ def coords(self) -> Dict[str, pd.Index]: def used_in_calculation(self) -> bool: return self._used_in_calculation - def sel(self, time: Optional[Union[str, slice, List[str], pd.Timestamp, pd.DatetimeIndex]] = None) -> 'FlowSystem': + def sel(self, time: Optional[Union[str, slice, List[str], pd.Timestamp, pd.DatetimeIndex]] = None, + scenario: Optional[Union[str, slice, List[str], pd.Index]] = None) -> 'FlowSystem': """ Select a subset of the flowsystem by the time coordinate. Args: time: Time selection (e.g., slice('2023-01-01', '2023-12-31'), '2023-06-15', or list of times) + scenario: Scenario selection (e.g., slice('scenario1', 'scenario2'), or list of scenarios) Returns: FlowSystem: New FlowSystem with selected data @@ -631,18 +633,22 @@ def sel(self, time: Optional[Union[str, slice, List[str], pd.Timestamp, pd.Datet if time is not None: indexers['time'] = time + if scenario is not None: + indexers['scenario'] = scenario + if not indexers: return self.copy() # Return a copy when no selection selected_dataset = self.to_dataset().sel(**indexers) return self.__class__.from_dataset(selected_dataset) - def isel(self, time: Optional[Union[int, slice, List[int]]] = None) -> 'FlowSystem': + def isel(self, time: Optional[Union[int, slice, List[int]]] = None, scenario: Optional[Union[int, slice, List[int]]] = None) -> 'FlowSystem': """ Select a subset of the flowsystem by integer indices. Args: time: Time selection by integer index (e.g., slice(0, 100), 50, or [0, 5, 10]) + scenario: Scenario selection by integer index (e.g., slice(0, 3), 50, or [0, 5, 10]) Returns: FlowSystem: New FlowSystem with selected data @@ -655,6 +661,9 @@ def isel(self, time: Optional[Union[int, slice, List[int]]] = None) -> 'FlowSyst if time is not None: indexers['time'] = time + if scenario is not None: + indexers['scenario'] = scenario + if not indexers: return self.copy() # Return a copy when no selection From 691a45e40e2ac75b1c0a6403bf51173dd40fafa2 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 2 Jul 2025 11:14:15 +0200 Subject: [PATCH 157/336] Improve scenario_weights_handling --- flixopt/flow_system.py | 7 +------ flixopt/structure.py | 25 ++++++++----------------- 2 files changed, 9 insertions(+), 23 deletions(-) diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index 0d77d046e..fbf65e489 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -68,6 +68,7 @@ def __init__( self.hours_of_previous_timesteps = self._calculate_hours_of_previous_timesteps(timesteps, hours_of_previous_timesteps) self.scenarios = None if scenarios is None else self._validate_scenarios(scenarios) + self.scenario_weights = scenario_weights hours_per_timestep = self.calculate_hours_per_timestep(self.timesteps_extra) @@ -75,12 +76,6 @@ def __init__( self.hours_per_timestep = self.fit_to_model_coords('hours_per_timestep', hours_per_timestep) - self.scenario_weights = self.fit_to_model_coords( - 'scenario_weights', - scenario_weights, - has_time_dim=False, - ) - # Element collections self.components: Dict[str, Component] = {} self.buses: Dict[str, Bus] = {} diff --git a/flixopt/structure.py b/flixopt/structure.py index 7732ffeb4..cbf8af599 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -57,7 +57,6 @@ def __init__(self, flow_system: 'FlowSystem'): super().__init__(force_dim_names=True) self.flow_system = flow_system self.effects: Optional[EffectCollectionModel] = None - self.scenario_weights = self._calculate_scenario_weights(flow_system.scenario_weights) def do_modeling(self): self.effects = self.flow_system.effects.create_model(self) @@ -69,22 +68,6 @@ def do_modeling(self): for bus_model in bus_models: # Buses after Components, because FlowModels are created in ComponentModels bus_model.do_modeling() - def _calculate_scenario_weights(self, weights: Optional[NonTemporalData] = None) -> xr.DataArray: - """Calculates the weights of the scenarios. If None, all scenarios have the same weight. All weights are normalized to 1. - If no scenarios are present, s single weight of 1 is returned. - """ - if weights is not None and not isinstance(weights, xr.DataArray): - raise TypeError(f'Weights must be a xr.DataArray or None, got {type(weights)}') - if self.flow_system.scenarios is None: - return xr.DataArray(1) - if weights is None: - weights = xr.DataArray( - np.ones(len(self.flow_system.scenarios)), - coords={'scenario': self.flow_system.scenarios} - ) - - return weights / weights.sum() - @property def solution(self): solution = super().solution @@ -152,6 +135,14 @@ def get_coords( return tuple(coords.values()) + @property + def scenario_weights(self) -> xr.DataArray: + """Returns the scenario weights of the FlowSystem.""" + if self.flow_system.scenarios is None: + return xr.DataArray(1) + + return self.flow_system.scenario_weights + class Interface: """ From 3931ac52e816d8ddc8b5029dba811009cc70576e Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 2 Jul 2025 11:17:56 +0200 Subject: [PATCH 158/336] Add warning for not scaled weights --- flixopt/flow_system.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index fbf65e489..74492bcd3 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -366,6 +366,10 @@ def connect_and_transform(self): self.scenario_weights = self.fit_to_model_coords( 'scenario_weights', self.scenario_weights, has_time_dim=False ) + if self.scenario_weights is not None and self.scenario_weights.sum() != 1: + logger.warning(f'Scenario weights are not normalized to 1. This is reccomended for a better scaled model. ' + f'Sum of weights={self.scenario_weights.sum().item()}') + if not self._connected_and_transformed: self._connect_network() for element in list(self.components.values()) + list(self.effects.effects.values()) + list(self.buses.values()): From 75a45e1552db8cbc89a5793da9d6a5e32c2d2ca0 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 2 Jul 2025 15:41:24 +0200 Subject: [PATCH 159/336] Update test_scenarios.py --- tests/test_scenarios.py | 45 ++++++++++++++++++++++++++--------------- 1 file changed, 29 insertions(+), 16 deletions(-) diff --git a/tests/test_scenarios.py b/tests/test_scenarios.py index 4abdafa5f..cc0be36c6 100644 --- a/tests/test_scenarios.py +++ b/tests/test_scenarios.py @@ -239,8 +239,8 @@ def flow_system_piecewise_conversion_scenarios(flow_system_complex_scenarios) -> { 'P_el': fx.Piecewise( [ - fx.Piece(np.linspace(5, 6, len(flow_system.time_series_collection.timesteps)), 30), - fx.Piece(40, np.linspace(60, 70, len(flow_system.time_series_collection.timesteps))), + fx.Piece(np.linspace(5, 6, len(flow_system.timesteps)), 30), + fx.Piece(40, np.linspace(60, 70, len(flow_system.timesteps))), ] ), 'Q_th': fx.Piecewise([fx.Piece(6, 35), fx.Piece(45, 100)]), @@ -256,7 +256,18 @@ def flow_system_piecewise_conversion_scenarios(flow_system_complex_scenarios) -> def test_scenario_weights(flow_system_piecewise_conversion_scenarios): """Test that scenario weights are correctly used in the model.""" - scenarios = flow_system_piecewise_conversion_scenarios.time_series_collection.scenarios + scenarios = flow_system_piecewise_conversion_scenarios.scenarios + weights = np.linspace(0.5, 1, len(scenarios)) + flow_system_piecewise_conversion_scenarios.scenario_weights = weights + model = create_linopy_model(flow_system_piecewise_conversion_scenarios) + np.testing.assert_allclose(model.scenario_weights.values, weights) + assert_linequal(model.objective.expression, + (model.variables['costs|total'] * weights).sum() + model.variables['Penalty|total']) + assert np.isclose(model.scenario_weights.sum().item(), 2.25) + +def test_scenario_weights_io(flow_system_piecewise_conversion_scenarios): + """Test that scenario weights are correctly used in the model.""" + scenarios = flow_system_piecewise_conversion_scenarios.scenarios weights = np.linspace(0.5, 1, len(scenarios)) / np.sum(np.linspace(0.5, 1, len(scenarios))) flow_system_piecewise_conversion_scenarios.scenario_weights = weights model = create_linopy_model(flow_system_piecewise_conversion_scenarios) @@ -273,7 +284,7 @@ def test_scenario_dimensions_in_variables(flow_system_piecewise_conversion_scena def test_full_scenario_optimization(flow_system_piecewise_conversion_scenarios): """Test a full optimization with scenarios and verify results.""" - scenarios = flow_system_piecewise_conversion_scenarios.time_series_collection.scenarios + scenarios = flow_system_piecewise_conversion_scenarios.scenarios weights = np.linspace(0.5, 1, len(scenarios)) / np.sum(np.linspace(0.5, 1, len(scenarios))) flow_system_piecewise_conversion_scenarios.scenario_weights = weights calc = create_calculation_and_solve(flow_system_piecewise_conversion_scenarios, @@ -292,7 +303,7 @@ def test_full_scenario_optimization(flow_system_piecewise_conversion_scenarios): @pytest.mark.skip(reason="This test is taking too long with highs and is too big for gurobipy free") def test_io_persistance(flow_system_piecewise_conversion_scenarios): """Test a full optimization with scenarios and verify results.""" - scenarios = flow_system_piecewise_conversion_scenarios.time_series_collection.scenarios + scenarios = flow_system_piecewise_conversion_scenarios.scenarios weights = np.linspace(0.5, 1, len(scenarios)) / np.sum(np.linspace(0.5, 1, len(scenarios))) flow_system_piecewise_conversion_scenarios.scenario_weights = weights calc = create_calculation_and_solve(flow_system_piecewise_conversion_scenarios, @@ -312,21 +323,23 @@ def test_io_persistance(flow_system_piecewise_conversion_scenarios): def test_scenarios_selection(flow_system_piecewise_conversion_scenarios): - flow_system = flow_system_piecewise_conversion_scenarios - scenarios = flow_system_piecewise_conversion_scenarios.time_series_collection.scenarios + flow_system_full = flow_system_piecewise_conversion_scenarios + scenarios = flow_system_full.scenarios weights = np.linspace(0.5, 1, len(scenarios)) / np.sum(np.linspace(0.5, 1, len(scenarios))) - flow_system_piecewise_conversion_scenarios.scenario_weights = weights - calc = fx.FullCalculation(flow_system=flow_system_piecewise_conversion_scenarios, - selected_scenarios=flow_system.time_series_collection.scenarios[0:2], - name='test_full_scenario') + flow_system_full.scenario_weights = weights + flow_system = flow_system_full.sel(scenario=scenarios[0:2]) + + assert flow_system.scenarios.equals(flow_system_full.scenarios[0:2]) + + np.testing.assert_allclose(flow_system.scenario_weights.values, flow_system_full.scenario_weights[0:2]) + + + calc = fx.FullCalculation(flow_system=flow_system, name='test_full_scenario') calc.do_modeling() calc.solve(fx.solvers.GurobiSolver(mip_gap=0.01, time_limit_seconds=60)) calc.results.to_file() - flow_system_2 = fx.FlowSystem.from_dataset(calc.results.flow_system_data) - - assert calc.results.solution.indexes['scenario'].equals(flow_system.time_series_collection.scenarios[0:2]) - assert flow_system_2.time_series_collection.scenarios.equals(flow_system.time_series_collection.scenarios[0:2]) + xr.testing.assert_allclose(calc.results.objective, calc.results.solution['costs|total'] * flow_system.scenario_weights) - np.testing.assert_allclose(flow_system_2.scenario_weights.selected_data.values, weights[0:2]) + assert calc.results.solution.indexes['scenario'].equals(flow_system_full.scenarios[0:2]) From 2882147805e0ebb3549ab790f435f7b40d097fab Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 2 Jul 2025 15:58:31 +0200 Subject: [PATCH 160/336] Improve util method --- flixopt/calculation.py | 4 ++-- flixopt/utils.py | 33 ++++++++++++++++++++++++++++++--- 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/flixopt/calculation.py b/flixopt/calculation.py index 88e509438..b7d17eef6 100644 --- a/flixopt/calculation.py +++ b/flixopt/calculation.py @@ -138,7 +138,7 @@ def main_results(self) -> Dict[str, Union[Scalar, Dict]]: ], } - return utils.round_floats(main_results) + return utils.round_nested_floats(main_results) @property def summary(self): @@ -205,7 +205,7 @@ def solve(self, solver: _Solver, log_file: Optional[pathlib.Path] = None, log_ma logger.info( '\n' + yaml.dump( - utils.round_floats(self.main_results), + utils.round_nested_floats(self.main_results), default_flow_style=False, sort_keys=False, allow_unicode=True, diff --git a/flixopt/utils.py b/flixopt/utils.py index 3e65328a4..1b5ad4497 100644 --- a/flixopt/utils.py +++ b/flixopt/utils.py @@ -11,11 +11,38 @@ logger = logging.getLogger('flixopt') -def round_floats(obj, decimals=2): +def round_nested_floats(obj, decimals=2): + """Recursively round floating point numbers in nested data structures. + + This function traverses nested data structures (dictionaries, lists) and rounds + any floating point numbers to the specified number of decimal places. It handles + various data types including NumPy arrays and xarray DataArrays by converting + them to lists with rounded values. + + Args: + obj: The object to process. Can be a dict, list, float, int, numpy.ndarray, + xarray.DataArray, or any other type. + decimals (int, optional): Number of decimal places to round to. Defaults to 2. + + Returns: + The processed object with the same structure as the input, but with all + floating point numbers rounded to the specified precision. NumPy arrays + and xarray DataArrays are converted to lists. + + Examples: + >>> data = {'a': 3.14159, 'b': [1.234, 2.678]} + >>> round_nested_floats(data, decimals=2) + {'a': 3.14, 'b': [1.23, 2.68]} + + >>> import numpy as np + >>> arr = np.array([1.234, 5.678]) + >>> round_nested_floats(arr, decimals=1) + [1.2, 5.7] + """ if isinstance(obj, dict): - return {k: round_floats(v, decimals) for k, v in obj.items()} + return {k: round_nested_floats(v, decimals) for k, v in obj.items()} elif isinstance(obj, list): - return [round_floats(v, decimals) for v in obj] + return [round_nested_floats(v, decimals) for v in obj] elif isinstance(obj, float): return round(obj, decimals) elif isinstance(obj, int): From 50491ffd3ae770c75ad6bf0f177d962498b41372 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 2 Jul 2025 16:02:10 +0200 Subject: [PATCH 161/336] Add objective to solution dataset. --- flixopt/results.py | 7 ++++++- flixopt/structure.py | 1 + 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/flixopt/results.py b/flixopt/results.py index 7a6f7926e..6f522e55c 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -221,7 +221,12 @@ def storages(self) -> List['ComponentResults']: @property def objective(self) -> float: """The objective result of the optimization.""" - return self.summary['Main Results']['Objective'] + # Deprecated. Fallback + if 'objective' not in self.solution: + logger.warning('Objective not found in solution. Fallback to summary (rounded value). This is deprecated') + return self.summary['Main Results']['Objective'] + + return self.solution['objective'].item() @property def variables(self) -> linopy.Variables: diff --git a/flixopt/structure.py b/flixopt/structure.py index cbf8af599..994ff5fc7 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -71,6 +71,7 @@ def do_modeling(self): @property def solution(self): solution = super().solution + solution['objective'] = self.objective.value solution.attrs = { 'Components': { comp.label_full: comp.model.results_structure() From 4f3a7981e01d7d16e7d4f977389bef04b0ee1886 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 2 Jul 2025 16:02:50 +0200 Subject: [PATCH 162/336] Update handling of scenario_weights update tests --- flixopt/structure.py | 6 +++--- tests/test_scenarios.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/flixopt/structure.py b/flixopt/structure.py index 994ff5fc7..b5b458cdb 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -137,10 +137,10 @@ def get_coords( return tuple(coords.values()) @property - def scenario_weights(self) -> xr.DataArray: + def scenario_weights(self) -> Union[int, xr.DataArray]: """Returns the scenario weights of the FlowSystem.""" - if self.flow_system.scenarios is None: - return xr.DataArray(1) + if self.flow_system.scenario_weights is None: + return 1 return self.flow_system.scenario_weights diff --git a/tests/test_scenarios.py b/tests/test_scenarios.py index cc0be36c6..cd3de4407 100644 --- a/tests/test_scenarios.py +++ b/tests/test_scenarios.py @@ -340,6 +340,6 @@ def test_scenarios_selection(flow_system_piecewise_conversion_scenarios): calc.results.to_file() - xr.testing.assert_allclose(calc.results.objective, calc.results.solution['costs|total'] * flow_system.scenario_weights) + np.testing.assert_allclose(calc.results.objective, ((calc.results.solution['costs|total'] * flow_system.scenario_weights).sum() + calc.results.solution['Penalty|total']).item()) ## Acount for rounding errors assert calc.results.solution.indexes['scenario'].equals(flow_system_full.scenarios[0:2]) From 314aef9333e63a2898ea74cd9fb249c69080905f Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 2 Jul 2025 16:10:52 +0200 Subject: [PATCH 163/336] Ruff check. Fix type hints --- flixopt/components.py | 2 +- flixopt/core.py | 2 +- flixopt/features.py | 2 +- flixopt/flow_system.py | 20 ++++++++++++++++++-- flixopt/interface.py | 24 ++++++++++++------------ flixopt/results.py | 5 +---- flixopt/structure.py | 2 +- tests/test_component.py | 8 +++++++- 8 files changed, 42 insertions(+), 23 deletions(-) diff --git a/flixopt/components.py b/flixopt/components.py index cf05af0ed..00d0c073f 100644 --- a/flixopt/components.py +++ b/flixopt/components.py @@ -10,7 +10,7 @@ import xarray as xr from . import utils -from .core import PlausibilityError, Scalar, TemporalData, TemporalDataUser, NonTemporalDataUser +from .core import NonTemporalDataUser, PlausibilityError, Scalar, TemporalData, TemporalDataUser from .elements import Component, ComponentModel, Flow from .features import InvestmentModel, OnOffModel, PiecewiseModel from .interface import InvestParameters, OnOffParameters, PiecewiseConversion diff --git a/flixopt/core.py b/flixopt/core.py index 43b529421..7d8db491e 100644 --- a/flixopt/core.py +++ b/flixopt/core.py @@ -5,7 +5,7 @@ import logging import warnings -from typing import Dict, Optional, Union, Tuple, Literal +from typing import Dict, Literal, Optional, Tuple, Union import numpy as np import pandas as pd diff --git a/flixopt/features.py b/flixopt/features.py index 5e0ed0e80..17bb43bcd 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -10,7 +10,7 @@ import numpy as np from .config import CONFIG -from .core import Scalar, TemporalData, NonTemporalData +from .core import NonTemporalData, Scalar, TemporalData from .interface import InvestParameters, OnOffParameters, Piecewise from .structure import Model, SystemModel diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index 74492bcd3..2cec86073 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -16,8 +16,24 @@ from rich.pretty import Pretty from . import io as fx_io -from .core import ConversionError, DataConverter, TemporalData, TemporalDataUser, TimeSeriesData, NonTemporalDataUser, NonTemporalData, FlowSystemDimensions -from .effects import Effect, EffectCollection, NonTemporalEffects, NonTemporalEffectsUser, TemporalEffects, TemporalEffectsUser +from .core import ( + ConversionError, + DataConverter, + FlowSystemDimensions, + NonTemporalData, + NonTemporalDataUser, + TemporalData, + TemporalDataUser, + TimeSeriesData, +) +from .effects import ( + Effect, + EffectCollection, + NonTemporalEffects, + NonTemporalEffectsUser, + TemporalEffects, + TemporalEffectsUser, +) from .elements import Bus, Component, Flow from .structure import Element, Interface, SystemModel diff --git a/flixopt/interface.py b/flixopt/interface.py index 81f351ebe..7abf574c6 100644 --- a/flixopt/interface.py +++ b/flixopt/interface.py @@ -7,11 +7,11 @@ from typing import TYPE_CHECKING, Dict, Iterator, List, Literal, Optional, Union from .config import CONFIG -from .core import Scalar, TemporalDataUser, NonTemporalDataUser, NonTemporalData +from .core import NonTemporalData, NonTemporalDataUser, Scalar, TemporalDataUser from .structure import Interface, register_class_for_io if TYPE_CHECKING: # for type checking and preventing circular imports - from .effects import EffectValuesUserScenario, EffectValuesUserTimestep + from .effects import NonTemporalEffectsUser, TemporalEffectsUser from .flow_system import FlowSystem @@ -150,10 +150,10 @@ def __init__( minimum_size: Optional[NonTemporalDataUser] = None, maximum_size: Optional[NonTemporalDataUser] = None, optional: bool = True, # Investition ist weglassbar - fix_effects: Optional['EffectValuesUserScenario'] = None, - specific_effects: Optional['EffectValuesUserScenario'] = None, # costs per Flow-Unit/Storage-Size/... + fix_effects: Optional[NonTemporalEffectsUser] = None, + specific_effects: Optional[NonTemporalEffectsUser] = None, # costs per Flow-Unit/Storage-Size/... piecewise_effects: Optional[PiecewiseEffects] = None, - divest_effects: Optional['EffectValuesUserScenario'] = None, + divest_effects: Optional[NonTemporalEffectsUser] = None, investment_scenarios: Optional[Union[Literal['individual'], List[Union[int, str]]]] = None, ): """ @@ -173,11 +173,11 @@ def __init__( - List of scenario names: Optimize the size for the passed scenario names (equal size in all). All other scenarios will have the size 0. - None: Equals to a list of all scenarios (default) """ - self.fix_effects: EffectValuesUserScenario = fix_effects if fix_effects is not None else {} - self.divest_effects: EffectValuesUserScenario = divest_effects if divest_effects is not None else {} + self.fix_effects: NonTemporalEffectsUser = fix_effects if fix_effects is not None else {} + self.divest_effects: NonTemporalEffectsUser = divest_effects if divest_effects is not None else {} self.fixed_size = fixed_size self.optional = optional - self.specific_effects: EffectValuesUserScenario = specific_effects if specific_effects is not None else {} + self.specific_effects: NonTemporalEffectsUser = specific_effects if specific_effects is not None else {} self.piecewise_effects = piecewise_effects self.minimum_size = minimum_size if minimum_size is not None else CONFIG.modeling.EPSILON self.maximum_size = maximum_size if maximum_size is not None else CONFIG.modeling.BIG # default maximum @@ -246,8 +246,8 @@ def maximum_or_fixed_size(self) -> NonTemporalData: class OnOffParameters(Interface): def __init__( self, - effects_per_switch_on: Optional['EffectValuesUser'] = None, - effects_per_running_hour: Optional['EffectValuesUser'] = None, + effects_per_switch_on: Optional[NonTemporalEffectsUser] = None, + effects_per_running_hour: Optional[NonTemporalEffectsUser] = None, on_hours_total_min: Optional[int] = None, on_hours_total_max: Optional[int] = None, consecutive_on_hours_min: Optional[TemporalDataUser] = None, @@ -277,8 +277,8 @@ def __init__( switch_on_total_max: max nr of switchOn operations force_switch_on: force creation of switch on variable, even if there is no switch_on_total_max """ - self.effects_per_switch_on: EffectValuesUserTimestep = effects_per_switch_on or {} - self.effects_per_running_hour: EffectValuesUserTimestep = effects_per_running_hour or {} + self.effects_per_switch_on: TemporalEffectsUser = effects_per_switch_on or {} + self.effects_per_running_hour: TemporalEffectsUser = effects_per_running_hour or {} self.on_hours_total_min: Scalar = on_hours_total_min self.on_hours_total_max: Scalar = on_hours_total_max self.consecutive_on_hours_min: TemporalDataUser = consecutive_on_hours_min diff --git a/flixopt/results.py b/flixopt/results.py index 6f522e55c..97dfc136a 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -1161,10 +1161,7 @@ def size(self) -> xr.DataArray: if name in self.solution: return self.solution[name] try: - return DataConverter.as_dataarray( - self._calculation_results.flow_system.flows[self.label].size, - scenarios=self._calculation_results.scenarios - ).rename(name) + return self._calculation_results.flow_system.flows[self.label].size.rename(name) except _FlowSystemRestorationError: logger.critical(f'Size of flow {self.label}.size not availlable. Returning NaN') return xr.DataArray(np.nan).rename(name) diff --git a/flixopt/structure.py b/flixopt/structure.py index b5b458cdb..5edee1bd3 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -19,7 +19,7 @@ from . import io as fx_io from .config import CONFIG -from .core import Scalar, TemporalDataUser, TimeSeriesData, get_dataarray_stats, NonTemporalData +from .core import NonTemporalData, Scalar, TemporalDataUser, TimeSeriesData, get_dataarray_stats if TYPE_CHECKING: # for type checking and preventing circular imports from .effects import EffectCollectionModel diff --git a/tests/test_component.py b/tests/test_component.py index 8a99b5d5b..11b5385c2 100644 --- a/tests/test_component.py +++ b/tests/test_component.py @@ -6,7 +6,13 @@ import flixopt as fx import flixopt.elements -from .conftest import assert_conequal, assert_var_equal, create_linopy_model, create_calculation_and_solve, assert_almost_equal_numeric +from .conftest import ( + assert_almost_equal_numeric, + assert_conequal, + assert_var_equal, + create_calculation_and_solve, + create_linopy_model, +) class TestComponentModel: From 0302947358667715af74531aca19b19330042203 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 3 Jul 2025 10:30:47 +0200 Subject: [PATCH 164/336] Fix type hints and improve None handling --- flixopt/effects.py | 6 ++++-- flixopt/features.py | 2 +- flixopt/interface.py | 20 ++++++++++---------- 3 files changed, 15 insertions(+), 13 deletions(-) diff --git a/flixopt/effects.py b/flixopt/effects.py index dc1d7d07f..381b5a3de 100644 --- a/flixopt/effects.py +++ b/flixopt/effects.py @@ -77,9 +77,11 @@ def __init__( self.is_standard = is_standard self.is_objective = is_objective self.specific_share_to_other_effects_operation: TemporalEffectsUser = ( - specific_share_to_other_effects_operation or {} + specific_share_to_other_effects_operation if specific_share_to_other_effects_operation is not None else {} + ) + self.specific_share_to_other_effects_invest: NonTemporalEffectsUser = ( + specific_share_to_other_effects_invest if specific_share_to_other_effects_invest is not None else {} ) - self.specific_share_to_other_effects_invest: NonTemporalEffectsUser = specific_share_to_other_effects_invest or {} self.minimum_operation = minimum_operation self.maximum_operation = maximum_operation self.minimum_operation_per_hour: TemporalDataUser = minimum_operation_per_hour diff --git a/flixopt/features.py b/flixopt/features.py index 17bb43bcd..4e47ace7f 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -274,7 +274,7 @@ def __init__( self._on_hours_total_min = on_hours_total_min if on_hours_total_min is not None else 0 self._on_hours_total_max = on_hours_total_max if on_hours_total_max is not None else np.inf self._use_off = use_off - self._effects_per_running_hour = effects_per_running_hour or {} + self._effects_per_running_hour = effects_per_running_hour if effects_per_running_hour is not None else {} self.on = None self.total_on_hours: Optional[linopy.Variable] = None diff --git a/flixopt/interface.py b/flixopt/interface.py index 7abf574c6..76e74616b 100644 --- a/flixopt/interface.py +++ b/flixopt/interface.py @@ -150,10 +150,10 @@ def __init__( minimum_size: Optional[NonTemporalDataUser] = None, maximum_size: Optional[NonTemporalDataUser] = None, optional: bool = True, # Investition ist weglassbar - fix_effects: Optional[NonTemporalEffectsUser] = None, - specific_effects: Optional[NonTemporalEffectsUser] = None, # costs per Flow-Unit/Storage-Size/... + fix_effects: Optional['NonTemporalEffectsUser'] = None, + specific_effects: Optional['NonTemporalEffectsUser'] = None, # costs per Flow-Unit/Storage-Size/... piecewise_effects: Optional[PiecewiseEffects] = None, - divest_effects: Optional[NonTemporalEffectsUser] = None, + divest_effects: Optional['NonTemporalEffectsUser'] = None, investment_scenarios: Optional[Union[Literal['individual'], List[Union[int, str]]]] = None, ): """ @@ -173,11 +173,11 @@ def __init__( - List of scenario names: Optimize the size for the passed scenario names (equal size in all). All other scenarios will have the size 0. - None: Equals to a list of all scenarios (default) """ - self.fix_effects: NonTemporalEffectsUser = fix_effects if fix_effects is not None else {} - self.divest_effects: NonTemporalEffectsUser = divest_effects if divest_effects is not None else {} + self.fix_effects: 'NonTemporalEffectsUser' = fix_effects if fix_effects is not None else {} + self.divest_effects: 'NonTemporalEffectsUser' = divest_effects if divest_effects is not None else {} self.fixed_size = fixed_size self.optional = optional - self.specific_effects: NonTemporalEffectsUser = specific_effects if specific_effects is not None else {} + self.specific_effects: 'NonTemporalEffectsUser' = specific_effects if specific_effects is not None else {} self.piecewise_effects = piecewise_effects self.minimum_size = minimum_size if minimum_size is not None else CONFIG.modeling.EPSILON self.maximum_size = maximum_size if maximum_size is not None else CONFIG.modeling.BIG # default maximum @@ -246,8 +246,8 @@ def maximum_or_fixed_size(self) -> NonTemporalData: class OnOffParameters(Interface): def __init__( self, - effects_per_switch_on: Optional[NonTemporalEffectsUser] = None, - effects_per_running_hour: Optional[NonTemporalEffectsUser] = None, + effects_per_switch_on: Optional['NonTemporalEffectsUser'] = None, + effects_per_running_hour: Optional['NonTemporalEffectsUser'] = None, on_hours_total_min: Optional[int] = None, on_hours_total_max: Optional[int] = None, consecutive_on_hours_min: Optional[TemporalDataUser] = None, @@ -277,8 +277,8 @@ def __init__( switch_on_total_max: max nr of switchOn operations force_switch_on: force creation of switch on variable, even if there is no switch_on_total_max """ - self.effects_per_switch_on: TemporalEffectsUser = effects_per_switch_on or {} - self.effects_per_running_hour: TemporalEffectsUser = effects_per_running_hour or {} + self.effects_per_switch_on: 'TemporalEffectsUser' = effects_per_switch_on if effects_per_switch_on is not None else {} + self.effects_per_running_hour: 'TemporalEffectsUser' = effects_per_running_hour if effects_per_running_hour is not None else {} self.on_hours_total_min: Scalar = on_hours_total_min self.on_hours_total_max: Scalar = on_hours_total_max self.consecutive_on_hours_min: TemporalDataUser = consecutive_on_hours_min From 40c437a533fc74710f4e5f075a9f1c7a5f9989d8 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 3 Jul 2025 10:52:48 +0200 Subject: [PATCH 165/336] Fix coords in AggregatedCalculation --- flixopt/calculation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flixopt/calculation.py b/flixopt/calculation.py index b7d17eef6..e035aaa13 100644 --- a/flixopt/calculation.py +++ b/flixopt/calculation.py @@ -323,7 +323,7 @@ def _perform_aggregation(self): if self.aggregation_parameters.aggregate_data_and_fix_non_binary_vars: ds = self.flow_system.to_dataset() for name, series in self.aggregation.aggregated_data.items(): - da = DataConverter.to_dataarray(series, timesteps=self.flow_system.timesteps).rename(name).assign_attrs(ds[name].attrs) + da = DataConverter.to_dataarray(series, self.flow_system.coords).rename(name).assign_attrs(ds[name].attrs) if TimeSeriesData.is_timeseries_data(da): da = TimeSeriesData.from_dataarray(da) From 8d2d208d4bce947c2487ceebb6c33cad0d210fe2 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 3 Jul 2025 11:23:14 +0200 Subject: [PATCH 166/336] Improve Error Messages of DataConversion --- flixopt/flow_system.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index 2cec86073..0568f3b3a 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -349,9 +349,13 @@ def fit_to_model_coords( ).rename(name) except ConversionError as e: raise ConversionError( - f'Could not convert time series data "{name}" to DataArray: Original Error: {e}') from e + f'Could not convert time series data "{name}" to DataArray:\n{data}\nOriginal Error: {e}') from e else: - return DataConverter.to_dataarray(data, coords=coords).rename(name) + try: + return DataConverter.to_dataarray(data, coords=coords).rename(name) + except ConversionError as e: + raise ConversionError( + f'Could not convert data "{name}" to DataArray:\n{data}\nOriginal Error: {e}') from e def fit_effects_to_model_coords( self, From 9c0c95fe86b51acc719c258265fd6fd38e18accc Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 3 Jul 2025 12:59:44 +0200 Subject: [PATCH 167/336] Allow multi dim data conversion and broadcasting by length --- flixopt/core.py | 89 ++++++++-- tests/test_dataconverter.py | 330 +++++++++++++++++++++++++++++++++++- 2 files changed, 407 insertions(+), 12 deletions(-) diff --git a/flixopt/core.py b/flixopt/core.py index 7d8db491e..ad5b011ba 100644 --- a/flixopt/core.py +++ b/flixopt/core.py @@ -279,6 +279,63 @@ def _broadcast_dataarray_to_more_dims( return xr.DataArray(result_data.copy(), coords=final_coords, dims=target_dims) + @staticmethod + def _convert_multid_array_to_dataarray( + data: np.ndarray, coords: Dict[str, pd.Index], target_dims: Tuple[str, ...] + ) -> xr.DataArray: + """ + Convert multi-dimensional numpy array to DataArray by matching dimensions by length. + + Args: + data: Multi-dimensional numpy array + coords: Available coordinates + target_dims: Target dimension names + + Returns: + DataArray with dimensions matched by length + + Raises: + ConversionError: If array dimensions cannot be uniquely matched to coordinates + """ + if len(target_dims) == 0: + if data.size != 1: + raise ConversionError('Cannot convert multi-element array without target dimensions') + return xr.DataArray(data.item()) + + if data.ndim != len(target_dims): + raise ConversionError(f'Array has {data.ndim} dimensions but {len(target_dims)} target dimensions provided') + + # Get lengths of each dimension + array_shape = data.shape + coord_lengths = {dim: len(coords[dim]) for dim in target_dims} + + # Try to find a unique mapping from array dimensions to coordinate dimensions + possible_mappings = [] + + # Generate all possible permutations of target dimensions + from itertools import permutations + + for dim_order in permutations(target_dims): + # Check if this permutation matches the array shape + if all(array_shape[i] == coord_lengths[dim_order[i]] for i in range(len(dim_order))): + possible_mappings.append(dim_order) + + if len(possible_mappings) == 0: + shape_info = f'Array shape: {array_shape}, Coordinate lengths: {coord_lengths}' + raise ConversionError(f'Array dimensions do not match any coordinate lengths. {shape_info}') + + if len(possible_mappings) > 1: + raise ConversionError( + f'Array shape {array_shape} matches multiple dimension orders: {possible_mappings}. ' + 'Cannot uniquely determine dimension mapping.' + ) + + # Use the unique mapping found + matched_dims = possible_mappings[0] + matched_coords = {dim: coords[dim] for dim in matched_dims} + + return xr.DataArray(data.copy(), coords=matched_coords, dims=matched_dims) + @staticmethod def to_dataarray( data: Union[Scalar, np.ndarray, pd.Series, pd.DataFrame, xr.DataArray, TimeSeriesData], @@ -289,7 +346,8 @@ def to_dataarray( Accepts: - Scalars (broadcast to all dimensions) - - 1D arrays, Series, or single-column DataFrames (matched to one dimension, broadcast to others) + - 1D arrays or Series (matched to one dimension, broadcast to others) + - Multi-D arrays or DataFrames (dimensions matched by length) - xr.DataArray (validated and potentially broadcast to additional dimensions) Args: @@ -304,31 +362,42 @@ def to_dataarray( validated_coords, target_dims = DataConverter._prepare_dimensions(coords) - # Step 1: Convert to DataArray (with safe 1D/2D logic for simple data) + # Step 1: Convert to DataArray if isinstance(data, (int, float, np.integer, np.floating)): # Scalars: create 0D DataArray, will be broadcast later intermediate = xr.DataArray(data.item() if hasattr(data, 'item') else data) elif isinstance(data, np.ndarray): - if data.ndim != 1: - raise ConversionError(f'Only 1D arrays supported, got {data.ndim}D array') - intermediate = DataConverter._convert_1d_data_to_dataarray(data, validated_coords, target_dims) + if data.ndim == 1: + intermediate = DataConverter._convert_1d_data_to_dataarray(data, validated_coords, target_dims) + else: + # Handle multi-dimensional arrays + intermediate = DataConverter._convert_multid_array_to_dataarray(data, validated_coords, target_dims) elif isinstance(data, pd.Series): + if isinstance(data.index, pd.MultiIndex): + raise ConversionError('Series index must be a single level Index. Multi-index Series are not supported.') intermediate = DataConverter._convert_1d_data_to_dataarray(data, validated_coords, target_dims) elif isinstance(data, pd.DataFrame): - if len(data.columns) != 1: - raise ConversionError(f'Only single-column DataFrames are supported, got {len(data.columns)} columns') - series = data.iloc[:, 0] - intermediate = DataConverter._convert_1d_data_to_dataarray(series, validated_coords, target_dims) + if isinstance(data.index, pd.MultiIndex): + raise ConversionError('DataFrame index must be a single level Index. Multi-index DataFrames are not supported.') + if len(data.columns) == 0 or data.empty: + raise ConversionError('DataFrame must have at least one column.') + + if len(data.columns) == 1: + intermediate = DataConverter._convert_1d_data_to_dataarray(data.iloc[:, 0], validated_coords, target_dims) + else: + # Handle multi-column DataFrames + logger.warning(f'Converting multi-column DataFrame to xr.DataArray. We advise to do this manually.') + intermediate = DataConverter._convert_multid_array_to_dataarray(data.to_numpy(), validated_coords, target_dims) elif isinstance(data, xr.DataArray): intermediate = data.copy() else: raise ConversionError( - f'Unsupported data type: {type(data).__name__}. Only scalars, 1D arrays, Series, single-column DataFrames, and DataArrays are supported.' + f'Unsupported data type: {type(data).__name__}. Only scalars, arrays, Series, DataFrames, and DataArrays are supported.' ) # Step 2: Broadcast to target dimensions if needed diff --git a/tests/test_dataconverter.py b/tests/test_dataconverter.py index 56f36aabf..659528c4d 100644 --- a/tests/test_dataconverter.py +++ b/tests/test_dataconverter.py @@ -211,14 +211,14 @@ def test_multi_column_dataframe_rejected(self, time_coords): 'value2': [15, 25, 35, 45, 55] }, index=time_coords) - with pytest.raises(ConversionError, match="Only single-column DataFrames are supported"): + with pytest.raises(ConversionError, match="Array has 2 dimensions but 1 target "): DataConverter.to_dataarray(df, coords={'time': time_coords}) def test_empty_dataframe_rejected(self, time_coords): """Empty DataFrame should be rejected.""" df = pd.DataFrame(index=time_coords) # No columns - with pytest.raises(ConversionError, match="Only single-column DataFrames are supported"): + with pytest.raises(ConversionError, match="DataFrame must have at least one"): DataConverter.to_dataarray(df, coords={'time': time_coords}) def test_dataframe_broadcast(self, time_coords, scenario_coords): @@ -855,5 +855,331 @@ def test_mixed_numeric_types(self, time_coords): assert np.array_equal(result.values, mixed_arr) +class TestMultiDimensionalArrayConversion: + """Test multi-dimensional numpy array conversions.""" + + @pytest.fixture + def standard_coords(self): + """Standard coordinates with unique lengths for easy testing.""" + return { + 'time': pd.date_range('2024-01-01', periods=5, freq='D', name='time'), # length 5 + 'scenario': pd.Index(['A', 'B', 'C'], name='scenario'), # length 3 + 'region': pd.Index(['north', 'south'], name='region') # length 2 + } + + def test_2d_array_unique_dimensions(self, standard_coords): + """2D array with unique dimension lengths should work.""" + # 5x3 array should map to time x scenario + data_2d = np.random.rand(5, 3) + result = DataConverter.to_dataarray(data_2d, coords={ + 'time': standard_coords['time'], + 'scenario': standard_coords['scenario'] + }) + + assert result.shape == (5, 3) + assert result.dims == ('time', 'scenario') + assert np.array_equal(result.values, data_2d) + + # 3x5 array should map to scenario x time + data_2d_flipped = np.random.rand(3, 5) + result_flipped = DataConverter.to_dataarray(data_2d_flipped, coords={ + 'time': standard_coords['time'], + 'scenario': standard_coords['scenario'] + }) + + assert result_flipped.shape == (3, 5) + assert result_flipped.dims == ('scenario', 'time') + assert np.array_equal(result_flipped.values, data_2d_flipped) + + def test_3d_array_unique_dimensions(self, standard_coords): + """3D array with unique dimension lengths should work.""" + # 5x3x2 array should map to time x scenario x region + data_3d = np.random.rand(5, 3, 2) + result = DataConverter.to_dataarray(data_3d, coords=standard_coords) + + assert result.shape == (5, 3, 2) + assert result.dims == ('time', 'scenario', 'region') + assert np.array_equal(result.values, data_3d) + + def test_3d_array_different_permutation(self, standard_coords): + """3D array with different dimension order should work.""" + # 2x5x3 array should map to region x time x scenario + data_3d = np.random.rand(2, 5, 3) + result = DataConverter.to_dataarray(data_3d, coords=standard_coords) + + assert result.shape == (2, 5, 3) + assert result.dims == ('region', 'time', 'scenario') + assert np.array_equal(result.values, data_3d) + + def test_4d_array_unique_dimensions(self): + """4D array with unique dimension lengths should work.""" + coords = { + 'time': pd.date_range('2024-01-01', periods=2, freq='D', name='time'), # length 2 + 'scenario': pd.Index(['A', 'B', 'C'], name='scenario'), # length 3 + 'region': pd.Index(['north', 'south', 'east', 'west'], name='region'), # length 4 + 'technology': pd.Index(['solar', 'wind', 'gas', 'coal', 'hydro'], name='technology') # length 5 + } + + # 3x5x2x4 array should map to scenario x technology x time x region + data_4d = np.random.rand(3, 5, 2, 4) + result = DataConverter.to_dataarray(data_4d, coords=coords) + + assert result.shape == (3, 5, 2, 4) + assert result.dims == ('scenario', 'technology', 'time', 'region') + assert np.array_equal(result.values, data_4d) + + def test_2d_array_ambiguous_dimensions_error(self): + """2D array with ambiguous dimension lengths should fail.""" + # Both dimensions have length 3 + coords_ambiguous = { + 'scenario': pd.Index(['A', 'B', 'C'], name='scenario'), # length 3 + 'region': pd.Index(['north', 'south', 'east'], name='region') # length 3 + } + + data_2d = np.random.rand(3, 3) + with pytest.raises(ConversionError, match="matches multiple dimension orders"): + DataConverter.to_dataarray(data_2d, coords=coords_ambiguous) + + def test_3d_array_ambiguous_dimensions_error(self): + """3D array with ambiguous dimension lengths should fail.""" + # All dimensions have length 2 + coords_ambiguous = { + 'scenario': pd.Index(['A', 'B'], name='scenario'), # length 2 + 'region': pd.Index(['north', 'south'], name='region'), # length 2 + 'technology': pd.Index(['solar', 'wind'], name='technology') # length 2 + } + + data_3d = np.random.rand(2, 2, 2) + with pytest.raises(ConversionError, match="matches multiple dimension orders"): + DataConverter.to_dataarray(data_3d, coords=coords_ambiguous) + + def test_array_dimension_count_mismatch_error(self, standard_coords): + """Array with wrong number of dimensions should fail.""" + # 2D array with 3D coordinates + data_2d = np.random.rand(5, 3) + with pytest.raises(ConversionError, match="Array has 2 dimensions but 3 target dimensions provided"): + DataConverter.to_dataarray(data_2d, coords=standard_coords) + + # 4D array with 3D coordinates + data_4d = np.random.rand(5, 3, 2, 4) + with pytest.raises(ConversionError, match="Array has 4 dimensions but 3 target dimensions provided"): + DataConverter.to_dataarray(data_4d, coords=standard_coords) + + def test_array_no_matching_dimensions_error(self, standard_coords): + """Array with no matching dimension lengths should fail.""" + # 7x8 array - no dimension has length 7 or 8 + data_2d = np.random.rand(7, 8) + coords_2d = { + 'time': standard_coords['time'], # length 5 + 'scenario': standard_coords['scenario'] # length 3 + } + + with pytest.raises(ConversionError, match="Array dimensions do not match any coordinate lengths"): + DataConverter.to_dataarray(data_2d, coords=coords_2d) + + def test_2d_array_custom_dimensions(self): + """2D array with custom dimension names should work.""" + coords = { + 'product': pd.Index(['A', 'B', 'C', 'D'], name='product'), # length 4 + 'factory': pd.Index(['F1', 'F2', 'F3'], name='factory') # length 3 + } + + # 4x3 array should map to product x factory + data_2d = np.array([[10, 11, 12], + [20, 21, 22], + [30, 31, 32], + [40, 41, 42]]) + + result = DataConverter.to_dataarray(data_2d, coords=coords) + + assert result.shape == (4, 3) + assert result.dims == ('product', 'factory') + assert np.array_equal(result.values, data_2d) + + # Verify coordinates are correct + assert result.indexes['product'].equals(coords['product']) + assert result.indexes['factory'].equals(coords['factory']) + + def test_multid_array_copy_independence(self, standard_coords): + """Multi-D arrays should be independent copies.""" + original_data = np.random.rand(5, 3) + result = DataConverter.to_dataarray(original_data, coords={ + 'time': standard_coords['time'], + 'scenario': standard_coords['scenario'] + }) + + # Modify result + result[0, 0] = 999 + + # Original should be unchanged + assert original_data[0, 0] != 999 + + def test_multid_array_special_values(self, standard_coords): + """Multi-D arrays should preserve special values.""" + # Create 2D array with special values + data_2d = np.array([[1.0, np.nan, 3.0], + [np.inf, 5.0, -np.inf], + [7.0, 8.0, 9.0], + [10.0, np.nan, 12.0], + [13.0, 14.0, np.inf]]) + + result = DataConverter.to_dataarray(data_2d, coords={ + 'time': standard_coords['time'], + 'scenario': standard_coords['scenario'] + }) + + assert result.shape == (5, 3) + assert np.array_equal(np.isnan(result.values), np.isnan(data_2d)) + assert np.array_equal(np.isinf(result.values), np.isinf(data_2d)) + + def test_multid_array_with_time_dimension(self): + """Multi-D arrays should work with time dimension.""" + time_coords = pd.date_range('2024-01-01', periods=4, freq='H', name='time') + scenario_coords = pd.Index(['base', 'high', 'low'], name='scenario') + + # 4x3 time series data + data_2d = np.array([[100, 110, 120], + [200, 210, 220], + [300, 310, 320], + [400, 410, 420]]) + + result = DataConverter.to_dataarray(data_2d, coords={ + 'time': time_coords, + 'scenario': scenario_coords + }) + + assert result.shape == (4, 3) + assert result.dims == ('time', 'scenario') + assert isinstance(result.indexes['time'], pd.DatetimeIndex) + assert np.array_equal(result.values, data_2d) + + def test_multid_array_dtype_preservation(self, standard_coords): + """Multi-D arrays should preserve data types.""" + # Integer array + int_data = np.array([[1, 2, 3], + [4, 5, 6], + [7, 8, 9], + [10, 11, 12], + [13, 14, 15]], dtype=np.int32) + + result_int = DataConverter.to_dataarray(int_data, coords={ + 'time': standard_coords['time'], + 'scenario': standard_coords['scenario'] + }) + + assert result_int.dtype == np.int32 + assert np.array_equal(result_int.values, int_data) + + # Float array + float_data = np.array([[1.1, 2.2, 3.3], + [4.4, 5.5, 6.6], + [7.7, 8.8, 9.9], + [10.1, 11.1, 12.1], + [13.1, 14.1, 15.1]], dtype=np.float64) + + result_float = DataConverter.to_dataarray(float_data, coords={ + 'time': standard_coords['time'], + 'scenario': standard_coords['scenario'] + }) + + assert result_float.dtype == np.float64 + assert np.array_equal(result_float.values, float_data) + + # Boolean array + bool_data = np.array([[True, False, True], + [False, True, False], + [True, True, False], + [False, False, True], + [True, False, True]]) + + result_bool = DataConverter.to_dataarray(bool_data, coords={ + 'time': standard_coords['time'], + 'scenario': standard_coords['scenario'] + }) + + assert result_bool.dtype == bool + assert np.array_equal(result_bool.values, bool_data) + + def test_multid_array_no_coords(self): + """Multi-D arrays without coords should fail unless scalar.""" + # Multi-element fails + data_2d = np.random.rand(2, 3) + with pytest.raises(ConversionError, match="Cannot convert multi-element array without target dimensions"): + DataConverter.to_dataarray(data_2d) + + # Single element succeeds + single_element = np.array([[42]]) + result = DataConverter.to_dataarray(single_element) + assert result.shape == () + assert result.item() == 42 + + def test_multid_array_empty_coords(self, standard_coords): + """Multi-D arrays with empty coords should fail.""" + data_2d = np.random.rand(5, 3) + with pytest.raises(ConversionError, match="Cannot convert multi-element array without target dimensions"): + DataConverter.to_dataarray(data_2d, coords={}) + + def test_multid_array_coordinate_validation(self): + """Multi-D arrays should validate coordinates properly.""" + # Test with time coordinate that's not DatetimeIndex + wrong_time = pd.Index([1, 2, 3, 4, 5], name='time') + scenario_coords = pd.Index(['A', 'B', 'C'], name='scenario') + + data_2d = np.random.rand(5, 3) + with pytest.raises(ConversionError, match="time coordinates must be a DatetimeIndex"): + DataConverter.to_dataarray(data_2d, coords={ + 'time': wrong_time, + 'scenario': scenario_coords + }) + + def test_multid_array_complex_scenario(self): + """Complex real-world scenario with multi-D array.""" + # Energy system data: time x technology x region + coords = { + 'time': pd.date_range('2024-01-01', periods=8760, freq='H', name='time'), # 1 year hourly + 'technology': pd.Index(['solar', 'wind', 'gas', 'coal'], name='technology'), # 4 technologies + 'region': pd.Index(['north', 'south', 'east'], name='region') # 3 regions + } + + # Capacity factors: 8760 x 4 x 3 + capacity_factors = np.random.rand(8760, 4, 3) + + result = DataConverter.to_dataarray(capacity_factors, coords=coords) + + assert result.shape == (8760, 4, 3) + assert result.dims == ('time', 'technology', 'region') + assert isinstance(result.indexes['time'], pd.DatetimeIndex) + assert len(result.indexes['time']) == 8760 + assert len(result.indexes['technology']) == 4 + assert len(result.indexes['region']) == 3 + + def test_multid_array_edge_cases(self): + """Test edge cases for multi-D arrays.""" + # Single dimension with multi-D array should fail + coords_1d = {'time': pd.date_range('2024-01-01', periods=5, freq='D', name='time')} + data_2d = np.random.rand(5, 3) + + with pytest.raises(ConversionError, match="Array has 2 dimensions but 1 target dimensions provided"): + DataConverter.to_dataarray(data_2d, coords=coords_1d) + + # Zero dimensions with multi-D array should fail + data_1d = np.array([1, 2, 3]) + with pytest.raises(ConversionError, match="Cannot convert multi-element array without target dimensions"): + DataConverter.to_dataarray(data_1d, coords={}) + + def test_multid_array_partial_dimension_match(self): + """Array where only some dimensions match should fail.""" + coords = { + 'time': pd.date_range('2024-01-01', periods=5, freq='D', name='time'), # length 5 + 'scenario': pd.Index(['A', 'B', 'C'], name='scenario') # length 3 + } + + # 5x7 array - first dimension matches time (5) but second doesn't match scenario (3) + data_2d = np.random.rand(5, 7) + with pytest.raises(ConversionError, match="Array dimensions do not match any coordinate lengths"): + DataConverter.to_dataarray(data_2d, coords=coords) + + + if __name__ == '__main__': pytest.main() From d568ad605659c81f1dd3de40625be774129a94d0 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 3 Jul 2025 14:13:49 +0200 Subject: [PATCH 168/336] Improve DataConverter to handle multi-dim arrays --- flixopt/core.py | 47 +- tests/test_dataconverter.py | 1139 +++++++++++++---------------------- 2 files changed, 451 insertions(+), 735 deletions(-) diff --git a/flixopt/core.py b/flixopt/core.py index ad5b011ba..97e91c274 100644 --- a/flixopt/core.py +++ b/flixopt/core.py @@ -285,6 +285,7 @@ def _convert_multid_array_to_dataarray( ) -> xr.DataArray: """ Convert multi-dimensional numpy array to DataArray by matching dimensions by length. + Returns a DataArray that may need further broadcasting to target dimensions. Args: data: Multi-dimensional numpy array @@ -292,7 +293,7 @@ def _convert_multid_array_to_dataarray( target_dims: Target dimension names Returns: - DataArray with dimensions matched by length + DataArray with dimensions matched by length (may be subset of target_dims) Raises: ConversionError: If array dimensions cannot be uniquely matched to coordinates @@ -302,23 +303,19 @@ def _convert_multid_array_to_dataarray( raise ConversionError('Cannot convert multi-element array without target dimensions') return xr.DataArray(data.item()) - if data.ndim != len(target_dims): - raise ConversionError(f'Array has {data.ndim} dimensions but {len(target_dims)} target dimensions provided') - # Get lengths of each dimension array_shape = data.shape coord_lengths = {dim: len(coords[dim]) for dim in target_dims} - # Try to find a unique mapping from array dimensions to coordinate dimensions - possible_mappings = [] - - # Generate all possible permutations of target dimensions + # Find all possible ways to match array dimensions to available coordinates from itertools import permutations - for dim_order in permutations(target_dims): + # Try all permutations of target_dims that match the array's number of dimensions + possible_mappings = [] + for dim_subset in permutations(target_dims, data.ndim): # Check if this permutation matches the array shape - if all(array_shape[i] == coord_lengths[dim_order[i]] for i in range(len(dim_order))): - possible_mappings.append(dim_order) + if all(array_shape[i] == coord_lengths[dim_subset[i]] for i in range(len(dim_subset))): + possible_mappings.append(dim_subset) if len(possible_mappings) == 0: shape_info = f'Array shape: {array_shape}, Coordinate lengths: {coord_lengths}' @@ -334,6 +331,7 @@ def _convert_multid_array_to_dataarray( matched_dims = possible_mappings[0] matched_coords = {dim: coords[dim] for dim in matched_dims} + # Return DataArray with matched dimensions - broadcasting will happen later if needed return xr.DataArray(data.copy(), coords=matched_coords, dims=matched_dims) @staticmethod @@ -347,7 +345,7 @@ def to_dataarray( Accepts: - Scalars (broadcast to all dimensions) - 1D arrays or Series (matched to one dimension, broadcast to others) - - Multi-D arrays or DataFrames (dimensions matched by length) + - Multi-D arrays or DataFrames (dimensions matched by length, broadcast to remaining) - xr.DataArray (validated and potentially broadcast to additional dimensions) Args: @@ -362,7 +360,7 @@ def to_dataarray( validated_coords, target_dims = DataConverter._prepare_dimensions(coords) - # Step 1: Convert to DataArray + # Step 1: Convert to DataArray (may have fewer dimensions than target) if isinstance(data, (int, float, np.integer, np.floating)): # Scalars: create 0D DataArray, will be broadcast later intermediate = xr.DataArray(data.item() if hasattr(data, 'item') else data) @@ -371,26 +369,34 @@ def to_dataarray( if data.ndim == 1: intermediate = DataConverter._convert_1d_data_to_dataarray(data, validated_coords, target_dims) else: - # Handle multi-dimensional arrays + # Handle multi-dimensional arrays - this now allows partial matching intermediate = DataConverter._convert_multid_array_to_dataarray(data, validated_coords, target_dims) elif isinstance(data, pd.Series): if isinstance(data.index, pd.MultiIndex): - raise ConversionError('Series index must be a single level Index. Multi-index Series are not supported.') + raise ConversionError( + 'Series index must be a single level Index. Multi-index Series are not supported.' + ) intermediate = DataConverter._convert_1d_data_to_dataarray(data, validated_coords, target_dims) elif isinstance(data, pd.DataFrame): if isinstance(data.index, pd.MultiIndex): - raise ConversionError('DataFrame index must be a single level Index. Multi-index DataFrames are not supported.') + raise ConversionError( + 'DataFrame index must be a single level Index. Multi-index DataFrames are not supported.' + ) if len(data.columns) == 0 or data.empty: raise ConversionError('DataFrame must have at least one column.') if len(data.columns) == 1: - intermediate = DataConverter._convert_1d_data_to_dataarray(data.iloc[:, 0], validated_coords, target_dims) + intermediate = DataConverter._convert_1d_data_to_dataarray( + data.iloc[:, 0], validated_coords, target_dims + ) else: - # Handle multi-column DataFrames - logger.warning(f'Converting multi-column DataFrame to xr.DataArray. We advise to do this manually.') - intermediate = DataConverter._convert_multid_array_to_dataarray(data.to_numpy(), validated_coords, target_dims) + # Handle multi-column DataFrames - this now allows partial matching + logger.warning('Converting multi-column DataFrame to xr.DataArray. We advise to do this manually.') + intermediate = DataConverter._convert_multid_array_to_dataarray( + data.to_numpy(), validated_coords, target_dims + ) elif isinstance(data, xr.DataArray): intermediate = data.copy() @@ -401,6 +407,7 @@ def to_dataarray( ) # Step 2: Broadcast to target dimensions if needed + # This now handles cases where intermediate has some but not all target dimensions return DataConverter._broadcast_to_target_dims(intermediate, validated_coords, target_dims) @staticmethod diff --git a/tests/test_dataconverter.py b/tests/test_dataconverter.py index 659528c4d..c174aebe8 100644 --- a/tests/test_dataconverter.py +++ b/tests/test_dataconverter.py @@ -25,8 +25,18 @@ def region_coords(): return pd.Index(['north', 'south', 'east'], name='region') -class TestBasicConversion: - """Test basic data type conversions with different coordinate configurations.""" +@pytest.fixture +def standard_coords(): + """Standard coordinates with unique lengths for easy testing.""" + return { + 'time': pd.date_range('2024-01-01', periods=5, freq='D', name='time'), # length 5 + 'scenario': pd.Index(['A', 'B', 'C'], name='scenario'), # length 3 + 'region': pd.Index(['north', 'south'], name='region') # length 2 + } + + +class TestScalarConversion: + """Test scalar data conversions with different coordinate configurations.""" def test_scalar_no_coords(self): """Scalar without coordinates should create 0D DataArray.""" @@ -56,9 +66,21 @@ def test_numpy_scalars(self, time_coords): assert result.shape == (5,) assert np.all(result.values == scalar.item()) + def test_scalar_many_dimensions(self, standard_coords): + """Scalar should broadcast to any number of dimensions.""" + coords = { + **standard_coords, + 'technology': pd.Index(['solar', 'wind'], name='technology') + } + + result = DataConverter.to_dataarray(42, coords=coords) + assert result.shape == (5, 3, 2, 2) + assert result.dims == ('time', 'scenario', 'region', 'technology') + assert np.all(result.values == 42) + -class TestArrayConversion: - """Test numpy array conversions.""" +class TestOneDimensionalArrayConversion: + """Test 1D numpy array and pandas Series conversions.""" def test_1d_array_no_coords(self): """1D array without coords should fail unless single element.""" @@ -119,11 +141,22 @@ def test_1d_array_ambiguous_length(self): with pytest.raises(ConversionError, match="matches multiple dimensions"): DataConverter.to_dataarray(arr, coords=coords_3x3) - def test_multidimensional_array_rejected(self, time_coords): - """Multidimensional arrays should be rejected.""" - arr_2d = np.array([[1, 2, 3], [4, 5, 6]]) - with pytest.raises(ConversionError, match="Only 1D arrays supported"): - DataConverter.to_dataarray(arr_2d, coords={'time': time_coords}) + def test_1d_array_broadcast_to_many_dimensions(self, standard_coords): + """1D array should broadcast to many dimensions.""" + # Array matching time dimension + time_arr = np.array([10, 20, 30, 40, 50]) + result = DataConverter.to_dataarray(time_arr, coords=standard_coords) + + assert result.shape == (5, 3, 2) + assert result.dims == ('time', 'scenario', 'region') + + # Check broadcasting - all scenarios and regions should have same time values + for scenario in standard_coords['scenario']: + for region in standard_coords['region']: + assert np.array_equal( + result.sel(scenario=scenario, region=region).values, + time_arr + ) class TestSeriesConversion: @@ -176,14 +209,6 @@ def test_series_broadcast_to_multiple_coords(self, time_coords, scenario_coords) for scenario in scenario_coords: assert np.array_equal(result.sel(scenario=scenario).values, time_series.values) - # Scenario series broadcast to time - scenario_series = pd.Series([100, 200, 300], index=scenario_coords) - result = DataConverter.to_dataarray(scenario_series, coords={'time': time_coords, 'scenario': scenario_coords}) - assert result.shape == (5, 3) - - for time in time_coords: - assert np.array_equal(result.sel(time=time).values, scenario_series.values) - def test_series_wrong_dimension(self, time_coords, region_coords): """Series indexed by dimension not in coords should fail.""" wrong_series = pd.Series([1, 2, 3], index=region_coords) @@ -191,6 +216,22 @@ def test_series_wrong_dimension(self, time_coords, region_coords): with pytest.raises(ConversionError): DataConverter.to_dataarray(wrong_series, coords={'time': time_coords}) + def test_series_broadcast_to_many_dimensions(self, standard_coords): + """Series should broadcast to many dimensions.""" + time_series = pd.Series([100, 200, 300, 400, 500], index=standard_coords['time']) + result = DataConverter.to_dataarray(time_series, coords=standard_coords) + + assert result.shape == (5, 3, 2) + assert result.dims == ('time', 'scenario', 'region') + + # Check that all non-time dimensions have the same time series values + for scenario in standard_coords['scenario']: + for region in standard_coords['region']: + assert np.array_equal( + result.sel(scenario=scenario, region=region).values, + time_series.values + ) + class TestDataFrameConversion: """Test pandas DataFrame conversions.""" @@ -204,21 +245,26 @@ def test_single_column_dataframe(self, time_coords): assert result.dims == ('time',) assert np.array_equal(result.values, df['value'].values) - def test_multi_column_dataframe_rejected(self, time_coords): - """Multi-column DataFrame should be rejected.""" + def test_multi_column_dataframe_accepted(self, time_coords, scenario_coords): + """Multi-column DataFrame should now be accepted and converted via numpy array path.""" df = pd.DataFrame({ 'value1': [10, 20, 30, 40, 50], - 'value2': [15, 25, 35, 45, 55] + 'value2': [15, 25, 35, 45, 55], + 'value3': [12, 22, 32, 42, 52] }, index=time_coords) - with pytest.raises(ConversionError, match="Array has 2 dimensions but 1 target "): - DataConverter.to_dataarray(df, coords={'time': time_coords}) + # Should work by converting to numpy array (5x3) and matching to time x scenario + result = DataConverter.to_dataarray(df, coords={'time': time_coords, 'scenario': scenario_coords}) + + assert result.shape == (5, 3) + assert result.dims == ('time', 'scenario') + assert np.array_equal(result.values, df.to_numpy()) def test_empty_dataframe_rejected(self, time_coords): """Empty DataFrame should be rejected.""" df = pd.DataFrame(index=time_coords) # No columns - with pytest.raises(ConversionError, match="DataFrame must have at least one"): + with pytest.raises(ConversionError, match="DataFrame must have at least one column"): DataConverter.to_dataarray(df, coords={'time': time_coords}) def test_dataframe_broadcast(self, time_coords, scenario_coords): @@ -231,6 +277,171 @@ def test_dataframe_broadcast(self, time_coords, scenario_coords): assert np.array_equal(result.sel(scenario=scenario).values, df['power'].values) +class TestMultiDimensionalArrayConversion: + """Test multi-dimensional numpy array conversions.""" + + def test_2d_array_unique_dimensions(self, standard_coords): + """2D array with unique dimension lengths should work.""" + # 5x3 array should map to time x scenario + data_2d = np.random.rand(5, 3) + result = DataConverter.to_dataarray(data_2d, coords={ + 'time': standard_coords['time'], + 'scenario': standard_coords['scenario'] + }) + + assert result.shape == (5, 3) + assert result.dims == ('time', 'scenario') + assert np.array_equal(result.values, data_2d) + + # 3x5 array should map to scenario x time + data_2d_flipped = np.random.rand(3, 5) + result_flipped = DataConverter.to_dataarray(data_2d_flipped, coords={ + 'time': standard_coords['time'], + 'scenario': standard_coords['scenario'] + }) + + assert result_flipped.shape == (5, 3) + assert result_flipped.dims == ('time', 'scenario') + assert np.array_equal(result_flipped.values.transpose(), data_2d_flipped) + + def test_2d_array_broadcast_to_3d(self, standard_coords): + """2D array should broadcast to additional dimensions when using partial matching.""" + # With improved integration, 2D array (5x3) should match time×scenario and broadcast to region + data_2d = np.random.rand(5, 3) + result = DataConverter.to_dataarray(data_2d, coords=standard_coords) + + assert result.shape == (5, 3, 2) + assert result.dims == ('time', 'scenario', 'region') + + # Check that all regions have the same time x scenario data + for region in standard_coords['region']: + assert np.array_equal(result.sel(region=region).values, data_2d) + + def test_3d_array_unique_dimensions(self, standard_coords): + """3D array with unique dimension lengths should work.""" + # 5x3x2 array should map to time x scenario x region + data_3d = np.random.rand(5, 3, 2) + result = DataConverter.to_dataarray(data_3d, coords=standard_coords) + + assert result.shape == (5, 3, 2) + assert result.dims == ('time', 'scenario', 'region') + assert np.array_equal(result.values, data_3d) + + def test_3d_array_different_permutation(self, standard_coords): + """3D array with different dimension order should work.""" + # 2x5x3 array should map to region x time x scenario + data_3d = np.random.rand(2, 5, 3) + result = DataConverter.to_dataarray(data_3d, coords=standard_coords) + + assert result.shape == (5, 3, 2) + assert result.dims == ('time', 'scenario', 'region') + assert np.array_equal(result.transpose('region', 'time', 'scenario').values, data_3d) + + def test_4d_array_unique_dimensions(self): + """4D array with unique dimension lengths should work.""" + coords = { + 'time': pd.date_range('2024-01-01', periods=2, freq='D', name='time'), # length 2 + 'scenario': pd.Index(['A', 'B', 'C'], name='scenario'), # length 3 + 'region': pd.Index(['north', 'south', 'east', 'west'], name='region'), # length 4 + 'technology': pd.Index(['solar', 'wind', 'gas', 'coal', 'hydro'], name='technology') # length 5 + } + + # 3x5x2x4 array should map to scenario x technology x time x region + data_4d = np.random.rand(3, 5, 2, 4) + result = DataConverter.to_dataarray(data_4d, coords=coords) + + assert result.shape == (2,3,4,5) + assert result.dims == ('time', 'scenario', 'region', 'technology') + assert np.array_equal(result.transpose('scenario', 'technology', 'time', 'region').values, data_4d) + + def test_2d_array_ambiguous_dimensions_error(self): + """2D array with ambiguous dimension lengths should fail.""" + # Both dimensions have length 3 + coords_ambiguous = { + 'scenario': pd.Index(['A', 'B', 'C'], name='scenario'), # length 3 + 'region': pd.Index(['north', 'south', 'east'], name='region') # length 3 + } + + data_2d = np.random.rand(3, 3) + with pytest.raises(ConversionError, match="matches multiple dimension orders"): + DataConverter.to_dataarray(data_2d, coords=coords_ambiguous) + + def test_multid_array_no_coords(self): + """Multi-D arrays without coords should fail unless scalar.""" + # Multi-element fails + data_2d = np.random.rand(2, 3) + with pytest.raises(ConversionError, match="Cannot convert multi-element array without target dimensions"): + DataConverter.to_dataarray(data_2d) + + # Single element succeeds + single_element = np.array([[42]]) + result = DataConverter.to_dataarray(single_element) + assert result.shape == () + assert result.item() == 42 + + def test_array_no_matching_dimensions_error(self, standard_coords): + """Array with no matching dimension lengths should fail.""" + # 7x8 array - no dimension has length 7 or 8 + data_2d = np.random.rand(7, 8) + coords_2d = { + 'time': standard_coords['time'], # length 5 + 'scenario': standard_coords['scenario'] # length 3 + } + + with pytest.raises(ConversionError, match="Array dimensions do not match any coordinate lengths"): + DataConverter.to_dataarray(data_2d, coords=coords_2d) + + def test_multid_array_special_values(self, standard_coords): + """Multi-D arrays should preserve special values.""" + # Create 2D array with special values + data_2d = np.array([[1.0, np.nan, 3.0], + [np.inf, 5.0, -np.inf], + [7.0, 8.0, 9.0], + [10.0, np.nan, 12.0], + [13.0, 14.0, np.inf]]) + + result = DataConverter.to_dataarray(data_2d, coords={ + 'time': standard_coords['time'], + 'scenario': standard_coords['scenario'] + }) + + assert result.shape == (5, 3) + assert np.array_equal(np.isnan(result.values), np.isnan(data_2d)) + assert np.array_equal(np.isinf(result.values), np.isinf(data_2d)) + + def test_multid_array_dtype_preservation(self, standard_coords): + """Multi-D arrays should preserve data types.""" + # Integer array + int_data = np.array([[1, 2, 3], + [4, 5, 6], + [7, 8, 9], + [10, 11, 12], + [13, 14, 15]], dtype=np.int32) + + result_int = DataConverter.to_dataarray(int_data, coords={ + 'time': standard_coords['time'], + 'scenario': standard_coords['scenario'] + }) + + assert result_int.dtype == np.int32 + assert np.array_equal(result_int.values, int_data) + + # Boolean array + bool_data = np.array([[True, False, True], + [False, True, False], + [True, True, False], + [False, False, True], + [True, False, True]]) + + result_bool = DataConverter.to_dataarray(bool_data, coords={ + 'time': standard_coords['time'], + 'scenario': standard_coords['scenario'] + }) + + assert result_bool.dtype == bool + assert np.array_equal(result_bool.values, bool_data) + + class TestDataArrayConversion: """Test xarray DataArray conversions.""" @@ -282,6 +493,28 @@ def test_scalar_dataarray_broadcast(self, time_coords, scenario_coords): assert result.shape == (5, 3) assert np.all(result.values == 42) + def test_2d_dataarray_broadcast_to_more_dimensions(self, standard_coords): + """DataArray should broadcast to additional dimensions.""" + # Start with 2D DataArray + original = xr.DataArray( + [[10, 20, 30], [40, 50, 60], [70, 80, 90], [100, 110, 120], [130, 140, 150]], + coords={'time': standard_coords['time'], 'scenario': standard_coords['scenario']}, + dims=['time', 'scenario'] + ) + + # Broadcast to 3D + result = DataConverter.to_dataarray(original, coords=standard_coords) + + assert result.shape == (5, 3, 2) + assert result.dims == ('time', 'scenario', 'region') + + # Check that all regions have the same time+scenario values + for region in standard_coords['region']: + assert np.array_equal( + result.sel(region=region).values, + original.values + ) + class TestTimeSeriesDataConversion: """Test TimeSeriesData conversions.""" @@ -309,275 +542,58 @@ def test_timeseries_data_broadcast(self, time_coords, scenario_coords): assert np.array_equal(result.sel(scenario=scenario).values, [10, 20, 30, 40, 50]) -class TestThreeDimensionConversion: - """Test conversions with exactly 3 dimensions for all data types.""" - - @pytest.fixture - def three_d_coords(self, time_coords, scenario_coords): - """Standard 3D coordinate system with unique lengths.""" - return { - 'time': time_coords, # length 5 - 'scenario': scenario_coords, # length 3 - 'region': pd.Index(['north', 'south'], name='region') # length 2 - unique! - } - - def test_scalar_three_dimensions(self, three_d_coords): - """Scalar should broadcast to 3 dimensions.""" - result = DataConverter.to_dataarray(42, coords=three_d_coords) +class TestCustomDimensions: + """Test with custom dimension names beyond time/scenario.""" - assert result.shape == (5, 3, 2) # time=5, scenario=3, region=2 - assert result.dims == ('time', 'scenario', 'region') + def test_custom_single_dimension(self, region_coords): + """Test with custom dimension name.""" + result = DataConverter.to_dataarray(42, coords={'region': region_coords}) + assert result.shape == (3,) + assert result.dims == ('region',) assert np.all(result.values == 42) - # Verify all coordinates are correct - assert result.indexes['time'].equals(three_d_coords['time']) - assert result.indexes['scenario'].equals(three_d_coords['scenario']) - assert result.indexes['region'].equals(three_d_coords['region']) - - def test_numpy_scalar_three_dimensions(self, three_d_coords): - """Numpy scalars should broadcast to 3 dimensions.""" - for scalar in [np.int32(100), np.float64(3.14)]: - result = DataConverter.to_dataarray(scalar, coords=three_d_coords) - - assert result.shape == (5, 3, 2) - assert result.dims == ('time', 'scenario', 'region') - assert np.all(result.values == scalar.item()) + def test_custom_multiple_dimensions(self): + """Test with multiple custom dimensions.""" + products = pd.Index(['A', 'B'], name='product') + technologies = pd.Index(['solar', 'wind', 'gas'], name='technology') - def test_1d_array_time_to_three_dimensions(self, three_d_coords): - """1D array matching time should broadcast to 3D.""" - time_arr = np.array([10, 20, 30, 40, 50]) - result = DataConverter.to_dataarray(time_arr, coords=three_d_coords) + # Array matching technology dimension + arr = np.array([100, 150, 80]) + result = DataConverter.to_dataarray(arr, coords={'product': products, 'technology': technologies}) - assert result.shape == (5, 3, 2) - assert result.dims == ('time', 'scenario', 'region') + assert result.shape == (2, 3) + assert result.dims == ('product', 'technology') - # Check broadcasting across scenarios and regions - for scenario in three_d_coords['scenario']: - for region in three_d_coords['region']: - slice_data = result.sel(scenario=scenario, region=region) - assert np.array_equal(slice_data.values, time_arr) + # Should broadcast across products + for product in products: + assert np.array_equal(result.sel(product=product).values, arr) - def test_1d_array_scenario_to_three_dimensions(self, three_d_coords): - """1D array matching scenario should broadcast to 3D.""" - scenario_arr = np.array([100, 200, 300]) - result = DataConverter.to_dataarray(scenario_arr, coords=three_d_coords) + def test_mixed_dimension_types(self): + """Test mixing time dimension with custom dimensions.""" + time_coords = pd.date_range('2024-01-01', periods=3, freq='D', name='time') + regions = pd.Index(['north', 'south'], name='region') - assert result.shape == (5, 3, 2) - assert result.dims == ('time', 'scenario', 'region') + # Time series should broadcast to regions + time_series = pd.Series([10, 20, 30], index=time_coords) + result = DataConverter.to_dataarray(time_series, coords={'time': time_coords, 'region': regions}) - # Check broadcasting across time and regions - for time in three_d_coords['time']: - for region in three_d_coords['region']: - slice_data = result.sel(time=time, region=region) - assert np.array_equal(slice_data.values, scenario_arr) + assert result.shape == (3, 2) + assert result.dims == ('time', 'region') - def test_1d_array_region_to_three_dimensions(self, three_d_coords): - """1D array matching region should broadcast to 3D.""" - region_arr = np.array([1000, 2000]) # Length 2 to match region - result = DataConverter.to_dataarray(region_arr, coords=three_d_coords) + def test_custom_dimensions_complex(self): + """Test complex scenario with custom dimensions.""" + coords = { + 'product': pd.Index(['A', 'B'], name='product'), + 'factory': pd.Index(['F1', 'F2', 'F3'], name='factory'), + 'quarter': pd.Index(['Q1', 'Q2', 'Q3', 'Q4'], name='quarter') + } - assert result.shape == (5, 3, 2) - assert result.dims == ('time', 'scenario', 'region') + # Array matching factory dimension + factory_arr = np.array([100, 200, 300]) + result = DataConverter.to_dataarray(factory_arr, coords=coords) - # Check broadcasting across time and scenarios - for time in three_d_coords['time']: - for scenario in three_d_coords['scenario']: - slice_data = result.sel(time=time, scenario=scenario) - assert np.array_equal(slice_data.values, region_arr) - - def test_series_time_to_three_dimensions(self, three_d_coords): - """Time-indexed Series should broadcast to 3D.""" - time_series = pd.Series([15, 25, 35, 45, 55], index=three_d_coords['time']) - result = DataConverter.to_dataarray(time_series, coords=three_d_coords) - - assert result.shape == (5, 3, 2) - assert result.dims == ('time', 'scenario', 'region') - - # Check broadcasting - for scenario in three_d_coords['scenario']: - for region in three_d_coords['region']: - slice_data = result.sel(scenario=scenario, region=region) - assert np.array_equal(slice_data.values, time_series.values) - - def test_series_scenario_to_three_dimensions(self, three_d_coords): - """Scenario-indexed Series should broadcast to 3D.""" - scenario_series = pd.Series([500, 600, 700], index=three_d_coords['scenario']) - result = DataConverter.to_dataarray(scenario_series, coords=three_d_coords) - - assert result.shape == (5, 3, 2) - assert result.dims == ('time', 'scenario', 'region') - - # Check broadcasting - for time in three_d_coords['time']: - for region in three_d_coords['region']: - slice_data = result.sel(time=time, region=region) - assert np.array_equal(slice_data.values, scenario_series.values) - - def test_series_region_to_three_dimensions(self, three_d_coords): - """Region-indexed Series should broadcast to 3D.""" - region_series = pd.Series([5000, 6000], index=three_d_coords['region']) # Length 2 - result = DataConverter.to_dataarray(region_series, coords=three_d_coords) - - assert result.shape == (5, 3, 2) - assert result.dims == ('time', 'scenario', 'region') - - # Check broadcasting - for time in three_d_coords['time']: - for scenario in three_d_coords['scenario']: - slice_data = result.sel(time=time, scenario=scenario) - assert np.array_equal(slice_data.values, region_series.values) - - def test_dataframe_time_to_three_dimensions(self, three_d_coords): - """Time-indexed DataFrame should broadcast to 3D.""" - df = pd.DataFrame({'power': [11, 22, 33, 44, 55]}, index=three_d_coords['time']) - result = DataConverter.to_dataarray(df, coords=three_d_coords) - - assert result.shape == (5, 3, 2) - assert result.dims == ('time', 'scenario', 'region') - - # Check broadcasting - for scenario in three_d_coords['scenario']: - for region in three_d_coords['region']: - slice_data = result.sel(scenario=scenario, region=region) - assert np.array_equal(slice_data.values, df['power'].values) - - def test_dataframe_scenario_to_three_dimensions(self, three_d_coords): - """Scenario-indexed DataFrame should broadcast to 3D.""" - df = pd.DataFrame({'cost': [1100, 1200, 1300]}, index=three_d_coords['scenario']) - result = DataConverter.to_dataarray(df, coords=three_d_coords) - - assert result.shape == (5, 3, 2) - assert result.dims == ('time', 'scenario', 'region') - - # Check broadcasting - for time in three_d_coords['time']: - for region in three_d_coords['region']: - slice_data = result.sel(time=time, region=region) - assert np.array_equal(slice_data.values, df['cost'].values) - - def test_1d_dataarray_time_to_three_dimensions(self, three_d_coords): - """1D time DataArray should broadcast to 3D.""" - original = xr.DataArray([101, 102, 103, 104, 105], - coords={'time': three_d_coords['time']}, - dims=['time']) - result = DataConverter.to_dataarray(original, coords=three_d_coords) - - assert result.shape == (5, 3, 2) - assert result.dims == ('time', 'scenario', 'region') - - # Check broadcasting - for scenario in three_d_coords['scenario']: - for region in three_d_coords['region']: - slice_data = result.sel(scenario=scenario, region=region) - assert np.array_equal(slice_data.values, original.values) - - def test_1d_dataarray_scenario_to_three_dimensions(self, three_d_coords): - """1D scenario DataArray should broadcast to 3D.""" - original = xr.DataArray([2001, 2002, 2003], - coords={'scenario': three_d_coords['scenario']}, - dims=['scenario']) - result = DataConverter.to_dataarray(original, coords=three_d_coords) - - assert result.shape == (5, 3, 2) - assert result.dims == ('time', 'scenario', 'region') - - # Check broadcasting - for time in three_d_coords['time']: - for region in three_d_coords['region']: - slice_data = result.sel(time=time, region=region) - assert np.array_equal(slice_data.values, original.values) - - def test_2d_dataarray_to_three_dimensions(self, three_d_coords): - """2D DataArray should broadcast to 3D.""" - # Create 2D time x scenario DataArray - data_2d = np.random.rand(5, 3) - original = xr.DataArray(data_2d, - coords={'time': three_d_coords['time'], - 'scenario': three_d_coords['scenario']}, - dims=['time', 'scenario']) - - result = DataConverter.to_dataarray(original, coords=three_d_coords) - - assert result.shape == (5, 3, 2) - assert result.dims == ('time', 'scenario', 'region') - - # Check that all regions have the same time x scenario data - for region in three_d_coords['region']: - slice_data = result.sel(region=region) - assert np.array_equal(slice_data.values, original.values) - - def test_timeseries_data_to_three_dimensions(self, three_d_coords): - """TimeSeriesData should broadcast to 3D.""" - data_array = xr.DataArray([99, 88, 77, 66, 55], - coords={'time': three_d_coords['time']}, - dims=['time']) - ts_data = TimeSeriesData(data_array, aggregation_group='test_3d') - - result = DataConverter.to_dataarray(ts_data, coords=three_d_coords) - - assert result.shape == (5, 3, 2) - assert result.dims == ('time', 'scenario', 'region') - - # Check broadcasting - for scenario in three_d_coords['scenario']: - for region in three_d_coords['region']: - slice_data = result.sel(scenario=scenario, region=region) - assert np.array_equal(slice_data.values, [99, 88, 77, 66, 55]) - - def test_three_d_copy_independence(self, three_d_coords): - """3D results should be independent copies.""" - original_arr = np.array([10, 20, 30, 40, 50]) - result = DataConverter.to_dataarray(original_arr, coords=three_d_coords) - - # Modify result - result[0, 0, 0] = 999 - - # Original should be unchanged - assert original_arr[0] == 10 - - def test_three_d_special_values(self, three_d_coords): - """3D conversion should preserve special values.""" - # Array with NaN and inf - special_arr = np.array([1, np.nan, np.inf, -np.inf, 5]) - result = DataConverter.to_dataarray(special_arr, coords=three_d_coords) - - assert result.shape == (5, 3, 2) - - # Check that special values are preserved in all broadcasts - for scenario in three_d_coords['scenario']: - for region in three_d_coords['region']: - slice_data = result.sel(scenario=scenario, region=region) - assert np.array_equal(np.isnan(slice_data.values), np.isnan(special_arr)) - assert np.array_equal(np.isinf(slice_data.values), np.isinf(special_arr)) - - def test_three_d_ambiguous_length_error(self): - """Should fail when array length matches multiple dimensions in 3D.""" - # All dimensions have length 3 - coords_3x3x3 = { - 'time': pd.date_range('2024-01-01', periods=3, freq='D', name='time'), - 'scenario': pd.Index(['A', 'B', 'C'], name='scenario'), - 'region': pd.Index(['X', 'Y', 'Z'], name='region') - } - - arr = np.array([1, 2, 3]) # Length 3 - matches all dimensions - - with pytest.raises(ConversionError, match="matches multiple dimensions"): - DataConverter.to_dataarray(arr, coords=coords_3x3x3) - - def test_three_d_custom_dimensions(self): - """3D conversion with custom dimension names.""" - coords = { - 'product': pd.Index(['A', 'B'], name='product'), - 'factory': pd.Index(['F1', 'F2', 'F3'], name='factory'), - 'quarter': pd.Index(['Q1', 'Q2', 'Q3', 'Q4'], name='quarter') - } - - # Array matching factory dimension - factory_arr = np.array([100, 200, 300]) - result = DataConverter.to_dataarray(factory_arr, coords=coords) - - assert result.shape == (2, 3, 4) - assert result.dims == ('product', 'factory', 'quarter') + assert result.shape == (2, 3, 4) + assert result.dims == ('product', 'factory', 'quarter') # Check broadcasting for product in coords['product']: @@ -586,143 +602,6 @@ def test_three_d_custom_dimensions(self): assert np.array_equal(slice_data.values, factory_arr) -class TestMultipleDimensions: - """Test support for more than 2 dimensions.""" - - def test_scalar_many_dimensions(self): - """Scalar should broadcast to any number of dimensions.""" - coords = { - 'time': pd.date_range('2024-01-01', periods=2, freq='D', name='time'), - 'scenario': pd.Index(['A', 'B'], name='scenario'), - 'region': pd.Index(['north', 'south'], name='region'), - 'technology': pd.Index(['solar', 'wind'], name='technology') - } - - result = DataConverter.to_dataarray(42, coords=coords) - assert result.shape == (2, 2, 2, 2) - assert result.dims == ('time', 'scenario', 'region', 'technology') - assert np.all(result.values == 42) - - def test_1d_array_broadcast_to_many_dimensions(self): - """1D array should broadcast to many dimensions.""" - coords = { - 'time': pd.date_range('2024-01-01', periods=3, freq='D', name='time'), - 'scenario': pd.Index(['A', 'B'], name='scenario'), - 'region': pd.Index(['north', 'south'], name='region') - } - - # Array matching time dimension - time_arr = np.array([10, 20, 30]) - result = DataConverter.to_dataarray(time_arr, coords=coords) - - assert result.shape == (3, 2, 2) - assert result.dims == ('time', 'scenario', 'region') - - # Check broadcasting - all scenarios and regions should have same time values - for scenario in coords['scenario']: - for region in coords['region']: - assert np.array_equal( - result.sel(scenario=scenario, region=region).values, - time_arr - ) - - def test_series_broadcast_to_many_dimensions(self): - """Series should broadcast to many dimensions.""" - time_coords = pd.date_range('2024-01-01', periods=3, freq='D', name='time') - coords = { - 'time': time_coords, - 'scenario': pd.Index(['A', 'B'], name='scenario'), - 'region': pd.Index(['north', 'south'], name='region'), - 'product': pd.Index(['X', 'Y', 'Z'], name='product') - } - - # Time-indexed series - time_series = pd.Series([100, 200, 300], index=time_coords) - result = DataConverter.to_dataarray(time_series, coords=coords) - - assert result.shape == (3, 2, 2, 3) - assert result.dims == ('time', 'scenario', 'region', 'product') - - # Check that all non-time dimensions have the same time series values - for scenario in coords['scenario']: - for region in coords['region']: - for product in coords['product']: - assert np.array_equal( - result.sel(scenario=scenario, region=region, product=product).values, - time_series.values - ) - - def test_dataarray_broadcast_to_more_dimensions(self): - """DataArray should broadcast to additional dimensions.""" - time_coords = pd.date_range('2024-01-01', periods=2, freq='D', name='time') - scenario_coords = pd.Index(['A', 'B'], name='scenario') - - # Start with 2D DataArray - original = xr.DataArray( - [[10, 20], [30, 40]], - coords={'time': time_coords, 'scenario': scenario_coords}, - dims=['time', 'scenario'] - ) - - # Broadcast to 3D - coords = { - 'time': time_coords, - 'scenario': scenario_coords, - 'region': pd.Index(['north', 'south'], name='region') - } - - result = DataConverter.to_dataarray(original, coords=coords) - - assert result.shape == (2, 2, 2) - assert result.dims == ('time', 'scenario', 'region') - - # Check that all regions have the same time+scenario values - for region in coords['region']: - assert np.array_equal( - result.sel(region=region).values, - original.values - ) - - -class TestCustomDimensions: - """Test with custom dimension names beyond time/scenario.""" - - def test_custom_single_dimension(self, region_coords): - """Test with custom dimension name.""" - result = DataConverter.to_dataarray(42, coords={'region': region_coords}) - assert result.shape == (3,) - assert result.dims == ('region',) - assert np.all(result.values == 42) - - def test_custom_multiple_dimensions(self): - """Test with multiple custom dimensions.""" - products = pd.Index(['A', 'B'], name='product') - technologies = pd.Index(['solar', 'wind', 'gas'], name='technology') - - # Array matching technology dimension - arr = np.array([100, 150, 80]) - result = DataConverter.to_dataarray(arr, coords={'product': products, 'technology': technologies}) - - assert result.shape == (2, 3) - assert result.dims == ('product', 'technology') - - # Should broadcast across products - for product in products: - assert np.array_equal(result.sel(product=product).values, arr) - - def test_mixed_dimension_types(self): - """Test mixing time dimension with custom dimensions.""" - time_coords = pd.date_range('2024-01-01', periods=3, freq='D', name='time') - regions = pd.Index(['north', 'south'], name='region') - - # Time series should broadcast to regions - time_series = pd.Series([10, 20, 30], index=time_coords) - result = DataConverter.to_dataarray(time_series, coords={'time': time_coords, 'region': regions}) - - assert result.shape == (3, 2) - assert result.dims == ('time', 'region') - - class TestValidation: """Test coordinate validation.""" @@ -782,6 +661,30 @@ def test_dimension_mismatch_messages(self, time_coords, scenario_coords): with pytest.raises(ConversionError, match="matches none of the target dimensions"): DataConverter.to_dataarray(wrong_arr, coords={'time': time_coords, 'scenario': scenario_coords}) + def test_multidimensional_array_dimension_count_mismatch(self, standard_coords): + """Array with wrong number of dimensions should fail with clear error.""" + # 4D array with 3D coordinates + data_4d = np.random.rand(5, 3, 2, 4) + with pytest.raises(ConversionError, match="matches multiple dimension orders|Array dimensions do not match"): + DataConverter.to_dataarray(data_4d, coords=standard_coords) + + def test_error_message_quality(self, standard_coords): + """Error messages should include helpful information.""" + # Wrong shape array + data_2d = np.random.rand(7, 8) + coords_2d = { + 'time': standard_coords['time'], # length 5 + 'scenario': standard_coords['scenario'] # length 3 + } + + try: + DataConverter.to_dataarray(data_2d, coords=coords_2d) + assert False, "Should have raised ConversionError" + except ConversionError as e: + error_msg = str(e) + assert "Array shape: (7, 8)" in error_msg + assert "Coordinate lengths:" in error_msg + class TestDataIntegrity: """Test data copying and integrity.""" @@ -819,6 +722,20 @@ def test_dataframe_copy_independence(self, time_coords): # Original should be unchanged assert original_df.loc[time_coords[0], 'value'] == 10 + def test_multid_array_copy_independence(self, standard_coords): + """Multi-D arrays should be independent copies.""" + original_data = np.random.rand(5, 3) + result = DataConverter.to_dataarray(original_data, coords={ + 'time': standard_coords['time'], + 'scenario': standard_coords['scenario'] + }) + + # Modify result + result[0, 0] = 999 + + # Original should be unchanged + assert original_data[0, 0] != 999 + class TestSpecialValues: """Test handling of special numeric values.""" @@ -854,331 +771,123 @@ def test_mixed_numeric_types(self, time_coords): assert np.issubdtype(result.dtype, np.floating) assert np.array_equal(result.values, mixed_arr) + def test_special_values_in_multid_arrays(self, standard_coords): + """Special values should be preserved in multi-D arrays and broadcasting.""" + # Array with NaN and inf + special_arr = np.array([1, np.nan, np.inf, -np.inf, 5]) + result = DataConverter.to_dataarray(special_arr, coords=standard_coords) -class TestMultiDimensionalArrayConversion: - """Test multi-dimensional numpy array conversions.""" - - @pytest.fixture - def standard_coords(self): - """Standard coordinates with unique lengths for easy testing.""" - return { - 'time': pd.date_range('2024-01-01', periods=5, freq='D', name='time'), # length 5 - 'scenario': pd.Index(['A', 'B', 'C'], name='scenario'), # length 3 - 'region': pd.Index(['north', 'south'], name='region') # length 2 - } - - def test_2d_array_unique_dimensions(self, standard_coords): - """2D array with unique dimension lengths should work.""" - # 5x3 array should map to time x scenario - data_2d = np.random.rand(5, 3) - result = DataConverter.to_dataarray(data_2d, coords={ - 'time': standard_coords['time'], - 'scenario': standard_coords['scenario'] - }) + assert result.shape == (5, 3, 2) - assert result.shape == (5, 3) - assert result.dims == ('time', 'scenario') - assert np.array_equal(result.values, data_2d) + # Check that special values are preserved in all broadcasts + for scenario in standard_coords['scenario']: + for region in standard_coords['region']: + slice_data = result.sel(scenario=scenario, region=region) + assert np.array_equal(np.isnan(slice_data.values), np.isnan(special_arr)) + assert np.array_equal(np.isinf(slice_data.values), np.isinf(special_arr)) - # 3x5 array should map to scenario x time - data_2d_flipped = np.random.rand(3, 5) - result_flipped = DataConverter.to_dataarray(data_2d_flipped, coords={ - 'time': standard_coords['time'], - 'scenario': standard_coords['scenario'] - }) - assert result_flipped.shape == (3, 5) - assert result_flipped.dims == ('scenario', 'time') - assert np.array_equal(result_flipped.values, data_2d_flipped) +class TestAdvancedBroadcasting: + """Test advanced broadcasting scenarios and edge cases.""" - def test_3d_array_unique_dimensions(self, standard_coords): - """3D array with unique dimension lengths should work.""" - # 5x3x2 array should map to time x scenario x region - data_3d = np.random.rand(5, 3, 2) - result = DataConverter.to_dataarray(data_3d, coords=standard_coords) + def test_partial_dimension_matching_with_broadcasting(self, standard_coords): + """Test that partial dimension matching works with the improved integration.""" + # 1D array matching one dimension should broadcast to all target dimensions + time_arr = np.array([10, 20, 30, 40, 50]) # matches time (length 5) + result = DataConverter.to_dataarray(time_arr, coords=standard_coords) assert result.shape == (5, 3, 2) assert result.dims == ('time', 'scenario', 'region') - assert np.array_equal(result.values, data_3d) - - def test_3d_array_different_permutation(self, standard_coords): - """3D array with different dimension order should work.""" - # 2x5x3 array should map to region x time x scenario - data_3d = np.random.rand(2, 5, 3) - result = DataConverter.to_dataarray(data_3d, coords=standard_coords) - - assert result.shape == (2, 5, 3) - assert result.dims == ('region', 'time', 'scenario') - assert np.array_equal(result.values, data_3d) - - def test_4d_array_unique_dimensions(self): - """4D array with unique dimension lengths should work.""" - coords = { - 'time': pd.date_range('2024-01-01', periods=2, freq='D', name='time'), # length 2 - 'scenario': pd.Index(['A', 'B', 'C'], name='scenario'), # length 3 - 'region': pd.Index(['north', 'south', 'east', 'west'], name='region'), # length 4 - 'technology': pd.Index(['solar', 'wind', 'gas', 'coal', 'hydro'], name='technology') # length 5 - } - - # 3x5x2x4 array should map to scenario x technology x time x region - data_4d = np.random.rand(3, 5, 2, 4) - result = DataConverter.to_dataarray(data_4d, coords=coords) - - assert result.shape == (3, 5, 2, 4) - assert result.dims == ('scenario', 'technology', 'time', 'region') - assert np.array_equal(result.values, data_4d) - - def test_2d_array_ambiguous_dimensions_error(self): - """2D array with ambiguous dimension lengths should fail.""" - # Both dimensions have length 3 - coords_ambiguous = { - 'scenario': pd.Index(['A', 'B', 'C'], name='scenario'), # length 3 - 'region': pd.Index(['north', 'south', 'east'], name='region') # length 3 - } - - data_2d = np.random.rand(3, 3) - with pytest.raises(ConversionError, match="matches multiple dimension orders"): - DataConverter.to_dataarray(data_2d, coords=coords_ambiguous) - - def test_3d_array_ambiguous_dimensions_error(self): - """3D array with ambiguous dimension lengths should fail.""" - # All dimensions have length 2 - coords_ambiguous = { - 'scenario': pd.Index(['A', 'B'], name='scenario'), # length 2 - 'region': pd.Index(['north', 'south'], name='region'), # length 2 - 'technology': pd.Index(['solar', 'wind'], name='technology') # length 2 - } - - data_3d = np.random.rand(2, 2, 2) - with pytest.raises(ConversionError, match="matches multiple dimension orders"): - DataConverter.to_dataarray(data_3d, coords=coords_ambiguous) - - def test_array_dimension_count_mismatch_error(self, standard_coords): - """Array with wrong number of dimensions should fail.""" - # 2D array with 3D coordinates - data_2d = np.random.rand(5, 3) - with pytest.raises(ConversionError, match="Array has 2 dimensions but 3 target dimensions provided"): - DataConverter.to_dataarray(data_2d, coords=standard_coords) - - # 4D array with 3D coordinates - data_4d = np.random.rand(5, 3, 2, 4) - with pytest.raises(ConversionError, match="Array has 4 dimensions but 3 target dimensions provided"): - DataConverter.to_dataarray(data_4d, coords=standard_coords) - - def test_array_no_matching_dimensions_error(self, standard_coords): - """Array with no matching dimension lengths should fail.""" - # 7x8 array - no dimension has length 7 or 8 - data_2d = np.random.rand(7, 8) - coords_2d = { - 'time': standard_coords['time'], # length 5 - 'scenario': standard_coords['scenario'] # length 3 - } - - with pytest.raises(ConversionError, match="Array dimensions do not match any coordinate lengths"): - DataConverter.to_dataarray(data_2d, coords=coords_2d) - - def test_2d_array_custom_dimensions(self): - """2D array with custom dimension names should work.""" - coords = { - 'product': pd.Index(['A', 'B', 'C', 'D'], name='product'), # length 4 - 'factory': pd.Index(['F1', 'F2', 'F3'], name='factory') # length 3 - } - - # 4x3 array should map to product x factory - data_2d = np.array([[10, 11, 12], - [20, 21, 22], - [30, 31, 32], - [40, 41, 42]]) - - result = DataConverter.to_dataarray(data_2d, coords=coords) - - assert result.shape == (4, 3) - assert result.dims == ('product', 'factory') - assert np.array_equal(result.values, data_2d) - - # Verify coordinates are correct - assert result.indexes['product'].equals(coords['product']) - assert result.indexes['factory'].equals(coords['factory']) - - def test_multid_array_copy_independence(self, standard_coords): - """Multi-D arrays should be independent copies.""" - original_data = np.random.rand(5, 3) - result = DataConverter.to_dataarray(original_data, coords={ - 'time': standard_coords['time'], - 'scenario': standard_coords['scenario'] - }) - - # Modify result - result[0, 0] = 999 - - # Original should be unchanged - assert original_data[0, 0] != 999 - - def test_multid_array_special_values(self, standard_coords): - """Multi-D arrays should preserve special values.""" - # Create 2D array with special values - data_2d = np.array([[1.0, np.nan, 3.0], - [np.inf, 5.0, -np.inf], - [7.0, 8.0, 9.0], - [10.0, np.nan, 12.0], - [13.0, 14.0, np.inf]]) - - result = DataConverter.to_dataarray(data_2d, coords={ - 'time': standard_coords['time'], - 'scenario': standard_coords['scenario'] - }) - - assert result.shape == (5, 3) - assert np.array_equal(np.isnan(result.values), np.isnan(data_2d)) - assert np.array_equal(np.isinf(result.values), np.isinf(data_2d)) - - def test_multid_array_with_time_dimension(self): - """Multi-D arrays should work with time dimension.""" - time_coords = pd.date_range('2024-01-01', periods=4, freq='H', name='time') - scenario_coords = pd.Index(['base', 'high', 'low'], name='scenario') - - # 4x3 time series data - data_2d = np.array([[100, 110, 120], - [200, 210, 220], - [300, 310, 320], - [400, 410, 420]]) - - result = DataConverter.to_dataarray(data_2d, coords={ - 'time': time_coords, - 'scenario': scenario_coords - }) - - assert result.shape == (4, 3) - assert result.dims == ('time', 'scenario') - assert isinstance(result.indexes['time'], pd.DatetimeIndex) - assert np.array_equal(result.values, data_2d) - - def test_multid_array_dtype_preservation(self, standard_coords): - """Multi-D arrays should preserve data types.""" - # Integer array - int_data = np.array([[1, 2, 3], - [4, 5, 6], - [7, 8, 9], - [10, 11, 12], - [13, 14, 15]], dtype=np.int32) - - result_int = DataConverter.to_dataarray(int_data, coords={ - 'time': standard_coords['time'], - 'scenario': standard_coords['scenario'] - }) - - assert result_int.dtype == np.int32 - assert np.array_equal(result_int.values, int_data) - - # Float array - float_data = np.array([[1.1, 2.2, 3.3], - [4.4, 5.5, 6.6], - [7.7, 8.8, 9.9], - [10.1, 11.1, 12.1], - [13.1, 14.1, 15.1]], dtype=np.float64) - - result_float = DataConverter.to_dataarray(float_data, coords={ - 'time': standard_coords['time'], - 'scenario': standard_coords['scenario'] - }) - - assert result_float.dtype == np.float64 - assert np.array_equal(result_float.values, float_data) - # Boolean array - bool_data = np.array([[True, False, True], - [False, True, False], - [True, True, False], - [False, False, True], - [True, False, True]]) - - result_bool = DataConverter.to_dataarray(bool_data, coords={ - 'time': standard_coords['time'], - 'scenario': standard_coords['scenario'] - }) - - assert result_bool.dtype == bool - assert np.array_equal(result_bool.values, bool_data) - - def test_multid_array_no_coords(self): - """Multi-D arrays without coords should fail unless scalar.""" - # Multi-element fails - data_2d = np.random.rand(2, 3) - with pytest.raises(ConversionError, match="Cannot convert multi-element array without target dimensions"): - DataConverter.to_dataarray(data_2d) - - # Single element succeeds - single_element = np.array([[42]]) - result = DataConverter.to_dataarray(single_element) - assert result.shape == () - assert result.item() == 42 + # Verify broadcasting + for scenario in standard_coords['scenario']: + for region in standard_coords['region']: + assert np.array_equal(result.sel(scenario=scenario, region=region).values, time_arr) - def test_multid_array_empty_coords(self, standard_coords): - """Multi-D arrays with empty coords should fail.""" - data_2d = np.random.rand(5, 3) - with pytest.raises(ConversionError, match="Cannot convert multi-element array without target dimensions"): - DataConverter.to_dataarray(data_2d, coords={}) - - def test_multid_array_coordinate_validation(self): - """Multi-D arrays should validate coordinates properly.""" - # Test with time coordinate that's not DatetimeIndex - wrong_time = pd.Index([1, 2, 3, 4, 5], name='time') - scenario_coords = pd.Index(['A', 'B', 'C'], name='scenario') - - data_2d = np.random.rand(5, 3) - with pytest.raises(ConversionError, match="time coordinates must be a DatetimeIndex"): - DataConverter.to_dataarray(data_2d, coords={ - 'time': wrong_time, - 'scenario': scenario_coords - }) - - def test_multid_array_complex_scenario(self): - """Complex real-world scenario with multi-D array.""" - # Energy system data: time x technology x region + def test_complex_multid_scenario(self): + """Complex real-world scenario with multi-D array and broadcasting.""" + # Energy system data: time x technology, broadcast to regions coords = { - 'time': pd.date_range('2024-01-01', periods=8760, freq='H', name='time'), # 1 year hourly + 'time': pd.date_range('2024-01-01', periods=24, freq='H', name='time'), # 24 hours 'technology': pd.Index(['solar', 'wind', 'gas', 'coal'], name='technology'), # 4 technologies 'region': pd.Index(['north', 'south', 'east'], name='region') # 3 regions } - # Capacity factors: 8760 x 4 x 3 - capacity_factors = np.random.rand(8760, 4, 3) + # Capacity factors: 24 x 4 (will broadcast to 24 x 4 x 3) + capacity_factors = np.random.rand(24, 4) result = DataConverter.to_dataarray(capacity_factors, coords=coords) - assert result.shape == (8760, 4, 3) + assert result.shape == (24, 4, 3) assert result.dims == ('time', 'technology', 'region') assert isinstance(result.indexes['time'], pd.DatetimeIndex) - assert len(result.indexes['time']) == 8760 - assert len(result.indexes['technology']) == 4 - assert len(result.indexes['region']) == 3 - - def test_multid_array_edge_cases(self): - """Test edge cases for multi-D arrays.""" - # Single dimension with multi-D array should fail - coords_1d = {'time': pd.date_range('2024-01-01', periods=5, freq='D', name='time')} - data_2d = np.random.rand(5, 3) - with pytest.raises(ConversionError, match="Array has 2 dimensions but 1 target dimensions provided"): - DataConverter.to_dataarray(data_2d, coords=coords_1d) + # Verify broadcasting: all regions should have same time×technology data + for region in coords['region']: + assert np.array_equal(result.sel(region=region).values, capacity_factors) - # Zero dimensions with multi-D array should fail - data_1d = np.array([1, 2, 3]) - with pytest.raises(ConversionError, match="Cannot convert multi-element array without target dimensions"): - DataConverter.to_dataarray(data_1d, coords={}) + def test_ambiguous_length_handling(self): + """Test handling of ambiguous length scenarios across different data types.""" + # All dimensions have length 3 + coords_3x3x3 = { + 'time': pd.date_range('2024-01-01', periods=3, freq='D', name='time'), + 'scenario': pd.Index(['A', 'B', 'C'], name='scenario'), + 'region': pd.Index(['X', 'Y', 'Z'], name='region') + } + + # 1D array - should fail + arr_1d = np.array([1, 2, 3]) + with pytest.raises(ConversionError, match="matches multiple dimensions"): + DataConverter.to_dataarray(arr_1d, coords=coords_3x3x3) + + # 2D array - should fail + arr_2d = np.random.rand(3, 3) + with pytest.raises(ConversionError, match="matches multiple dimension orders"): + DataConverter.to_dataarray(arr_2d, coords=coords_3x3x3) - def test_multid_array_partial_dimension_match(self): - """Array where only some dimensions match should fail.""" + # 3D array - should fail + arr_3d = np.random.rand(3, 3, 3) + with pytest.raises(ConversionError, match="matches multiple dimension orders"): + DataConverter.to_dataarray(arr_3d, coords=coords_3x3x3) + + def test_mixed_broadcasting_scenarios(self): + """Test various broadcasting scenarios with different input types.""" coords = { - 'time': pd.date_range('2024-01-01', periods=5, freq='D', name='time'), # length 5 - 'scenario': pd.Index(['A', 'B', 'C'], name='scenario') # length 3 + 'time': pd.date_range('2024-01-01', periods=4, freq='D', name='time'), # length 4 + 'scenario': pd.Index(['A', 'B'], name='scenario'), # length 2 + 'region': pd.Index(['north', 'south', 'east'], name='region'), # length 3 + 'product': pd.Index(['X', 'Y', 'Z', 'W', 'V'], name='product') # length 5 } - # 5x7 array - first dimension matches time (5) but second doesn't match scenario (3) - data_2d = np.random.rand(5, 7) - with pytest.raises(ConversionError, match="Array dimensions do not match any coordinate lengths"): - DataConverter.to_dataarray(data_2d, coords=coords) + # Scalar to 4D + scalar_result = DataConverter.to_dataarray(42, coords=coords) + assert scalar_result.shape == (4, 2, 3, 5) + assert np.all(scalar_result.values == 42) + # 1D array (length 4, matches time) to 4D + arr_1d = np.array([10, 20, 30, 40]) + arr_result = DataConverter.to_dataarray(arr_1d, coords=coords) + assert arr_result.shape == (4, 2, 3, 5) + # Verify broadcasting + for scenario in coords['scenario']: + for region in coords['region']: + for product in coords['product']: + assert np.array_equal( + arr_result.sel(scenario=scenario, region=region, product=product).values, + arr_1d + ) + + # 2D array (4x2, matches time×scenario) to 4D + arr_2d = np.random.rand(4, 2) + arr_2d_result = DataConverter.to_dataarray(arr_2d, coords=coords) + assert arr_2d_result.shape == (4, 2, 3, 5) + # Verify broadcasting + for region in coords['region']: + for product in coords['product']: + assert np.array_equal( + arr_2d_result.sel(region=region, product=product).values, + arr_2d + ) if __name__ == '__main__': From ffc196a4f70efbe3a7165bcf7ac98777c43bb25d Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 5 Jul 2025 08:36:49 +0200 Subject: [PATCH 169/336] Rename methods and remove unused code --- flixopt/core.py | 192 ++++++------------------------------------------ 1 file changed, 22 insertions(+), 170 deletions(-) diff --git a/flixopt/core.py b/flixopt/core.py index 97e91c274..d4f83f5a1 100644 --- a/flixopt/core.py +++ b/flixopt/core.py @@ -141,11 +141,11 @@ class DataConverter: """ @staticmethod - def _convert_1d_data_to_dataarray( + def _convert_1d_with_index_matching( data: Union[np.ndarray, pd.Series], coords: Dict[str, pd.Index], target_dims: Tuple[str, ...] ) -> xr.DataArray: """ - Convert 1D data (array or Series) to DataArray by matching to one dimension. + Convert 1D data to DataArray, trying index matching for Series first, then length matching. Args: data: 1D numpy array or pandas Series @@ -161,16 +161,16 @@ def _convert_1d_data_to_dataarray( raise ConversionError('Cannot convert multi-element data without target dimensions') return xr.DataArray(data[0] if isinstance(data, np.ndarray) else data.iloc[0]) - # For Series, try to match index to coordinates + # For Series, try to match index to coordinates first if isinstance(data, pd.Series): for dim_name in target_dims: if data.index.equals(coords[dim_name]): return xr.DataArray(data.values.copy(), coords={dim_name: coords[dim_name]}, dims=[dim_name]) - # If no index matches, raise error - raise ConversionError(f'Data {data} does not match any of the target dimensions: {target_dims}') + # If no index matches, raise error for Series (they should match by index) + raise ConversionError(f'Series index does not match any target dimension coordinates: {target_dims}') - # For arrays or unmatched Series, match by length + # For arrays, match by length matching_dims = [] for dim_name in target_dims: if len(data) == len(coords[dim_name]): @@ -178,10 +178,10 @@ def _convert_1d_data_to_dataarray( if len(matching_dims) == 0: dim_info = {dim: len(coords[dim]) for dim in target_dims} - raise ConversionError(f'Data length {len(data)} matches none of the target dimensions: {dim_info}') + raise ConversionError(f'Array length {len(data)} matches none of the target dimensions: {dim_info}') elif len(matching_dims) > 1: raise ConversionError( - f'Data length {len(data)} matches multiple dimensions: {matching_dims}. Cannot determine which dimension to use.' + f'Array length {len(data)} matches multiple dimensions: {matching_dims}. Cannot determine which dimension to use.' ) # Match to the single matching dimension @@ -228,7 +228,7 @@ def _broadcast_to_target_dims( # Handle broadcasting from fewer to more dimensions if len(data.dims) < len(target_dims): - return DataConverter._broadcast_dataarray_to_more_dims(data, coords, target_dims) + return DataConverter._expand_to_more_dims(data, coords, target_dims) # Cannot handle more dimensions than target if len(data.dims) > len(target_dims): @@ -237,10 +237,10 @@ def _broadcast_to_target_dims( raise ConversionError(f'Cannot convert DataArray with dims {data.dims} to target dims {target_dims}') @staticmethod - def _broadcast_dataarray_to_more_dims( + def _expand_to_more_dims( data: xr.DataArray, coords: Dict[str, pd.Index], target_dims: Tuple[str, ...] ) -> xr.DataArray: - """Broadcast DataArray to additional dimensions.""" + """Expand DataArray to additional dimensions by broadcasting.""" # Validate that all source dimensions exist in target for dim in data.dims: if dim not in target_dims: @@ -280,11 +280,11 @@ def _broadcast_dataarray_to_more_dims( return xr.DataArray(result_data.copy(), coords=final_coords, dims=target_dims) @staticmethod - def _convert_multid_array_to_dataarray( + def _convert_multid_array_by_shape( data: np.ndarray, coords: Dict[str, pd.Index], target_dims: Tuple[str, ...] ) -> xr.DataArray: """ - Convert multi-dimensional numpy array to DataArray by matching dimensions by length. + Convert multi-dimensional numpy array to DataArray by matching dimensions by shape. Returns a DataArray that may need further broadcasting to target dimensions. Args: @@ -293,7 +293,7 @@ def _convert_multid_array_to_dataarray( target_dims: Target dimension names Returns: - DataArray with dimensions matched by length (may be subset of target_dims) + DataArray with dimensions matched by shape (may be subset of target_dims) Raises: ConversionError: If array dimensions cannot be uniquely matched to coordinates @@ -358,7 +358,7 @@ def to_dataarray( if coords is None: coords = {} - validated_coords, target_dims = DataConverter._prepare_dimensions(coords) + validated_coords, target_dims = DataConverter._validate_and_prepare_coords(coords) # Step 1: Convert to DataArray (may have fewer dimensions than target) if isinstance(data, (int, float, np.integer, np.floating)): @@ -367,17 +367,17 @@ def to_dataarray( elif isinstance(data, np.ndarray): if data.ndim == 1: - intermediate = DataConverter._convert_1d_data_to_dataarray(data, validated_coords, target_dims) + intermediate = DataConverter._convert_1d_with_index_matching(data, validated_coords, target_dims) else: # Handle multi-dimensional arrays - this now allows partial matching - intermediate = DataConverter._convert_multid_array_to_dataarray(data, validated_coords, target_dims) + intermediate = DataConverter._convert_multid_array_by_shape(data, validated_coords, target_dims) elif isinstance(data, pd.Series): if isinstance(data.index, pd.MultiIndex): raise ConversionError( 'Series index must be a single level Index. Multi-index Series are not supported.' ) - intermediate = DataConverter._convert_1d_data_to_dataarray(data, validated_coords, target_dims) + intermediate = DataConverter._convert_1d_with_index_matching(data, validated_coords, target_dims) elif isinstance(data, pd.DataFrame): if isinstance(data.index, pd.MultiIndex): @@ -388,13 +388,13 @@ def to_dataarray( raise ConversionError('DataFrame must have at least one column.') if len(data.columns) == 1: - intermediate = DataConverter._convert_1d_data_to_dataarray( + intermediate = DataConverter._convert_1d_with_index_matching( data.iloc[:, 0], validated_coords, target_dims ) else: # Handle multi-column DataFrames - this now allows partial matching logger.warning('Converting multi-column DataFrame to xr.DataArray. We advise to do this manually.') - intermediate = DataConverter._convert_multid_array_to_dataarray( + intermediate = DataConverter._convert_multid_array_by_shape( data.to_numpy(), validated_coords, target_dims ) @@ -411,9 +411,9 @@ def to_dataarray( return DataConverter._broadcast_to_target_dims(intermediate, validated_coords, target_dims) @staticmethod - def _prepare_dimensions(coords: Dict[str, pd.Index]) -> Tuple[Dict[str, pd.Index], Tuple[str, ...]]: + def _validate_and_prepare_coords(coords: Dict[str, pd.Index]) -> Tuple[Dict[str, pd.Index], Tuple[str, ...]]: """ - Prepare and validate coordinates for the DataArray. + Validate and prepare coordinates for the DataArray. Args: coords: Dictionary mapping dimension names to coordinate indices @@ -442,154 +442,6 @@ def _prepare_dimensions(coords: Dict[str, pd.Index]) -> Tuple[Dict[str, pd.Index return validated_coords, tuple(dims) - @staticmethod - def _convert_scalar( - data: Union[int, float, np.integer, np.floating], coords: Dict[str, pd.Index], dims: Tuple[str, ...] - ) -> xr.DataArray: - """Convert scalar to DataArray, broadcasting to all dimensions.""" - if isinstance(data, (np.integer, np.floating)): - data = data.item() - return xr.DataArray(data, coords=coords, dims=dims) - - @staticmethod - def _convert_1d_array(data: np.ndarray, coords: Dict[str, pd.Index], dims: Tuple[str, ...]) -> xr.DataArray: - """Convert 1D array to DataArray.""" - if len(dims) == 0: - if len(data) != 1: - raise ConversionError('Cannot convert multi-element array without dimensions') - return xr.DataArray(data[0]) - - elif len(dims) == 1: - dim_name = dims[0] - if len(data) != len(coords[dim_name]): - raise ConversionError( - f'Array length {len(data)} does not match {dim_name} length {len(coords[dim_name])}' - ) - return xr.DataArray(data.copy(), coords=coords, dims=dims) - - elif len(dims) == 2: - # Broadcast 1D array to 2D based on which dimension it matches - dim_lengths = {dim: len(coords[dim]) for dim in dims} - - # Find which dimension the array length matches - matching_dims = [dim for dim, length in dim_lengths.items() if len(data) == length] - - if len(matching_dims) == 0: - raise ConversionError(f'Array length {len(data)} matches none of the dimensions: {dim_lengths}') - elif len(matching_dims) > 1: - raise ConversionError( - f'Array length {len(data)} matches multiple dimensions: {matching_dims}. Cannot determine broadcasting direction.' - ) - - # Broadcast along the matching dimension - match_dim = matching_dims[0] - other_dim = [d for d in dims if d != match_dim][0] - - if dims.index(match_dim) == 0: # First dimension - values = np.repeat(data[:, np.newaxis], len(coords[other_dim]), axis=1) - else: # Second dimension - values = np.repeat(data[np.newaxis, :], len(coords[other_dim]), axis=0) - - return xr.DataArray(values.copy(), coords=coords, dims=dims) - - else: - raise ConversionError(f'Maximum 2 dimensions currently supported, got {len(dims)}') - - @staticmethod - def _convert_series(data: pd.Series, coords: Dict[str, pd.Index], dims: Tuple[str, ...]) -> xr.DataArray: - """Convert pandas Series to DataArray.""" - if len(dims) == 0: - if len(data) != 1: - raise ConversionError('Cannot convert multi-element Series without dimensions') - return xr.DataArray(data.iloc[0]) - - elif len(dims) == 1: - dim_name = dims[0] - if not data.index.equals(coords[dim_name]): - raise ConversionError(f'Series index does not match {dim_name} coordinates') - return xr.DataArray(data.values.copy(), coords=coords, dims=dims) - - elif len(dims) == 2: - # Check which dimension the Series index matches - if 'time' in coords and data.index.equals(coords['time']): - # Broadcast across other dimensions - other_dims = [d for d in dims if d != 'time'] - if len(other_dims) == 1: - other_dim = other_dims[0] - values = np.repeat(data.values[:, np.newaxis], len(coords[other_dim]), axis=1) - return xr.DataArray(values.copy(), coords=coords, dims=dims) - - elif len([d for d in dims if d != 'time']) == 1: - # Check if Series matches the non-time dimension - other_dim = [d for d in dims if d != 'time'][0] - if data.index.equals(coords[other_dim]): - # Broadcast across time - values = np.repeat(data.values[np.newaxis, :], len(coords['time']), axis=0) - return xr.DataArray(values.copy(), coords=coords, dims=dims) - - raise ConversionError(f'Series index must match one of the target dimensions: {list(coords.keys())}') - - else: - raise ConversionError('Maximum 2 dimensions supported') - - @staticmethod - def _handle_dataarray(data: xr.DataArray, coords: Dict[str, pd.Index], dims: Tuple[str, ...]) -> xr.DataArray: - """Handle existing DataArray - check compatibility or broadcast.""" - # If no target dimensions, data must be scalar - if len(dims) == 0: - if data.size != 1: - raise ConversionError('DataArray must be scalar when no dimensions specified') - return xr.DataArray(data.values.item()) - - # Check if already compatible - if data.dims == dims: - # Check if coordinates match - compatible = True - for dim in dims: - if not np.array_equal(data.coords[dim].values, coords[dim].values): - compatible = False - break - if compatible: - return data.copy() - - # Handle broadcasting from smaller to larger dimensions - if len(data.dims) < len(dims): - return DataConverter._broadcast_dataarray(data, coords, dims) - - # If dimensions don't match and can't broadcast, raise error - raise ConversionError(f'Cannot convert DataArray with dims {data.dims} to target dims {dims}') - - @staticmethod - def _broadcast_dataarray(data: xr.DataArray, coords: Dict[str, pd.Index], dims: Tuple[str, ...]) -> xr.DataArray: - """Broadcast DataArray to target dimensions.""" - if len(data.dims) == 0: - # Scalar DataArray - broadcast to all dimensions - return xr.DataArray(data.values.item(), coords=coords, dims=dims) - - elif len(data.dims) == 1 and len(dims) == 2: - source_dim = data.dims[0] - - # Check if source dimension exists in target - if source_dim not in coords: - raise ConversionError(f'Source dimension "{source_dim}" not found in target coordinates') - - # Check coordinate compatibility - if not np.array_equal(data.coords[source_dim].values, coords[source_dim].values): - raise ConversionError(f'Source {source_dim} coordinates do not match target coordinates') - - # Find the other dimension to broadcast to - other_dim = [d for d in dims if d != source_dim][0] - - # Broadcast based on dimension order - if dims.index(source_dim) == 0: # Source is first dimension - values = np.repeat(data.values[:, np.newaxis], len(coords[other_dim]), axis=1) - else: # Source is second dimension - values = np.repeat(data.values[np.newaxis, :], len(coords[other_dim]), axis=0) - - return xr.DataArray(values.copy(), coords=coords, dims=dims) - - raise ConversionError(f'Cannot broadcast from {data.dims} to {dims}') - def get_dataarray_stats(arr: xr.DataArray) -> Dict: """Generate statistical summary of a DataArray.""" From 6142f44ca44df5aec97f87533db68d97a539d246 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 5 Jul 2025 08:47:32 +0200 Subject: [PATCH 170/336] Improve DataConverter by better splitting handling per datatype. Series only matches index (for one dim). Numpy matches shape --- flixopt/core.py | 80 ++++++++++++++++++++++++++++++++----------------- 1 file changed, 53 insertions(+), 27 deletions(-) diff --git a/flixopt/core.py b/flixopt/core.py index d4f83f5a1..d1e61b4c5 100644 --- a/flixopt/core.py +++ b/flixopt/core.py @@ -141,36 +141,60 @@ class DataConverter: """ @staticmethod - def _convert_1d_with_index_matching( - data: Union[np.ndarray, pd.Series], coords: Dict[str, pd.Index], target_dims: Tuple[str, ...] + def _convert_series_by_index( + data: pd.Series, coords: Dict[str, pd.Index], target_dims: Tuple[str, ...] ) -> xr.DataArray: """ - Convert 1D data to DataArray, trying index matching for Series first, then length matching. + Convert pandas Series to DataArray by matching index to coordinates. Args: - data: 1D numpy array or pandas Series + data: pandas Series coords: Available coordinates target_dims: Target dimension names Returns: - DataArray with the data matched to appropriate dimension + DataArray with the Series matched to appropriate dimension by index + + Raises: + ConversionError: If Series index doesn't match any target dimension coordinates """ if len(target_dims) == 0: - # No target dimensions - data must be single element if len(data) != 1: - raise ConversionError('Cannot convert multi-element data without target dimensions') - return xr.DataArray(data[0] if isinstance(data, np.ndarray) else data.iloc[0]) + raise ConversionError('Cannot convert multi-element Series without target dimensions') + return xr.DataArray(data.iloc[0]) + + # Try to match Series index to coordinates + for dim_name in target_dims: + if data.index.equals(coords[dim_name]): + return xr.DataArray(data.values.copy(), coords={dim_name: coords[dim_name]}, dims=[dim_name]) + + # If no index matches, raise error + raise ConversionError(f'Series index does not match any target dimension coordinates: {target_dims}') + + @staticmethod + def _convert_1d_array_by_length( + data: np.ndarray, coords: Dict[str, pd.Index], target_dims: Tuple[str, ...] + ) -> xr.DataArray: + """ + Convert 1D numpy array to DataArray by matching length to coordinates. + + Args: + data: 1D numpy array + coords: Available coordinates + target_dims: Target dimension names - # For Series, try to match index to coordinates first - if isinstance(data, pd.Series): - for dim_name in target_dims: - if data.index.equals(coords[dim_name]): - return xr.DataArray(data.values.copy(), coords={dim_name: coords[dim_name]}, dims=[dim_name]) + Returns: + DataArray with the array matched to appropriate dimension by length - # If no index matches, raise error for Series (they should match by index) - raise ConversionError(f'Series index does not match any target dimension coordinates: {target_dims}') + Raises: + ConversionError: If array length doesn't uniquely match a target dimension + """ + if len(target_dims) == 0: + if len(data) != 1: + raise ConversionError('Cannot convert multi-element array without target dimensions') + return xr.DataArray(data[0]) - # For arrays, match by length + # Match by length matching_dims = [] for dim_name in target_dims: if len(data) == len(coords[dim_name]): @@ -181,13 +205,14 @@ def _convert_1d_with_index_matching( raise ConversionError(f'Array length {len(data)} matches none of the target dimensions: {dim_info}') elif len(matching_dims) > 1: raise ConversionError( - f'Array length {len(data)} matches multiple dimensions: {matching_dims}. Cannot determine which dimension to use.' + f'Array length {len(data)} matches multiple dimensions: {matching_dims}. Cannot determine which ' + f'dimension to use. To avoid this error, convert the array to a DataArray with the correct dimensions ' + f'yourself.' ) # Match to the single matching dimension match_dim = matching_dims[0] - values = data.values.copy() if isinstance(data, pd.Series) else data.copy() - return xr.DataArray(values, coords={match_dim: coords[match_dim]}, dims=[match_dim]) + return xr.DataArray(data.copy(), coords={match_dim: coords[match_dim]}, dims=[match_dim]) @staticmethod def _broadcast_to_target_dims( @@ -334,8 +359,9 @@ def _convert_multid_array_by_shape( # Return DataArray with matched dimensions - broadcasting will happen later if needed return xr.DataArray(data.copy(), coords=matched_coords, dims=matched_dims) - @staticmethod + @classmethod def to_dataarray( + cls, data: Union[Scalar, np.ndarray, pd.Series, pd.DataFrame, xr.DataArray, TimeSeriesData], coords: Optional[Dict[str, pd.Index]] = None, ) -> xr.DataArray: @@ -358,7 +384,7 @@ def to_dataarray( if coords is None: coords = {} - validated_coords, target_dims = DataConverter._validate_and_prepare_coords(coords) + validated_coords, target_dims = cls._validate_and_prepare_coords(coords) # Step 1: Convert to DataArray (may have fewer dimensions than target) if isinstance(data, (int, float, np.integer, np.floating)): @@ -367,17 +393,17 @@ def to_dataarray( elif isinstance(data, np.ndarray): if data.ndim == 1: - intermediate = DataConverter._convert_1d_with_index_matching(data, validated_coords, target_dims) + intermediate = cls._convert_1d_array_by_length(data, validated_coords, target_dims) else: # Handle multi-dimensional arrays - this now allows partial matching - intermediate = DataConverter._convert_multid_array_by_shape(data, validated_coords, target_dims) + intermediate = cls._convert_multid_array_by_shape(data, validated_coords, target_dims) elif isinstance(data, pd.Series): if isinstance(data.index, pd.MultiIndex): raise ConversionError( 'Series index must be a single level Index. Multi-index Series are not supported.' ) - intermediate = DataConverter._convert_1d_with_index_matching(data, validated_coords, target_dims) + intermediate = cls._convert_series_by_index(data, validated_coords, target_dims) elif isinstance(data, pd.DataFrame): if isinstance(data.index, pd.MultiIndex): @@ -388,13 +414,13 @@ def to_dataarray( raise ConversionError('DataFrame must have at least one column.') if len(data.columns) == 1: - intermediate = DataConverter._convert_1d_with_index_matching( + intermediate = cls._convert_series_by_index( data.iloc[:, 0], validated_coords, target_dims ) else: # Handle multi-column DataFrames - this now allows partial matching logger.warning('Converting multi-column DataFrame to xr.DataArray. We advise to do this manually.') - intermediate = DataConverter._convert_multid_array_by_shape( + intermediate = cls._convert_multid_array_by_shape( data.to_numpy(), validated_coords, target_dims ) @@ -408,7 +434,7 @@ def to_dataarray( # Step 2: Broadcast to target dimensions if needed # This now handles cases where intermediate has some but not all target dimensions - return DataConverter._broadcast_to_target_dims(intermediate, validated_coords, target_dims) + return cls._broadcast_to_target_dims(intermediate, validated_coords, target_dims) @staticmethod def _validate_and_prepare_coords(coords: Dict[str, pd.Index]) -> Tuple[Dict[str, pd.Index], Tuple[str, ...]]: From cec7367687f16b08cc12973cc7bca943457f9e4f Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 5 Jul 2025 08:53:23 +0200 Subject: [PATCH 171/336] Add test for error handling --- tests/test_dataconverter.py | 271 +++++++++++++++++++++++++++++++++++- 1 file changed, 270 insertions(+), 1 deletion(-) diff --git a/tests/test_dataconverter.py b/tests/test_dataconverter.py index c174aebe8..2fbad4a13 100644 --- a/tests/test_dataconverter.py +++ b/tests/test_dataconverter.py @@ -679,7 +679,7 @@ def test_error_message_quality(self, standard_coords): try: DataConverter.to_dataarray(data_2d, coords=coords_2d) - assert False, "Should have raised ConversionError" + raise AssertionError('Should have raised ConversionError') except ConversionError as e: error_msg = str(e) assert "Array shape: (7, 8)" in error_msg @@ -889,6 +889,275 @@ def test_mixed_broadcasting_scenarios(self): arr_2d ) +class TestAmbiguousDimensionLengthHandling: + """Test that DataConverter correctly raises errors when multiple dimensions have the same length.""" + + def test_1d_array_ambiguous_dimensions_simple(self): + """Test 1D array with two dimensions of same length should fail.""" + # Both dimensions have length 3 + coords_ambiguous = { + 'scenario': pd.Index(['A', 'B', 'C'], name='scenario'), # length 3 + 'region': pd.Index(['north', 'south', 'east'], name='region'), # length 3 + } + + arr_1d = np.array([1, 2, 3]) # length 3 - matches both dimensions + + with pytest.raises(ConversionError, match='matches multiple dimensions'): + DataConverter.to_dataarray(arr_1d, coords=coords_ambiguous) + + def test_1d_array_ambiguous_dimensions_complex(self): + """Test 1D array with multiple dimensions of same length.""" + # Three dimensions have length 4 + coords_4x4x4 = { + 'time': pd.date_range('2024-01-01', periods=4, freq='D', name='time'), # length 4 + 'scenario': pd.Index(['A', 'B', 'C', 'D'], name='scenario'), # length 4 + 'region': pd.Index(['north', 'south', 'east', 'west'], name='region'), # length 4 + 'product': pd.Index(['X', 'Y'], name='product'), # length 2 - unique + } + + # Array matching the ambiguous length + arr_1d = np.array([10, 20, 30, 40]) # length 4 - matches time, scenario, region + + with pytest.raises(ConversionError, match='matches multiple dimensions'): + DataConverter.to_dataarray(arr_1d, coords=coords_4x4x4) + + # Array matching the unique length should work + arr_1d_unique = np.array([100, 200]) # length 2 - matches only product + result = DataConverter.to_dataarray(arr_1d_unique, coords=coords_4x4x4) + assert result.shape == (4, 4, 4, 2) # broadcast to all dimensions + assert result.dims == ('time', 'scenario', 'region', 'product') + + def test_2d_array_ambiguous_dimensions_both_same(self): + """Test 2D array where both dimensions have the same ambiguous length.""" + # All dimensions have length 3 + coords_3x3x3 = { + 'time': pd.date_range('2024-01-01', periods=3, freq='D', name='time'), # length 3 + 'scenario': pd.Index(['A', 'B', 'C'], name='scenario'), # length 3 + 'region': pd.Index(['X', 'Y', 'Z'], name='region'), # length 3 + } + + # 3x3 array - could be any combination of the three dimensions + arr_2d = np.random.rand(3, 3) + + with pytest.raises(ConversionError, match='matches multiple dimension orders'): + DataConverter.to_dataarray(arr_2d, coords=coords_3x3x3) + + def test_2d_array_one_dimension_ambiguous(self): + """Test 2D array where only one dimension length is ambiguous.""" + coords_mixed = { + 'time': pd.date_range('2024-01-01', periods=5, freq='D', name='time'), # length 5 - unique + 'scenario': pd.Index(['A', 'B', 'C'], name='scenario'), # length 3 + 'region': pd.Index(['X', 'Y', 'Z'], name='region'), # length 3 - same as scenario + 'product': pd.Index(['P1', 'P2'], name='product'), # length 2 - unique + } + + # 5x3 array - first dimension clearly maps to time (unique length 5) + # but second dimension could be scenario or region (both length 3) + arr_5x3 = np.random.rand(5, 3) + + with pytest.raises(ConversionError, match='matches multiple dimension orders'): + DataConverter.to_dataarray(arr_5x3, coords=coords_mixed) + + # 5x2 array should work - dimensions are unambiguous + arr_5x2 = np.random.rand(5, 2) + result = DataConverter.to_dataarray( + arr_5x2, coords={'time': coords_mixed['time'], 'product': coords_mixed['product']} + ) + assert result.shape == (5, 2) + assert result.dims == ('time', 'product') + + def test_3d_array_all_dimensions_ambiguous(self): + """Test 3D array where all dimension lengths are ambiguous.""" + # All dimensions have length 2 + coords_2x2x2x2 = { + 'scenario': pd.Index(['A', 'B'], name='scenario'), # length 2 + 'region': pd.Index(['north', 'south'], name='region'), # length 2 + 'technology': pd.Index(['solar', 'wind'], name='technology'), # length 2 + 'product': pd.Index(['X', 'Y'], name='product'), # length 2 + } + + # 2x2x2 array - could be any combination of 3 dimensions from the 4 available + arr_3d = np.random.rand(2, 2, 2) + + with pytest.raises(ConversionError, match='matches multiple dimension orders'): + DataConverter.to_dataarray(arr_3d, coords=coords_2x2x2x2) + + def test_3d_array_partial_ambiguity(self): + """Test 3D array with partial dimension ambiguity.""" + coords_partial = { + 'time': pd.date_range('2024-01-01', periods=4, freq='D', name='time'), # length 4 - unique + 'scenario': pd.Index(['A', 'B', 'C'], name='scenario'), # length 3 + 'region': pd.Index(['X', 'Y', 'Z'], name='region'), # length 3 - same as scenario + 'technology': pd.Index(['solar', 'wind'], name='technology'), # length 2 - unique + } + + # 4x3x2 array - first and third dimensions are unique, middle is ambiguous + # This should still fail because middle dimension (length 3) could be scenario or region + arr_4x3x2 = np.random.rand(4, 3, 2) + + with pytest.raises(ConversionError, match='matches multiple dimension orders'): + DataConverter.to_dataarray(arr_4x3x2, coords=coords_partial) + + def test_pandas_series_ambiguous_dimensions(self): + """Test pandas Series with ambiguous dimension lengths.""" + coords_ambiguous = { + 'scenario': pd.Index(['A', 'B', 'C'], name='scenario'), # length 3 + 'region': pd.Index(['north', 'south', 'east'], name='region'), # length 3 + } + + # Series with length 3 but index that doesn't match either coordinate exactly + generic_series = pd.Series([10, 20, 30], index=[0, 1, 2]) + + # Should fail because length matches multiple dimensions and index doesn't match any + with pytest.raises(ConversionError, match='index does not match any target dimension'): + DataConverter.to_dataarray(generic_series, coords=coords_ambiguous) + + # Series with index that matches one of the ambiguous coordinates should work + scenario_series = pd.Series([10, 20, 30], index=coords_ambiguous['scenario']) + result = DataConverter.to_dataarray(scenario_series, coords=coords_ambiguous) + assert result.shape == (3, 3) # should broadcast to both dimensions + assert result.dims == ('scenario', 'region') + + def test_edge_case_many_same_lengths(self): + """Test edge case with many dimensions having the same length.""" + # Five dimensions all have length 2 + coords_many = { + 'dim1': pd.Index(['A', 'B'], name='dim1'), + 'dim2': pd.Index(['X', 'Y'], name='dim2'), + 'dim3': pd.Index(['P', 'Q'], name='dim3'), + 'dim4': pd.Index(['M', 'N'], name='dim4'), + 'dim5': pd.Index(['U', 'V'], name='dim5'), + } + + # 1D array + arr_1d = np.array([1, 2]) + with pytest.raises(ConversionError, match='matches multiple dimensions'): + DataConverter.to_dataarray(arr_1d, coords=coords_many) + + # 2D array + arr_2d = np.random.rand(2, 2) + with pytest.raises(ConversionError, match='matches multiple dimension orders'): + DataConverter.to_dataarray(arr_2d, coords=coords_many) + + # 3D array + arr_3d = np.random.rand(2, 2, 2) + with pytest.raises(ConversionError, match='matches multiple dimension orders'): + DataConverter.to_dataarray(arr_3d, coords=coords_many) + + def test_mixed_lengths_with_duplicates(self): + """Test mixed scenario with some duplicate and some unique lengths.""" + coords_mixed = { + 'time': pd.date_range('2024-01-01', periods=8, freq='D', name='time'), # length 8 - unique + 'scenario': pd.Index(['A', 'B', 'C'], name='scenario'), # length 3 + 'region': pd.Index(['X', 'Y', 'Z'], name='region'), # length 3 - same as scenario + 'technology': pd.Index(['solar'], name='technology'), # length 1 - unique + 'product': pd.Index(['P1', 'P2', 'P3', 'P4', 'P5'], name='product'), # length 5 - unique + } + + # Arrays with unique lengths should work + arr_8 = np.arange(8) + result_8 = DataConverter.to_dataarray(arr_8, coords=coords_mixed) + assert result_8.dims == ('time', 'scenario', 'region', 'technology', 'product') + + arr_1 = np.array([42]) + result_1 = DataConverter.to_dataarray(arr_1, coords={'technology': coords_mixed['technology']}) + assert result_1.shape == (1,) + + arr_5 = np.arange(5) + result_5 = DataConverter.to_dataarray(arr_5, coords={'product': coords_mixed['product']}) + assert result_5.shape == (5,) + + # Arrays with ambiguous length should fail + arr_3 = np.array([1, 2, 3]) # matches both scenario and region + with pytest.raises(ConversionError, match='matches multiple dimensions'): + DataConverter.to_dataarray(arr_3, coords=coords_mixed) + + def test_dataframe_with_ambiguous_dimensions(self): + """Test DataFrame handling with ambiguous dimensions.""" + coords_ambiguous = { + 'scenario': pd.Index(['A', 'B', 'C'], name='scenario'), # length 3 + 'region': pd.Index(['X', 'Y', 'Z'], name='region'), # length 3 + } + + # Multi-column DataFrame with ambiguous dimensions + df = pd.DataFrame({'col1': [1, 2, 3], 'col2': [4, 5, 6], 'col3': [7, 8, 9]}) # 3x3 DataFrame + + # Should fail due to ambiguous dimensions + with pytest.raises(ConversionError, match='matches multiple dimension orders'): + DataConverter.to_dataarray(df, coords=coords_ambiguous) + + def test_error_message_quality_for_ambiguous_dimensions(self): + """Test that error messages for ambiguous dimensions are helpful.""" + coords_ambiguous = { + 'scenario': pd.Index(['A', 'B', 'C'], name='scenario'), + 'region': pd.Index(['north', 'south', 'east'], name='region'), + 'technology': pd.Index(['solar', 'wind', 'gas'], name='technology'), + } + + # 1D array case + arr_1d = np.array([1, 2, 3]) + try: + DataConverter.to_dataarray(arr_1d, coords=coords_ambiguous) + raise AssertionError('Should have raised ConversionError') + except ConversionError as e: + error_msg = str(e) + assert 'matches multiple dimensions' in error_msg + assert 'scenario' in error_msg + assert 'region' in error_msg + assert 'technology' in error_msg + + # 2D array case + arr_2d = np.random.rand(3, 3) + try: + DataConverter.to_dataarray(arr_2d, coords=coords_ambiguous) + raise AssertionError('Should have raised ConversionError') + except ConversionError as e: + error_msg = str(e) + assert 'matches multiple dimension orders' in error_msg + assert '(3, 3)' in error_msg + + def test_ambiguous_with_broadcasting_target(self): + """Test ambiguous dimensions when target includes broadcasting.""" + coords_ambiguous_plus = { + 'time': pd.date_range('2024-01-01', periods=5, freq='D', name='time'), # length 5 + 'scenario': pd.Index(['A', 'B', 'C'], name='scenario'), # length 3 + 'region': pd.Index(['X', 'Y', 'Z'], name='region'), # length 3 - same as scenario + 'technology': pd.Index(['solar', 'wind'], name='technology'), # length 2 + } + + # 1D array with ambiguous length, but targeting broadcast scenario + arr_3 = np.array([10, 20, 30]) # length 3, matches scenario and region + + # Should fail even though it would broadcast to other dimensions + with pytest.raises(ConversionError, match='matches multiple dimensions'): + DataConverter.to_dataarray(arr_3, coords=coords_ambiguous_plus) + + # 2D array with one ambiguous dimension + arr_5x3 = np.random.rand(5, 3) # 5 is unique (time), 3 is ambiguous (scenario/region) + + with pytest.raises(ConversionError, match='matches multiple dimension orders'): + DataConverter.to_dataarray(arr_5x3, coords=coords_ambiguous_plus) + + def test_time_dimension_ambiguity(self): + """Test ambiguity specifically involving time dimension.""" + # Create scenario where time has same length as another dimension + coords_time_ambiguous = { + 'time': pd.date_range('2024-01-01', periods=3, freq='D', name='time'), # length 3 + 'scenario': pd.Index(['base', 'high', 'low'], name='scenario'), # length 3 - same as time + 'region': pd.Index(['north', 'south'], name='region'), # length 2 - unique + } + + # Time-indexed series should work even with ambiguous lengths (index matching takes precedence) + time_series = pd.Series([100, 200, 300], index=coords_time_ambiguous['time']) + result = DataConverter.to_dataarray(time_series, coords=coords_time_ambiguous) + assert result.shape == (3, 3, 2) + assert result.dims == ('time', 'scenario', 'region') + + # But generic array with length 3 should still fail + generic_array = np.array([100, 200, 300]) + with pytest.raises(ConversionError, match='matches multiple dimensions'): + DataConverter.to_dataarray(generic_array, coords=coords_time_ambiguous) + if __name__ == '__main__': pytest.main() From eba1ec436ad4156051582b001f6f7ef6c290c737 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 5 Jul 2025 08:53:36 +0200 Subject: [PATCH 172/336] Update scenario example --- examples/04_Scenarios/scenario_example.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/examples/04_Scenarios/scenario_example.py b/examples/04_Scenarios/scenario_example.py index b9932a016..ae53cc1ff 100644 --- a/examples/04_Scenarios/scenario_example.py +++ b/examples/04_Scenarios/scenario_example.py @@ -70,7 +70,8 @@ discharging=fx.Flow('Q_th_unload', bus='Fernwärme', size=1000), capacity_in_flow_hours=fx.InvestParameters(fix_effects=20, fixed_size=30, optional=False), initial_charge_state=0, # Initial storage state: empty - relative_maximum_charge_state=np.array([80, 70, 80, 80, 80, 80, 80, 80, 80, 80]) * 0.01, + relative_maximum_charge_state=np.array([80, 70, 80, 80, 80, 80, 80, 80, 80]) * 0.01, + relative_maximum_final_charge_state=0.8, eta_charge=0.9, eta_discharge=1, # Efficiency factors for charging/discharging relative_loss_per_hour=0.08, # 8% loss per hour. Absolute loss depends on current charge state From 5f97bf3cab08c1efa628b96430f791e8fbbad478 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 7 Jul 2025 14:10:16 +0200 Subject: [PATCH 173/336] Fix Handling of TimeSeriesData --- flixopt/calculation.py | 6 ++++-- flixopt/core.py | 28 ++++++++++++++++++++++++++-- flixopt/flow_system.py | 5 +---- 3 files changed, 31 insertions(+), 8 deletions(-) diff --git a/flixopt/calculation.py b/flixopt/calculation.py index e035aaa13..b3eec2d06 100644 --- a/flixopt/calculation.py +++ b/flixopt/calculation.py @@ -61,8 +61,10 @@ def __init__( """ self.name = name if flow_system.used_in_calculation: - logging.warning(f'FlowSystem {flow_system} is already used in a calculation. ' - f'Creating a copy for Calculation "{self.name}".') + logger.warning( + f'FlowSystem {flow_system} is already used in a calculation. ' + f'Creating a copy of the FlowSystem for Calculation "{self.name}".' + ) flow_system = flow_system.copy() if active_timesteps is not None: diff --git a/flixopt/core.py b/flixopt/core.py index d1e61b4c5..39490f536 100644 --- a/flixopt/core.py +++ b/flixopt/core.py @@ -51,8 +51,15 @@ class TimeSeriesData(xr.DataArray): __slots__ = () # No additional instance attributes - everything goes in attrs - def __init__(self, *args, aggregation_group: Optional[str] = None, aggregation_weight: Optional[float] = None, - agg_group: Optional[str] = None, agg_weight: Optional[float] = None, **kwargs): + def __init__( + self, + *args, + aggregation_group: Optional[str] = None, + aggregation_weight: Optional[float] = None, + agg_group: Optional[str] = None, + agg_weight: Optional[float] = None, + **kwargs + ): """ Args: *args: Arguments passed to DataArray @@ -84,6 +91,23 @@ def __init__(self, *args, aggregation_group: Optional[str] = None, aggregation_w # Always mark as TimeSeriesData self.attrs['__timeseries_data__'] = True + def fit_to_coords( + self, + coords: Dict[str, pd.Index], + name: Optional[str] = None, + ) -> 'TimeSeriesData': + """Fit the data to the given coordinates. Returns a new TimeSeriesData object if the current coords are different.""" + if self.coords.equals(xr.Coordinates(coords)): + return self + + da = DataConverter.to_dataarray(self.data, coords=coords) + return self.__class__( + da, + aggregation_group=self.aggregation_group, + aggregation_weight=self.aggregation_weight, + name=name if name is not None else self.name + ) + @property def aggregation_group(self) -> Optional[str]: return self.attrs.get('aggregation_group') diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index 0568f3b3a..3fa920b84 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -343,10 +343,7 @@ def fit_to_model_coords( if isinstance(data, TimeSeriesData): try: data.name = name # Set name of previous object! - return TimeSeriesData( - DataConverter.to_dataarray(data, coords=coords), - aggregation_group=data.aggregation_group, aggregation_weight=data.aggregation_weight - ).rename(name) + return data.fit_to_coords(coords) except ConversionError as e: raise ConversionError( f'Could not convert time series data "{name}" to DataArray:\n{data}\nOriginal Error: {e}') from e From 9351083c65bb893863d040f96852e517f918f048 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 7 Jul 2025 14:31:05 +0200 Subject: [PATCH 174/336] Improve DataConverter --- flixopt/core.py | 207 +++++++++++++++--------------------------------- 1 file changed, 66 insertions(+), 141 deletions(-) diff --git a/flixopt/core.py b/flixopt/core.py index 39490f536..ee0ef0540 100644 --- a/flixopt/core.py +++ b/flixopt/core.py @@ -158,6 +158,7 @@ class DataConverter: Supports: - Scalars (broadcast to all dimensions) - 1D data (np.ndarray, pd.Series, single-column DataFrame) + - Multi-dimensional arrays - xr.DataArray (validated and potentially broadcast) Simple 1D data is matched to one dimension and broadcast to others. @@ -165,11 +166,11 @@ class DataConverter: """ @staticmethod - def _convert_series_by_index( + def _match_series_to_dimension( data: pd.Series, coords: Dict[str, pd.Index], target_dims: Tuple[str, ...] ) -> xr.DataArray: """ - Convert pandas Series to DataArray by matching index to coordinates. + Match pandas Series to a dimension by comparing its index to coordinates. Args: data: pandas Series @@ -196,11 +197,11 @@ def _convert_series_by_index( raise ConversionError(f'Series index does not match any target dimension coordinates: {target_dims}') @staticmethod - def _convert_1d_array_by_length( + def _match_array_to_dimension( data: np.ndarray, coords: Dict[str, pd.Index], target_dims: Tuple[str, ...] ) -> xr.DataArray: """ - Convert 1D numpy array to DataArray by matching length to coordinates. + Match 1D numpy array to a dimension by comparing its length to coordinate lengths. Args: data: 1D numpy array @@ -218,7 +219,7 @@ def _convert_1d_array_by_length( raise ConversionError('Cannot convert multi-element array without target dimensions') return xr.DataArray(data[0]) - # Match by length + # Find dimensions with matching lengths matching_dims = [] for dim_name in target_dims: if len(data) == len(coords[dim_name]): @@ -239,102 +240,11 @@ def _convert_1d_array_by_length( return xr.DataArray(data.copy(), coords={match_dim: coords[match_dim]}, dims=[match_dim]) @staticmethod - def _broadcast_to_target_dims( - data: xr.DataArray, coords: Dict[str, pd.Index], target_dims: Tuple[str, ...] - ) -> xr.DataArray: - """ - Broadcast DataArray to match target dimensions. - - Args: - data: Source DataArray - coords: Target coordinates - target_dims: Target dimension names - - Returns: - DataArray broadcast to target dimensions - """ - if len(target_dims) == 0: - # Target is scalar - if data.size != 1: - raise ConversionError('Cannot convert multi-element DataArray to scalar') - return xr.DataArray(data.values.item()) - - # If data already matches target, validate coordinates and return - if set(data.dims) == set(target_dims) and len(data.dims) == len(target_dims): - # Check coordinate compatibility - for dim in data.dims: - if dim in coords and not np.array_equal(data.coords[dim].values, coords[dim].values): - raise ConversionError(f'DataArray {dim} coordinates do not match target coordinates') - - # Ensure correct dimension order - if data.dims != target_dims: - data = data.transpose(*target_dims) - return data.copy() - - # Handle scalar data (0D) - broadcast to all dimensions - if data.ndim == 0: - return xr.DataArray(data.item(), coords=coords, dims=target_dims) - - # Handle broadcasting from fewer to more dimensions - if len(data.dims) < len(target_dims): - return DataConverter._expand_to_more_dims(data, coords, target_dims) - - # Cannot handle more dimensions than target - if len(data.dims) > len(target_dims): - raise ConversionError(f'Cannot reduce DataArray from {len(data.dims)} to {len(target_dims)} dimensions') - - raise ConversionError(f'Cannot convert DataArray with dims {data.dims} to target dims {target_dims}') - - @staticmethod - def _expand_to_more_dims( - data: xr.DataArray, coords: Dict[str, pd.Index], target_dims: Tuple[str, ...] - ) -> xr.DataArray: - """Expand DataArray to additional dimensions by broadcasting.""" - # Validate that all source dimensions exist in target - for dim in data.dims: - if dim not in target_dims: - raise ConversionError(f'Source dimension "{dim}" not found in target dimensions {target_dims}') - - # Check coordinate compatibility - if not np.array_equal(data.coords[dim].values, coords[dim].values): - raise ConversionError(f'Source {dim} coordinates do not match target coordinates') - - # Start with the original data - result_data = data.values - result_dims = list(data.dims) - result_coords = {dim: data.coords[dim] for dim in data.dims} - - # Add missing dimensions one by one - for target_dim in target_dims: - if target_dim not in result_dims: - # Add this dimension at the end - result_data = np.expand_dims(result_data, axis=-1) - result_dims.append(target_dim) - result_coords[target_dim] = coords[target_dim] - - # Broadcast along the new dimension - new_shape = list(result_data.shape) - new_shape[-1] = len(coords[target_dim]) - result_data = np.broadcast_to(result_data, new_shape) - - # Reorder dimensions to match target order - if tuple(result_dims) != target_dims: - # Create mapping from current to target order - dim_indices = [result_dims.index(dim) for dim in target_dims] - result_data = np.transpose(result_data, dim_indices) - - # Build final coordinates dict in target order - final_coords = {dim: coords[dim] for dim in target_dims} - - return xr.DataArray(result_data.copy(), coords=final_coords, dims=target_dims) - - @staticmethod - def _convert_multid_array_by_shape( + def _match_multidim_array_to_dimensions( data: np.ndarray, coords: Dict[str, pd.Index], target_dims: Tuple[str, ...] ) -> xr.DataArray: """ - Convert multi-dimensional numpy array to DataArray by matching dimensions by shape. - Returns a DataArray that may need further broadcasting to target dimensions. + Match multi-dimensional numpy array to dimensions by finding the correct shape permutation. Args: data: Multi-dimensional numpy array @@ -342,7 +252,7 @@ def _convert_multid_array_by_shape( target_dims: Target dimension names Returns: - DataArray with dimensions matched by shape (may be subset of target_dims) + DataArray with dimensions matched by shape Raises: ConversionError: If array dimensions cannot be uniquely matched to coordinates @@ -352,17 +262,14 @@ def _convert_multid_array_by_shape( raise ConversionError('Cannot convert multi-element array without target dimensions') return xr.DataArray(data.item()) - # Get lengths of each dimension + from itertools import permutations + array_shape = data.shape coord_lengths = {dim: len(coords[dim]) for dim in target_dims} - # Find all possible ways to match array dimensions to available coordinates - from itertools import permutations - - # Try all permutations of target_dims that match the array's number of dimensions + # Find all possible dimension mappings possible_mappings = [] for dim_subset in permutations(target_dims, data.ndim): - # Check if this permutation matches the array shape if all(array_shape[i] == coord_lengths[dim_subset[i]] for i in range(len(dim_subset))): possible_mappings.append(dim_subset) @@ -376,58 +283,80 @@ def _convert_multid_array_by_shape( 'Cannot uniquely determine dimension mapping.' ) - # Use the unique mapping found matched_dims = possible_mappings[0] matched_coords = {dim: coords[dim] for dim in matched_dims} - # Return DataArray with matched dimensions - broadcasting will happen later if needed return xr.DataArray(data.copy(), coords=matched_coords, dims=matched_dims) + @staticmethod + def _broadcast_to_target( + data: xr.DataArray, coords: Dict[str, pd.Index], target_dims: Tuple[str, ...] + ) -> xr.DataArray: + """ + Broadcast DataArray to target dimensions with validation. + + Handles all cases: scalar expansion, dimension validation, coordinate matching, + and broadcasting to additional dimensions using xarray's capabilities. + """ + # Cannot reduce dimensions of data + if len(data.dims) > len(target_dims): + raise ConversionError(f'Cannot reduce DataArray from {len(data.dims)} to {len(target_dims)} dimensions') + + # Validate coordinate compatibility + for dim in data.dims: + if dim not in target_dims: + raise ConversionError(f'Source dimension "{dim}" not found in target dimensions {target_dims}') + + if not np.array_equal(data.coords[dim].values, coords[dim].values): + raise ConversionError(f'DataArray {dim} coordinates do not match target coordinates') + + # Use xarray's broadcast_like for efficient expansion and broadcasting + target_template = xr.DataArray( + np.empty([len(coords[dim]) for dim in target_dims]), coords=coords, dims=target_dims + ) + return data.broadcast_like(target_template).transpose(*target_dims) + @classmethod def to_dataarray( cls, - data: Union[Scalar, np.ndarray, pd.Series, pd.DataFrame, xr.DataArray, TimeSeriesData], + data: Union[float, int, np.ndarray, pd.Series, pd.DataFrame, xr.DataArray], coords: Optional[Dict[str, pd.Index]] = None, ) -> xr.DataArray: """ - Convert data to xarray.DataArray with specified coordinates. - - Accepts: - - Scalars (broadcast to all dimensions) - - 1D arrays or Series (matched to one dimension, broadcast to others) - - Multi-D arrays or DataFrames (dimensions matched by length, broadcast to remaining) - - xr.DataArray (validated and potentially broadcast to additional dimensions) + Convert various data types to xarray.DataArray with specified coordinates. Args: - data: Data to convert + data: Data to convert (scalar, array, Series, DataFrame, or DataArray) coords: Dictionary mapping dimension names to coordinate indices Returns: - DataArray with the converted data + DataArray with the converted data broadcast to target dimensions + + Raises: + ConversionError: If data cannot be converted or dimensions are ambiguous """ if coords is None: coords = {} - validated_coords, target_dims = cls._validate_and_prepare_coords(coords) + validated_coords, target_dims = cls._prepare_coordinates(coords) - # Step 1: Convert to DataArray (may have fewer dimensions than target) + # Step 1: Convert input data to initial DataArray if isinstance(data, (int, float, np.integer, np.floating)): - # Scalars: create 0D DataArray, will be broadcast later + # Scalar values intermediate = xr.DataArray(data.item() if hasattr(data, 'item') else data) elif isinstance(data, np.ndarray): if data.ndim == 1: - intermediate = cls._convert_1d_array_by_length(data, validated_coords, target_dims) + intermediate = cls._match_array_to_dimension(data, validated_coords, target_dims) else: - # Handle multi-dimensional arrays - this now allows partial matching - intermediate = cls._convert_multid_array_by_shape(data, validated_coords, target_dims) + intermediate = cls._match_multidim_array_to_dimensions(data, validated_coords, target_dims) elif isinstance(data, pd.Series): if isinstance(data.index, pd.MultiIndex): raise ConversionError( 'Series index must be a single level Index. Multi-index Series are not supported.' ) - intermediate = cls._convert_series_by_index(data, validated_coords, target_dims) + intermediate = cls._match_series_to_dimension(data, validated_coords, target_dims) elif isinstance(data, pd.DataFrame): if isinstance(data.index, pd.MultiIndex): @@ -438,44 +367,40 @@ def to_dataarray( raise ConversionError('DataFrame must have at least one column.') if len(data.columns) == 1: - intermediate = cls._convert_series_by_index( - data.iloc[:, 0], validated_coords, target_dims - ) + # Single-column DataFrame - treat as Series + intermediate = cls._match_series_to_dimension(data.iloc[:, 0], validated_coords, target_dims) else: - # Handle multi-column DataFrames - this now allows partial matching - logger.warning('Converting multi-column DataFrame to xr.DataArray. We advise to do this manually.') - intermediate = cls._convert_multid_array_by_shape( - data.to_numpy(), validated_coords, target_dims - ) + # Multi-column DataFrame - treat as multi-dimensional array + intermediate = cls._match_multidim_array_to_dimensions(data.to_numpy(), validated_coords, target_dims) elif isinstance(data, xr.DataArray): intermediate = data.copy() else: - raise ConversionError( - f'Unsupported data type: {type(data).__name__}. Only scalars, arrays, Series, DataFrames, and DataArrays are supported.' - ) + raise ConversionError(f'Unsupported data type: {type(data).__name__}.') - # Step 2: Broadcast to target dimensions if needed - # This now handles cases where intermediate has some but not all target dimensions - return cls._broadcast_to_target_dims(intermediate, validated_coords, target_dims) + # Step 2: Broadcast to target dimensions + return cls._broadcast_to_target(intermediate, validated_coords, target_dims) @staticmethod - def _validate_and_prepare_coords(coords: Dict[str, pd.Index]) -> Tuple[Dict[str, pd.Index], Tuple[str, ...]]: + def _prepare_coordinates(coords: Dict[str, pd.Index]) -> Tuple[Dict[str, pd.Index], Tuple[str, ...]]: """ - Validate and prepare coordinates for the DataArray. + Validate coordinates and prepare them for DataArray creation. Args: coords: Dictionary mapping dimension names to coordinate indices Returns: Tuple of (validated coordinates dict, dimensions tuple) + + Raises: + ConversionError: If coordinates are invalid """ validated_coords = {} dims = [] for dim_name, coord_index in coords.items(): - # Validate coordinate index + # Basic validation if not isinstance(coord_index, pd.Index) or len(coord_index) == 0: raise ConversionError(f'{dim_name} coordinates must be a non-empty pandas Index') From 99e6b1956f88f16e95818111286c86b2b606c87c Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 15 Jul 2025 15:19:32 +0200 Subject: [PATCH 175/336] Fix resampling of the FlowSystem --- flixopt/flow_system.py | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index 3fa920b84..7001ca9e3 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -694,6 +694,7 @@ def resample( ) -> 'FlowSystem': """ Create a resampled FlowSystem by resampling data along the time dimension (like xr.Dataset.resample()). + Only resamples data variables that have a time dimension. Args: time: Resampling frequency (e.g., '3h', '2D', '1M') @@ -707,12 +708,32 @@ def resample( self.connect_and_transform() dataset = self.to_dataset() - resampler = dataset.resample(time=time, **kwargs) + + # Separate variables with and without time dimension + time_vars = {} + non_time_vars = {} + + for var_name, var in dataset.data_vars.items(): + if 'time' in var.dims: + time_vars[var_name] = var + else: + non_time_vars[var_name] = var + + # Only resample variables that have time dimension + time_dataset = dataset[list(time_vars.keys())] + resampler = time_dataset.resample(time=time, **kwargs) if hasattr(resampler, method): - resampled_dataset = getattr(resampler, method)() + resampled_time_data = getattr(resampler, method)() else: available_methods = ['mean', 'sum', 'max', 'min', 'first', 'last', 'std', 'var', 'median', 'count'] raise ValueError(f'Unsupported resampling method: {method}. Available: {available_methods}') + # Combine resampled time variables with non-time variables + if non_time_vars: + non_time_dataset = dataset[list(non_time_vars.keys())] + resampled_dataset = xr.merge([resampled_time_data, non_time_dataset]) + else: + resampled_dataset = resampled_time_data + return self.__class__.from_dataset(resampled_dataset) From 4981a9c87222b6103e8a293a9fb596d6455b3236 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 15 Jul 2025 15:35:30 +0200 Subject: [PATCH 176/336] Improve Warning Message --- flixopt/calculation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flixopt/calculation.py b/flixopt/calculation.py index b3eec2d06..0fb735bef 100644 --- a/flixopt/calculation.py +++ b/flixopt/calculation.py @@ -62,7 +62,7 @@ def __init__( self.name = name if flow_system.used_in_calculation: logger.warning( - f'FlowSystem {flow_system} is already used in a calculation. ' + f'This FlowSystem is already used in a calculation:\n{flow_system}\n' f'Creating a copy of the FlowSystem for Calculation "{self.name}".' ) flow_system = flow_system.copy() From 516f45b4b356b0f7db04670707e5506bab4540da Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 15 Jul 2025 15:39:25 +0200 Subject: [PATCH 177/336] Add example that leverages resampling --- .../Zeitreihen2020.csv | 35137 ++++++++++++++++ .../two_stage_optimization.py | 148 + 2 files changed, 35285 insertions(+) create mode 100644 examples/05_Two-stage-optimization/Zeitreihen2020.csv create mode 100644 examples/05_Two-stage-optimization/two_stage_optimization.py diff --git a/examples/05_Two-stage-optimization/Zeitreihen2020.csv b/examples/05_Two-stage-optimization/Zeitreihen2020.csv new file mode 100644 index 000000000..9b660ef9c --- /dev/null +++ b/examples/05_Two-stage-optimization/Zeitreihen2020.csv @@ -0,0 +1,35137 @@ +Zeit,P_Netz/MW,Q_Netz/MW,Strompr.€/MWh,Gaspr.€/MWh +2020-01-01 00:00:00,58.39,127.059,7.461,32.459 +2020-01-01 00:15:00,58.36,122.156,7.461,32.459 +2020-01-01 00:30:00,58.11,124.412,7.461,32.459 +2020-01-01 00:45:00,57.71,127.713,7.461,32.459 +2020-01-01 01:00:00,55.53,130.69899999999998,2.65,32.459 +2020-01-01 01:15:00,56.24,132.166,2.65,32.459 +2020-01-01 01:30:00,55.17,132.394,2.65,32.459 +2020-01-01 01:45:00,54.5,132.431,2.65,32.459 +2020-01-01 02:00:00,52.95,134.403,-2.949,32.459 +2020-01-01 02:15:00,51.75,134.755,-2.949,32.459 +2020-01-01 02:30:00,50.7,135.631,-2.949,32.459 +2020-01-01 02:45:00,50.33,138.345,-2.949,32.459 +2020-01-01 03:00:00,47.11,141.071,-3.2680000000000002,32.459 +2020-01-01 03:15:00,49.35,141.319,-3.2680000000000002,32.459 +2020-01-01 03:30:00,48.33,143.024,-3.2680000000000002,32.459 +2020-01-01 03:45:00,48.73,144.697,-3.2680000000000002,32.459 +2020-01-01 04:00:00,46.6,152.569,-3.2680000000000002,32.459 +2020-01-01 04:15:00,47.33,160.688,-3.2680000000000002,32.459 +2020-01-01 04:30:00,47.5,161.673,-3.2680000000000002,32.459 +2020-01-01 04:45:00,48.62,163.165,-3.2680000000000002,32.459 +2020-01-01 05:00:00,49.18,176.398,-3.2680000000000002,32.459 +2020-01-01 05:15:00,50.0,184.233,-3.2680000000000002,32.459 +2020-01-01 05:30:00,50.1,181.12,-3.2680000000000002,32.459 +2020-01-01 05:45:00,50.36,179.642,-3.2680000000000002,32.459 +2020-01-01 06:00:00,50.32,196.364,-3.2680000000000002,32.459 +2020-01-01 06:15:00,50.22,215.918,-3.2680000000000002,32.459 +2020-01-01 06:30:00,52.17,211.0,-3.2680000000000002,32.459 +2020-01-01 06:45:00,54.2,206.111,-3.2680000000000002,32.459 +2020-01-01 07:00:00,55.6,202.635,-3.2680000000000002,32.459 +2020-01-01 07:15:00,55.35,207.093,-3.2680000000000002,32.459 +2020-01-01 07:30:00,55.44,211.667,-3.2680000000000002,32.459 +2020-01-01 07:45:00,55.15,215.882,-3.2680000000000002,32.459 +2020-01-01 08:00:00,56.21,219.793,-2.146,32.459 +2020-01-01 08:15:00,55.94,223.388,-2.146,32.459 +2020-01-01 08:30:00,58.2,226.081,-2.146,32.459 +2020-01-01 08:45:00,56.11,227.016,-2.146,32.459 +2020-01-01 09:00:00,61.62,222.30700000000002,1.7519999999999998,32.459 +2020-01-01 09:15:00,63.5,221.018,1.7519999999999998,32.459 +2020-01-01 09:30:00,64.24,219.09,1.7519999999999998,32.459 +2020-01-01 09:45:00,64.02,215.80700000000002,1.7519999999999998,32.459 +2020-01-01 10:00:00,61.38,212.231,4.19,32.459 +2020-01-01 10:15:00,62.05,209.50099999999998,4.19,32.459 +2020-01-01 10:30:00,61.84,206.963,4.19,32.459 +2020-01-01 10:45:00,65.9,204.111,4.19,32.459 +2020-01-01 11:00:00,67.33,203.37599999999998,5.517,32.459 +2020-01-01 11:15:00,68.34,200.575,5.517,32.459 +2020-01-01 11:30:00,67.43,198.755,5.517,32.459 +2020-01-01 11:45:00,68.73,197.22099999999998,5.517,32.459 +2020-01-01 12:00:00,74.2,191.555,4.27,32.459 +2020-01-01 12:15:00,69.91,191.37,4.27,32.459 +2020-01-01 12:30:00,68.83,190.28799999999998,4.27,32.459 +2020-01-01 12:45:00,70.01,189.851,4.27,32.459 +2020-01-01 13:00:00,69.02,188.09599999999998,3.484,32.459 +2020-01-01 13:15:00,68.68,189.604,3.484,32.459 +2020-01-01 13:30:00,68.45,188.61,3.484,32.459 +2020-01-01 13:45:00,68.18,188.303,3.484,32.459 +2020-01-01 14:00:00,68.94,187.555,2.523,32.459 +2020-01-01 14:15:00,66.1,188.747,2.523,32.459 +2020-01-01 14:30:00,70.61,189.53099999999998,2.523,32.459 +2020-01-01 14:45:00,70.58,189.93,2.523,32.459 +2020-01-01 15:00:00,73.81,189.581,5.667999999999999,32.459 +2020-01-01 15:15:00,69.6,191.454,5.667999999999999,32.459 +2020-01-01 15:30:00,68.66,194.55599999999998,5.667999999999999,32.459 +2020-01-01 15:45:00,71.32,197.227,5.667999999999999,32.459 +2020-01-01 16:00:00,73.3,197.49900000000002,12.109000000000002,32.459 +2020-01-01 16:15:00,77.24,199.38099999999997,12.109000000000002,32.459 +2020-01-01 16:30:00,83.2,202.511,12.109000000000002,32.459 +2020-01-01 16:45:00,86.91,205.047,12.109000000000002,32.459 +2020-01-01 17:00:00,89.85,207.06099999999998,22.824,32.459 +2020-01-01 17:15:00,90.61,208.69799999999998,22.824,32.459 +2020-01-01 17:30:00,92.03,209.25099999999998,22.824,32.459 +2020-01-01 17:45:00,93.49,210.672,22.824,32.459 +2020-01-01 18:00:00,95.17,211.632,21.656,32.459 +2020-01-01 18:15:00,94.51,211.645,21.656,32.459 +2020-01-01 18:30:00,93.87,209.94,21.656,32.459 +2020-01-01 18:45:00,93.48,208.489,21.656,32.459 +2020-01-01 19:00:00,92.14,209.826,19.749000000000002,32.459 +2020-01-01 19:15:00,90.85,207.487,19.749000000000002,32.459 +2020-01-01 19:30:00,89.81,204.959,19.749000000000002,32.459 +2020-01-01 19:45:00,88.14,202.29,19.749000000000002,32.459 +2020-01-01 20:00:00,84.73,200.835,24.274,32.459 +2020-01-01 20:15:00,83.37,197.709,24.274,32.459 +2020-01-01 20:30:00,82.23,194.57299999999998,24.274,32.459 +2020-01-01 20:45:00,80.34,191.52200000000002,24.274,32.459 +2020-01-01 21:00:00,78.43,188.843,23.044,32.459 +2020-01-01 21:15:00,77.95,186.56099999999998,23.044,32.459 +2020-01-01 21:30:00,77.04,186.183,23.044,32.459 +2020-01-01 21:45:00,76.57,184.824,23.044,32.459 +2020-01-01 22:00:00,72.56,178.928,25.155,32.459 +2020-01-01 22:15:00,72.48,174.81799999999998,25.155,32.459 +2020-01-01 22:30:00,70.04,170.76,25.155,32.459 +2020-01-01 22:45:00,67.68,167.32,25.155,32.459 +2020-01-01 23:00:00,59.3,159.845,20.101,32.459 +2020-01-01 23:15:00,63.51,156.17700000000002,20.101,32.459 +2020-01-01 23:30:00,61.08,153.764,20.101,32.459 +2020-01-01 23:45:00,60.81,150.845,20.101,32.459 +2020-01-02 00:00:00,72.56,131.099,38.399,32.641 +2020-01-02 00:15:00,75.51,130.822,38.399,32.641 +2020-01-02 00:30:00,76.2,132.15,38.399,32.641 +2020-01-02 00:45:00,79.94,133.753,38.399,32.641 +2020-01-02 01:00:00,76.22,136.549,36.94,32.641 +2020-01-02 01:15:00,75.52,136.971,36.94,32.641 +2020-01-02 01:30:00,69.71,137.433,36.94,32.641 +2020-01-02 01:45:00,72.15,137.96200000000002,36.94,32.641 +2020-01-02 02:00:00,68.46,139.933,35.275,32.641 +2020-01-02 02:15:00,68.93,141.73,35.275,32.641 +2020-01-02 02:30:00,69.13,142.344,35.275,32.641 +2020-01-02 02:45:00,71.05,144.38,35.275,32.641 +2020-01-02 03:00:00,77.24,147.17700000000002,35.329,32.641 +2020-01-02 03:15:00,78.88,148.17700000000002,35.329,32.641 +2020-01-02 03:30:00,79.6,150.07,35.329,32.641 +2020-01-02 03:45:00,73.47,151.487,35.329,32.641 +2020-01-02 04:00:00,71.66,163.75,36.275,32.641 +2020-01-02 04:15:00,72.6,175.77599999999998,36.275,32.641 +2020-01-02 04:30:00,74.43,178.856,36.275,32.641 +2020-01-02 04:45:00,76.31,181.783,36.275,32.641 +2020-01-02 05:00:00,80.9,217.042,42.193999999999996,32.641 +2020-01-02 05:15:00,83.91,245.78799999999998,42.193999999999996,32.641 +2020-01-02 05:30:00,88.55,241.4,42.193999999999996,32.641 +2020-01-02 05:45:00,92.98,234.083,42.193999999999996,32.641 +2020-01-02 06:00:00,101.92,230.607,56.422,32.641 +2020-01-02 06:15:00,106.02,236.44099999999997,56.422,32.641 +2020-01-02 06:30:00,110.67,239.31400000000002,56.422,32.641 +2020-01-02 06:45:00,115.36,243.33,56.422,32.641 +2020-01-02 07:00:00,121.21,242.197,72.569,32.641 +2020-01-02 07:15:00,125.44,247.68200000000002,72.569,32.641 +2020-01-02 07:30:00,127.04,250.858,72.569,32.641 +2020-01-02 07:45:00,129.3,252.55900000000003,72.569,32.641 +2020-01-02 08:00:00,131.83,251.364,67.704,32.641 +2020-01-02 08:15:00,129.92,251.618,67.704,32.641 +2020-01-02 08:30:00,130.03,249.708,67.704,32.641 +2020-01-02 08:45:00,129.65,246.764,67.704,32.641 +2020-01-02 09:00:00,130.4,240.053,63.434,32.641 +2020-01-02 09:15:00,132.18,236.87900000000002,63.434,32.641 +2020-01-02 09:30:00,133.68,234.63099999999997,63.434,32.641 +2020-01-02 09:45:00,133.99,231.472,63.434,32.641 +2020-01-02 10:00:00,133.44,226.187,61.88399999999999,32.641 +2020-01-02 10:15:00,135.64,221.955,61.88399999999999,32.641 +2020-01-02 10:30:00,134.92,218.68400000000003,61.88399999999999,32.641 +2020-01-02 10:45:00,136.65,216.98,61.88399999999999,32.641 +2020-01-02 11:00:00,136.01,214.97299999999998,61.481,32.641 +2020-01-02 11:15:00,135.6,213.702,61.481,32.641 +2020-01-02 11:30:00,135.49,212.15200000000002,61.481,32.641 +2020-01-02 11:45:00,136.56,210.945,61.481,32.641 +2020-01-02 12:00:00,136.24,205.8,59.527,32.641 +2020-01-02 12:15:00,135.89,205.143,59.527,32.641 +2020-01-02 12:30:00,133.92,205.12400000000002,59.527,32.641 +2020-01-02 12:45:00,134.1,205.96099999999998,59.527,32.641 +2020-01-02 13:00:00,130.94,204.35,58.794,32.641 +2020-01-02 13:15:00,129.98,203.88099999999997,58.794,32.641 +2020-01-02 13:30:00,129.31,203.575,58.794,32.641 +2020-01-02 13:45:00,127.59,203.519,58.794,32.641 +2020-01-02 14:00:00,126.5,202.428,60.32,32.641 +2020-01-02 14:15:00,129.81,203.06900000000002,60.32,32.641 +2020-01-02 14:30:00,128.98,203.95,60.32,32.641 +2020-01-02 14:45:00,127.66,204.167,60.32,32.641 +2020-01-02 15:00:00,131.69,205.303,62.52,32.641 +2020-01-02 15:15:00,134.08,205.93900000000002,62.52,32.641 +2020-01-02 15:30:00,130.49,208.357,62.52,32.641 +2020-01-02 15:45:00,132.71,210.109,62.52,32.641 +2020-01-02 16:00:00,134.12,210.895,64.199,32.641 +2020-01-02 16:15:00,134.79,212.476,64.199,32.641 +2020-01-02 16:30:00,137.68,215.345,64.199,32.641 +2020-01-02 16:45:00,138.3,216.87599999999998,64.199,32.641 +2020-01-02 17:00:00,141.23,219.28900000000002,68.19800000000001,32.641 +2020-01-02 17:15:00,140.55,219.918,68.19800000000001,32.641 +2020-01-02 17:30:00,141.63,220.68200000000002,68.19800000000001,32.641 +2020-01-02 17:45:00,141.55,220.424,68.19800000000001,32.641 +2020-01-02 18:00:00,140.02,221.84400000000002,67.899,32.641 +2020-01-02 18:15:00,138.28,218.955,67.899,32.641 +2020-01-02 18:30:00,137.19,217.683,67.899,32.641 +2020-01-02 18:45:00,137.6,217.707,67.899,32.641 +2020-01-02 19:00:00,134.26,217.453,64.72399999999999,32.641 +2020-01-02 19:15:00,133.22,213.481,64.72399999999999,32.641 +2020-01-02 19:30:00,134.64,210.797,64.72399999999999,32.641 +2020-01-02 19:45:00,136.88,207.28599999999997,64.72399999999999,32.641 +2020-01-02 20:00:00,130.24,203.518,64.062,32.641 +2020-01-02 20:15:00,119.33,197.062,64.062,32.641 +2020-01-02 20:30:00,118.31,192.979,64.062,32.641 +2020-01-02 20:45:00,112.7,191.03099999999998,64.062,32.641 +2020-01-02 21:00:00,107.44,188.12900000000002,57.971000000000004,32.641 +2020-01-02 21:15:00,112.86,185.61900000000003,57.971000000000004,32.641 +2020-01-02 21:30:00,111.93,183.535,57.971000000000004,32.641 +2020-01-02 21:45:00,108.29,181.894,57.971000000000004,32.641 +2020-01-02 22:00:00,98.52,174.86,53.715,32.641 +2020-01-02 22:15:00,94.85,168.98,53.715,32.641 +2020-01-02 22:30:00,98.1,155.042,53.715,32.641 +2020-01-02 22:45:00,96.62,146.733,53.715,32.641 +2020-01-02 23:00:00,92.22,140.148,47.8,32.641 +2020-01-02 23:15:00,84.12,138.344,47.8,32.641 +2020-01-02 23:30:00,79.31,138.518,47.8,32.641 +2020-01-02 23:45:00,83.54,137.971,47.8,32.641 +2020-01-03 00:00:00,83.23,130.233,43.656000000000006,32.641 +2020-01-03 00:15:00,82.94,130.13299999999998,43.656000000000006,32.641 +2020-01-03 00:30:00,78.25,131.279,43.656000000000006,32.641 +2020-01-03 00:45:00,76.9,132.954,43.656000000000006,32.641 +2020-01-03 01:00:00,71.11,135.453,41.263000000000005,32.641 +2020-01-03 01:15:00,78.36,136.91899999999998,41.263000000000005,32.641 +2020-01-03 01:30:00,78.99,137.079,41.263000000000005,32.641 +2020-01-03 01:45:00,77.55,137.732,41.263000000000005,32.641 +2020-01-03 02:00:00,71.19,139.738,40.799,32.641 +2020-01-03 02:15:00,72.17,141.41299999999998,40.799,32.641 +2020-01-03 02:30:00,74.79,142.542,40.799,32.641 +2020-01-03 02:45:00,78.06,144.681,40.799,32.641 +2020-01-03 03:00:00,76.51,146.311,41.398,32.641 +2020-01-03 03:15:00,74.89,148.47,41.398,32.641 +2020-01-03 03:30:00,79.0,150.364,41.398,32.641 +2020-01-03 03:45:00,77.85,152.072,41.398,32.641 +2020-01-03 04:00:00,73.23,164.549,42.38,32.641 +2020-01-03 04:15:00,74.31,176.43200000000002,42.38,32.641 +2020-01-03 04:30:00,74.97,179.67700000000002,42.38,32.641 +2020-01-03 04:45:00,78.19,181.39700000000002,42.38,32.641 +2020-01-03 05:00:00,82.49,215.25400000000002,46.181000000000004,32.641 +2020-01-03 05:15:00,85.31,245.535,46.181000000000004,32.641 +2020-01-03 05:30:00,88.01,242.33599999999998,46.181000000000004,32.641 +2020-01-03 05:45:00,93.72,235.011,46.181000000000004,32.641 +2020-01-03 06:00:00,101.82,232.016,59.33,32.641 +2020-01-03 06:15:00,106.19,236.21900000000002,59.33,32.641 +2020-01-03 06:30:00,110.85,238.166,59.33,32.641 +2020-01-03 06:45:00,115.45,244.024,59.33,32.641 +2020-01-03 07:00:00,122.71,241.924,72.454,32.641 +2020-01-03 07:15:00,123.17,248.451,72.454,32.641 +2020-01-03 07:30:00,125.55,251.585,72.454,32.641 +2020-01-03 07:45:00,128.9,252.31099999999998,72.454,32.641 +2020-01-03 08:00:00,130.12,249.83599999999998,67.175,32.641 +2020-01-03 08:15:00,128.89,249.597,67.175,32.641 +2020-01-03 08:30:00,128.21,248.743,67.175,32.641 +2020-01-03 08:45:00,128.6,244.05900000000003,67.175,32.641 +2020-01-03 09:00:00,128.71,238.011,65.365,32.641 +2020-01-03 09:15:00,131.57,235.324,65.365,32.641 +2020-01-03 09:30:00,133.77,232.672,65.365,32.641 +2020-01-03 09:45:00,136.47,229.359,65.365,32.641 +2020-01-03 10:00:00,135.77,222.843,63.95,32.641 +2020-01-03 10:15:00,137.43,219.39,63.95,32.641 +2020-01-03 10:30:00,136.81,215.986,63.95,32.641 +2020-01-03 10:45:00,137.07,213.808,63.95,32.641 +2020-01-03 11:00:00,136.69,211.75,63.92100000000001,32.641 +2020-01-03 11:15:00,137.82,209.576,63.92100000000001,32.641 +2020-01-03 11:30:00,137.25,209.956,63.92100000000001,32.641 +2020-01-03 11:45:00,136.75,208.86900000000003,63.92100000000001,32.641 +2020-01-03 12:00:00,135.72,204.88400000000001,60.79600000000001,32.641 +2020-01-03 12:15:00,135.16,202.011,60.79600000000001,32.641 +2020-01-03 12:30:00,133.9,202.153,60.79600000000001,32.641 +2020-01-03 12:45:00,134.58,203.607,60.79600000000001,32.641 +2020-01-03 13:00:00,130.87,202.96200000000002,59.393,32.641 +2020-01-03 13:15:00,131.78,203.36,59.393,32.641 +2020-01-03 13:30:00,130.35,203.00799999999998,59.393,32.641 +2020-01-03 13:45:00,131.11,202.855,59.393,32.641 +2020-01-03 14:00:00,130.67,200.58700000000002,57.943999999999996,32.641 +2020-01-03 14:15:00,130.14,201.0,57.943999999999996,32.641 +2020-01-03 14:30:00,129.02,202.34799999999998,57.943999999999996,32.641 +2020-01-03 14:45:00,129.81,202.954,57.943999999999996,32.641 +2020-01-03 15:00:00,130.32,203.59099999999998,60.153999999999996,32.641 +2020-01-03 15:15:00,129.09,203.75099999999998,60.153999999999996,32.641 +2020-01-03 15:30:00,128.33,204.56099999999998,60.153999999999996,32.641 +2020-01-03 15:45:00,128.17,206.41299999999998,60.153999999999996,32.641 +2020-01-03 16:00:00,131.49,205.998,62.933,32.641 +2020-01-03 16:15:00,133.11,207.87400000000002,62.933,32.641 +2020-01-03 16:30:00,134.7,210.864,62.933,32.641 +2020-01-03 16:45:00,133.81,212.332,62.933,32.641 +2020-01-03 17:00:00,137.76,214.84900000000002,68.657,32.641 +2020-01-03 17:15:00,136.16,215.076,68.657,32.641 +2020-01-03 17:30:00,140.54,215.513,68.657,32.641 +2020-01-03 17:45:00,137.57,215.02900000000002,68.657,32.641 +2020-01-03 18:00:00,136.81,217.225,67.111,32.641 +2020-01-03 18:15:00,136.99,213.96900000000002,67.111,32.641 +2020-01-03 18:30:00,135.65,213.13,67.111,32.641 +2020-01-03 18:45:00,136.2,213.142,67.111,32.641 +2020-01-03 19:00:00,132.87,213.791,62.434,32.641 +2020-01-03 19:15:00,131.87,211.248,62.434,32.641 +2020-01-03 19:30:00,132.37,208.11900000000003,62.434,32.641 +2020-01-03 19:45:00,134.77,204.162,62.434,32.641 +2020-01-03 20:00:00,128.17,200.44400000000002,61.763000000000005,32.641 +2020-01-03 20:15:00,120.91,193.95,61.763000000000005,32.641 +2020-01-03 20:30:00,117.84,189.834,61.763000000000005,32.641 +2020-01-03 20:45:00,113.28,188.551,61.763000000000005,32.641 +2020-01-03 21:00:00,110.73,186.10299999999998,56.785,32.641 +2020-01-03 21:15:00,113.2,183.95,56.785,32.641 +2020-01-03 21:30:00,110.05,181.924,56.785,32.641 +2020-01-03 21:45:00,104.45,180.86900000000003,56.785,32.641 +2020-01-03 22:00:00,98.56,174.898,52.693000000000005,32.641 +2020-01-03 22:15:00,102.19,168.90400000000002,52.693000000000005,32.641 +2020-01-03 22:30:00,100.03,161.57,52.693000000000005,32.641 +2020-01-03 22:45:00,96.9,157.013,52.693000000000005,32.641 +2020-01-03 23:00:00,89.83,149.83100000000002,45.443999999999996,32.641 +2020-01-03 23:15:00,91.91,146.02700000000002,45.443999999999996,32.641 +2020-01-03 23:30:00,88.18,144.751,45.443999999999996,32.641 +2020-01-03 23:45:00,85.54,143.495,45.443999999999996,32.641 +2020-01-04 00:00:00,82.22,127.199,44.738,32.459 +2020-01-04 00:15:00,83.18,122.48899999999999,44.738,32.459 +2020-01-04 00:30:00,81.48,125.102,44.738,32.459 +2020-01-04 00:45:00,73.34,127.589,44.738,32.459 +2020-01-04 01:00:00,72.21,130.76,40.303000000000004,32.459 +2020-01-04 01:15:00,77.36,131.101,40.303000000000004,32.459 +2020-01-04 01:30:00,78.27,130.754,40.303000000000004,32.459 +2020-01-04 01:45:00,75.98,131.06799999999998,40.303000000000004,32.459 +2020-01-04 02:00:00,69.25,133.903,38.61,32.459 +2020-01-04 02:15:00,74.64,135.233,38.61,32.459 +2020-01-04 02:30:00,74.85,135.217,38.61,32.459 +2020-01-04 02:45:00,73.61,137.407,38.61,32.459 +2020-01-04 03:00:00,68.1,139.812,37.554,32.459 +2020-01-04 03:15:00,73.13,140.717,37.554,32.459 +2020-01-04 03:30:00,75.4,140.84799999999998,37.554,32.459 +2020-01-04 03:45:00,70.56,142.566,37.554,32.459 +2020-01-04 04:00:00,67.07,150.611,37.176,32.459 +2020-01-04 04:15:00,67.97,159.77100000000002,37.176,32.459 +2020-01-04 04:30:00,67.71,160.769,37.176,32.459 +2020-01-04 04:45:00,68.5,161.921,37.176,32.459 +2020-01-04 05:00:00,67.87,178.856,36.893,32.459 +2020-01-04 05:15:00,67.75,189.139,36.893,32.459 +2020-01-04 05:30:00,66.1,186.215,36.893,32.459 +2020-01-04 05:45:00,67.33,184.52,36.893,32.459 +2020-01-04 06:00:00,69.91,201.26,37.803000000000004,32.459 +2020-01-04 06:15:00,69.0,222.77200000000002,37.803000000000004,32.459 +2020-01-04 06:30:00,69.5,219.078,37.803000000000004,32.459 +2020-01-04 06:45:00,71.88,215.377,37.803000000000004,32.459 +2020-01-04 07:00:00,76.68,209.287,41.086999999999996,32.459 +2020-01-04 07:15:00,77.71,214.53599999999997,41.086999999999996,32.459 +2020-01-04 07:30:00,80.15,220.502,41.086999999999996,32.459 +2020-01-04 07:45:00,83.14,225.497,41.086999999999996,32.459 +2020-01-04 08:00:00,85.43,227.446,48.222,32.459 +2020-01-04 08:15:00,85.69,231.175,48.222,32.459 +2020-01-04 08:30:00,89.46,232.06099999999998,48.222,32.459 +2020-01-04 08:45:00,91.91,230.729,48.222,32.459 +2020-01-04 09:00:00,92.65,226.387,52.791000000000004,32.459 +2020-01-04 09:15:00,93.59,224.49200000000002,52.791000000000004,32.459 +2020-01-04 09:30:00,94.68,222.80200000000002,52.791000000000004,32.459 +2020-01-04 09:45:00,96.53,219.69,52.791000000000004,32.459 +2020-01-04 10:00:00,98.05,213.424,54.341,32.459 +2020-01-04 10:15:00,95.0,210.122,54.341,32.459 +2020-01-04 10:30:00,94.99,206.91099999999997,54.341,32.459 +2020-01-04 10:45:00,92.74,206.176,54.341,32.459 +2020-01-04 11:00:00,96.61,204.363,51.94,32.459 +2020-01-04 11:15:00,100.28,201.382,51.94,32.459 +2020-01-04 11:30:00,99.55,200.535,51.94,32.459 +2020-01-04 11:45:00,98.58,198.36700000000002,51.94,32.459 +2020-01-04 12:00:00,96.09,193.4,50.973,32.459 +2020-01-04 12:15:00,98.67,191.18099999999998,50.973,32.459 +2020-01-04 12:30:00,96.52,191.675,50.973,32.459 +2020-01-04 12:45:00,95.72,192.248,50.973,32.459 +2020-01-04 13:00:00,93.57,191.208,48.06399999999999,32.459 +2020-01-04 13:15:00,92.81,189.408,48.06399999999999,32.459 +2020-01-04 13:30:00,91.55,188.543,48.06399999999999,32.459 +2020-01-04 13:45:00,89.25,188.99,48.06399999999999,32.459 +2020-01-04 14:00:00,89.5,188.077,45.707,32.459 +2020-01-04 14:15:00,89.52,187.99,45.707,32.459 +2020-01-04 14:30:00,90.0,187.408,45.707,32.459 +2020-01-04 14:45:00,91.09,188.234,45.707,32.459 +2020-01-04 15:00:00,91.5,189.60299999999998,47.567,32.459 +2020-01-04 15:15:00,91.94,190.57,47.567,32.459 +2020-01-04 15:30:00,92.53,193.02900000000002,47.567,32.459 +2020-01-04 15:45:00,94.28,194.947,47.567,32.459 +2020-01-04 16:00:00,96.74,193.149,52.031000000000006,32.459 +2020-01-04 16:15:00,95.42,196.053,52.031000000000006,32.459 +2020-01-04 16:30:00,100.94,198.982,52.031000000000006,32.459 +2020-01-04 16:45:00,102.46,201.418,52.031000000000006,32.459 +2020-01-04 17:00:00,105.49,203.416,58.218999999999994,32.459 +2020-01-04 17:15:00,104.81,205.575,58.218999999999994,32.459 +2020-01-04 17:30:00,107.13,205.94299999999998,58.218999999999994,32.459 +2020-01-04 17:45:00,108.26,205.013,58.218999999999994,32.459 +2020-01-04 18:00:00,110.16,206.662,57.65,32.459 +2020-01-04 18:15:00,109.91,205.239,57.65,32.459 +2020-01-04 18:30:00,109.66,205.75799999999998,57.65,32.459 +2020-01-04 18:45:00,109.05,202.394,57.65,32.459 +2020-01-04 19:00:00,107.78,204.105,51.261,32.459 +2020-01-04 19:15:00,103.06,201.082,51.261,32.459 +2020-01-04 19:30:00,101.89,198.71599999999998,51.261,32.459 +2020-01-04 19:45:00,102.31,194.47799999999998,51.261,32.459 +2020-01-04 20:00:00,94.91,193.0,44.068000000000005,32.459 +2020-01-04 20:15:00,93.02,188.81400000000002,44.068000000000005,32.459 +2020-01-04 20:30:00,88.29,184.359,44.068000000000005,32.459 +2020-01-04 20:45:00,87.9,182.563,44.068000000000005,32.459 +2020-01-04 21:00:00,84.51,182.58599999999998,38.861,32.459 +2020-01-04 21:15:00,83.28,180.90400000000002,38.861,32.459 +2020-01-04 21:30:00,82.28,180.18200000000002,38.861,32.459 +2020-01-04 21:45:00,81.25,178.737,38.861,32.459 +2020-01-04 22:00:00,77.7,174.207,39.485,32.459 +2020-01-04 22:15:00,77.25,170.87,39.485,32.459 +2020-01-04 22:30:00,74.24,170.22299999999998,39.485,32.459 +2020-01-04 22:45:00,73.18,167.65200000000002,39.485,32.459 +2020-01-04 23:00:00,68.36,163.07399999999998,32.027,32.459 +2020-01-04 23:15:00,68.86,157.509,32.027,32.459 +2020-01-04 23:30:00,66.13,154.256,32.027,32.459 +2020-01-04 23:45:00,65.23,150.387,32.027,32.459 +2020-01-05 00:00:00,61.83,127.708,26.96,32.459 +2020-01-05 00:15:00,59.65,122.706,26.96,32.459 +2020-01-05 00:30:00,56.73,124.90899999999999,26.96,32.459 +2020-01-05 00:45:00,58.04,128.141,26.96,32.459 +2020-01-05 01:00:00,54.64,131.15200000000002,24.295,32.459 +2020-01-05 01:15:00,53.12,132.619,24.295,32.459 +2020-01-05 01:30:00,54.89,132.84799999999998,24.295,32.459 +2020-01-05 01:45:00,55.39,132.83700000000002,24.295,32.459 +2020-01-05 02:00:00,52.81,134.87,24.268,32.459 +2020-01-05 02:15:00,53.85,135.224,24.268,32.459 +2020-01-05 02:30:00,53.36,136.131,24.268,32.459 +2020-01-05 02:45:00,53.22,138.842,24.268,32.459 +2020-01-05 03:00:00,51.24,141.54,23.373,32.459 +2020-01-05 03:15:00,52.77,141.878,23.373,32.459 +2020-01-05 03:30:00,53.33,143.584,23.373,32.459 +2020-01-05 03:45:00,52.95,145.284,23.373,32.459 +2020-01-05 04:00:00,53.02,153.043,23.874000000000002,32.459 +2020-01-05 04:15:00,54.36,161.123,23.874000000000002,32.459 +2020-01-05 04:30:00,55.19,162.089,23.874000000000002,32.459 +2020-01-05 04:45:00,54.83,163.567,23.874000000000002,32.459 +2020-01-05 05:00:00,56.2,176.618,24.871,32.459 +2020-01-05 05:15:00,57.61,184.28099999999998,24.871,32.459 +2020-01-05 05:30:00,58.05,181.218,24.871,32.459 +2020-01-05 05:45:00,58.68,179.833,24.871,32.459 +2020-01-05 06:00:00,58.97,196.65900000000002,23.84,32.459 +2020-01-05 06:15:00,60.37,216.24099999999999,23.84,32.459 +2020-01-05 06:30:00,60.21,211.415,23.84,32.459 +2020-01-05 06:45:00,60.83,206.671,23.84,32.459 +2020-01-05 07:00:00,63.06,203.24599999999998,27.430999999999997,32.459 +2020-01-05 07:15:00,64.35,207.676,27.430999999999997,32.459 +2020-01-05 07:30:00,66.38,212.19400000000002,27.430999999999997,32.459 +2020-01-05 07:45:00,68.85,216.345,27.430999999999997,32.459 +2020-01-05 08:00:00,71.87,220.25099999999998,33.891999999999996,32.459 +2020-01-05 08:15:00,72.96,223.791,33.891999999999996,32.459 +2020-01-05 08:30:00,75.42,226.391,33.891999999999996,32.459 +2020-01-05 08:45:00,77.7,227.232,33.891999999999996,32.459 +2020-01-05 09:00:00,79.34,222.44799999999998,37.571,32.459 +2020-01-05 09:15:00,79.42,221.187,37.571,32.459 +2020-01-05 09:30:00,80.81,219.317,37.571,32.459 +2020-01-05 09:45:00,82.3,216.00799999999998,37.571,32.459 +2020-01-05 10:00:00,83.72,212.428,40.594,32.459 +2020-01-05 10:15:00,84.02,209.692,40.594,32.459 +2020-01-05 10:30:00,86.92,207.107,40.594,32.459 +2020-01-05 10:45:00,89.06,204.26,40.594,32.459 +2020-01-05 11:00:00,91.79,203.445,44.133,32.459 +2020-01-05 11:15:00,98.14,200.63099999999997,44.133,32.459 +2020-01-05 11:30:00,99.24,198.81799999999998,44.133,32.459 +2020-01-05 11:45:00,100.24,197.28900000000002,44.133,32.459 +2020-01-05 12:00:00,99.8,191.658,41.198,32.459 +2020-01-05 12:15:00,96.2,191.544,41.198,32.459 +2020-01-05 12:30:00,92.34,190.454,41.198,32.459 +2020-01-05 12:45:00,91.8,190.02599999999998,41.198,32.459 +2020-01-05 13:00:00,86.98,188.218,37.014,32.459 +2020-01-05 13:15:00,86.22,189.688,37.014,32.459 +2020-01-05 13:30:00,85.32,188.65200000000002,37.014,32.459 +2020-01-05 13:45:00,85.43,188.31799999999998,37.014,32.459 +2020-01-05 14:00:00,83.74,187.613,34.934,32.459 +2020-01-05 14:15:00,83.86,188.78400000000002,34.934,32.459 +2020-01-05 14:30:00,84.5,189.609,34.934,32.459 +2020-01-05 14:45:00,84.63,190.06799999999998,34.934,32.459 +2020-01-05 15:00:00,85.54,189.8,34.588,32.459 +2020-01-05 15:15:00,85.23,191.607,34.588,32.459 +2020-01-05 15:30:00,84.85,194.704,34.588,32.459 +2020-01-05 15:45:00,85.8,197.34099999999998,34.588,32.459 +2020-01-05 16:00:00,87.47,197.61700000000002,37.874,32.459 +2020-01-05 16:15:00,90.49,199.547,37.874,32.459 +2020-01-05 16:30:00,92.21,202.707,37.874,32.459 +2020-01-05 16:45:00,93.29,205.295,37.874,32.459 +2020-01-05 17:00:00,98.04,207.237,47.303999999999995,32.459 +2020-01-05 17:15:00,97.7,208.99900000000002,47.303999999999995,32.459 +2020-01-05 17:30:00,99.31,209.662,47.303999999999995,32.459 +2020-01-05 17:45:00,100.34,211.15,47.303999999999995,32.459 +2020-01-05 18:00:00,102.25,212.196,48.879,32.459 +2020-01-05 18:15:00,100.54,212.206,48.879,32.459 +2020-01-05 18:30:00,100.22,210.523,48.879,32.459 +2020-01-05 18:45:00,99.47,209.142,48.879,32.459 +2020-01-05 19:00:00,99.14,210.358,44.826,32.459 +2020-01-05 19:15:00,98.01,208.015,44.826,32.459 +2020-01-05 19:30:00,96.07,205.486,44.826,32.459 +2020-01-05 19:45:00,94.14,202.801,44.826,32.459 +2020-01-05 20:00:00,93.2,201.287,40.154,32.459 +2020-01-05 20:15:00,91.82,198.157,40.154,32.459 +2020-01-05 20:30:00,90.16,194.97099999999998,40.154,32.459 +2020-01-05 20:45:00,88.47,191.995,40.154,32.459 +2020-01-05 21:00:00,84.27,189.24099999999999,36.549,32.459 +2020-01-05 21:15:00,84.17,186.889,36.549,32.459 +2020-01-05 21:30:00,84.43,186.51,36.549,32.459 +2020-01-05 21:45:00,85.26,185.207,36.549,32.459 +2020-01-05 22:00:00,83.7,179.30900000000003,37.663000000000004,32.459 +2020-01-05 22:15:00,82.23,175.25599999999997,37.663000000000004,32.459 +2020-01-05 22:30:00,80.43,171.27900000000002,37.663000000000004,32.459 +2020-01-05 22:45:00,79.58,167.87099999999998,37.663000000000004,32.459 +2020-01-05 23:00:00,75.67,160.305,31.945,32.459 +2020-01-05 23:15:00,77.15,156.664,31.945,32.459 +2020-01-05 23:30:00,74.59,154.317,31.945,32.459 +2020-01-05 23:45:00,73.41,151.388,31.945,32.459 +2020-01-06 00:00:00,70.86,132.295,31.533,32.641 +2020-01-06 00:15:00,69.7,130.435,31.533,32.641 +2020-01-06 00:30:00,69.11,132.78799999999998,31.533,32.641 +2020-01-06 00:45:00,68.33,135.447,31.533,32.641 +2020-01-06 01:00:00,67.32,138.469,30.56,32.641 +2020-01-06 01:15:00,67.69,139.35399999999998,30.56,32.641 +2020-01-06 01:30:00,67.44,139.616,30.56,32.641 +2020-01-06 01:45:00,67.1,139.725,30.56,32.641 +2020-01-06 02:00:00,67.52,141.722,29.55,32.641 +2020-01-06 02:15:00,67.29,143.738,29.55,32.641 +2020-01-06 02:30:00,67.05,145.003,29.55,32.641 +2020-01-06 02:45:00,67.2,147.05200000000002,29.55,32.641 +2020-01-06 03:00:00,68.35,151.1,27.059,32.641 +2020-01-06 03:15:00,68.78,153.201,27.059,32.641 +2020-01-06 03:30:00,69.82,154.562,27.059,32.641 +2020-01-06 03:45:00,69.92,155.701,27.059,32.641 +2020-01-06 04:00:00,67.73,167.963,28.384,32.641 +2020-01-06 04:15:00,71.53,180.334,28.384,32.641 +2020-01-06 04:30:00,73.1,183.72799999999998,28.384,32.641 +2020-01-06 04:45:00,76.51,185.351,28.384,32.641 +2020-01-06 05:00:00,81.52,215.055,35.915,32.641 +2020-01-06 05:15:00,84.58,243.92,35.915,32.641 +2020-01-06 05:30:00,89.42,241.291,35.915,32.641 +2020-01-06 05:45:00,94.89,233.987,35.915,32.641 +2020-01-06 06:00:00,103.99,232.019,56.18,32.641 +2020-01-06 06:15:00,110.77,236.107,56.18,32.641 +2020-01-06 06:30:00,116.07,239.75,56.18,32.641 +2020-01-06 06:45:00,119.86,244.24,56.18,32.641 +2020-01-06 07:00:00,126.05,243.37599999999998,70.877,32.641 +2020-01-06 07:15:00,129.26,248.982,70.877,32.641 +2020-01-06 07:30:00,131.09,252.704,70.877,32.641 +2020-01-06 07:45:00,132.63,254.012,70.877,32.641 +2020-01-06 08:00:00,136.79,252.68599999999998,65.65,32.641 +2020-01-06 08:15:00,137.86,253.967,65.65,32.641 +2020-01-06 08:30:00,137.45,252.168,65.65,32.641 +2020-01-06 08:45:00,136.78,249.345,65.65,32.641 +2020-01-06 09:00:00,137.29,243.503,62.037,32.641 +2020-01-06 09:15:00,138.96,238.595,62.037,32.641 +2020-01-06 09:30:00,139.49,235.72099999999998,62.037,32.641 +2020-01-06 09:45:00,139.62,232.888,62.037,32.641 +2020-01-06 10:00:00,140.37,228.12599999999998,60.409,32.641 +2020-01-06 10:15:00,140.63,225.046,60.409,32.641 +2020-01-06 10:30:00,138.89,221.533,60.409,32.641 +2020-01-06 10:45:00,138.54,219.613,60.409,32.641 +2020-01-06 11:00:00,137.59,215.89700000000002,60.211999999999996,32.641 +2020-01-06 11:15:00,138.33,215.011,60.211999999999996,32.641 +2020-01-06 11:30:00,139.52,214.644,60.211999999999996,32.641 +2020-01-06 11:45:00,136.44,212.643,60.211999999999996,32.641 +2020-01-06 12:00:00,137.83,209.015,57.733000000000004,32.641 +2020-01-06 12:15:00,134.41,208.912,57.733000000000004,32.641 +2020-01-06 12:30:00,133.37,208.162,57.733000000000004,32.641 +2020-01-06 12:45:00,131.24,209.385,57.733000000000004,32.641 +2020-01-06 13:00:00,130.49,208.141,58.695,32.641 +2020-01-06 13:15:00,127.09,208.16099999999997,58.695,32.641 +2020-01-06 13:30:00,123.67,206.52900000000002,58.695,32.641 +2020-01-06 13:45:00,127.05,206.16299999999998,58.695,32.641 +2020-01-06 14:00:00,129.68,204.84099999999998,59.505,32.641 +2020-01-06 14:15:00,129.73,205.275,59.505,32.641 +2020-01-06 14:30:00,130.81,205.53400000000002,59.505,32.641 +2020-01-06 14:45:00,131.3,205.83599999999998,59.505,32.641 +2020-01-06 15:00:00,131.31,207.50599999999997,59.946000000000005,32.641 +2020-01-06 15:15:00,131.43,207.78400000000002,59.946000000000005,32.641 +2020-01-06 15:30:00,130.17,209.935,59.946000000000005,32.641 +2020-01-06 15:45:00,131.03,212.113,59.946000000000005,32.641 +2020-01-06 16:00:00,134.41,212.476,61.766999999999996,32.641 +2020-01-06 16:15:00,134.55,213.59400000000002,61.766999999999996,32.641 +2020-01-06 16:30:00,140.59,215.763,61.766999999999996,32.641 +2020-01-06 16:45:00,140.87,217.083,61.766999999999996,32.641 +2020-01-06 17:00:00,140.83,218.85299999999998,67.85600000000001,32.641 +2020-01-06 17:15:00,140.84,219.60299999999998,67.85600000000001,32.641 +2020-01-06 17:30:00,142.16,219.729,67.85600000000001,32.641 +2020-01-06 17:45:00,140.41,219.657,67.85600000000001,32.641 +2020-01-06 18:00:00,137.78,221.21599999999998,64.564,32.641 +2020-01-06 18:15:00,136.67,218.96900000000002,64.564,32.641 +2020-01-06 18:30:00,133.62,218.032,64.564,32.641 +2020-01-06 18:45:00,134.52,217.257,64.564,32.641 +2020-01-06 19:00:00,131.97,216.72299999999998,58.536,32.641 +2020-01-06 19:15:00,129.61,213.06,58.536,32.641 +2020-01-06 19:30:00,131.14,211.083,58.536,32.641 +2020-01-06 19:45:00,133.72,207.54,58.536,32.641 +2020-01-06 20:00:00,128.55,203.563,59.888999999999996,32.641 +2020-01-06 20:15:00,117.39,197.7,59.888999999999996,32.641 +2020-01-06 20:30:00,115.28,192.479,59.888999999999996,32.641 +2020-01-06 20:45:00,111.64,191.25099999999998,59.888999999999996,32.641 +2020-01-06 21:00:00,108.63,189.085,52.652,32.641 +2020-01-06 21:15:00,110.16,185.43099999999998,52.652,32.641 +2020-01-06 21:30:00,111.85,184.145,52.652,32.641 +2020-01-06 21:45:00,110.02,182.327,52.652,32.641 +2020-01-06 22:00:00,103.97,173.459,46.17,32.641 +2020-01-06 22:15:00,101.93,167.895,46.17,32.641 +2020-01-06 22:30:00,100.68,153.954,46.17,32.641 +2020-01-06 22:45:00,97.59,145.379,46.17,32.641 +2020-01-06 23:00:00,90.0,138.627,36.281,32.641 +2020-01-06 23:15:00,87.29,137.92700000000002,36.281,32.641 +2020-01-06 23:30:00,83.89,138.554,36.281,32.641 +2020-01-06 23:45:00,88.74,138.484,36.281,32.641 +2020-01-07 00:00:00,86.26,131.85,38.821999999999996,32.641 +2020-01-07 00:15:00,81.91,131.452,38.821999999999996,32.641 +2020-01-07 00:30:00,81.41,132.71200000000002,38.821999999999996,32.641 +2020-01-07 00:45:00,83.42,134.23,38.821999999999996,32.641 +2020-01-07 01:00:00,82.73,137.08700000000002,36.936,32.641 +2020-01-07 01:15:00,80.08,137.469,36.936,32.641 +2020-01-07 01:30:00,78.9,137.929,36.936,32.641 +2020-01-07 01:45:00,81.69,138.401,36.936,32.641 +2020-01-07 02:00:00,81.57,140.446,34.42,32.641 +2020-01-07 02:15:00,80.71,142.245,34.42,32.641 +2020-01-07 02:30:00,78.77,142.898,34.42,32.641 +2020-01-07 02:45:00,81.99,144.93200000000002,34.42,32.641 +2020-01-07 03:00:00,82.0,147.695,33.585,32.641 +2020-01-07 03:15:00,79.21,148.804,33.585,32.641 +2020-01-07 03:30:00,82.09,150.697,33.585,32.641 +2020-01-07 03:45:00,82.69,152.15,33.585,32.641 +2020-01-07 04:00:00,77.46,164.275,35.622,32.641 +2020-01-07 04:15:00,77.37,176.252,35.622,32.641 +2020-01-07 04:30:00,77.18,179.312,35.622,32.641 +2020-01-07 04:45:00,80.05,182.22,35.622,32.641 +2020-01-07 05:00:00,84.68,217.25599999999997,40.599000000000004,32.641 +2020-01-07 05:15:00,88.21,245.796,40.599000000000004,32.641 +2020-01-07 05:30:00,91.8,241.465,40.599000000000004,32.641 +2020-01-07 05:45:00,96.21,234.25900000000001,40.599000000000004,32.641 +2020-01-07 06:00:00,104.74,230.912,55.203,32.641 +2020-01-07 06:15:00,111.62,236.783,55.203,32.641 +2020-01-07 06:30:00,115.53,239.761,55.203,32.641 +2020-01-07 06:45:00,119.58,243.951,55.203,32.641 +2020-01-07 07:00:00,121.42,242.885,69.029,32.641 +2020-01-07 07:15:00,129.39,248.329,69.029,32.641 +2020-01-07 07:30:00,132.48,251.43200000000002,69.029,32.641 +2020-01-07 07:45:00,134.56,253.047,69.029,32.641 +2020-01-07 08:00:00,136.12,251.842,65.85300000000001,32.641 +2020-01-07 08:15:00,135.08,252.024,65.85300000000001,32.641 +2020-01-07 08:30:00,135.3,249.989,65.85300000000001,32.641 +2020-01-07 08:45:00,134.8,246.93400000000003,65.85300000000001,32.641 +2020-01-07 09:00:00,135.59,240.132,61.566,32.641 +2020-01-07 09:15:00,137.31,236.993,61.566,32.641 +2020-01-07 09:30:00,139.13,234.81900000000002,61.566,32.641 +2020-01-07 09:45:00,138.88,231.628,61.566,32.641 +2020-01-07 10:00:00,138.47,226.34,61.244,32.641 +2020-01-07 10:15:00,136.8,222.109,61.244,32.641 +2020-01-07 10:30:00,135.87,218.78,61.244,32.641 +2020-01-07 10:45:00,136.63,217.085,61.244,32.641 +2020-01-07 11:00:00,136.36,214.982,61.16,32.641 +2020-01-07 11:15:00,139.62,213.697,61.16,32.641 +2020-01-07 11:30:00,138.15,212.15400000000002,61.16,32.641 +2020-01-07 11:45:00,134.33,210.957,61.16,32.641 +2020-01-07 12:00:00,132.11,205.859,59.09,32.641 +2020-01-07 12:15:00,133.48,205.292,59.09,32.641 +2020-01-07 12:30:00,132.6,205.255,59.09,32.641 +2020-01-07 12:45:00,132.01,206.10299999999998,59.09,32.641 +2020-01-07 13:00:00,132.8,204.433,60.21,32.641 +2020-01-07 13:15:00,132.97,203.91299999999998,60.21,32.641 +2020-01-07 13:30:00,131.24,203.553,60.21,32.641 +2020-01-07 13:45:00,130.77,203.46599999999998,60.21,32.641 +2020-01-07 14:00:00,129.75,202.437,60.673,32.641 +2020-01-07 14:15:00,129.53,203.049,60.673,32.641 +2020-01-07 14:30:00,124.81,203.976,60.673,32.641 +2020-01-07 14:45:00,126.69,204.27,60.673,32.641 +2020-01-07 15:00:00,127.02,205.505,62.232,32.641 +2020-01-07 15:15:00,126.96,206.055,62.232,32.641 +2020-01-07 15:30:00,127.74,208.46,62.232,32.641 +2020-01-07 15:45:00,128.19,210.165,62.232,32.641 +2020-01-07 16:00:00,130.28,210.956,63.611999999999995,32.641 +2020-01-07 16:15:00,132.81,212.595,63.611999999999995,32.641 +2020-01-07 16:30:00,135.09,215.49900000000002,63.611999999999995,32.641 +2020-01-07 16:45:00,135.68,217.08900000000003,63.611999999999995,32.641 +2020-01-07 17:00:00,136.74,219.416,70.658,32.641 +2020-01-07 17:15:00,137.73,220.201,70.658,32.641 +2020-01-07 17:30:00,140.3,221.10299999999998,70.658,32.641 +2020-01-07 17:45:00,142.14,220.933,70.658,32.641 +2020-01-07 18:00:00,139.97,222.459,68.361,32.641 +2020-01-07 18:15:00,138.96,219.579,68.361,32.641 +2020-01-07 18:30:00,138.24,218.333,68.361,32.641 +2020-01-07 18:45:00,139.74,218.446,68.361,32.641 +2020-01-07 19:00:00,132.95,218.03900000000002,62.922,32.641 +2020-01-07 19:15:00,131.59,214.065,62.922,32.641 +2020-01-07 19:30:00,137.99,211.38400000000001,62.922,32.641 +2020-01-07 19:45:00,136.02,207.86,62.922,32.641 +2020-01-07 20:00:00,126.68,204.014,63.251999999999995,32.641 +2020-01-07 20:15:00,117.77,197.558,63.251999999999995,32.641 +2020-01-07 20:30:00,114.85,193.41400000000002,63.251999999999995,32.641 +2020-01-07 20:45:00,111.71,191.56,63.251999999999995,32.641 +2020-01-07 21:00:00,108.54,188.565,54.47,32.641 +2020-01-07 21:15:00,108.54,185.96599999999998,54.47,32.641 +2020-01-07 21:30:00,111.64,183.882,54.47,32.641 +2020-01-07 21:45:00,109.59,182.312,54.47,32.641 +2020-01-07 22:00:00,99.32,175.27200000000002,51.12,32.641 +2020-01-07 22:15:00,98.89,169.467,51.12,32.641 +2020-01-07 22:30:00,93.04,155.618,51.12,32.641 +2020-01-07 22:45:00,92.29,147.349,51.12,32.641 +2020-01-07 23:00:00,94.19,140.65200000000002,42.156000000000006,32.641 +2020-01-07 23:15:00,93.18,138.885,42.156000000000006,32.641 +2020-01-07 23:30:00,88.85,139.142,42.156000000000006,32.641 +2020-01-07 23:45:00,80.72,138.58700000000002,42.156000000000006,32.641 +2020-01-08 00:00:00,80.57,131.975,37.192,32.641 +2020-01-08 00:15:00,77.7,131.555,37.192,32.641 +2020-01-08 00:30:00,82.22,132.80200000000002,37.192,32.641 +2020-01-08 00:45:00,83.18,134.30200000000002,37.192,32.641 +2020-01-08 01:00:00,80.31,137.168,32.24,32.641 +2020-01-08 01:15:00,80.35,137.54,32.24,32.641 +2020-01-08 01:30:00,81.14,138.001,32.24,32.641 +2020-01-08 01:45:00,83.23,138.461,32.24,32.641 +2020-01-08 02:00:00,82.4,140.52,30.34,32.641 +2020-01-08 02:15:00,76.87,142.319,30.34,32.641 +2020-01-08 02:30:00,81.52,142.981,30.34,32.641 +2020-01-08 02:45:00,82.44,145.014,30.34,32.641 +2020-01-08 03:00:00,82.38,147.77100000000002,29.129,32.641 +2020-01-08 03:15:00,76.92,148.901,29.129,32.641 +2020-01-08 03:30:00,80.45,150.79399999999998,29.129,32.641 +2020-01-08 03:45:00,83.8,152.255,29.129,32.641 +2020-01-08 04:00:00,82.13,164.354,30.075,32.641 +2020-01-08 04:15:00,80.51,176.32,30.075,32.641 +2020-01-08 04:30:00,79.95,179.37900000000002,30.075,32.641 +2020-01-08 04:45:00,82.2,182.28,30.075,32.641 +2020-01-08 05:00:00,86.19,217.273,35.684,32.641 +2020-01-08 05:15:00,86.69,245.778,35.684,32.641 +2020-01-08 05:30:00,92.91,241.455,35.684,32.641 +2020-01-08 05:45:00,97.02,234.27,35.684,32.641 +2020-01-08 06:00:00,105.5,230.947,51.49,32.641 +2020-01-08 06:15:00,111.76,236.826,51.49,32.641 +2020-01-08 06:30:00,116.68,239.821,51.49,32.641 +2020-01-08 06:45:00,121.17,244.044,51.49,32.641 +2020-01-08 07:00:00,125.64,242.99099999999999,68.242,32.641 +2020-01-08 07:15:00,129.08,248.426,68.242,32.641 +2020-01-08 07:30:00,132.63,251.513,68.242,32.641 +2020-01-08 07:45:00,135.49,253.108,68.242,32.641 +2020-01-08 08:00:00,134.07,251.9,63.619,32.641 +2020-01-08 08:15:00,136.8,252.067,63.619,32.641 +2020-01-08 08:30:00,136.39,250.003,63.619,32.641 +2020-01-08 08:45:00,135.84,246.926,63.619,32.641 +2020-01-08 09:00:00,136.47,240.108,61.333,32.641 +2020-01-08 09:15:00,137.33,236.976,61.333,32.641 +2020-01-08 09:30:00,137.5,234.81799999999998,61.333,32.641 +2020-01-08 09:45:00,137.48,231.62099999999998,61.333,32.641 +2020-01-08 10:00:00,134.72,226.333,59.663000000000004,32.641 +2020-01-08 10:15:00,132.18,222.105,59.663000000000004,32.641 +2020-01-08 10:30:00,130.36,218.767,59.663000000000004,32.641 +2020-01-08 10:45:00,128.21,217.075,59.663000000000004,32.641 +2020-01-08 11:00:00,129.5,214.952,59.771,32.641 +2020-01-08 11:15:00,129.67,213.665,59.771,32.641 +2020-01-08 11:30:00,130.99,212.125,59.771,32.641 +2020-01-08 11:45:00,126.7,210.93099999999998,59.771,32.641 +2020-01-08 12:00:00,126.02,205.842,58.723,32.641 +2020-01-08 12:15:00,124.09,205.294,58.723,32.641 +2020-01-08 12:30:00,124.12,205.25099999999998,58.723,32.641 +2020-01-08 12:45:00,123.16,206.101,58.723,32.641 +2020-01-08 13:00:00,121.39,204.422,58.727,32.641 +2020-01-08 13:15:00,121.87,203.891,58.727,32.641 +2020-01-08 13:30:00,120.34,203.52,58.727,32.641 +2020-01-08 13:45:00,120.5,203.426,58.727,32.641 +2020-01-08 14:00:00,121.17,202.41299999999998,59.803999999999995,32.641 +2020-01-08 14:15:00,121.88,203.019,59.803999999999995,32.641 +2020-01-08 14:30:00,122.69,203.952,59.803999999999995,32.641 +2020-01-08 14:45:00,123.96,204.262,59.803999999999995,32.641 +2020-01-08 15:00:00,124.01,205.516,61.05,32.641 +2020-01-08 15:15:00,124.57,206.048,61.05,32.641 +2020-01-08 15:30:00,121.73,208.44799999999998,61.05,32.641 +2020-01-08 15:45:00,124.55,210.141,61.05,32.641 +2020-01-08 16:00:00,126.01,210.935,64.012,32.641 +2020-01-08 16:15:00,128.33,212.583,64.012,32.641 +2020-01-08 16:30:00,131.93,215.49400000000003,64.012,32.641 +2020-01-08 16:45:00,136.83,217.09400000000002,64.012,32.641 +2020-01-08 17:00:00,138.72,219.403,66.751,32.641 +2020-01-08 17:15:00,141.55,220.22,66.751,32.641 +2020-01-08 17:30:00,141.31,221.15200000000002,66.751,32.641 +2020-01-08 17:45:00,141.4,220.99900000000002,66.751,32.641 +2020-01-08 18:00:00,140.41,222.546,65.91199999999999,32.641 +2020-01-08 18:15:00,138.77,219.672,65.91199999999999,32.641 +2020-01-08 18:30:00,137.47,218.43200000000002,65.91199999999999,32.641 +2020-01-08 18:45:00,137.79,218.563,65.91199999999999,32.641 +2020-01-08 19:00:00,135.13,218.12400000000002,63.324,32.641 +2020-01-08 19:15:00,133.38,214.15,63.324,32.641 +2020-01-08 19:30:00,137.98,211.472,63.324,32.641 +2020-01-08 19:45:00,138.21,207.94799999999998,63.324,32.641 +2020-01-08 20:00:00,130.16,204.08599999999998,63.573,32.641 +2020-01-08 20:15:00,121.28,197.63099999999997,63.573,32.641 +2020-01-08 20:30:00,114.19,193.47799999999998,63.573,32.641 +2020-01-08 20:45:00,114.46,191.641,63.573,32.641 +2020-01-08 21:00:00,111.07,188.627,55.073,32.641 +2020-01-08 21:15:00,113.94,186.01,55.073,32.641 +2020-01-08 21:30:00,114.23,183.926,55.073,32.641 +2020-01-08 21:45:00,109.84,182.37,55.073,32.641 +2020-01-08 22:00:00,103.51,175.329,51.321999999999996,32.641 +2020-01-08 22:15:00,99.13,169.53799999999998,51.321999999999996,32.641 +2020-01-08 22:30:00,95.71,155.703,51.321999999999996,32.641 +2020-01-08 22:45:00,101.09,147.44299999999998,51.321999999999996,32.641 +2020-01-08 23:00:00,97.81,140.725,42.09,32.641 +2020-01-08 23:15:00,93.34,138.966,42.09,32.641 +2020-01-08 23:30:00,90.01,139.239,42.09,32.641 +2020-01-08 23:45:00,90.33,138.685,42.09,32.641 +2020-01-09 00:00:00,89.51,132.093,38.399,32.641 +2020-01-09 00:15:00,88.2,131.65,38.399,32.641 +2020-01-09 00:30:00,82.53,132.88299999999998,38.399,32.641 +2020-01-09 00:45:00,87.05,134.366,38.399,32.641 +2020-01-09 01:00:00,85.85,137.24,36.94,32.641 +2020-01-09 01:15:00,83.8,137.60399999999998,36.94,32.641 +2020-01-09 01:30:00,79.51,138.062,36.94,32.641 +2020-01-09 01:45:00,77.14,138.512,36.94,32.641 +2020-01-09 02:00:00,82.48,140.584,35.275,32.641 +2020-01-09 02:15:00,82.45,142.384,35.275,32.641 +2020-01-09 02:30:00,84.42,143.055,35.275,32.641 +2020-01-09 02:45:00,79.29,145.088,35.275,32.641 +2020-01-09 03:00:00,83.67,147.839,35.329,32.641 +2020-01-09 03:15:00,85.27,148.987,35.329,32.641 +2020-01-09 03:30:00,86.25,150.881,35.329,32.641 +2020-01-09 03:45:00,82.05,152.349,35.329,32.641 +2020-01-09 04:00:00,85.8,164.422,36.275,32.641 +2020-01-09 04:15:00,85.66,176.38,36.275,32.641 +2020-01-09 04:30:00,84.93,179.43599999999998,36.275,32.641 +2020-01-09 04:45:00,85.52,182.334,36.275,32.641 +2020-01-09 05:00:00,88.07,217.282,42.193999999999996,32.641 +2020-01-09 05:15:00,91.08,245.75099999999998,42.193999999999996,32.641 +2020-01-09 05:30:00,94.82,241.43599999999998,42.193999999999996,32.641 +2020-01-09 05:45:00,99.1,234.27200000000002,42.193999999999996,32.641 +2020-01-09 06:00:00,105.02,230.97299999999998,56.422,32.641 +2020-01-09 06:15:00,114.36,236.86,56.422,32.641 +2020-01-09 06:30:00,118.47,239.872,56.422,32.641 +2020-01-09 06:45:00,123.49,244.12599999999998,56.422,32.641 +2020-01-09 07:00:00,128.98,243.08900000000003,72.569,32.641 +2020-01-09 07:15:00,133.18,248.513,72.569,32.641 +2020-01-09 07:30:00,135.84,251.582,72.569,32.641 +2020-01-09 07:45:00,136.0,253.157,72.569,32.641 +2020-01-09 08:00:00,136.35,251.945,67.704,32.641 +2020-01-09 08:15:00,136.41,252.09599999999998,67.704,32.641 +2020-01-09 08:30:00,136.61,250.002,67.704,32.641 +2020-01-09 08:45:00,135.57,246.90599999999998,67.704,32.641 +2020-01-09 09:00:00,134.91,240.072,63.434,32.641 +2020-01-09 09:15:00,135.13,236.947,63.434,32.641 +2020-01-09 09:30:00,132.82,234.805,63.434,32.641 +2020-01-09 09:45:00,134.04,231.602,63.434,32.641 +2020-01-09 10:00:00,133.79,226.315,61.88399999999999,32.641 +2020-01-09 10:15:00,132.44,222.09,61.88399999999999,32.641 +2020-01-09 10:30:00,131.18,218.74200000000002,61.88399999999999,32.641 +2020-01-09 10:45:00,129.86,217.054,61.88399999999999,32.641 +2020-01-09 11:00:00,129.31,214.91099999999997,61.481,32.641 +2020-01-09 11:15:00,129.73,213.62400000000002,61.481,32.641 +2020-01-09 11:30:00,129.62,212.08599999999998,61.481,32.641 +2020-01-09 11:45:00,128.23,210.895,61.481,32.641 +2020-01-09 12:00:00,128.19,205.817,59.527,32.641 +2020-01-09 12:15:00,126.99,205.28599999999997,59.527,32.641 +2020-01-09 12:30:00,126.29,205.238,59.527,32.641 +2020-01-09 12:45:00,125.88,206.08900000000003,59.527,32.641 +2020-01-09 13:00:00,125.21,204.40099999999998,58.794,32.641 +2020-01-09 13:15:00,125.26,203.859,58.794,32.641 +2020-01-09 13:30:00,120.6,203.476,58.794,32.641 +2020-01-09 13:45:00,123.97,203.37599999999998,58.794,32.641 +2020-01-09 14:00:00,127.41,202.38099999999997,60.32,32.641 +2020-01-09 14:15:00,128.59,202.979,60.32,32.641 +2020-01-09 14:30:00,127.7,203.919,60.32,32.641 +2020-01-09 14:45:00,128.7,204.245,60.32,32.641 +2020-01-09 15:00:00,129.88,205.519,62.52,32.641 +2020-01-09 15:15:00,129.65,206.03,62.52,32.641 +2020-01-09 15:30:00,128.81,208.423,62.52,32.641 +2020-01-09 15:45:00,129.3,210.108,62.52,32.641 +2020-01-09 16:00:00,130.58,210.90200000000002,64.199,32.641 +2020-01-09 16:15:00,132.89,212.55900000000003,64.199,32.641 +2020-01-09 16:30:00,135.51,215.477,64.199,32.641 +2020-01-09 16:45:00,138.94,217.085,64.199,32.641 +2020-01-09 17:00:00,140.54,219.38,68.19800000000001,32.641 +2020-01-09 17:15:00,141.52,220.227,68.19800000000001,32.641 +2020-01-09 17:30:00,142.06,221.187,68.19800000000001,32.641 +2020-01-09 17:45:00,142.3,221.053,68.19800000000001,32.641 +2020-01-09 18:00:00,140.27,222.622,67.899,32.641 +2020-01-09 18:15:00,139.4,219.75400000000002,67.899,32.641 +2020-01-09 18:30:00,137.51,218.52,67.899,32.641 +2020-01-09 18:45:00,137.88,218.67,67.899,32.641 +2020-01-09 19:00:00,135.66,218.199,64.72399999999999,32.641 +2020-01-09 19:15:00,133.63,214.226,64.72399999999999,32.641 +2020-01-09 19:30:00,136.63,211.551,64.72399999999999,32.641 +2020-01-09 19:45:00,136.33,208.028,64.72399999999999,32.641 +2020-01-09 20:00:00,130.0,204.149,64.062,32.641 +2020-01-09 20:15:00,120.43,197.695,64.062,32.641 +2020-01-09 20:30:00,115.58,193.532,64.062,32.641 +2020-01-09 20:45:00,114.76,191.713,64.062,32.641 +2020-01-09 21:00:00,111.28,188.68,57.971000000000004,32.641 +2020-01-09 21:15:00,114.3,186.046,57.971000000000004,32.641 +2020-01-09 21:30:00,112.74,183.96200000000002,57.971000000000004,32.641 +2020-01-09 21:45:00,107.12,182.422,57.971000000000004,32.641 +2020-01-09 22:00:00,103.37,175.37599999999998,53.715,32.641 +2020-01-09 22:15:00,97.81,169.60299999999998,53.715,32.641 +2020-01-09 22:30:00,96.27,155.78,53.715,32.641 +2020-01-09 22:45:00,99.06,147.52700000000002,53.715,32.641 +2020-01-09 23:00:00,96.54,140.78799999999998,47.8,32.641 +2020-01-09 23:15:00,90.59,139.037,47.8,32.641 +2020-01-09 23:30:00,83.45,139.326,47.8,32.641 +2020-01-09 23:45:00,85.64,138.773,47.8,32.641 +2020-01-10 00:00:00,84.68,131.172,43.656000000000006,32.641 +2020-01-10 00:15:00,87.29,130.907,43.656000000000006,32.641 +2020-01-10 00:30:00,86.37,131.95600000000002,43.656000000000006,32.641 +2020-01-10 00:45:00,81.92,133.513,43.656000000000006,32.641 +2020-01-10 01:00:00,80.49,136.08100000000002,41.263000000000005,32.641 +2020-01-10 01:15:00,84.15,137.489,41.263000000000005,32.641 +2020-01-10 01:30:00,82.86,137.643,41.263000000000005,32.641 +2020-01-10 01:45:00,80.26,138.219,41.263000000000005,32.641 +2020-01-10 02:00:00,82.41,140.32299999999998,40.799,32.641 +2020-01-10 02:15:00,82.9,142.0,40.799,32.641 +2020-01-10 02:30:00,82.52,143.188,40.799,32.641 +2020-01-10 02:45:00,78.57,145.32399999999998,40.799,32.641 +2020-01-10 03:00:00,82.79,146.91,41.398,32.641 +2020-01-10 03:15:00,83.34,149.214,41.398,32.641 +2020-01-10 03:30:00,81.33,151.107,41.398,32.641 +2020-01-10 03:45:00,83.38,152.868,41.398,32.641 +2020-01-10 04:00:00,84.86,165.15900000000002,42.38,32.641 +2020-01-10 04:15:00,82.09,176.97400000000002,42.38,32.641 +2020-01-10 04:30:00,80.74,180.197,42.38,32.641 +2020-01-10 04:45:00,81.91,181.887,42.38,32.641 +2020-01-10 05:00:00,85.77,215.43599999999998,46.181000000000004,32.641 +2020-01-10 05:15:00,88.51,245.451,46.181000000000004,32.641 +2020-01-10 05:30:00,92.4,242.31599999999997,46.181000000000004,32.641 +2020-01-10 05:45:00,97.86,235.141,46.181000000000004,32.641 +2020-01-10 06:00:00,105.68,232.324,59.33,32.641 +2020-01-10 06:15:00,110.88,236.578,59.33,32.641 +2020-01-10 06:30:00,114.95,238.657,59.33,32.641 +2020-01-10 06:45:00,120.02,244.748,59.33,32.641 +2020-01-10 07:00:00,125.32,242.745,72.454,32.641 +2020-01-10 07:15:00,129.26,249.206,72.454,32.641 +2020-01-10 07:30:00,133.08,252.229,72.454,32.641 +2020-01-10 07:45:00,137.34,252.824,72.454,32.641 +2020-01-10 08:00:00,138.92,250.329,67.175,32.641 +2020-01-10 08:15:00,139.19,249.984,67.175,32.641 +2020-01-10 08:30:00,142.05,248.93900000000002,67.175,32.641 +2020-01-10 08:45:00,139.98,244.104,67.175,32.641 +2020-01-10 09:00:00,139.84,237.938,65.365,32.641 +2020-01-10 09:15:00,142.47,235.299,65.365,32.641 +2020-01-10 09:30:00,143.87,232.75599999999997,65.365,32.641 +2020-01-10 09:45:00,144.34,229.4,65.365,32.641 +2020-01-10 10:00:00,143.86,222.885,63.95,32.641 +2020-01-10 10:15:00,145.24,219.442,63.95,32.641 +2020-01-10 10:30:00,146.08,215.968,63.95,32.641 +2020-01-10 10:45:00,145.52,213.80700000000002,63.95,32.641 +2020-01-10 11:00:00,143.53,211.615,63.92100000000001,32.641 +2020-01-10 11:15:00,144.41,209.428,63.92100000000001,32.641 +2020-01-10 11:30:00,142.84,209.82,63.92100000000001,32.641 +2020-01-10 11:45:00,140.57,208.75099999999998,63.92100000000001,32.641 +2020-01-10 12:00:00,139.98,204.835,60.79600000000001,32.641 +2020-01-10 12:15:00,139.61,202.088,60.79600000000001,32.641 +2020-01-10 12:30:00,138.38,202.197,60.79600000000001,32.641 +2020-01-10 12:45:00,137.77,203.66400000000002,60.79600000000001,32.641 +2020-01-10 13:00:00,135.76,202.949,59.393,32.641 +2020-01-10 13:15:00,137.41,203.271,59.393,32.641 +2020-01-10 13:30:00,133.35,202.84099999999998,59.393,32.641 +2020-01-10 13:45:00,131.39,202.644,59.393,32.641 +2020-01-10 14:00:00,131.97,200.481,57.943999999999996,32.641 +2020-01-10 14:15:00,132.24,200.84900000000002,57.943999999999996,32.641 +2020-01-10 14:30:00,130.8,202.25,57.943999999999996,32.641 +2020-01-10 14:45:00,131.39,202.96599999999998,57.943999999999996,32.641 +2020-01-10 15:00:00,131.3,203.739,60.153999999999996,32.641 +2020-01-10 15:15:00,130.95,203.773,60.153999999999996,32.641 +2020-01-10 15:30:00,130.15,204.55,60.153999999999996,32.641 +2020-01-10 15:45:00,129.69,206.333,60.153999999999996,32.641 +2020-01-10 16:00:00,130.75,205.926,62.933,32.641 +2020-01-10 16:15:00,132.79,207.87400000000002,62.933,32.641 +2020-01-10 16:30:00,135.68,210.912,62.933,32.641 +2020-01-10 16:45:00,137.72,212.451,62.933,32.641 +2020-01-10 17:00:00,139.5,214.852,68.657,32.641 +2020-01-10 17:15:00,139.88,215.297,68.657,32.641 +2020-01-10 17:30:00,142.57,215.93400000000003,68.657,32.641 +2020-01-10 17:45:00,139.22,215.576,68.657,32.641 +2020-01-10 18:00:00,136.73,217.919,67.111,32.641 +2020-01-10 18:15:00,136.11,214.695,67.111,32.641 +2020-01-10 18:30:00,134.73,213.893,67.111,32.641 +2020-01-10 18:45:00,135.6,214.03400000000002,67.111,32.641 +2020-01-10 19:00:00,132.42,214.46200000000002,62.434,32.641 +2020-01-10 19:15:00,130.68,211.921,62.434,32.641 +2020-01-10 19:30:00,135.53,208.804,62.434,32.641 +2020-01-10 19:45:00,135.12,204.845,62.434,32.641 +2020-01-10 20:00:00,127.21,201.00900000000001,61.763000000000005,32.641 +2020-01-10 20:15:00,118.7,194.521,61.763000000000005,32.641 +2020-01-10 20:30:00,112.79,190.331,61.763000000000005,32.641 +2020-01-10 20:45:00,111.77,189.175,61.763000000000005,32.641 +2020-01-10 21:00:00,108.32,186.59400000000002,56.785,32.641 +2020-01-10 21:15:00,110.88,184.317,56.785,32.641 +2020-01-10 21:30:00,102.73,182.29,56.785,32.641 +2020-01-10 21:45:00,100.86,181.33900000000003,56.785,32.641 +2020-01-10 22:00:00,96.64,175.354,52.693000000000005,32.641 +2020-01-10 22:15:00,93.07,169.468,52.693000000000005,32.641 +2020-01-10 22:30:00,92.88,162.24,52.693000000000005,32.641 +2020-01-10 22:45:00,93.82,157.739,52.693000000000005,32.641 +2020-01-10 23:00:00,88.99,150.405,45.443999999999996,32.641 +2020-01-10 23:15:00,89.2,146.657,45.443999999999996,32.641 +2020-01-10 23:30:00,85.72,145.495,45.443999999999996,32.641 +2020-01-10 23:45:00,80.79,144.237,45.443999999999996,32.641 +2020-01-11 00:00:00,75.56,128.079,44.738,32.459 +2020-01-11 00:15:00,80.09,123.209,44.738,32.459 +2020-01-11 00:30:00,80.07,125.723,44.738,32.459 +2020-01-11 00:45:00,77.68,128.094,44.738,32.459 +2020-01-11 01:00:00,71.96,131.326,40.303000000000004,32.459 +2020-01-11 01:15:00,70.77,131.606,40.303000000000004,32.459 +2020-01-11 01:30:00,68.37,131.253,40.303000000000004,32.459 +2020-01-11 01:45:00,68.2,131.491,40.303000000000004,32.459 +2020-01-11 02:00:00,69.18,134.41899999999998,38.61,32.459 +2020-01-11 02:15:00,74.74,135.753,38.61,32.459 +2020-01-11 02:30:00,74.65,135.797,38.61,32.459 +2020-01-11 02:45:00,74.36,137.984,38.61,32.459 +2020-01-11 03:00:00,67.49,140.34799999999998,37.554,32.459 +2020-01-11 03:15:00,72.14,141.393,37.554,32.459 +2020-01-11 03:30:00,74.34,141.52200000000002,37.554,32.459 +2020-01-11 03:45:00,74.02,143.29399999999998,37.554,32.459 +2020-01-11 04:00:00,69.83,151.159,37.176,32.459 +2020-01-11 04:15:00,66.48,160.25,37.176,32.459 +2020-01-11 04:30:00,66.65,161.23,37.176,32.459 +2020-01-11 04:45:00,68.43,162.349,37.176,32.459 +2020-01-11 05:00:00,69.3,178.98,36.893,32.459 +2020-01-11 05:15:00,68.52,189.00599999999997,36.893,32.459 +2020-01-11 05:30:00,68.35,186.139,36.893,32.459 +2020-01-11 05:45:00,69.45,184.59400000000002,36.893,32.459 +2020-01-11 06:00:00,71.01,201.507,37.803000000000004,32.459 +2020-01-11 06:15:00,71.8,223.074,37.803000000000004,32.459 +2020-01-11 06:30:00,72.53,219.50099999999998,37.803000000000004,32.459 +2020-01-11 06:45:00,74.42,216.02700000000002,37.803000000000004,32.459 +2020-01-11 07:00:00,77.16,210.03799999999998,41.086999999999996,32.459 +2020-01-11 07:15:00,79.89,215.21599999999998,41.086999999999996,32.459 +2020-01-11 07:30:00,82.88,221.06599999999997,41.086999999999996,32.459 +2020-01-11 07:45:00,87.25,225.924,41.086999999999996,32.459 +2020-01-11 08:00:00,89.61,227.84799999999998,48.222,32.459 +2020-01-11 08:15:00,91.87,231.47099999999998,48.222,32.459 +2020-01-11 08:30:00,94.54,232.15900000000002,48.222,32.459 +2020-01-11 08:45:00,97.83,230.68099999999998,48.222,32.459 +2020-01-11 09:00:00,99.9,226.22400000000002,52.791000000000004,32.459 +2020-01-11 09:15:00,102.11,224.375,52.791000000000004,32.459 +2020-01-11 09:30:00,103.03,222.796,52.791000000000004,32.459 +2020-01-11 09:45:00,104.11,219.641,52.791000000000004,32.459 +2020-01-11 10:00:00,105.67,213.37900000000002,54.341,32.459 +2020-01-11 10:15:00,106.52,210.09400000000002,54.341,32.459 +2020-01-11 10:30:00,107.3,206.815,54.341,32.459 +2020-01-11 10:45:00,107.94,206.1,54.341,32.459 +2020-01-11 11:00:00,109.65,204.15400000000002,51.94,32.459 +2020-01-11 11:15:00,111.25,201.162,51.94,32.459 +2020-01-11 11:30:00,111.53,200.33,51.94,32.459 +2020-01-11 11:45:00,111.53,198.183,51.94,32.459 +2020-01-11 12:00:00,110.54,193.285,50.973,32.459 +2020-01-11 12:15:00,109.01,191.19299999999998,50.973,32.459 +2020-01-11 12:30:00,105.95,191.648,50.973,32.459 +2020-01-11 12:45:00,104.43,192.233,50.973,32.459 +2020-01-11 13:00:00,101.49,191.13,48.06399999999999,32.459 +2020-01-11 13:15:00,99.78,189.25,48.06399999999999,32.459 +2020-01-11 13:30:00,98.12,188.30700000000002,48.06399999999999,32.459 +2020-01-11 13:45:00,96.61,188.71200000000002,48.06399999999999,32.459 +2020-01-11 14:00:00,95.14,187.91299999999998,45.707,32.459 +2020-01-11 14:15:00,94.37,187.77599999999998,45.707,32.459 +2020-01-11 14:30:00,94.65,187.243,45.707,32.459 +2020-01-11 14:45:00,94.99,188.178,45.707,32.459 +2020-01-11 15:00:00,94.68,189.683,47.567,32.459 +2020-01-11 15:15:00,94.55,190.521,47.567,32.459 +2020-01-11 15:30:00,93.53,192.94,47.567,32.459 +2020-01-11 15:45:00,96.02,194.787,47.567,32.459 +2020-01-11 16:00:00,97.55,192.99599999999998,52.031000000000006,32.459 +2020-01-11 16:15:00,98.75,195.96900000000002,52.031000000000006,32.459 +2020-01-11 16:30:00,102.68,198.946,52.031000000000006,32.459 +2020-01-11 16:45:00,104.37,201.447,52.031000000000006,32.459 +2020-01-11 17:00:00,106.29,203.333,58.218999999999994,32.459 +2020-01-11 17:15:00,107.17,205.708,58.218999999999994,32.459 +2020-01-11 17:30:00,108.36,206.278,58.218999999999994,32.459 +2020-01-11 17:45:00,109.4,205.47799999999998,58.218999999999994,32.459 +2020-01-11 18:00:00,108.57,207.27200000000002,57.65,32.459 +2020-01-11 18:15:00,108.91,205.892,57.65,32.459 +2020-01-11 18:30:00,108.15,206.44799999999998,57.65,32.459 +2020-01-11 18:45:00,107.83,203.21400000000003,57.65,32.459 +2020-01-11 19:00:00,105.71,204.699,51.261,32.459 +2020-01-11 19:15:00,103.93,201.683,51.261,32.459 +2020-01-11 19:30:00,102.6,199.333,51.261,32.459 +2020-01-11 19:45:00,101.86,195.09900000000002,51.261,32.459 +2020-01-11 20:00:00,97.56,193.503,44.068000000000005,32.459 +2020-01-11 20:15:00,92.87,189.32299999999998,44.068000000000005,32.459 +2020-01-11 20:30:00,90.31,184.799,44.068000000000005,32.459 +2020-01-11 20:45:00,88.48,183.128,44.068000000000005,32.459 +2020-01-11 21:00:00,86.43,183.017,38.861,32.459 +2020-01-11 21:15:00,84.3,181.21200000000002,38.861,32.459 +2020-01-11 21:30:00,83.24,180.489,38.861,32.459 +2020-01-11 21:45:00,82.41,179.149,38.861,32.459 +2020-01-11 22:00:00,79.49,174.601,39.485,32.459 +2020-01-11 22:15:00,77.77,171.377,39.485,32.459 +2020-01-11 22:30:00,76.13,170.824,39.485,32.459 +2020-01-11 22:45:00,75.32,168.31,39.485,32.459 +2020-01-11 23:00:00,72.71,163.583,32.027,32.459 +2020-01-11 23:15:00,71.28,158.07399999999998,32.027,32.459 +2020-01-11 23:30:00,69.11,154.937,32.027,32.459 +2020-01-11 23:45:00,66.62,151.07,32.027,32.459 +2020-01-12 00:00:00,64.99,128.533,26.96,32.459 +2020-01-12 00:15:00,63.46,123.37200000000001,26.96,32.459 +2020-01-12 00:30:00,61.72,125.475,26.96,32.459 +2020-01-12 00:45:00,60.63,128.591,26.96,32.459 +2020-01-12 01:00:00,59.84,131.655,24.295,32.459 +2020-01-12 01:15:00,59.13,133.06,24.295,32.459 +2020-01-12 01:30:00,58.42,133.279,24.295,32.459 +2020-01-12 01:45:00,57.74,133.195,24.295,32.459 +2020-01-12 02:00:00,57.23,135.319,24.268,32.459 +2020-01-12 02:15:00,56.86,135.67700000000002,24.268,32.459 +2020-01-12 02:30:00,56.65,136.64600000000002,24.268,32.459 +2020-01-12 02:45:00,56.47,139.35399999999998,24.268,32.459 +2020-01-12 03:00:00,56.4,142.011,23.373,32.459 +2020-01-12 03:15:00,56.47,142.487,23.373,32.459 +2020-01-12 03:30:00,56.48,144.191,23.373,32.459 +2020-01-12 03:45:00,56.69,145.944,23.373,32.459 +2020-01-12 04:00:00,57.05,153.52700000000002,23.874000000000002,32.459 +2020-01-12 04:15:00,56.86,161.537,23.874000000000002,32.459 +2020-01-12 04:30:00,57.57,162.489,23.874000000000002,32.459 +2020-01-12 04:45:00,58.1,163.93400000000003,23.874000000000002,32.459 +2020-01-12 05:00:00,58.61,176.683,24.871,32.459 +2020-01-12 05:15:00,59.09,184.1,24.871,32.459 +2020-01-12 05:30:00,58.93,181.088,24.871,32.459 +2020-01-12 05:45:00,59.64,179.84799999999998,24.871,32.459 +2020-01-12 06:00:00,60.42,196.84599999999998,23.84,32.459 +2020-01-12 06:15:00,60.79,216.484,23.84,32.459 +2020-01-12 06:30:00,60.98,211.77,23.84,32.459 +2020-01-12 06:45:00,62.52,207.247,23.84,32.459 +2020-01-12 07:00:00,64.57,203.924,27.430999999999997,32.459 +2020-01-12 07:15:00,66.17,208.28099999999998,27.430999999999997,32.459 +2020-01-12 07:30:00,67.93,212.678,27.430999999999997,32.459 +2020-01-12 07:45:00,69.79,216.687,27.430999999999997,32.459 +2020-01-12 08:00:00,71.93,220.56599999999997,33.891999999999996,32.459 +2020-01-12 08:15:00,74.46,223.995,33.891999999999996,32.459 +2020-01-12 08:30:00,76.78,226.389,33.891999999999996,32.459 +2020-01-12 08:45:00,78.97,227.08900000000003,33.891999999999996,32.459 +2020-01-12 09:00:00,80.51,222.19299999999998,37.571,32.459 +2020-01-12 09:15:00,81.13,220.979,37.571,32.459 +2020-01-12 09:30:00,81.84,219.22099999999998,37.571,32.459 +2020-01-12 09:45:00,82.37,215.87099999999998,37.571,32.459 +2020-01-12 10:00:00,81.27,212.296,40.594,32.459 +2020-01-12 10:15:00,79.61,209.584,40.594,32.459 +2020-01-12 10:30:00,79.77,206.93400000000003,40.594,32.459 +2020-01-12 10:45:00,85.58,204.109,40.594,32.459 +2020-01-12 11:00:00,86.51,203.16299999999998,44.133,32.459 +2020-01-12 11:15:00,88.46,200.34099999999998,44.133,32.459 +2020-01-12 11:30:00,91.87,198.543,44.133,32.459 +2020-01-12 11:45:00,93.46,197.037,44.133,32.459 +2020-01-12 12:00:00,91.63,191.477,41.198,32.459 +2020-01-12 12:15:00,90.3,191.49099999999999,41.198,32.459 +2020-01-12 12:30:00,88.06,190.357,41.198,32.459 +2020-01-12 12:45:00,86.68,189.94,41.198,32.459 +2020-01-12 13:00:00,85.19,188.076,37.014,32.459 +2020-01-12 13:15:00,83.88,189.463,37.014,32.459 +2020-01-12 13:30:00,82.6,188.347,37.014,32.459 +2020-01-12 13:45:00,82.08,187.97099999999998,37.014,32.459 +2020-01-12 14:00:00,82.67,187.389,34.934,32.459 +2020-01-12 14:15:00,82.79,188.51,34.934,32.459 +2020-01-12 14:30:00,82.64,189.378,34.934,32.459 +2020-01-12 14:45:00,85.76,189.947,34.934,32.459 +2020-01-12 15:00:00,83.46,189.813,34.588,32.459 +2020-01-12 15:15:00,83.64,191.487,34.588,32.459 +2020-01-12 15:30:00,84.7,194.537,34.588,32.459 +2020-01-12 15:45:00,85.25,197.101,34.588,32.459 +2020-01-12 16:00:00,86.79,197.385,37.874,32.459 +2020-01-12 16:15:00,88.0,199.38,37.874,32.459 +2020-01-12 16:30:00,92.21,202.588,37.874,32.459 +2020-01-12 16:45:00,94.95,205.236,37.874,32.459 +2020-01-12 17:00:00,97.3,207.06599999999997,47.303999999999995,32.459 +2020-01-12 17:15:00,99.15,209.045,47.303999999999995,32.459 +2020-01-12 17:30:00,100.86,209.912,47.303999999999995,32.459 +2020-01-12 17:45:00,102.12,211.532,47.303999999999995,32.459 +2020-01-12 18:00:00,102.63,212.722,48.879,32.459 +2020-01-12 18:15:00,102.78,212.78599999999997,48.879,32.459 +2020-01-12 18:30:00,101.93,211.138,48.879,32.459 +2020-01-12 18:45:00,100.6,209.889,48.879,32.459 +2020-01-12 19:00:00,99.22,210.878,44.826,32.459 +2020-01-12 19:15:00,96.93,208.544,44.826,32.459 +2020-01-12 19:30:00,98.09,206.03599999999997,44.826,32.459 +2020-01-12 19:45:00,94.04,203.36,44.826,32.459 +2020-01-12 20:00:00,92.48,201.725,40.154,32.459 +2020-01-12 20:15:00,90.52,198.604,40.154,32.459 +2020-01-12 20:30:00,88.61,195.35299999999998,40.154,32.459 +2020-01-12 20:45:00,86.91,192.50099999999998,40.154,32.459 +2020-01-12 21:00:00,86.27,189.613,36.549,32.459 +2020-01-12 21:15:00,85.53,187.137,36.549,32.459 +2020-01-12 21:30:00,85.67,186.76,36.549,32.459 +2020-01-12 21:45:00,86.26,185.56099999999998,36.549,32.459 +2020-01-12 22:00:00,84.68,179.642,37.663000000000004,32.459 +2020-01-12 22:15:00,84.59,175.704,37.663000000000004,32.459 +2020-01-12 22:30:00,81.91,171.81099999999998,37.663000000000004,32.459 +2020-01-12 22:45:00,80.26,168.46,37.663000000000004,32.459 +2020-01-12 23:00:00,77.38,160.747,31.945,32.459 +2020-01-12 23:15:00,76.18,157.165,31.945,32.459 +2020-01-12 23:30:00,75.03,154.933,31.945,32.459 +2020-01-12 23:45:00,75.75,152.011,31.945,32.459 +2020-01-13 00:00:00,70.95,133.061,31.533,32.641 +2020-01-13 00:15:00,70.9,131.047,31.533,32.641 +2020-01-13 00:30:00,70.47,133.298,31.533,32.641 +2020-01-13 00:45:00,69.99,135.843,31.533,32.641 +2020-01-13 01:00:00,67.64,138.91,30.56,32.641 +2020-01-13 01:15:00,66.93,139.731,30.56,32.641 +2020-01-13 01:30:00,67.33,139.983,30.56,32.641 +2020-01-13 01:45:00,67.11,140.019,30.56,32.641 +2020-01-13 02:00:00,66.76,142.105,29.55,32.641 +2020-01-13 02:15:00,67.45,144.125,29.55,32.641 +2020-01-13 02:30:00,67.68,145.453,29.55,32.641 +2020-01-13 02:45:00,67.43,147.497,29.55,32.641 +2020-01-13 03:00:00,67.66,151.507,27.059,32.641 +2020-01-13 03:15:00,68.77,153.741,27.059,32.641 +2020-01-13 03:30:00,68.92,155.1,27.059,32.641 +2020-01-13 03:45:00,68.84,156.29399999999998,27.059,32.641 +2020-01-13 04:00:00,70.83,168.38299999999998,28.384,32.641 +2020-01-13 04:15:00,73.73,180.68599999999998,28.384,32.641 +2020-01-13 04:30:00,73.75,184.06799999999998,28.384,32.641 +2020-01-13 04:45:00,77.08,185.655,28.384,32.641 +2020-01-13 05:00:00,81.53,215.06400000000002,35.915,32.641 +2020-01-13 05:15:00,84.06,243.69099999999997,35.915,32.641 +2020-01-13 05:30:00,88.5,241.104,35.915,32.641 +2020-01-13 05:45:00,94.62,233.94400000000002,35.915,32.641 +2020-01-13 06:00:00,104.44,232.146,56.18,32.641 +2020-01-13 06:15:00,112.23,236.292,56.18,32.641 +2020-01-13 06:30:00,114.44,240.037,56.18,32.641 +2020-01-13 06:45:00,119.14,244.743,56.18,32.641 +2020-01-13 07:00:00,126.22,243.983,70.877,32.641 +2020-01-13 07:15:00,129.2,249.511,70.877,32.641 +2020-01-13 07:30:00,131.57,253.109,70.877,32.641 +2020-01-13 07:45:00,131.68,254.268,70.877,32.641 +2020-01-13 08:00:00,135.22,252.912,65.65,32.641 +2020-01-13 08:15:00,134.08,254.079,65.65,32.641 +2020-01-13 08:30:00,135.2,252.06799999999998,65.65,32.641 +2020-01-13 08:45:00,132.28,249.106,65.65,32.641 +2020-01-13 09:00:00,133.04,243.158,62.037,32.641 +2020-01-13 09:15:00,133.98,238.296,62.037,32.641 +2020-01-13 09:30:00,132.22,235.535,62.037,32.641 +2020-01-13 09:45:00,130.3,232.66299999999998,62.037,32.641 +2020-01-13 10:00:00,129.71,227.908,60.409,32.641 +2020-01-13 10:15:00,129.27,224.858,60.409,32.641 +2020-01-13 10:30:00,127.57,221.285,60.409,32.641 +2020-01-13 10:45:00,130.5,219.388,60.409,32.641 +2020-01-13 11:00:00,125.92,215.54,60.211999999999996,32.641 +2020-01-13 11:15:00,126.17,214.65,60.211999999999996,32.641 +2020-01-13 11:30:00,125.52,214.301,60.211999999999996,32.641 +2020-01-13 11:45:00,127.51,212.32299999999998,60.211999999999996,32.641 +2020-01-13 12:00:00,124.16,208.769,57.733000000000004,32.641 +2020-01-13 12:15:00,123.73,208.793,57.733000000000004,32.641 +2020-01-13 12:30:00,123.65,207.995,57.733000000000004,32.641 +2020-01-13 12:45:00,119.92,209.227,57.733000000000004,32.641 +2020-01-13 13:00:00,118.02,207.935,58.695,32.641 +2020-01-13 13:15:00,118.69,207.868,58.695,32.641 +2020-01-13 13:30:00,116.48,206.155,58.695,32.641 +2020-01-13 13:45:00,117.81,205.75,58.695,32.641 +2020-01-13 14:00:00,124.13,204.56,59.505,32.641 +2020-01-13 14:15:00,122.13,204.937,59.505,32.641 +2020-01-13 14:30:00,125.07,205.237,59.505,32.641 +2020-01-13 14:45:00,121.35,205.65,59.505,32.641 +2020-01-13 15:00:00,123.98,207.451,59.946000000000005,32.641 +2020-01-13 15:15:00,125.58,207.59400000000002,59.946000000000005,32.641 +2020-01-13 15:30:00,124.7,209.69,59.946000000000005,32.641 +2020-01-13 15:45:00,124.42,211.794,59.946000000000005,32.641 +2020-01-13 16:00:00,126.95,212.16400000000002,61.766999999999996,32.641 +2020-01-13 16:15:00,128.1,213.343,61.766999999999996,32.641 +2020-01-13 16:30:00,133.84,215.56,61.766999999999996,32.641 +2020-01-13 16:45:00,134.97,216.93400000000003,61.766999999999996,32.641 +2020-01-13 17:00:00,137.72,218.595,67.85600000000001,32.641 +2020-01-13 17:15:00,139.22,219.562,67.85600000000001,32.641 +2020-01-13 17:30:00,140.31,219.893,67.85600000000001,32.641 +2020-01-13 17:45:00,138.79,219.953,67.85600000000001,32.641 +2020-01-13 18:00:00,138.41,221.658,64.564,32.641 +2020-01-13 18:15:00,136.09,219.475,64.564,32.641 +2020-01-13 18:30:00,135.55,218.57299999999998,64.564,32.641 +2020-01-13 18:45:00,135.23,217.93200000000002,64.564,32.641 +2020-01-13 19:00:00,132.57,217.167,58.536,32.641 +2020-01-13 19:15:00,131.58,213.516,58.536,32.641 +2020-01-13 19:30:00,129.12,211.56400000000002,58.536,32.641 +2020-01-13 19:45:00,134.32,208.04,58.536,32.641 +2020-01-13 20:00:00,128.64,203.938,59.888999999999996,32.641 +2020-01-13 20:15:00,125.25,198.085,59.888999999999996,32.641 +2020-01-13 20:30:00,116.68,192.804,59.888999999999996,32.641 +2020-01-13 20:45:00,117.43,191.696,59.888999999999996,32.641 +2020-01-13 21:00:00,109.83,189.396,52.652,32.641 +2020-01-13 21:15:00,114.34,185.62,52.652,32.641 +2020-01-13 21:30:00,113.42,184.334,52.652,32.641 +2020-01-13 21:45:00,107.41,182.62400000000002,52.652,32.641 +2020-01-13 22:00:00,100.72,173.732,46.17,32.641 +2020-01-13 22:15:00,98.52,168.285,46.17,32.641 +2020-01-13 22:30:00,100.74,154.417,46.17,32.641 +2020-01-13 22:45:00,100.33,145.899,46.17,32.641 +2020-01-13 23:00:00,94.57,139.002,36.281,32.641 +2020-01-13 23:15:00,89.14,138.363,36.281,32.641 +2020-01-13 23:30:00,91.26,139.106,36.281,32.641 +2020-01-13 23:45:00,91.53,139.046,36.281,32.641 +2020-01-14 00:00:00,87.82,132.558,38.821999999999996,32.641 +2020-01-14 00:15:00,81.82,132.01,38.821999999999996,32.641 +2020-01-14 00:30:00,84.32,133.167,38.821999999999996,32.641 +2020-01-14 00:45:00,87.32,134.571,38.821999999999996,32.641 +2020-01-14 01:00:00,84.42,137.464,36.936,32.641 +2020-01-14 01:15:00,79.89,137.782,36.936,32.641 +2020-01-14 01:30:00,77.82,138.22899999999998,36.936,32.641 +2020-01-14 01:45:00,79.93,138.631,36.936,32.641 +2020-01-14 02:00:00,82.47,140.761,34.42,32.641 +2020-01-14 02:15:00,81.18,142.563,34.42,32.641 +2020-01-14 02:30:00,78.27,143.282,34.42,32.641 +2020-01-14 02:45:00,83.03,145.312,34.42,32.641 +2020-01-14 03:00:00,82.63,148.03799999999998,33.585,32.641 +2020-01-14 03:15:00,80.68,149.27700000000002,33.585,32.641 +2020-01-14 03:30:00,74.84,151.167,33.585,32.641 +2020-01-14 03:45:00,75.84,152.67600000000002,33.585,32.641 +2020-01-14 04:00:00,76.53,164.63299999999998,35.622,32.641 +2020-01-14 04:15:00,77.34,176.54,35.622,32.641 +2020-01-14 04:30:00,78.06,179.59400000000002,35.622,32.641 +2020-01-14 04:45:00,80.59,182.463,35.622,32.641 +2020-01-14 05:00:00,83.73,217.205,40.599000000000004,32.641 +2020-01-14 05:15:00,86.8,245.519,40.599000000000004,32.641 +2020-01-14 05:30:00,90.08,241.22400000000002,40.599000000000004,32.641 +2020-01-14 05:45:00,95.43,234.15900000000002,40.599000000000004,32.641 +2020-01-14 06:00:00,104.29,230.98,55.203,32.641 +2020-01-14 06:15:00,109.76,236.90900000000002,55.203,32.641 +2020-01-14 06:30:00,114.01,239.98,55.203,32.641 +2020-01-14 06:45:00,118.59,244.38,55.203,32.641 +2020-01-14 07:00:00,125.25,243.42,69.029,32.641 +2020-01-14 07:15:00,128.89,248.783,69.029,32.641 +2020-01-14 07:30:00,129.58,251.75599999999997,69.029,32.641 +2020-01-14 07:45:00,135.04,253.217,69.029,32.641 +2020-01-14 08:00:00,137.03,251.979,65.85300000000001,32.641 +2020-01-14 08:15:00,136.03,252.045,65.85300000000001,32.641 +2020-01-14 08:30:00,135.16,249.79,65.85300000000001,32.641 +2020-01-14 08:45:00,132.47,246.59900000000002,65.85300000000001,32.641 +2020-01-14 09:00:00,135.18,239.695,61.566,32.641 +2020-01-14 09:15:00,137.97,236.602,61.566,32.641 +2020-01-14 09:30:00,137.36,234.543,61.566,32.641 +2020-01-14 09:45:00,138.26,231.315,61.566,32.641 +2020-01-14 10:00:00,140.95,226.03599999999997,61.244,32.641 +2020-01-14 10:15:00,144.05,221.83900000000003,61.244,32.641 +2020-01-14 10:30:00,146.07,218.455,61.244,32.641 +2020-01-14 10:45:00,144.01,216.787,61.244,32.641 +2020-01-14 11:00:00,132.46,214.55200000000002,61.16,32.641 +2020-01-14 11:15:00,135.5,213.266,61.16,32.641 +2020-01-14 11:30:00,136.58,211.74099999999999,61.16,32.641 +2020-01-14 11:45:00,138.0,210.571,61.16,32.641 +2020-01-14 12:00:00,137.63,205.547,59.09,32.641 +2020-01-14 12:15:00,135.35,205.107,59.09,32.641 +2020-01-14 12:30:00,134.93,205.018,59.09,32.641 +2020-01-14 12:45:00,134.94,205.87400000000002,59.09,32.641 +2020-01-14 13:00:00,133.76,204.162,60.21,32.641 +2020-01-14 13:15:00,131.09,203.554,60.21,32.641 +2020-01-14 13:30:00,129.61,203.111,60.21,32.641 +2020-01-14 13:45:00,134.96,202.985,60.21,32.641 +2020-01-14 14:00:00,133.08,202.09599999999998,60.673,32.641 +2020-01-14 14:15:00,132.64,202.65099999999998,60.673,32.641 +2020-01-14 14:30:00,131.69,203.612,60.673,32.641 +2020-01-14 14:45:00,133.24,204.018,60.673,32.641 +2020-01-14 15:00:00,134.24,205.382,62.232,32.641 +2020-01-14 15:15:00,133.79,205.793,62.232,32.641 +2020-01-14 15:30:00,132.79,208.138,62.232,32.641 +2020-01-14 15:45:00,132.99,209.766,62.232,32.641 +2020-01-14 16:00:00,134.62,210.56599999999997,63.611999999999995,32.641 +2020-01-14 16:15:00,133.49,212.261,63.611999999999995,32.641 +2020-01-14 16:30:00,136.5,215.21200000000002,63.611999999999995,32.641 +2020-01-14 16:45:00,140.39,216.851,63.611999999999995,32.641 +2020-01-14 17:00:00,141.25,219.071,70.658,32.641 +2020-01-14 17:15:00,141.82,220.072,70.658,32.641 +2020-01-14 17:30:00,142.91,221.18200000000002,70.658,32.641 +2020-01-14 17:45:00,141.94,221.146,70.658,32.641 +2020-01-14 18:00:00,140.95,222.817,68.361,32.641 +2020-01-14 18:15:00,139.33,220.011,68.361,32.641 +2020-01-14 18:30:00,138.51,218.8,68.361,32.641 +2020-01-14 18:45:00,138.78,219.049,68.361,32.641 +2020-01-14 19:00:00,134.6,218.407,62.922,32.641 +2020-01-14 19:15:00,133.06,214.447,62.922,32.641 +2020-01-14 19:30:00,130.38,211.797,62.922,32.641 +2020-01-14 19:45:00,135.52,208.298,62.922,32.641 +2020-01-14 20:00:00,131.04,204.324,63.251999999999995,32.641 +2020-01-14 20:15:00,124.9,197.88099999999997,63.251999999999995,32.641 +2020-01-14 20:30:00,119.8,193.68200000000002,63.251999999999995,32.641 +2020-01-14 20:45:00,113.77,191.946,63.251999999999995,32.641 +2020-01-14 21:00:00,111.04,188.81599999999997,54.47,32.641 +2020-01-14 21:15:00,115.36,186.09599999999998,54.47,32.641 +2020-01-14 21:30:00,114.69,184.012,54.47,32.641 +2020-01-14 21:45:00,110.23,182.55,54.47,32.641 +2020-01-14 22:00:00,103.08,175.484,51.12,32.641 +2020-01-14 22:15:00,98.43,169.798,51.12,32.641 +2020-01-14 22:30:00,97.92,156.011,51.12,32.641 +2020-01-14 22:45:00,100.32,147.8,51.12,32.641 +2020-01-14 23:00:00,96.47,140.96200000000002,42.156000000000006,32.641 +2020-01-14 23:15:00,92.03,139.256,42.156000000000006,32.641 +2020-01-14 23:30:00,86.85,139.628,42.156000000000006,32.641 +2020-01-14 23:45:00,87.06,139.088,42.156000000000006,32.641 +2020-01-15 00:00:00,86.86,132.626,37.192,32.641 +2020-01-15 00:15:00,87.23,132.058,37.192,32.641 +2020-01-15 00:30:00,85.9,133.19799999999998,37.192,32.641 +2020-01-15 00:45:00,82.51,134.589,37.192,32.641 +2020-01-15 01:00:00,83.73,137.483,32.24,32.641 +2020-01-15 01:15:00,83.66,137.791,32.24,32.641 +2020-01-15 01:30:00,80.75,138.233,32.24,32.641 +2020-01-15 01:45:00,76.71,138.627,32.24,32.641 +2020-01-15 02:00:00,83.36,140.768,30.34,32.641 +2020-01-15 02:15:00,84.01,142.57,30.34,32.641 +2020-01-15 02:30:00,81.99,143.299,30.34,32.641 +2020-01-15 02:45:00,77.46,145.328,30.34,32.641 +2020-01-15 03:00:00,75.23,148.05,29.129,32.641 +2020-01-15 03:15:00,76.54,149.305,29.129,32.641 +2020-01-15 03:30:00,77.67,151.195,29.129,32.641 +2020-01-15 03:45:00,77.78,152.713,29.129,32.641 +2020-01-15 04:00:00,83.87,164.64700000000002,30.075,32.641 +2020-01-15 04:15:00,85.62,176.545,30.075,32.641 +2020-01-15 04:30:00,87.03,179.59900000000002,30.075,32.641 +2020-01-15 04:45:00,87.26,182.463,30.075,32.641 +2020-01-15 05:00:00,92.08,217.16400000000002,35.684,32.641 +2020-01-15 05:15:00,95.05,245.451,35.684,32.641 +2020-01-15 05:30:00,93.94,241.158,35.684,32.641 +2020-01-15 05:45:00,97.28,234.112,35.684,32.641 +2020-01-15 06:00:00,105.8,230.955,51.49,32.641 +2020-01-15 06:15:00,110.68,236.893,51.49,32.641 +2020-01-15 06:30:00,116.49,239.972,51.49,32.641 +2020-01-15 06:45:00,120.13,244.398,51.49,32.641 +2020-01-15 07:00:00,127.25,243.456,68.242,32.641 +2020-01-15 07:15:00,130.04,248.804,68.242,32.641 +2020-01-15 07:30:00,133.29,251.75599999999997,68.242,32.641 +2020-01-15 07:45:00,136.04,253.19299999999998,68.242,32.641 +2020-01-15 08:00:00,138.64,251.947,63.619,32.641 +2020-01-15 08:15:00,135.35,251.99599999999998,63.619,32.641 +2020-01-15 08:30:00,135.07,249.706,63.619,32.641 +2020-01-15 08:45:00,135.36,246.498,63.619,32.641 +2020-01-15 09:00:00,138.48,239.581,61.333,32.641 +2020-01-15 09:15:00,137.35,236.493,61.333,32.641 +2020-01-15 09:30:00,139.08,234.452,61.333,32.641 +2020-01-15 09:45:00,139.42,231.21900000000002,61.333,32.641 +2020-01-15 10:00:00,139.42,225.94299999999998,59.663000000000004,32.641 +2020-01-15 10:15:00,139.9,221.75400000000002,59.663000000000004,32.641 +2020-01-15 10:30:00,142.04,218.365,59.663000000000004,32.641 +2020-01-15 10:45:00,143.07,216.702,59.663000000000004,32.641 +2020-01-15 11:00:00,139.95,214.44799999999998,59.771,32.641 +2020-01-15 11:15:00,139.24,213.165,59.771,32.641 +2020-01-15 11:30:00,137.7,211.642,59.771,32.641 +2020-01-15 11:45:00,137.77,210.477,59.771,32.641 +2020-01-15 12:00:00,137.15,205.465,58.723,32.641 +2020-01-15 12:15:00,135.27,205.044,58.723,32.641 +2020-01-15 12:30:00,135.67,204.94400000000002,58.723,32.641 +2020-01-15 12:45:00,137.77,205.801,58.723,32.641 +2020-01-15 13:00:00,136.15,204.08700000000002,58.727,32.641 +2020-01-15 13:15:00,136.78,203.465,58.727,32.641 +2020-01-15 13:30:00,136.5,203.01,58.727,32.641 +2020-01-15 13:45:00,136.87,202.877,58.727,32.641 +2020-01-15 14:00:00,136.85,202.014,59.803999999999995,32.641 +2020-01-15 14:15:00,134.91,202.55900000000003,59.803999999999995,32.641 +2020-01-15 14:30:00,134.1,203.52200000000002,59.803999999999995,32.641 +2020-01-15 14:45:00,135.12,203.94400000000002,59.803999999999995,32.641 +2020-01-15 15:00:00,135.73,205.327,61.05,32.641 +2020-01-15 15:15:00,134.79,205.715,61.05,32.641 +2020-01-15 15:30:00,132.55,208.048,61.05,32.641 +2020-01-15 15:45:00,131.89,209.665,61.05,32.641 +2020-01-15 16:00:00,132.28,210.465,64.012,32.641 +2020-01-15 16:15:00,134.26,212.166,64.012,32.641 +2020-01-15 16:30:00,136.16,215.123,64.012,32.641 +2020-01-15 16:45:00,138.74,216.766,64.012,32.641 +2020-01-15 17:00:00,141.87,218.972,66.751,32.641 +2020-01-15 17:15:00,142.23,220.003,66.751,32.641 +2020-01-15 17:30:00,143.82,221.144,66.751,32.641 +2020-01-15 17:45:00,143.42,221.12900000000002,66.751,32.641 +2020-01-15 18:00:00,142.04,222.82,65.91199999999999,32.641 +2020-01-15 18:15:00,140.16,220.03099999999998,65.91199999999999,32.641 +2020-01-15 18:30:00,139.4,218.824,65.91199999999999,32.641 +2020-01-15 18:45:00,140.52,219.09400000000002,65.91199999999999,32.641 +2020-01-15 19:00:00,137.94,218.416,63.324,32.641 +2020-01-15 19:15:00,133.64,214.46099999999998,63.324,32.641 +2020-01-15 19:30:00,130.94,211.817,63.324,32.641 +2020-01-15 19:45:00,129.29,208.326,63.324,32.641 +2020-01-15 20:00:00,123.9,204.331,63.573,32.641 +2020-01-15 20:15:00,121.7,197.892,63.573,32.641 +2020-01-15 20:30:00,116.5,193.687,63.573,32.641 +2020-01-15 20:45:00,115.14,191.967,63.573,32.641 +2020-01-15 21:00:00,110.98,188.81799999999998,55.073,32.641 +2020-01-15 21:15:00,115.16,186.081,55.073,32.641 +2020-01-15 21:30:00,114.07,183.99599999999998,55.073,32.641 +2020-01-15 21:45:00,110.47,182.551,55.073,32.641 +2020-01-15 22:00:00,101.67,175.48,51.321999999999996,32.641 +2020-01-15 22:15:00,100.33,169.81099999999998,51.321999999999996,32.641 +2020-01-15 22:30:00,96.77,156.029,51.321999999999996,32.641 +2020-01-15 22:45:00,95.32,147.825,51.321999999999996,32.641 +2020-01-15 23:00:00,97.65,140.968,42.09,32.641 +2020-01-15 23:15:00,96.42,139.27200000000002,42.09,32.641 +2020-01-15 23:30:00,91.99,139.661,42.09,32.641 +2020-01-15 23:45:00,86.16,139.125,42.09,32.641 +2020-01-16 00:00:00,87.33,139.56,38.399,32.641 +2020-01-16 00:15:00,88.49,139.33,38.399,32.641 +2020-01-16 00:30:00,88.26,140.88299999999998,38.399,32.641 +2020-01-16 00:45:00,83.0,142.724,38.399,32.641 +2020-01-16 01:00:00,83.99,145.79399999999998,36.94,32.641 +2020-01-16 01:15:00,85.07,145.787,36.94,32.641 +2020-01-16 01:30:00,84.84,146.149,36.94,32.641 +2020-01-16 01:45:00,79.18,146.644,36.94,32.641 +2020-01-16 02:00:00,80.14,148.93200000000002,35.275,32.641 +2020-01-16 02:15:00,85.15,151.02200000000002,35.275,32.641 +2020-01-16 02:30:00,84.32,152.084,35.275,32.641 +2020-01-16 02:45:00,82.42,154.27100000000002,35.275,32.641 +2020-01-16 03:00:00,77.71,157.27,35.329,32.641 +2020-01-16 03:15:00,84.16,158.311,35.329,32.641 +2020-01-16 03:30:00,85.53,160.137,35.329,32.641 +2020-01-16 03:45:00,85.45,162.055,35.329,32.641 +2020-01-16 04:00:00,78.58,173.658,36.275,32.641 +2020-01-16 04:15:00,79.47,185.22099999999998,36.275,32.641 +2020-01-16 04:30:00,81.13,188.989,36.275,32.641 +2020-01-16 04:45:00,82.77,192.137,36.275,32.641 +2020-01-16 05:00:00,86.6,227.294,42.193999999999996,32.641 +2020-01-16 05:15:00,89.37,255.28799999999998,42.193999999999996,32.641 +2020-01-16 05:30:00,93.2,250.99099999999999,42.193999999999996,32.641 +2020-01-16 05:45:00,98.25,244.456,42.193999999999996,32.641 +2020-01-16 06:00:00,106.36,241.44099999999997,56.422,32.641 +2020-01-16 06:15:00,111.91,247.579,56.422,32.641 +2020-01-16 06:30:00,115.81,250.795,56.422,32.641 +2020-01-16 06:45:00,121.56,255.86900000000003,56.422,32.641 +2020-01-16 07:00:00,125.43,254.107,72.569,32.641 +2020-01-16 07:15:00,128.69,259.99,72.569,32.641 +2020-01-16 07:30:00,132.2,263.454,72.569,32.641 +2020-01-16 07:45:00,136.04,265.41200000000003,72.569,32.641 +2020-01-16 08:00:00,137.28,264.069,67.704,32.641 +2020-01-16 08:15:00,136.74,264.55400000000003,67.704,32.641 +2020-01-16 08:30:00,137.81,262.16900000000004,67.704,32.641 +2020-01-16 08:45:00,135.89,259.421,67.704,32.641 +2020-01-16 09:00:00,137.97,252.798,63.434,32.641 +2020-01-16 09:15:00,140.1,249.953,63.434,32.641 +2020-01-16 09:30:00,141.78,248.072,63.434,32.641 +2020-01-16 09:45:00,141.49,244.673,63.434,32.641 +2020-01-16 10:00:00,142.24,238.75599999999997,61.88399999999999,32.641 +2020-01-16 10:15:00,140.48,234.90400000000002,61.88399999999999,32.641 +2020-01-16 10:30:00,140.15,230.903,61.88399999999999,32.641 +2020-01-16 10:45:00,140.59,229.00799999999998,61.88399999999999,32.641 +2020-01-16 11:00:00,140.53,225.988,61.481,32.641 +2020-01-16 11:15:00,141.45,224.646,61.481,32.641 +2020-01-16 11:30:00,141.71,222.722,61.481,32.641 +2020-01-16 11:45:00,139.21,222.058,61.481,32.641 +2020-01-16 12:00:00,136.2,218.021,59.527,32.641 +2020-01-16 12:15:00,135.53,217.612,59.527,32.641 +2020-01-16 12:30:00,135.7,217.34900000000002,59.527,32.641 +2020-01-16 12:45:00,136.9,217.868,59.527,32.641 +2020-01-16 13:00:00,135.4,215.40400000000002,58.794,32.641 +2020-01-16 13:15:00,134.78,214.74099999999999,58.794,32.641 +2020-01-16 13:30:00,132.47,213.917,58.794,32.641 +2020-01-16 13:45:00,132.22,214.22,58.794,32.641 +2020-01-16 14:00:00,128.13,213.833,60.32,32.641 +2020-01-16 14:15:00,126.7,214.40200000000002,60.32,32.641 +2020-01-16 14:30:00,124.84,215.28599999999997,60.32,32.641 +2020-01-16 14:45:00,124.2,215.984,60.32,32.641 +2020-01-16 15:00:00,125.48,216.856,62.52,32.641 +2020-01-16 15:15:00,125.05,217.75799999999998,62.52,32.641 +2020-01-16 15:30:00,126.77,219.547,62.52,32.641 +2020-01-16 15:45:00,129.95,220.50099999999998,62.52,32.641 +2020-01-16 16:00:00,132.47,222.225,64.199,32.641 +2020-01-16 16:15:00,131.37,224.09099999999998,64.199,32.641 +2020-01-16 16:30:00,133.46,226.852,64.199,32.641 +2020-01-16 16:45:00,136.14,228.108,64.199,32.641 +2020-01-16 17:00:00,140.04,230.606,68.19800000000001,32.641 +2020-01-16 17:15:00,139.73,231.262,68.19800000000001,32.641 +2020-01-16 17:30:00,140.43,232.16,68.19800000000001,32.641 +2020-01-16 17:45:00,140.42,231.972,68.19800000000001,32.641 +2020-01-16 18:00:00,138.81,233.65400000000002,67.899,32.641 +2020-01-16 18:15:00,136.53,230.218,67.899,32.641 +2020-01-16 18:30:00,137.16,229.18400000000003,67.899,32.641 +2020-01-16 18:45:00,136.92,229.485,67.899,32.641 +2020-01-16 19:00:00,133.32,227.865,64.72399999999999,32.641 +2020-01-16 19:15:00,131.23,223.705,64.72399999999999,32.641 +2020-01-16 19:30:00,133.3,220.62099999999998,64.72399999999999,32.641 +2020-01-16 19:45:00,135.03,217.56900000000002,64.72399999999999,32.641 +2020-01-16 20:00:00,127.62,213.46200000000002,64.062,32.641 +2020-01-16 20:15:00,119.26,206.58700000000002,64.062,32.641 +2020-01-16 20:30:00,116.31,202.333,64.062,32.641 +2020-01-16 20:45:00,111.77,201.076,64.062,32.641 +2020-01-16 21:00:00,108.51,197.449,57.971000000000004,32.641 +2020-01-16 21:15:00,112.3,194.61,57.971000000000004,32.641 +2020-01-16 21:30:00,110.27,192.56900000000002,57.971000000000004,32.641 +2020-01-16 21:45:00,106.27,190.99400000000003,57.971000000000004,32.641 +2020-01-16 22:00:00,97.28,183.658,53.715,32.641 +2020-01-16 22:15:00,96.36,177.688,53.715,32.641 +2020-01-16 22:30:00,94.99,163.819,53.715,32.641 +2020-01-16 22:45:00,98.2,155.327,53.715,32.641 +2020-01-16 23:00:00,94.28,147.961,47.8,32.641 +2020-01-16 23:15:00,91.98,146.525,47.8,32.641 +2020-01-16 23:30:00,84.84,146.689,47.8,32.641 +2020-01-16 23:45:00,82.59,146.202,47.8,32.641 +2020-01-17 00:00:00,85.95,138.651,43.656000000000006,32.641 +2020-01-17 00:15:00,86.53,138.59799999999998,43.656000000000006,32.641 +2020-01-17 00:30:00,85.43,139.931,43.656000000000006,32.641 +2020-01-17 00:45:00,79.36,141.822,43.656000000000006,32.641 +2020-01-17 01:00:00,80.97,144.585,41.263000000000005,32.641 +2020-01-17 01:15:00,83.5,145.747,41.263000000000005,32.641 +2020-01-17 01:30:00,83.45,145.722,41.263000000000005,32.641 +2020-01-17 01:45:00,80.4,146.374,41.263000000000005,32.641 +2020-01-17 02:00:00,78.23,148.619,40.799,32.641 +2020-01-17 02:15:00,74.91,150.584,40.799,32.641 +2020-01-17 02:30:00,73.54,152.134,40.799,32.641 +2020-01-17 02:45:00,74.37,154.489,40.799,32.641 +2020-01-17 03:00:00,79.04,156.16899999999998,41.398,32.641 +2020-01-17 03:15:00,83.49,158.55200000000002,41.398,32.641 +2020-01-17 03:30:00,84.6,160.399,41.398,32.641 +2020-01-17 03:45:00,82.01,162.561,41.398,32.641 +2020-01-17 04:00:00,77.58,174.391,42.38,32.641 +2020-01-17 04:15:00,78.35,185.93099999999998,42.38,32.641 +2020-01-17 04:30:00,79.29,189.803,42.38,32.641 +2020-01-17 04:45:00,82.16,191.699,42.38,32.641 +2020-01-17 05:00:00,85.97,225.387,46.181000000000004,32.641 +2020-01-17 05:15:00,87.85,254.97799999999998,46.181000000000004,32.641 +2020-01-17 05:30:00,94.06,251.938,46.181000000000004,32.641 +2020-01-17 05:45:00,97.27,245.42700000000002,46.181000000000004,32.641 +2020-01-17 06:00:00,106.38,242.907,59.33,32.641 +2020-01-17 06:15:00,111.42,247.27,59.33,32.641 +2020-01-17 06:30:00,115.82,249.47,59.33,32.641 +2020-01-17 06:45:00,119.88,256.536,59.33,32.641 +2020-01-17 07:00:00,127.7,253.679,72.454,32.641 +2020-01-17 07:15:00,130.26,260.601,72.454,32.641 +2020-01-17 07:30:00,132.73,264.178,72.454,32.641 +2020-01-17 07:45:00,134.99,265.091,72.454,32.641 +2020-01-17 08:00:00,136.8,262.293,67.175,32.641 +2020-01-17 08:15:00,135.31,262.188,67.175,32.641 +2020-01-17 08:30:00,136.28,260.932,67.175,32.641 +2020-01-17 08:45:00,136.61,256.327,67.175,32.641 +2020-01-17 09:00:00,136.32,250.61700000000002,65.365,32.641 +2020-01-17 09:15:00,137.58,248.14,65.365,32.641 +2020-01-17 09:30:00,139.47,245.88400000000001,65.365,32.641 +2020-01-17 09:45:00,139.06,242.29,65.365,32.641 +2020-01-17 10:00:00,137.15,235.06400000000002,63.95,32.641 +2020-01-17 10:15:00,136.31,232.081,63.95,32.641 +2020-01-17 10:30:00,137.15,227.899,63.95,32.641 +2020-01-17 10:45:00,138.46,225.50599999999997,63.95,32.641 +2020-01-17 11:00:00,138.45,222.419,63.92100000000001,32.641 +2020-01-17 11:15:00,140.66,220.19799999999998,63.92100000000001,32.641 +2020-01-17 11:30:00,142.55,220.382,63.92100000000001,32.641 +2020-01-17 11:45:00,141.43,219.925,63.92100000000001,32.641 +2020-01-17 12:00:00,140.64,217.108,60.79600000000001,32.641 +2020-01-17 12:15:00,141.7,214.36599999999999,60.79600000000001,32.641 +2020-01-17 12:30:00,140.79,214.257,60.79600000000001,32.641 +2020-01-17 12:45:00,140.1,215.50099999999998,60.79600000000001,32.641 +2020-01-17 13:00:00,138.62,214.043,59.393,32.641 +2020-01-17 13:15:00,137.23,214.293,59.393,32.641 +2020-01-17 13:30:00,134.36,213.351,59.393,32.641 +2020-01-17 13:45:00,134.11,213.528,59.393,32.641 +2020-01-17 14:00:00,133.51,211.956,57.943999999999996,32.641 +2020-01-17 14:15:00,133.72,212.239,57.943999999999996,32.641 +2020-01-17 14:30:00,131.64,213.493,57.943999999999996,32.641 +2020-01-17 14:45:00,132.21,214.667,57.943999999999996,32.641 +2020-01-17 15:00:00,131.29,215.00400000000002,60.153999999999996,32.641 +2020-01-17 15:15:00,129.71,215.408,60.153999999999996,32.641 +2020-01-17 15:30:00,128.64,215.49900000000002,60.153999999999996,32.641 +2020-01-17 15:45:00,127.96,216.497,60.153999999999996,32.641 +2020-01-17 16:00:00,129.48,217.007,62.933,32.641 +2020-01-17 16:15:00,129.9,219.145,62.933,32.641 +2020-01-17 16:30:00,132.34,222.05,62.933,32.641 +2020-01-17 16:45:00,137.1,223.28400000000002,62.933,32.641 +2020-01-17 17:00:00,139.8,225.78599999999997,68.657,32.641 +2020-01-17 17:15:00,138.22,226.025,68.657,32.641 +2020-01-17 17:30:00,139.12,226.56900000000002,68.657,32.641 +2020-01-17 17:45:00,139.16,226.158,68.657,32.641 +2020-01-17 18:00:00,138.22,228.674,67.111,32.641 +2020-01-17 18:15:00,136.69,224.929,67.111,32.641 +2020-01-17 18:30:00,135.95,224.361,67.111,32.641 +2020-01-17 18:45:00,135.85,224.62599999999998,67.111,32.641 +2020-01-17 19:00:00,132.67,223.908,62.434,32.641 +2020-01-17 19:15:00,129.94,221.23,62.434,32.641 +2020-01-17 19:30:00,127.9,217.68,62.434,32.641 +2020-01-17 19:45:00,126.36,214.235,62.434,32.641 +2020-01-17 20:00:00,119.39,210.176,61.763000000000005,32.641 +2020-01-17 20:15:00,116.31,203.215,61.763000000000005,32.641 +2020-01-17 20:30:00,112.3,198.968,61.763000000000005,32.641 +2020-01-17 20:45:00,109.84,198.465,61.763000000000005,32.641 +2020-01-17 21:00:00,104.58,195.234,56.785,32.641 +2020-01-17 21:15:00,101.37,192.667,56.785,32.641 +2020-01-17 21:30:00,99.26,190.696,56.785,32.641 +2020-01-17 21:45:00,97.84,189.733,56.785,32.641 +2020-01-17 22:00:00,96.2,183.52700000000002,52.693000000000005,32.641 +2020-01-17 22:15:00,91.12,177.456,52.693000000000005,32.641 +2020-01-17 22:30:00,88.41,170.262,52.693000000000005,32.641 +2020-01-17 22:45:00,87.24,165.695,52.693000000000005,32.641 +2020-01-17 23:00:00,83.19,157.593,45.443999999999996,32.641 +2020-01-17 23:15:00,82.01,154.141,45.443999999999996,32.641 +2020-01-17 23:30:00,80.42,152.884,45.443999999999996,32.641 +2020-01-17 23:45:00,79.42,151.662,45.443999999999996,32.641 +2020-01-18 00:00:00,75.81,135.17700000000002,44.738,32.459 +2020-01-18 00:15:00,73.43,130.239,44.738,32.459 +2020-01-18 00:30:00,72.12,133.179,44.738,32.459 +2020-01-18 00:45:00,70.8,136.001,44.738,32.459 +2020-01-18 01:00:00,68.46,139.438,40.303000000000004,32.459 +2020-01-18 01:15:00,68.68,139.34,40.303000000000004,32.459 +2020-01-18 01:30:00,68.0,138.83100000000002,40.303000000000004,32.459 +2020-01-18 01:45:00,67.64,139.023,40.303000000000004,32.459 +2020-01-18 02:00:00,66.26,142.22299999999998,38.61,32.459 +2020-01-18 02:15:00,66.19,143.881,38.61,32.459 +2020-01-18 02:30:00,65.47,144.265,38.61,32.459 +2020-01-18 02:45:00,65.76,146.61,38.61,32.459 +2020-01-18 03:00:00,65.61,149.232,37.554,32.459 +2020-01-18 03:15:00,65.84,150.313,37.554,32.459 +2020-01-18 03:30:00,64.93,150.23,37.554,32.459 +2020-01-18 03:45:00,66.07,152.283,37.554,32.459 +2020-01-18 04:00:00,65.87,159.47899999999998,37.176,32.459 +2020-01-18 04:15:00,66.39,168.15,37.176,32.459 +2020-01-18 04:30:00,65.9,169.74400000000003,37.176,32.459 +2020-01-18 04:45:00,68.46,170.998,37.176,32.459 +2020-01-18 05:00:00,67.62,187.06400000000002,36.893,32.459 +2020-01-18 05:15:00,67.44,195.977,36.893,32.459 +2020-01-18 05:30:00,68.22,193.09,36.893,32.459 +2020-01-18 05:45:00,68.87,192.283,36.893,32.459 +2020-01-18 06:00:00,70.41,210.16299999999998,37.803000000000004,32.459 +2020-01-18 06:15:00,71.3,232.584,37.803000000000004,32.459 +2020-01-18 06:30:00,73.03,228.908,37.803000000000004,32.459 +2020-01-18 06:45:00,75.0,225.896,37.803000000000004,32.459 +2020-01-18 07:00:00,78.98,218.862,41.086999999999996,32.459 +2020-01-18 07:15:00,80.53,224.50599999999997,41.086999999999996,32.459 +2020-01-18 07:30:00,83.81,231.007,41.086999999999996,32.459 +2020-01-18 07:45:00,85.94,236.44799999999998,41.086999999999996,32.459 +2020-01-18 08:00:00,88.7,238.415,48.222,32.459 +2020-01-18 08:15:00,89.91,242.604,48.222,32.459 +2020-01-18 08:30:00,92.41,243.21,48.222,32.459 +2020-01-18 08:45:00,95.69,242.15099999999998,48.222,32.459 +2020-01-18 09:00:00,98.41,238.05700000000002,52.791000000000004,32.459 +2020-01-18 09:15:00,98.48,236.391,52.791000000000004,32.459 +2020-01-18 09:30:00,102.21,235.132,52.791000000000004,32.459 +2020-01-18 09:45:00,100.36,231.793,52.791000000000004,32.459 +2020-01-18 10:00:00,100.12,224.771,54.341,32.459 +2020-01-18 10:15:00,100.75,221.933,54.341,32.459 +2020-01-18 10:30:00,101.87,217.99599999999998,54.341,32.459 +2020-01-18 10:45:00,102.85,217.19299999999998,54.341,32.459 +2020-01-18 11:00:00,101.48,214.399,51.94,32.459 +2020-01-18 11:15:00,103.12,211.248,51.94,32.459 +2020-01-18 11:30:00,103.04,210.08900000000003,51.94,32.459 +2020-01-18 11:45:00,100.05,208.422,51.94,32.459 +2020-01-18 12:00:00,97.04,204.50599999999997,50.973,32.459 +2020-01-18 12:15:00,95.62,202.4,50.973,32.459 +2020-01-18 12:30:00,92.58,202.668,50.973,32.459 +2020-01-18 12:45:00,92.09,202.90200000000002,50.973,32.459 +2020-01-18 13:00:00,90.24,201.095,48.06399999999999,32.459 +2020-01-18 13:15:00,87.19,199.011,48.06399999999999,32.459 +2020-01-18 13:30:00,86.44,197.50099999999998,48.06399999999999,32.459 +2020-01-18 13:45:00,89.07,198.437,48.06399999999999,32.459 +2020-01-18 14:00:00,85.42,198.355,45.707,32.459 +2020-01-18 14:15:00,85.02,198.19299999999998,45.707,32.459 +2020-01-18 14:30:00,88.14,197.393,45.707,32.459 +2020-01-18 14:45:00,88.52,198.765,45.707,32.459 +2020-01-18 15:00:00,88.89,199.88299999999998,47.567,32.459 +2020-01-18 15:15:00,89.04,201.095,47.567,32.459 +2020-01-18 15:30:00,91.84,202.926,47.567,32.459 +2020-01-18 15:45:00,89.9,204.063,47.567,32.459 +2020-01-18 16:00:00,91.8,202.982,52.031000000000006,32.459 +2020-01-18 16:15:00,92.59,206.278,52.031000000000006,32.459 +2020-01-18 16:30:00,94.37,209.09900000000002,52.031000000000006,32.459 +2020-01-18 16:45:00,98.68,211.359,52.031000000000006,32.459 +2020-01-18 17:00:00,104.16,213.451,58.218999999999994,32.459 +2020-01-18 17:15:00,105.49,215.894,58.218999999999994,32.459 +2020-01-18 17:30:00,106.99,216.37900000000002,58.218999999999994,32.459 +2020-01-18 17:45:00,107.98,215.463,58.218999999999994,32.459 +2020-01-18 18:00:00,108.94,217.34,57.65,32.459 +2020-01-18 18:15:00,110.75,215.455,57.65,32.459 +2020-01-18 18:30:00,108.32,216.24099999999999,57.65,32.459 +2020-01-18 18:45:00,107.67,213.148,57.65,32.459 +2020-01-18 19:00:00,105.41,213.674,51.261,32.459 +2020-01-18 19:15:00,104.23,210.55700000000002,51.261,32.459 +2020-01-18 19:30:00,103.1,207.775,51.261,32.459 +2020-01-18 19:45:00,104.08,203.93900000000002,51.261,32.459 +2020-01-18 20:00:00,96.04,202.172,44.068000000000005,32.459 +2020-01-18 20:15:00,93.37,197.706,44.068000000000005,32.459 +2020-01-18 20:30:00,90.26,193.16400000000002,44.068000000000005,32.459 +2020-01-18 20:45:00,88.26,191.99400000000003,44.068000000000005,32.459 +2020-01-18 21:00:00,85.63,191.48,38.861,32.459 +2020-01-18 21:15:00,83.97,189.435,38.861,32.459 +2020-01-18 21:30:00,82.78,188.83599999999998,38.861,32.459 +2020-01-18 21:45:00,81.38,187.49400000000003,38.861,32.459 +2020-01-18 22:00:00,78.87,182.81099999999998,39.485,32.459 +2020-01-18 22:15:00,77.45,179.549,39.485,32.459 +2020-01-18 22:30:00,74.7,179.446,39.485,32.459 +2020-01-18 22:45:00,73.83,176.96,39.485,32.459 +2020-01-18 23:00:00,70.86,171.66400000000002,32.027,32.459 +2020-01-18 23:15:00,70.25,166.322,32.027,32.459 +2020-01-18 23:30:00,67.49,162.817,32.027,32.459 +2020-01-18 23:45:00,66.15,158.811,32.027,32.459 +2020-01-19 00:00:00,63.53,135.497,26.96,32.459 +2020-01-19 00:15:00,61.94,130.341,26.96,32.459 +2020-01-19 00:30:00,60.49,132.85,26.96,32.459 +2020-01-19 00:45:00,59.53,136.483,26.96,32.459 +2020-01-19 01:00:00,58.12,139.71,24.295,32.459 +2020-01-19 01:15:00,58.27,140.829,24.295,32.459 +2020-01-19 01:30:00,57.64,140.945,24.295,32.459 +2020-01-19 01:45:00,57.35,140.821,24.295,32.459 +2020-01-19 02:00:00,56.61,143.149,24.268,32.459 +2020-01-19 02:15:00,56.08,143.701,24.268,32.459 +2020-01-19 02:30:00,55.8,145.066,24.268,32.459 +2020-01-19 02:45:00,56.32,147.996,24.268,32.459 +2020-01-19 03:00:00,55.66,150.884,23.373,32.459 +2020-01-19 03:15:00,55.71,151.326,23.373,32.459 +2020-01-19 03:30:00,55.81,153.001,23.373,32.459 +2020-01-19 03:45:00,56.11,155.10299999999998,23.373,32.459 +2020-01-19 04:00:00,55.85,162.005,23.874000000000002,32.459 +2020-01-19 04:15:00,56.49,169.545,23.874000000000002,32.459 +2020-01-19 04:30:00,57.02,170.997,23.874000000000002,32.459 +2020-01-19 04:45:00,57.49,172.642,23.874000000000002,32.459 +2020-01-19 05:00:00,58.49,184.49599999999998,24.871,32.459 +2020-01-19 05:15:00,58.83,190.658,24.871,32.459 +2020-01-19 05:30:00,58.88,187.644,24.871,32.459 +2020-01-19 05:45:00,59.64,187.18900000000002,24.871,32.459 +2020-01-19 06:00:00,59.96,205.362,23.84,32.459 +2020-01-19 06:15:00,60.41,225.62599999999998,23.84,32.459 +2020-01-19 06:30:00,60.71,220.766,23.84,32.459 +2020-01-19 06:45:00,64.2,216.699,23.84,32.459 +2020-01-19 07:00:00,64.77,212.528,27.430999999999997,32.459 +2020-01-19 07:15:00,65.87,217.419,27.430999999999997,32.459 +2020-01-19 07:30:00,68.27,222.238,27.430999999999997,32.459 +2020-01-19 07:45:00,70.01,226.76,27.430999999999997,32.459 +2020-01-19 08:00:00,72.98,230.78099999999998,33.891999999999996,32.459 +2020-01-19 08:15:00,74.51,234.66099999999997,33.891999999999996,32.459 +2020-01-19 08:30:00,77.33,237.02900000000002,33.891999999999996,32.459 +2020-01-19 08:45:00,79.56,238.33,33.891999999999996,32.459 +2020-01-19 09:00:00,81.57,233.78099999999998,37.571,32.459 +2020-01-19 09:15:00,83.04,232.83599999999998,37.571,32.459 +2020-01-19 09:30:00,86.55,231.352,37.571,32.459 +2020-01-19 09:45:00,86.23,227.72299999999998,37.571,32.459 +2020-01-19 10:00:00,87.7,223.55200000000002,40.594,32.459 +2020-01-19 10:15:00,89.59,221.326,40.594,32.459 +2020-01-19 10:30:00,90.48,218.054,40.594,32.459 +2020-01-19 10:45:00,92.41,214.893,40.594,32.459 +2020-01-19 11:00:00,94.56,213.213,44.133,32.459 +2020-01-19 11:15:00,98.06,210.283,44.133,32.459 +2020-01-19 11:30:00,99.96,208.044,44.133,32.459 +2020-01-19 11:45:00,100.24,207.048,44.133,32.459 +2020-01-19 12:00:00,97.6,202.331,41.198,32.459 +2020-01-19 12:15:00,96.12,202.545,41.198,32.459 +2020-01-19 12:30:00,92.64,201.079,41.198,32.459 +2020-01-19 12:45:00,92.83,200.282,41.198,32.459 +2020-01-19 13:00:00,87.94,197.68400000000003,37.014,32.459 +2020-01-19 13:15:00,87.17,199.167,37.014,32.459 +2020-01-19 13:30:00,85.79,197.56099999999998,37.014,32.459 +2020-01-19 13:45:00,85.29,197.56599999999997,37.014,32.459 +2020-01-19 14:00:00,84.54,197.63099999999997,34.934,32.459 +2020-01-19 14:15:00,83.97,198.785,34.934,32.459 +2020-01-19 14:30:00,84.35,199.588,34.934,32.459 +2020-01-19 14:45:00,85.14,200.653,34.934,32.459 +2020-01-19 15:00:00,84.99,199.99,34.588,32.459 +2020-01-19 15:15:00,84.82,202.16,34.588,32.459 +2020-01-19 15:30:00,85.3,204.67700000000002,34.588,32.459 +2020-01-19 15:45:00,84.88,206.55900000000003,34.588,32.459 +2020-01-19 16:00:00,86.12,207.805,37.874,32.459 +2020-01-19 16:15:00,86.01,210.03400000000002,37.874,32.459 +2020-01-19 16:30:00,87.08,213.02700000000002,37.874,32.459 +2020-01-19 16:45:00,89.57,215.435,37.874,32.459 +2020-01-19 17:00:00,97.87,217.421,47.303999999999995,32.459 +2020-01-19 17:15:00,102.85,219.338,47.303999999999995,32.459 +2020-01-19 17:30:00,105.44,220.085,47.303999999999995,32.459 +2020-01-19 17:45:00,106.76,221.72400000000002,47.303999999999995,32.459 +2020-01-19 18:00:00,106.95,222.90200000000002,48.879,32.459 +2020-01-19 18:15:00,108.0,222.58700000000002,48.879,32.459 +2020-01-19 18:30:00,104.37,221.048,48.879,32.459 +2020-01-19 18:45:00,103.14,220.05900000000003,48.879,32.459 +2020-01-19 19:00:00,101.77,219.907,44.826,32.459 +2020-01-19 19:15:00,99.54,217.583,44.826,32.459 +2020-01-19 19:30:00,98.11,214.65400000000002,44.826,32.459 +2020-01-19 19:45:00,96.83,212.50400000000002,44.826,32.459 +2020-01-19 20:00:00,98.04,210.7,40.154,32.459 +2020-01-19 20:15:00,101.48,207.36900000000003,40.154,32.459 +2020-01-19 20:30:00,97.9,204.132,40.154,32.459 +2020-01-19 20:45:00,89.29,201.812,40.154,32.459 +2020-01-19 21:00:00,89.58,198.354,36.549,32.459 +2020-01-19 21:15:00,86.78,195.61900000000003,36.549,32.459 +2020-01-19 21:30:00,86.94,195.425,36.549,32.459 +2020-01-19 21:45:00,87.39,194.21200000000002,36.549,32.459 +2020-01-19 22:00:00,87.92,187.94099999999997,37.663000000000004,32.459 +2020-01-19 22:15:00,85.91,184.03,37.663000000000004,32.459 +2020-01-19 22:30:00,86.09,180.403,37.663000000000004,32.459 +2020-01-19 22:45:00,90.89,177.109,37.663000000000004,32.459 +2020-01-19 23:00:00,87.77,168.668,31.945,32.459 +2020-01-19 23:15:00,86.14,165.292,31.945,32.459 +2020-01-19 23:30:00,80.64,162.781,31.945,32.459 +2020-01-19 23:45:00,82.46,159.77200000000002,31.945,32.459 +2020-01-20 00:00:00,84.43,140.184,31.533,32.641 +2020-01-20 00:15:00,82.35,138.34799999999998,31.533,32.641 +2020-01-20 00:30:00,79.55,141.045,31.533,32.641 +2020-01-20 00:45:00,74.22,144.09799999999998,31.533,32.641 +2020-01-20 01:00:00,68.93,147.299,30.56,32.641 +2020-01-20 01:15:00,74.43,147.789,30.56,32.641 +2020-01-20 01:30:00,78.16,147.909,30.56,32.641 +2020-01-20 01:45:00,77.86,147.925,30.56,32.641 +2020-01-20 02:00:00,74.11,150.179,29.55,32.641 +2020-01-20 02:15:00,78.18,152.602,29.55,32.641 +2020-01-20 02:30:00,78.02,154.341,29.55,32.641 +2020-01-20 02:45:00,78.43,156.56799999999998,29.55,32.641 +2020-01-20 03:00:00,75.4,160.888,27.059,32.641 +2020-01-20 03:15:00,72.87,163.166,27.059,32.641 +2020-01-20 03:30:00,73.43,164.412,27.059,32.641 +2020-01-20 03:45:00,74.76,165.95,27.059,32.641 +2020-01-20 04:00:00,81.45,177.48,28.384,32.641 +2020-01-20 04:15:00,80.59,189.41,28.384,32.641 +2020-01-20 04:30:00,78.24,193.53599999999997,28.384,32.641 +2020-01-20 04:45:00,80.69,195.301,28.384,32.641 +2020-01-20 05:00:00,85.34,224.606,35.915,32.641 +2020-01-20 05:15:00,87.03,252.854,35.915,32.641 +2020-01-20 05:30:00,92.23,250.43200000000002,35.915,32.641 +2020-01-20 05:45:00,96.51,243.827,35.915,32.641 +2020-01-20 06:00:00,105.11,242.514,56.18,32.641 +2020-01-20 06:15:00,109.59,246.791,56.18,32.641 +2020-01-20 06:30:00,116.86,250.736,56.18,32.641 +2020-01-20 06:45:00,120.28,256.20099999999996,56.18,32.641 +2020-01-20 07:00:00,128.01,254.76,70.877,32.641 +2020-01-20 07:15:00,133.06,260.723,70.877,32.641 +2020-01-20 07:30:00,134.78,264.745,70.877,32.641 +2020-01-20 07:45:00,134.45,266.157,70.877,32.641 +2020-01-20 08:00:00,137.24,264.639,65.65,32.641 +2020-01-20 08:15:00,136.02,266.177,65.65,32.641 +2020-01-20 08:30:00,137.76,263.861,65.65,32.641 +2020-01-20 08:45:00,135.54,261.198,65.65,32.641 +2020-01-20 09:00:00,137.3,255.574,62.037,32.641 +2020-01-20 09:15:00,140.43,250.84400000000002,62.037,32.641 +2020-01-20 09:30:00,141.76,248.355,62.037,32.641 +2020-01-20 09:45:00,141.7,245.425,62.037,32.641 +2020-01-20 10:00:00,140.32,239.98,60.409,32.641 +2020-01-20 10:15:00,143.66,237.407,60.409,32.641 +2020-01-20 10:30:00,143.46,233.18,60.409,32.641 +2020-01-20 10:45:00,142.34,231.155,60.409,32.641 +2020-01-20 11:00:00,141.51,226.299,60.211999999999996,32.641 +2020-01-20 11:15:00,141.96,225.43,60.211999999999996,32.641 +2020-01-20 11:30:00,141.04,224.695,60.211999999999996,32.641 +2020-01-20 11:45:00,140.21,223.153,60.211999999999996,32.641 +2020-01-20 12:00:00,137.28,220.753,57.733000000000004,32.641 +2020-01-20 12:15:00,135.08,220.96900000000002,57.733000000000004,32.641 +2020-01-20 12:30:00,134.0,219.947,57.733000000000004,32.641 +2020-01-20 12:45:00,133.78,220.92700000000002,57.733000000000004,32.641 +2020-01-20 13:00:00,132.27,218.886,58.695,32.641 +2020-01-20 13:15:00,132.8,218.87,58.695,32.641 +2020-01-20 13:30:00,131.49,216.606,58.695,32.641 +2020-01-20 13:45:00,135.03,216.507,58.695,32.641 +2020-01-20 14:00:00,134.0,215.979,59.505,32.641 +2020-01-20 14:15:00,133.27,216.283,59.505,32.641 +2020-01-20 14:30:00,132.16,216.48,59.505,32.641 +2020-01-20 14:45:00,133.29,217.21,59.505,32.641 +2020-01-20 15:00:00,134.34,218.625,59.946000000000005,32.641 +2020-01-20 15:15:00,131.87,219.19099999999997,59.946000000000005,32.641 +2020-01-20 15:30:00,131.59,220.61599999999999,59.946000000000005,32.641 +2020-01-20 15:45:00,130.93,222.04,59.946000000000005,32.641 +2020-01-20 16:00:00,132.11,223.24900000000002,61.766999999999996,32.641 +2020-01-20 16:15:00,130.14,224.595,61.766999999999996,32.641 +2020-01-20 16:30:00,131.7,226.576,61.766999999999996,32.641 +2020-01-20 16:45:00,135.3,227.615,61.766999999999996,32.641 +2020-01-20 17:00:00,141.09,229.46099999999998,67.85600000000001,32.641 +2020-01-20 17:15:00,139.31,230.275,67.85600000000001,32.641 +2020-01-20 17:30:00,138.86,230.47799999999998,67.85600000000001,32.641 +2020-01-20 17:45:00,138.41,230.486,67.85600000000001,32.641 +2020-01-20 18:00:00,138.69,232.252,64.564,32.641 +2020-01-20 18:15:00,137.29,229.68,64.564,32.641 +2020-01-20 18:30:00,136.61,228.98,64.564,32.641 +2020-01-20 18:45:00,137.05,228.43400000000003,64.564,32.641 +2020-01-20 19:00:00,133.91,226.423,58.536,32.641 +2020-01-20 19:15:00,132.7,222.625,58.536,32.641 +2020-01-20 19:30:00,128.93,220.31099999999998,58.536,32.641 +2020-01-20 19:45:00,128.61,217.30700000000002,58.536,32.641 +2020-01-20 20:00:00,121.04,212.972,59.888999999999996,32.641 +2020-01-20 20:15:00,117.83,206.653,59.888999999999996,32.641 +2020-01-20 20:30:00,115.43,201.231,59.888999999999996,32.641 +2020-01-20 20:45:00,114.45,200.747,59.888999999999996,32.641 +2020-01-20 21:00:00,108.29,197.956,52.652,32.641 +2020-01-20 21:15:00,112.06,193.812,52.652,32.641 +2020-01-20 21:30:00,112.01,192.627,52.652,32.641 +2020-01-20 21:45:00,109.57,190.88400000000001,52.652,32.641 +2020-01-20 22:00:00,100.81,181.58599999999998,46.17,32.641 +2020-01-20 22:15:00,98.31,175.95,46.17,32.641 +2020-01-20 22:30:00,96.61,162.003,46.17,32.641 +2020-01-20 22:45:00,99.56,153.227,46.17,32.641 +2020-01-20 23:00:00,96.3,145.66,36.281,32.641 +2020-01-20 23:15:00,94.34,145.513,36.281,32.641 +2020-01-20 23:30:00,84.13,146.162,36.281,32.641 +2020-01-20 23:45:00,85.68,146.227,36.281,32.641 +2020-01-21 00:00:00,88.44,139.76,38.821999999999996,32.641 +2020-01-21 00:15:00,87.49,139.435,38.821999999999996,32.641 +2020-01-21 00:30:00,86.84,140.901,38.821999999999996,32.641 +2020-01-21 00:45:00,82.6,142.671,38.821999999999996,32.641 +2020-01-21 01:00:00,84.63,145.717,36.936,32.641 +2020-01-21 01:15:00,85.35,145.653,36.936,32.641 +2020-01-21 01:30:00,83.29,145.993,36.936,32.641 +2020-01-21 01:45:00,81.04,146.444,36.936,32.641 +2020-01-21 02:00:00,83.81,148.781,34.42,32.641 +2020-01-21 02:15:00,86.37,150.878,34.42,32.641 +2020-01-21 02:30:00,82.71,151.991,34.42,32.641 +2020-01-21 02:45:00,82.96,154.17600000000002,34.42,32.641 +2020-01-21 03:00:00,82.05,157.155,33.585,32.641 +2020-01-21 03:15:00,84.36,158.278,33.585,32.641 +2020-01-21 03:30:00,78.36,160.099,33.585,32.641 +2020-01-21 03:45:00,85.53,162.072,33.585,32.641 +2020-01-21 04:00:00,85.62,173.56400000000002,35.622,32.641 +2020-01-21 04:15:00,85.41,185.06599999999997,35.622,32.641 +2020-01-21 04:30:00,84.33,188.84900000000002,35.622,32.641 +2020-01-21 04:45:00,89.36,191.958,35.622,32.641 +2020-01-21 05:00:00,95.38,226.903,40.599000000000004,32.641 +2020-01-21 05:15:00,91.27,254.767,40.599000000000004,32.641 +2020-01-21 05:30:00,93.24,250.47400000000002,40.599000000000004,32.641 +2020-01-21 05:45:00,98.12,244.033,40.599000000000004,32.641 +2020-01-21 06:00:00,108.67,241.128,55.203,32.641 +2020-01-21 06:15:00,112.52,247.321,55.203,32.641 +2020-01-21 06:30:00,114.67,250.555,55.203,32.641 +2020-01-21 06:45:00,119.03,255.76,55.203,32.641 +2020-01-21 07:00:00,126.8,254.093,69.029,32.641 +2020-01-21 07:15:00,129.56,259.89599999999996,69.029,32.641 +2020-01-21 07:30:00,135.6,263.238,69.029,32.641 +2020-01-21 07:45:00,132.37,265.056,69.029,32.641 +2020-01-21 08:00:00,134.51,263.666,65.85300000000001,32.641 +2020-01-21 08:15:00,134.53,264.052,65.85300000000001,32.641 +2020-01-21 08:30:00,135.3,261.45099999999996,65.85300000000001,32.641 +2020-01-21 08:45:00,136.72,258.621,65.85300000000001,32.641 +2020-01-21 09:00:00,137.69,251.945,61.566,32.641 +2020-01-21 09:15:00,138.99,249.12900000000002,61.566,32.641 +2020-01-21 09:30:00,140.22,247.34099999999998,61.566,32.641 +2020-01-21 09:45:00,141.41,243.92,61.566,32.641 +2020-01-21 10:00:00,138.32,238.024,61.244,32.641 +2020-01-21 10:15:00,140.84,234.231,61.244,32.641 +2020-01-21 10:30:00,140.25,230.209,61.244,32.641 +2020-01-21 10:45:00,139.51,228.35,61.244,32.641 +2020-01-21 11:00:00,139.77,225.236,61.16,32.641 +2020-01-21 11:15:00,140.28,223.91,61.16,32.641 +2020-01-21 11:30:00,141.43,222.003,61.16,32.641 +2020-01-21 11:45:00,141.15,221.368,61.16,32.641 +2020-01-21 12:00:00,137.98,217.40200000000002,59.09,32.641 +2020-01-21 12:15:00,136.33,217.08900000000003,59.09,32.641 +2020-01-21 12:30:00,134.76,216.75599999999997,59.09,32.641 +2020-01-21 12:45:00,135.83,217.27200000000002,59.09,32.641 +2020-01-21 13:00:00,136.03,214.812,60.21,32.641 +2020-01-21 13:15:00,137.42,214.06099999999998,60.21,32.641 +2020-01-21 13:30:00,136.56,213.168,60.21,32.641 +2020-01-21 13:45:00,136.51,213.452,60.21,32.641 +2020-01-21 14:00:00,133.43,213.22400000000002,60.673,32.641 +2020-01-21 14:15:00,132.93,213.732,60.673,32.641 +2020-01-21 14:30:00,131.47,214.611,60.673,32.641 +2020-01-21 14:45:00,132.26,215.398,60.673,32.641 +2020-01-21 15:00:00,133.45,216.356,62.232,32.641 +2020-01-21 15:15:00,131.81,217.135,62.232,32.641 +2020-01-21 15:30:00,129.92,218.83700000000002,62.232,32.641 +2020-01-21 15:45:00,129.62,219.72,62.232,32.641 +2020-01-21 16:00:00,130.89,221.453,63.611999999999995,32.641 +2020-01-21 16:15:00,130.05,223.34099999999998,63.611999999999995,32.641 +2020-01-21 16:30:00,131.99,226.138,63.611999999999995,32.641 +2020-01-21 16:45:00,133.7,227.4,63.611999999999995,32.641 +2020-01-21 17:00:00,141.22,229.834,70.658,32.641 +2020-01-21 17:15:00,139.73,230.655,70.658,32.641 +2020-01-21 17:30:00,139.98,231.72299999999998,70.658,32.641 +2020-01-21 17:45:00,140.83,231.653,70.658,32.641 +2020-01-21 18:00:00,139.47,233.438,68.361,32.641 +2020-01-21 18:15:00,137.77,230.11900000000003,68.361,32.641 +2020-01-21 18:30:00,136.43,229.109,68.361,32.641 +2020-01-21 18:45:00,136.05,229.52200000000002,68.361,32.641 +2020-01-21 19:00:00,132.77,227.708,62.922,32.641 +2020-01-21 19:15:00,131.36,223.574,62.922,32.641 +2020-01-21 19:30:00,136.19,220.53799999999998,62.922,32.641 +2020-01-21 19:45:00,137.49,217.548,62.922,32.641 +2020-01-21 20:00:00,125.64,213.32299999999998,63.251999999999995,32.641 +2020-01-21 20:15:00,119.24,206.47299999999998,63.251999999999995,32.641 +2020-01-21 20:30:00,114.43,202.202,63.251999999999995,32.641 +2020-01-21 20:45:00,114.99,201.021,63.251999999999995,32.641 +2020-01-21 21:00:00,107.66,197.293,54.47,32.641 +2020-01-21 21:15:00,113.17,194.36599999999999,54.47,32.641 +2020-01-21 21:30:00,112.31,192.32,54.47,32.641 +2020-01-21 21:45:00,106.88,190.829,54.47,32.641 +2020-01-21 22:00:00,100.25,183.455,51.12,32.641 +2020-01-21 22:15:00,96.44,177.588,51.12,32.641 +2020-01-21 22:30:00,95.56,163.696,51.12,32.641 +2020-01-21 22:45:00,98.08,155.245,51.12,32.641 +2020-01-21 23:00:00,92.47,147.793,42.156000000000006,32.641 +2020-01-21 23:15:00,91.54,146.417,42.156000000000006,32.641 +2020-01-21 23:30:00,85.61,146.672,42.156000000000006,32.641 +2020-01-21 23:45:00,89.01,146.222,42.156000000000006,32.641 +2020-01-22 00:00:00,85.62,139.774,37.192,32.641 +2020-01-22 00:15:00,81.83,139.43200000000002,37.192,32.641 +2020-01-22 00:30:00,78.79,140.88,37.192,32.641 +2020-01-22 00:45:00,84.5,142.636,37.192,32.641 +2020-01-22 01:00:00,81.61,145.673,32.24,32.641 +2020-01-22 01:15:00,82.94,145.59799999999998,32.24,32.641 +2020-01-22 01:30:00,75.82,145.93200000000002,32.24,32.641 +2020-01-22 01:45:00,81.67,146.376,32.24,32.641 +2020-01-22 02:00:00,79.49,148.722,30.34,32.641 +2020-01-22 02:15:00,76.77,150.81799999999998,30.34,32.641 +2020-01-22 02:30:00,76.68,151.94299999999998,30.34,32.641 +2020-01-22 02:45:00,75.36,154.127,30.34,32.641 +2020-01-22 03:00:00,80.37,157.10399999999998,29.129,32.641 +2020-01-22 03:15:00,81.93,158.24,29.129,32.641 +2020-01-22 03:30:00,80.31,160.061,29.129,32.641 +2020-01-22 03:45:00,81.37,162.04399999999998,29.129,32.641 +2020-01-22 04:00:00,84.25,173.516,30.075,32.641 +2020-01-22 04:15:00,85.11,185.007,30.075,32.641 +2020-01-22 04:30:00,83.8,188.795,30.075,32.641 +2020-01-22 04:45:00,87.55,191.895,30.075,32.641 +2020-01-22 05:00:00,91.73,226.801,35.684,32.641 +2020-01-22 05:15:00,94.75,254.644,35.684,32.641 +2020-01-22 05:30:00,92.38,250.34900000000002,35.684,32.641 +2020-01-22 05:45:00,96.78,243.924,35.684,32.641 +2020-01-22 06:00:00,106.02,241.03900000000002,51.49,32.641 +2020-01-22 06:15:00,111.14,247.24400000000003,51.49,32.641 +2020-01-22 06:30:00,115.37,250.47799999999998,51.49,32.641 +2020-01-22 06:45:00,120.3,255.707,51.49,32.641 +2020-01-22 07:00:00,127.38,254.05900000000003,68.242,32.641 +2020-01-22 07:15:00,127.79,259.843,68.242,32.641 +2020-01-22 07:30:00,130.4,263.16,68.242,32.641 +2020-01-22 07:45:00,128.61,264.947,68.242,32.641 +2020-01-22 08:00:00,135.29,263.547,63.619,32.641 +2020-01-22 08:15:00,132.82,263.91200000000003,63.619,32.641 +2020-01-22 08:30:00,133.56,261.265,63.619,32.641 +2020-01-22 08:45:00,131.49,258.42,63.619,32.641 +2020-01-22 09:00:00,132.7,251.735,61.333,32.641 +2020-01-22 09:15:00,134.67,248.925,61.333,32.641 +2020-01-22 09:30:00,136.96,247.155,61.333,32.641 +2020-01-22 09:45:00,137.72,243.732,61.333,32.641 +2020-01-22 10:00:00,135.4,237.84,59.663000000000004,32.641 +2020-01-22 10:15:00,135.86,234.06,59.663000000000004,32.641 +2020-01-22 10:30:00,135.23,230.037,59.663000000000004,32.641 +2020-01-22 10:45:00,135.61,228.18599999999998,59.663000000000004,32.641 +2020-01-22 11:00:00,134.31,225.054,59.771,32.641 +2020-01-22 11:15:00,134.36,223.733,59.771,32.641 +2020-01-22 11:30:00,133.1,221.829,59.771,32.641 +2020-01-22 11:45:00,129.04,221.201,59.771,32.641 +2020-01-22 12:00:00,125.42,217.24900000000002,58.723,32.641 +2020-01-22 12:15:00,124.01,216.957,58.723,32.641 +2020-01-22 12:30:00,120.56,216.608,58.723,32.641 +2020-01-22 12:45:00,119.44,217.122,58.723,32.641 +2020-01-22 13:00:00,117.85,214.667,58.727,32.641 +2020-01-22 13:15:00,117.79,213.896,58.727,32.641 +2020-01-22 13:30:00,112.65,212.987,58.727,32.641 +2020-01-22 13:45:00,116.56,213.269,58.727,32.641 +2020-01-22 14:00:00,117.4,213.078,59.803999999999995,32.641 +2020-01-22 14:15:00,121.18,213.571,59.803999999999995,32.641 +2020-01-22 14:30:00,122.83,214.44799999999998,59.803999999999995,32.641 +2020-01-22 14:45:00,124.29,215.252,59.803999999999995,32.641 +2020-01-22 15:00:00,121.07,216.226,61.05,32.641 +2020-01-22 15:15:00,118.8,216.98,61.05,32.641 +2020-01-22 15:30:00,120.0,218.66099999999997,61.05,32.641 +2020-01-22 15:45:00,121.57,219.53,61.05,32.641 +2020-01-22 16:00:00,124.33,221.263,64.012,32.641 +2020-01-22 16:15:00,123.18,223.155,64.012,32.641 +2020-01-22 16:30:00,126.48,225.959,64.012,32.641 +2020-01-22 16:45:00,129.37,227.22,64.012,32.641 +2020-01-22 17:00:00,135.39,229.643,66.751,32.641 +2020-01-22 17:15:00,135.01,230.495,66.751,32.641 +2020-01-22 17:30:00,137.03,231.59799999999998,66.751,32.641 +2020-01-22 17:45:00,138.51,231.55200000000002,66.751,32.641 +2020-01-22 18:00:00,137.18,233.358,65.91199999999999,32.641 +2020-01-22 18:15:00,135.84,230.067,65.91199999999999,32.641 +2020-01-22 18:30:00,133.59,229.06,65.91199999999999,32.641 +2020-01-22 18:45:00,134.99,229.49599999999998,65.91199999999999,32.641 +2020-01-22 19:00:00,132.74,227.643,63.324,32.641 +2020-01-22 19:15:00,130.93,223.517,63.324,32.641 +2020-01-22 19:30:00,136.71,220.49200000000002,63.324,32.641 +2020-01-22 19:45:00,135.36,217.517,63.324,32.641 +2020-01-22 20:00:00,124.6,213.267,63.573,32.641 +2020-01-22 20:15:00,117.48,206.424,63.573,32.641 +2020-01-22 20:30:00,112.16,202.15200000000002,63.573,32.641 +2020-01-22 20:45:00,113.32,200.983,63.573,32.641 +2020-01-22 21:00:00,105.36,197.236,55.073,32.641 +2020-01-22 21:15:00,110.61,194.291,55.073,32.641 +2020-01-22 21:30:00,110.22,192.24599999999998,55.073,32.641 +2020-01-22 21:45:00,104.07,190.769,55.073,32.641 +2020-01-22 22:00:00,99.2,183.387,51.321999999999996,32.641 +2020-01-22 22:15:00,94.35,177.542,51.321999999999996,32.641 +2020-01-22 22:30:00,90.06,163.64,51.321999999999996,32.641 +2020-01-22 22:45:00,89.79,155.197,51.321999999999996,32.641 +2020-01-22 23:00:00,82.0,147.72899999999998,42.09,32.641 +2020-01-22 23:15:00,82.55,146.36700000000002,42.09,32.641 +2020-01-22 23:30:00,87.0,146.639,42.09,32.641 +2020-01-22 23:45:00,88.39,146.19899999999998,42.09,32.641 +2020-01-23 00:00:00,83.32,139.778,38.399,32.641 +2020-01-23 00:15:00,78.11,139.42,38.399,32.641 +2020-01-23 00:30:00,80.92,140.849,38.399,32.641 +2020-01-23 00:45:00,83.96,142.593,38.399,32.641 +2020-01-23 01:00:00,80.81,145.619,36.94,32.641 +2020-01-23 01:15:00,75.12,145.533,36.94,32.641 +2020-01-23 01:30:00,75.03,145.861,36.94,32.641 +2020-01-23 01:45:00,72.02,146.297,36.94,32.641 +2020-01-23 02:00:00,77.81,148.651,35.275,32.641 +2020-01-23 02:15:00,79.24,150.75,35.275,32.641 +2020-01-23 02:30:00,80.05,151.886,35.275,32.641 +2020-01-23 02:45:00,75.79,154.06799999999998,35.275,32.641 +2020-01-23 03:00:00,71.98,157.043,35.329,32.641 +2020-01-23 03:15:00,76.32,158.19299999999998,35.329,32.641 +2020-01-23 03:30:00,81.55,160.012,35.329,32.641 +2020-01-23 03:45:00,81.7,162.007,35.329,32.641 +2020-01-23 04:00:00,79.93,173.46,36.275,32.641 +2020-01-23 04:15:00,78.74,184.93900000000002,36.275,32.641 +2020-01-23 04:30:00,85.06,188.732,36.275,32.641 +2020-01-23 04:45:00,87.85,191.82299999999998,36.275,32.641 +2020-01-23 05:00:00,88.57,226.69,42.193999999999996,32.641 +2020-01-23 05:15:00,86.51,254.516,42.193999999999996,32.641 +2020-01-23 05:30:00,89.63,250.215,42.193999999999996,32.641 +2020-01-23 05:45:00,95.12,243.808,42.193999999999996,32.641 +2020-01-23 06:00:00,102.82,240.94299999999998,56.422,32.641 +2020-01-23 06:15:00,109.43,247.16,56.422,32.641 +2020-01-23 06:30:00,111.99,250.392,56.422,32.641 +2020-01-23 06:45:00,117.62,255.642,56.422,32.641 +2020-01-23 07:00:00,125.86,254.014,72.569,32.641 +2020-01-23 07:15:00,132.26,259.78,72.569,32.641 +2020-01-23 07:30:00,134.8,263.07,72.569,32.641 +2020-01-23 07:45:00,132.33,264.827,72.569,32.641 +2020-01-23 08:00:00,134.07,263.414,67.704,32.641 +2020-01-23 08:15:00,132.87,263.758,67.704,32.641 +2020-01-23 08:30:00,136.34,261.065,67.704,32.641 +2020-01-23 08:45:00,132.86,258.20599999999996,67.704,32.641 +2020-01-23 09:00:00,133.61,251.513,63.434,32.641 +2020-01-23 09:15:00,133.41,248.707,63.434,32.641 +2020-01-23 09:30:00,135.43,246.957,63.434,32.641 +2020-01-23 09:45:00,135.05,243.53,63.434,32.641 +2020-01-23 10:00:00,134.75,237.644,61.88399999999999,32.641 +2020-01-23 10:15:00,136.32,233.87900000000002,61.88399999999999,32.641 +2020-01-23 10:30:00,134.68,229.854,61.88399999999999,32.641 +2020-01-23 10:45:00,136.01,228.01,61.88399999999999,32.641 +2020-01-23 11:00:00,136.01,224.862,61.481,32.641 +2020-01-23 11:15:00,134.32,223.547,61.481,32.641 +2020-01-23 11:30:00,130.61,221.64700000000002,61.481,32.641 +2020-01-23 11:45:00,133.98,221.024,61.481,32.641 +2020-01-23 12:00:00,134.51,217.088,59.527,32.641 +2020-01-23 12:15:00,132.67,216.815,59.527,32.641 +2020-01-23 12:30:00,132.94,216.449,59.527,32.641 +2020-01-23 12:45:00,133.32,216.96200000000002,59.527,32.641 +2020-01-23 13:00:00,132.61,214.511,58.794,32.641 +2020-01-23 13:15:00,132.78,213.72099999999998,58.794,32.641 +2020-01-23 13:30:00,128.88,212.799,58.794,32.641 +2020-01-23 13:45:00,128.87,213.078,58.794,32.641 +2020-01-23 14:00:00,127.62,212.922,60.32,32.641 +2020-01-23 14:15:00,129.42,213.40200000000002,60.32,32.641 +2020-01-23 14:30:00,128.31,214.275,60.32,32.641 +2020-01-23 14:45:00,128.39,215.09599999999998,60.32,32.641 +2020-01-23 15:00:00,129.2,216.08700000000002,62.52,32.641 +2020-01-23 15:15:00,128.37,216.81400000000002,62.52,32.641 +2020-01-23 15:30:00,125.84,218.475,62.52,32.641 +2020-01-23 15:45:00,125.84,219.328,62.52,32.641 +2020-01-23 16:00:00,127.22,221.063,64.199,32.641 +2020-01-23 16:15:00,127.3,222.956,64.199,32.641 +2020-01-23 16:30:00,128.99,225.766,64.199,32.641 +2020-01-23 16:45:00,131.38,227.025,64.199,32.641 +2020-01-23 17:00:00,137.71,229.438,68.19800000000001,32.641 +2020-01-23 17:15:00,137.9,230.322,68.19800000000001,32.641 +2020-01-23 17:30:00,140.75,231.459,68.19800000000001,32.641 +2020-01-23 17:45:00,138.94,231.43900000000002,68.19800000000001,32.641 +2020-01-23 18:00:00,139.21,233.265,67.899,32.641 +2020-01-23 18:15:00,140.6,230.005,67.899,32.641 +2020-01-23 18:30:00,135.9,229.002,67.899,32.641 +2020-01-23 18:45:00,134.83,229.46099999999998,67.899,32.641 +2020-01-23 19:00:00,132.4,227.56799999999998,64.72399999999999,32.641 +2020-01-23 19:15:00,130.3,223.447,64.72399999999999,32.641 +2020-01-23 19:30:00,136.27,220.435,64.72399999999999,32.641 +2020-01-23 19:45:00,136.38,217.476,64.72399999999999,32.641 +2020-01-23 20:00:00,124.03,213.202,64.062,32.641 +2020-01-23 20:15:00,118.44,206.364,64.062,32.641 +2020-01-23 20:30:00,114.15,202.092,64.062,32.641 +2020-01-23 20:45:00,111.79,200.937,64.062,32.641 +2020-01-23 21:00:00,107.54,197.17,57.971000000000004,32.641 +2020-01-23 21:15:00,110.3,194.208,57.971000000000004,32.641 +2020-01-23 21:30:00,111.1,192.162,57.971000000000004,32.641 +2020-01-23 21:45:00,106.2,190.703,57.971000000000004,32.641 +2020-01-23 22:00:00,98.72,183.31099999999998,53.715,32.641 +2020-01-23 22:15:00,96.15,177.487,53.715,32.641 +2020-01-23 22:30:00,92.08,163.576,53.715,32.641 +2020-01-23 22:45:00,91.81,155.14,53.715,32.641 +2020-01-23 23:00:00,94.02,147.657,47.8,32.641 +2020-01-23 23:15:00,94.02,146.306,47.8,32.641 +2020-01-23 23:30:00,89.04,146.597,47.8,32.641 +2020-01-23 23:45:00,85.37,146.167,47.8,32.641 +2020-01-24 00:00:00,86.99,138.808,43.656000000000006,32.641 +2020-01-24 00:15:00,84.7,138.631,43.656000000000006,32.641 +2020-01-24 00:30:00,84.43,139.84,43.656000000000006,32.641 +2020-01-24 00:45:00,78.25,141.634,43.656000000000006,32.641 +2020-01-24 01:00:00,78.02,144.345,41.263000000000005,32.641 +2020-01-24 01:15:00,81.93,145.425,41.263000000000005,32.641 +2020-01-24 01:30:00,81.42,145.365,41.263000000000005,32.641 +2020-01-24 01:45:00,76.21,145.961,41.263000000000005,32.641 +2020-01-24 02:00:00,78.49,148.269,40.799,32.641 +2020-01-24 02:15:00,80.51,150.241,40.799,32.641 +2020-01-24 02:30:00,80.14,151.866,40.799,32.641 +2020-01-24 02:45:00,75.42,154.216,40.799,32.641 +2020-01-24 03:00:00,74.46,155.875,41.398,32.641 +2020-01-24 03:15:00,81.27,158.363,41.398,32.641 +2020-01-24 03:30:00,82.2,160.202,41.398,32.641 +2020-01-24 03:45:00,82.59,162.441,41.398,32.641 +2020-01-24 04:00:00,78.55,174.128,42.38,32.641 +2020-01-24 04:15:00,81.62,185.585,42.38,32.641 +2020-01-24 04:30:00,86.32,189.485,42.38,32.641 +2020-01-24 04:45:00,88.91,191.32299999999998,42.38,32.641 +2020-01-24 05:00:00,90.44,224.72799999999998,46.181000000000004,32.641 +2020-01-24 05:15:00,88.25,254.162,46.181000000000004,32.641 +2020-01-24 05:30:00,91.02,251.11,46.181000000000004,32.641 +2020-01-24 05:45:00,95.66,244.72299999999998,46.181000000000004,32.641 +2020-01-24 06:00:00,105.46,242.351,59.33,32.641 +2020-01-24 06:15:00,108.13,246.793,59.33,32.641 +2020-01-24 06:30:00,112.97,249.0,59.33,32.641 +2020-01-24 06:45:00,118.74,256.23400000000004,59.33,32.641 +2020-01-24 07:00:00,125.57,253.513,72.454,32.641 +2020-01-24 07:15:00,127.25,260.314,72.454,32.641 +2020-01-24 07:30:00,132.41,263.71299999999997,72.454,32.641 +2020-01-24 07:45:00,133.17,264.42,72.454,32.641 +2020-01-24 08:00:00,137.53,261.54900000000004,67.175,32.641 +2020-01-24 08:15:00,135.9,261.298,67.175,32.641 +2020-01-24 08:30:00,136.3,259.728,67.175,32.641 +2020-01-24 08:45:00,136.6,255.017,67.175,32.641 +2020-01-24 09:00:00,137.1,249.24099999999999,65.365,32.641 +2020-01-24 09:15:00,138.33,246.803,65.365,32.641 +2020-01-24 09:30:00,139.13,244.678,65.365,32.641 +2020-01-24 09:45:00,139.22,241.058,65.365,32.641 +2020-01-24 10:00:00,138.08,233.864,63.95,32.641 +2020-01-24 10:15:00,138.61,230.97299999999998,63.95,32.641 +2020-01-24 10:30:00,138.28,226.773,63.95,32.641 +2020-01-24 10:45:00,137.57,224.433,63.95,32.641 +2020-01-24 11:00:00,138.32,221.21900000000002,63.92100000000001,32.641 +2020-01-24 11:15:00,138.78,219.02900000000002,63.92100000000001,32.641 +2020-01-24 11:30:00,139.38,219.236,63.92100000000001,32.641 +2020-01-24 11:45:00,138.06,218.825,63.92100000000001,32.641 +2020-01-24 12:00:00,136.09,216.109,60.79600000000001,32.641 +2020-01-24 12:15:00,134.0,213.50099999999998,60.79600000000001,32.641 +2020-01-24 12:30:00,131.28,213.28599999999997,60.79600000000001,32.641 +2020-01-24 12:45:00,132.51,214.524,60.79600000000001,32.641 +2020-01-24 13:00:00,130.47,213.08599999999998,59.393,32.641 +2020-01-24 13:15:00,129.98,213.205,59.393,32.641 +2020-01-24 13:30:00,128.13,212.16400000000002,59.393,32.641 +2020-01-24 13:45:00,128.71,212.31799999999998,59.393,32.641 +2020-01-24 14:00:00,126.8,210.986,57.943999999999996,32.641 +2020-01-24 14:15:00,129.56,211.17700000000002,57.943999999999996,32.641 +2020-01-24 14:30:00,127.19,212.415,57.943999999999996,32.641 +2020-01-24 14:45:00,127.61,213.71200000000002,57.943999999999996,32.641 +2020-01-24 15:00:00,129.17,214.165,60.153999999999996,32.641 +2020-01-24 15:15:00,125.93,214.391,60.153999999999996,32.641 +2020-01-24 15:30:00,127.46,214.347,60.153999999999996,32.641 +2020-01-24 15:45:00,126.28,215.24400000000003,60.153999999999996,32.641 +2020-01-24 16:00:00,127.34,215.765,62.933,32.641 +2020-01-24 16:15:00,126.66,217.925,62.933,32.641 +2020-01-24 16:30:00,128.07,220.878,62.933,32.641 +2020-01-24 16:45:00,131.69,222.109,62.933,32.641 +2020-01-24 17:00:00,137.39,224.52900000000002,68.657,32.641 +2020-01-24 17:15:00,135.66,224.99599999999998,68.657,32.641 +2020-01-24 17:30:00,133.93,225.78099999999998,68.657,32.641 +2020-01-24 17:45:00,135.2,225.53799999999998,68.657,32.641 +2020-01-24 18:00:00,136.43,228.196,67.111,32.641 +2020-01-24 18:15:00,134.39,224.639,67.111,32.641 +2020-01-24 18:30:00,133.68,224.102,67.111,32.641 +2020-01-24 18:45:00,133.53,224.52700000000002,67.111,32.641 +2020-01-24 19:00:00,130.37,223.533,62.434,32.641 +2020-01-24 19:15:00,128.1,220.89700000000002,62.434,32.641 +2020-01-24 19:30:00,130.9,217.423,62.434,32.641 +2020-01-24 19:45:00,136.04,214.078,62.434,32.641 +2020-01-24 20:00:00,125.76,209.85,61.763000000000005,32.641 +2020-01-24 20:15:00,117.6,202.93,61.763000000000005,32.641 +2020-01-24 20:30:00,114.72,198.669,61.763000000000005,32.641 +2020-01-24 20:45:00,110.33,198.265,61.763000000000005,32.641 +2020-01-24 21:00:00,104.51,194.895,56.785,32.641 +2020-01-24 21:15:00,102.91,192.205,56.785,32.641 +2020-01-24 21:30:00,102.15,190.22799999999998,56.785,32.641 +2020-01-24 21:45:00,104.05,189.38099999999997,56.785,32.641 +2020-01-24 22:00:00,99.83,183.11700000000002,52.693000000000005,32.641 +2020-01-24 22:15:00,94.61,177.195,52.693000000000005,32.641 +2020-01-24 22:30:00,89.29,169.947,52.693000000000005,32.641 +2020-01-24 22:45:00,85.86,165.43400000000003,52.693000000000005,32.641 +2020-01-24 23:00:00,82.29,157.219,45.443999999999996,32.641 +2020-01-24 23:15:00,81.42,153.85399999999998,45.443999999999996,32.641 +2020-01-24 23:30:00,80.16,152.725,45.443999999999996,32.641 +2020-01-24 23:45:00,77.47,151.564,45.443999999999996,32.641 +2020-01-25 00:00:00,72.4,135.27200000000002,44.738,32.459 +2020-01-25 00:15:00,71.11,130.214,44.738,32.459 +2020-01-25 00:30:00,72.19,133.029,44.738,32.459 +2020-01-25 00:45:00,70.85,135.756,44.738,32.459 +2020-01-25 01:00:00,71.22,139.132,40.303000000000004,32.459 +2020-01-25 01:15:00,72.27,138.951,40.303000000000004,32.459 +2020-01-25 01:30:00,76.25,138.406,40.303000000000004,32.459 +2020-01-25 01:45:00,75.27,138.54399999999998,40.303000000000004,32.459 +2020-01-25 02:00:00,69.95,141.80200000000002,38.61,32.459 +2020-01-25 02:15:00,68.12,143.468,38.61,32.459 +2020-01-25 02:30:00,65.73,143.929,38.61,32.459 +2020-01-25 02:45:00,65.96,146.268,38.61,32.459 +2020-01-25 03:00:00,64.75,148.87,37.554,32.459 +2020-01-25 03:15:00,65.49,150.054,37.554,32.459 +2020-01-25 03:30:00,64.8,149.96,37.554,32.459 +2020-01-25 03:45:00,65.17,152.093,37.554,32.459 +2020-01-25 04:00:00,64.99,159.15,37.176,32.459 +2020-01-25 04:15:00,65.24,167.739,37.176,32.459 +2020-01-25 04:30:00,65.75,169.365,37.176,32.459 +2020-01-25 04:45:00,66.44,170.56,37.176,32.459 +2020-01-25 05:00:00,66.97,186.34799999999998,36.893,32.459 +2020-01-25 05:15:00,67.29,195.11700000000002,36.893,32.459 +2020-01-25 05:30:00,68.56,192.21200000000002,36.893,32.459 +2020-01-25 05:45:00,69.68,191.523,36.893,32.459 +2020-01-25 06:00:00,69.94,209.548,37.803000000000004,32.459 +2020-01-25 06:15:00,70.69,232.051,37.803000000000004,32.459 +2020-01-25 06:30:00,71.35,228.36900000000003,37.803000000000004,32.459 +2020-01-25 06:45:00,73.5,225.519,37.803000000000004,32.459 +2020-01-25 07:00:00,77.71,218.623,41.086999999999996,32.459 +2020-01-25 07:15:00,78.56,224.142,41.086999999999996,32.459 +2020-01-25 07:30:00,82.94,230.46200000000002,41.086999999999996,32.459 +2020-01-25 07:45:00,84.55,235.69099999999997,41.086999999999996,32.459 +2020-01-25 08:00:00,88.98,237.581,48.222,32.459 +2020-01-25 08:15:00,90.69,241.622,48.222,32.459 +2020-01-25 08:30:00,92.22,241.90599999999998,48.222,32.459 +2020-01-25 08:45:00,95.97,240.745,48.222,32.459 +2020-01-25 09:00:00,98.38,236.59099999999998,52.791000000000004,32.459 +2020-01-25 09:15:00,98.75,234.963,52.791000000000004,32.459 +2020-01-25 09:30:00,99.49,233.83700000000002,52.791000000000004,32.459 +2020-01-25 09:45:00,101.44,230.472,52.791000000000004,32.459 +2020-01-25 10:00:00,102.45,223.484,54.341,32.459 +2020-01-25 10:15:00,103.89,220.745,54.341,32.459 +2020-01-25 10:30:00,102.9,216.793,54.341,32.459 +2020-01-25 10:45:00,104.81,216.046,54.341,32.459 +2020-01-25 11:00:00,103.72,213.127,51.94,32.459 +2020-01-25 11:15:00,105.87,210.00900000000001,51.94,32.459 +2020-01-25 11:30:00,107.38,208.875,51.94,32.459 +2020-01-25 11:45:00,106.98,207.255,51.94,32.459 +2020-01-25 12:00:00,104.05,203.44,50.973,32.459 +2020-01-25 12:15:00,103.31,201.46900000000002,50.973,32.459 +2020-01-25 12:30:00,100.71,201.627,50.973,32.459 +2020-01-25 12:45:00,99.96,201.85299999999998,50.973,32.459 +2020-01-25 13:00:00,97.04,200.072,48.06399999999999,32.459 +2020-01-25 13:15:00,97.34,197.856,48.06399999999999,32.459 +2020-01-25 13:30:00,95.63,196.245,48.06399999999999,32.459 +2020-01-25 13:45:00,94.67,197.16,48.06399999999999,32.459 +2020-01-25 14:00:00,91.46,197.327,45.707,32.459 +2020-01-25 14:15:00,92.11,197.06900000000002,45.707,32.459 +2020-01-25 14:30:00,91.72,196.25,45.707,32.459 +2020-01-25 14:45:00,91.73,197.745,45.707,32.459 +2020-01-25 15:00:00,90.8,198.975,47.567,32.459 +2020-01-25 15:15:00,90.03,200.00599999999997,47.567,32.459 +2020-01-25 15:30:00,89.45,201.695,47.567,32.459 +2020-01-25 15:45:00,88.86,202.729,47.567,32.459 +2020-01-25 16:00:00,90.25,201.65900000000002,52.031000000000006,32.459 +2020-01-25 16:15:00,89.46,204.97400000000002,52.031000000000006,32.459 +2020-01-25 16:30:00,90.4,207.842,52.031000000000006,32.459 +2020-01-25 16:45:00,93.84,210.09400000000002,52.031000000000006,32.459 +2020-01-25 17:00:00,100.62,212.105,58.218999999999994,32.459 +2020-01-25 17:15:00,102.8,214.77700000000002,58.218999999999994,32.459 +2020-01-25 17:30:00,106.7,215.502,58.218999999999994,32.459 +2020-01-25 17:45:00,106.99,214.757,58.218999999999994,32.459 +2020-01-25 18:00:00,107.5,216.775,57.65,32.459 +2020-01-25 18:15:00,107.19,215.09,57.65,32.459 +2020-01-25 18:30:00,109.91,215.905,57.65,32.459 +2020-01-25 18:45:00,105.29,212.97400000000002,57.65,32.459 +2020-01-25 19:00:00,104.0,213.22099999999998,51.261,32.459 +2020-01-25 19:15:00,102.55,210.149,51.261,32.459 +2020-01-25 19:30:00,101.86,207.44799999999998,51.261,32.459 +2020-01-25 19:45:00,102.2,203.72,51.261,32.459 +2020-01-25 20:00:00,94.93,201.78,44.068000000000005,32.459 +2020-01-25 20:15:00,92.01,197.358,44.068000000000005,32.459 +2020-01-25 20:30:00,88.94,192.80700000000002,44.068000000000005,32.459 +2020-01-25 20:45:00,87.29,191.732,44.068000000000005,32.459 +2020-01-25 21:00:00,83.53,191.08,38.861,32.459 +2020-01-25 21:15:00,82.66,188.91299999999998,38.861,32.459 +2020-01-25 21:30:00,80.26,188.308,38.861,32.459 +2020-01-25 21:45:00,80.05,187.084,38.861,32.459 +2020-01-25 22:00:00,77.81,182.33900000000003,39.485,32.459 +2020-01-25 22:15:00,76.34,179.227,39.485,32.459 +2020-01-25 22:30:00,73.65,179.05900000000003,39.485,32.459 +2020-01-25 22:45:00,72.2,176.628,39.485,32.459 +2020-01-25 23:00:00,67.88,171.222,32.027,32.459 +2020-01-25 23:15:00,68.21,165.968,32.027,32.459 +2020-01-25 23:30:00,62.43,162.59,32.027,32.459 +2020-01-25 23:45:00,63.07,158.65,32.027,32.459 +2020-01-26 00:00:00,60.64,135.531,26.96,32.459 +2020-01-26 00:15:00,59.92,130.25799999999998,26.96,32.459 +2020-01-26 00:30:00,59.03,132.641,26.96,32.459 +2020-01-26 00:45:00,58.62,136.18200000000002,26.96,32.459 +2020-01-26 01:00:00,54.93,139.338,24.295,32.459 +2020-01-26 01:15:00,55.99,140.374,24.295,32.459 +2020-01-26 01:30:00,55.67,140.451,24.295,32.459 +2020-01-26 01:45:00,54.2,140.276,24.295,32.459 +2020-01-26 02:00:00,53.39,142.659,24.268,32.459 +2020-01-26 02:15:00,54.08,143.218,24.268,32.459 +2020-01-26 02:30:00,52.98,144.661,24.268,32.459 +2020-01-26 02:45:00,53.02,147.586,24.268,32.459 +2020-01-26 03:00:00,53.08,150.45600000000002,23.373,32.459 +2020-01-26 03:15:00,50.37,150.995,23.373,32.459 +2020-01-26 03:30:00,52.45,152.66,23.373,32.459 +2020-01-26 03:45:00,52.94,154.84,23.373,32.459 +2020-01-26 04:00:00,52.89,161.61,23.874000000000002,32.459 +2020-01-26 04:15:00,53.22,169.06799999999998,23.874000000000002,32.459 +2020-01-26 04:30:00,54.0,170.55700000000002,23.874000000000002,32.459 +2020-01-26 04:45:00,54.34,172.14,23.874000000000002,32.459 +2020-01-26 05:00:00,55.82,183.72400000000002,24.871,32.459 +2020-01-26 05:15:00,56.59,189.75599999999997,24.871,32.459 +2020-01-26 05:30:00,55.44,186.71400000000003,24.871,32.459 +2020-01-26 05:45:00,56.61,186.375,24.871,32.459 +2020-01-26 06:00:00,56.99,204.688,23.84,32.459 +2020-01-26 06:15:00,56.78,225.035,23.84,32.459 +2020-01-26 06:30:00,57.8,220.16099999999997,23.84,32.459 +2020-01-26 06:45:00,58.26,216.248,23.84,32.459 +2020-01-26 07:00:00,61.45,212.21599999999998,27.430999999999997,32.459 +2020-01-26 07:15:00,62.62,216.979,27.430999999999997,32.459 +2020-01-26 07:30:00,64.42,221.612,27.430999999999997,32.459 +2020-01-26 07:45:00,65.23,225.917,27.430999999999997,32.459 +2020-01-26 08:00:00,67.41,229.857,33.891999999999996,32.459 +2020-01-26 08:15:00,70.28,233.58700000000002,33.891999999999996,32.459 +2020-01-26 08:30:00,72.53,235.627,33.891999999999996,32.459 +2020-01-26 08:45:00,74.96,236.83,33.891999999999996,32.459 +2020-01-26 09:00:00,76.29,232.225,37.571,32.459 +2020-01-26 09:15:00,77.45,231.31799999999998,37.571,32.459 +2020-01-26 09:30:00,78.94,229.967,37.571,32.459 +2020-01-26 09:45:00,80.75,226.315,37.571,32.459 +2020-01-26 10:00:00,81.35,222.179,40.594,32.459 +2020-01-26 10:15:00,83.76,220.058,40.594,32.459 +2020-01-26 10:30:00,85.49,216.775,40.594,32.459 +2020-01-26 10:45:00,88.12,213.672,40.594,32.459 +2020-01-26 11:00:00,88.23,211.86900000000003,44.133,32.459 +2020-01-26 11:15:00,92.2,208.975,44.133,32.459 +2020-01-26 11:30:00,92.74,206.762,44.133,32.459 +2020-01-26 11:45:00,93.24,205.81400000000002,44.133,32.459 +2020-01-26 12:00:00,91.73,201.201,41.198,32.459 +2020-01-26 12:15:00,89.36,201.549,41.198,32.459 +2020-01-26 12:30:00,84.83,199.968,41.198,32.459 +2020-01-26 12:45:00,83.74,199.162,41.198,32.459 +2020-01-26 13:00:00,80.41,196.597,37.014,32.459 +2020-01-26 13:15:00,80.17,197.945,37.014,32.459 +2020-01-26 13:30:00,81.07,196.236,37.014,32.459 +2020-01-26 13:45:00,80.59,196.222,37.014,32.459 +2020-01-26 14:00:00,79.11,196.545,34.934,32.459 +2020-01-26 14:15:00,78.6,197.6,34.934,32.459 +2020-01-26 14:30:00,78.67,198.37900000000002,34.934,32.459 +2020-01-26 14:45:00,79.38,199.56599999999997,34.934,32.459 +2020-01-26 15:00:00,79.93,199.014,34.588,32.459 +2020-01-26 15:15:00,78.78,201.0,34.588,32.459 +2020-01-26 15:30:00,78.6,203.368,34.588,32.459 +2020-01-26 15:45:00,77.74,205.146,34.588,32.459 +2020-01-26 16:00:00,80.38,206.40099999999998,37.874,32.459 +2020-01-26 16:15:00,79.54,208.645,37.874,32.459 +2020-01-26 16:30:00,81.49,211.685,37.874,32.459 +2020-01-26 16:45:00,84.68,214.078,37.874,32.459 +2020-01-26 17:00:00,92.75,215.987,47.303999999999995,32.459 +2020-01-26 17:15:00,94.5,218.13099999999997,47.303999999999995,32.459 +2020-01-26 17:30:00,96.47,219.12099999999998,47.303999999999995,32.459 +2020-01-26 17:45:00,98.7,220.93200000000002,47.303999999999995,32.459 +2020-01-26 18:00:00,100.59,222.25099999999998,48.879,32.459 +2020-01-26 18:15:00,99.21,222.146,48.879,32.459 +2020-01-26 18:30:00,99.63,220.636,48.879,32.459 +2020-01-26 18:45:00,98.32,219.80900000000003,48.879,32.459 +2020-01-26 19:00:00,96.9,219.37599999999998,44.826,32.459 +2020-01-26 19:15:00,95.53,217.101,44.826,32.459 +2020-01-26 19:30:00,98.34,214.25599999999997,44.826,32.459 +2020-01-26 19:45:00,100.34,212.222,44.826,32.459 +2020-01-26 20:00:00,98.08,210.243,40.154,32.459 +2020-01-26 20:15:00,89.23,206.959,40.154,32.459 +2020-01-26 20:30:00,88.79,203.717,40.154,32.459 +2020-01-26 20:45:00,85.18,201.489,40.154,32.459 +2020-01-26 21:00:00,83.88,197.894,36.549,32.459 +2020-01-26 21:15:00,90.22,195.03599999999997,36.549,32.459 +2020-01-26 21:30:00,90.09,194.83700000000002,36.549,32.459 +2020-01-26 21:45:00,86.75,193.743,36.549,32.459 +2020-01-26 22:00:00,84.32,187.405,37.663000000000004,32.459 +2020-01-26 22:15:00,87.31,183.648,37.663000000000004,32.459 +2020-01-26 22:30:00,87.15,179.94400000000002,37.663000000000004,32.459 +2020-01-26 22:45:00,85.87,176.705,37.663000000000004,32.459 +2020-01-26 23:00:00,78.89,168.15599999999998,31.945,32.459 +2020-01-26 23:15:00,82.8,164.87,31.945,32.459 +2020-01-26 23:30:00,81.36,162.487,31.945,32.459 +2020-01-26 23:45:00,80.85,159.548,31.945,32.459 +2020-01-27 00:00:00,72.01,140.157,31.533,32.641 +2020-01-27 00:15:00,74.98,138.209,31.533,32.641 +2020-01-27 00:30:00,75.17,140.77700000000002,31.533,32.641 +2020-01-27 00:45:00,73.37,143.741,31.533,32.641 +2020-01-27 01:00:00,65.99,146.861,30.56,32.641 +2020-01-27 01:15:00,72.24,147.267,30.56,32.641 +2020-01-27 01:30:00,72.74,147.346,30.56,32.641 +2020-01-27 01:45:00,73.19,147.313,30.56,32.641 +2020-01-27 02:00:00,68.81,149.619,29.55,32.641 +2020-01-27 02:15:00,72.66,152.05,29.55,32.641 +2020-01-27 02:30:00,73.33,153.86700000000002,29.55,32.641 +2020-01-27 02:45:00,72.31,156.089,29.55,32.641 +2020-01-27 03:00:00,67.49,160.394,27.059,32.641 +2020-01-27 03:15:00,73.56,162.764,27.059,32.641 +2020-01-27 03:30:00,75.02,163.99900000000002,27.059,32.641 +2020-01-27 03:45:00,71.92,165.61599999999999,27.059,32.641 +2020-01-27 04:00:00,66.96,177.02,28.384,32.641 +2020-01-27 04:15:00,68.37,188.86900000000003,28.384,32.641 +2020-01-27 04:30:00,70.66,193.035,28.384,32.641 +2020-01-27 04:45:00,72.68,194.738,28.384,32.641 +2020-01-27 05:00:00,77.25,223.778,35.915,32.641 +2020-01-27 05:15:00,79.29,251.91099999999997,35.915,32.641 +2020-01-27 05:30:00,84.6,249.452,35.915,32.641 +2020-01-27 05:45:00,89.92,242.958,35.915,32.641 +2020-01-27 06:00:00,99.95,241.782,56.18,32.641 +2020-01-27 06:15:00,104.93,246.144,56.18,32.641 +2020-01-27 06:30:00,110.18,250.06400000000002,56.18,32.641 +2020-01-27 06:45:00,113.96,255.674,56.18,32.641 +2020-01-27 07:00:00,121.51,254.377,70.877,32.641 +2020-01-27 07:15:00,123.92,260.205,70.877,32.641 +2020-01-27 07:30:00,123.09,264.038,70.877,32.641 +2020-01-27 07:45:00,125.61,265.228,70.877,32.641 +2020-01-27 08:00:00,127.27,263.626,65.65,32.641 +2020-01-27 08:15:00,127.65,265.01099999999997,65.65,32.641 +2020-01-27 08:30:00,129.37,262.36,65.65,32.641 +2020-01-27 08:45:00,125.27,259.60400000000004,65.65,32.641 +2020-01-27 09:00:00,124.61,253.929,62.037,32.641 +2020-01-27 09:15:00,126.22,249.234,62.037,32.641 +2020-01-27 09:30:00,126.26,246.88,62.037,32.641 +2020-01-27 09:45:00,122.4,243.92700000000002,62.037,32.641 +2020-01-27 10:00:00,124.71,238.52,60.409,32.641 +2020-01-27 10:15:00,124.92,236.058,60.409,32.641 +2020-01-27 10:30:00,126.43,231.824,60.409,32.641 +2020-01-27 10:45:00,127.62,229.86,60.409,32.641 +2020-01-27 11:00:00,128.14,224.88299999999998,60.211999999999996,32.641 +2020-01-27 11:15:00,129.57,224.053,60.211999999999996,32.641 +2020-01-27 11:30:00,130.44,223.34599999999998,60.211999999999996,32.641 +2020-01-27 11:45:00,132.01,221.85299999999998,60.211999999999996,32.641 +2020-01-27 12:00:00,131.87,219.55700000000002,57.733000000000004,32.641 +2020-01-27 12:15:00,130.69,219.908,57.733000000000004,32.641 +2020-01-27 12:30:00,127.99,218.766,57.733000000000004,32.641 +2020-01-27 12:45:00,128.57,219.735,57.733000000000004,32.641 +2020-01-27 13:00:00,127.25,217.735,58.695,32.641 +2020-01-27 13:15:00,127.54,217.582,58.695,32.641 +2020-01-27 13:30:00,126.3,215.213,58.695,32.641 +2020-01-27 13:45:00,124.1,215.097,58.695,32.641 +2020-01-27 14:00:00,120.85,214.834,59.505,32.641 +2020-01-27 14:15:00,122.78,215.037,59.505,32.641 +2020-01-27 14:30:00,121.73,215.206,59.505,32.641 +2020-01-27 14:45:00,122.16,216.058,59.505,32.641 +2020-01-27 15:00:00,122.79,217.581,59.946000000000005,32.641 +2020-01-27 15:15:00,122.37,217.96099999999998,59.946000000000005,32.641 +2020-01-27 15:30:00,120.21,219.23,59.946000000000005,32.641 +2020-01-27 15:45:00,121.58,220.549,59.946000000000005,32.641 +2020-01-27 16:00:00,125.44,221.766,61.766999999999996,32.641 +2020-01-27 16:15:00,124.32,223.122,61.766999999999996,32.641 +2020-01-27 16:30:00,125.38,225.149,61.766999999999996,32.641 +2020-01-27 16:45:00,128.84,226.167,61.766999999999996,32.641 +2020-01-27 17:00:00,134.98,227.94099999999997,67.85600000000001,32.641 +2020-01-27 17:15:00,133.74,228.979,67.85600000000001,32.641 +2020-01-27 17:30:00,134.91,229.42700000000002,67.85600000000001,32.641 +2020-01-27 17:45:00,134.47,229.61,67.85600000000001,32.641 +2020-01-27 18:00:00,134.73,231.514,64.564,32.641 +2020-01-27 18:15:00,131.96,229.16400000000002,64.564,32.641 +2020-01-27 18:30:00,133.73,228.49200000000002,64.564,32.641 +2020-01-27 18:45:00,132.73,228.109,64.564,32.641 +2020-01-27 19:00:00,129.78,225.815,58.536,32.641 +2020-01-27 19:15:00,126.63,222.06799999999998,58.536,32.641 +2020-01-27 19:30:00,126.74,219.84400000000002,58.536,32.641 +2020-01-27 19:45:00,131.0,216.96200000000002,58.536,32.641 +2020-01-27 20:00:00,124.95,212.45,59.888999999999996,32.641 +2020-01-27 20:15:00,116.65,206.18,59.888999999999996,32.641 +2020-01-27 20:30:00,113.27,200.757,59.888999999999996,32.641 +2020-01-27 20:45:00,109.79,200.363,59.888999999999996,32.641 +2020-01-27 21:00:00,104.5,197.43400000000003,52.652,32.641 +2020-01-27 21:15:00,102.34,193.169,52.652,32.641 +2020-01-27 21:30:00,105.41,191.98,52.652,32.641 +2020-01-27 21:45:00,106.02,190.355,52.652,32.641 +2020-01-27 22:00:00,103.67,180.989,46.17,32.641 +2020-01-27 22:15:00,92.1,175.507,46.17,32.641 +2020-01-27 22:30:00,90.73,161.47299999999998,46.17,32.641 +2020-01-27 22:45:00,86.82,152.751,46.17,32.641 +2020-01-27 23:00:00,85.71,145.082,36.281,32.641 +2020-01-27 23:15:00,90.44,145.025,36.281,32.641 +2020-01-27 23:30:00,87.95,145.799,36.281,32.641 +2020-01-27 23:45:00,82.47,145.94,36.281,32.641 +2020-01-28 00:00:00,78.26,139.671,38.821999999999996,32.641 +2020-01-28 00:15:00,75.34,139.238,38.821999999999996,32.641 +2020-01-28 00:30:00,79.52,140.57399999999998,38.821999999999996,32.641 +2020-01-28 00:45:00,82.22,142.25799999999998,38.821999999999996,32.641 +2020-01-28 01:00:00,79.73,145.21200000000002,36.936,32.641 +2020-01-28 01:15:00,76.69,145.066,36.936,32.641 +2020-01-28 01:30:00,75.14,145.361,36.936,32.641 +2020-01-28 01:45:00,76.58,145.766,36.936,32.641 +2020-01-28 02:00:00,79.49,148.15200000000002,34.42,32.641 +2020-01-28 02:15:00,79.78,150.255,34.42,32.641 +2020-01-28 02:30:00,77.63,151.44899999999998,34.42,32.641 +2020-01-28 02:45:00,72.78,153.629,34.42,32.641 +2020-01-28 03:00:00,78.0,156.595,33.585,32.641 +2020-01-28 03:15:00,81.7,157.804,33.585,32.641 +2020-01-28 03:30:00,83.51,159.614,33.585,32.641 +2020-01-28 03:45:00,82.44,161.667,33.585,32.641 +2020-01-28 04:00:00,77.73,173.03900000000002,35.622,32.641 +2020-01-28 04:15:00,84.2,184.46099999999998,35.622,32.641 +2020-01-28 04:30:00,87.03,188.28799999999998,35.622,32.641 +2020-01-28 04:45:00,89.61,191.332,35.622,32.641 +2020-01-28 05:00:00,89.09,226.02,40.599000000000004,32.641 +2020-01-28 05:15:00,87.98,253.782,40.599000000000004,32.641 +2020-01-28 05:30:00,94.26,249.44400000000002,40.599000000000004,32.641 +2020-01-28 05:45:00,98.87,243.109,40.599000000000004,32.641 +2020-01-28 06:00:00,105.04,240.338,55.203,32.641 +2020-01-28 06:15:00,109.62,246.618,55.203,32.641 +2020-01-28 06:30:00,113.28,249.817,55.203,32.641 +2020-01-28 06:45:00,117.53,255.15900000000002,55.203,32.641 +2020-01-28 07:00:00,128.45,253.637,69.029,32.641 +2020-01-28 07:15:00,128.39,259.301,69.029,32.641 +2020-01-28 07:30:00,128.01,262.45099999999996,69.029,32.641 +2020-01-28 07:45:00,129.0,264.04200000000003,69.029,32.641 +2020-01-28 08:00:00,131.4,262.563,65.85300000000001,32.641 +2020-01-28 08:15:00,131.16,262.79400000000004,65.85300000000001,32.641 +2020-01-28 08:30:00,129.91,259.853,65.85300000000001,32.641 +2020-01-28 08:45:00,128.94,256.934,65.85300000000001,32.641 +2020-01-28 09:00:00,130.01,250.21099999999998,61.566,32.641 +2020-01-28 09:15:00,130.06,247.43,61.566,32.641 +2020-01-28 09:30:00,128.11,245.77700000000002,61.566,32.641 +2020-01-28 09:45:00,126.64,242.335,61.566,32.641 +2020-01-28 10:00:00,125.99,236.479,61.244,32.641 +2020-01-28 10:15:00,125.95,232.801,61.244,32.641 +2020-01-28 10:30:00,127.09,228.778,61.244,32.641 +2020-01-28 10:45:00,125.87,226.981,61.244,32.641 +2020-01-28 11:00:00,127.54,223.747,61.16,32.641 +2020-01-28 11:15:00,126.56,222.465,61.16,32.641 +2020-01-28 11:30:00,125.17,220.58599999999998,61.16,32.641 +2020-01-28 11:45:00,127.37,220.002,61.16,32.641 +2020-01-28 12:00:00,124.87,216.142,59.09,32.641 +2020-01-28 12:15:00,123.4,215.963,59.09,32.641 +2020-01-28 12:30:00,122.31,215.505,59.09,32.641 +2020-01-28 12:45:00,119.99,216.01,59.09,32.641 +2020-01-28 13:00:00,117.55,213.6,60.21,32.641 +2020-01-28 13:15:00,119.52,212.706,60.21,32.641 +2020-01-28 13:30:00,119.94,211.708,60.21,32.641 +2020-01-28 13:45:00,117.83,211.977,60.21,32.641 +2020-01-28 14:00:00,115.55,212.02200000000002,60.673,32.641 +2020-01-28 14:15:00,117.18,212.426,60.673,32.641 +2020-01-28 14:30:00,118.0,213.271,60.673,32.641 +2020-01-28 14:45:00,119.26,214.18,60.673,32.641 +2020-01-28 15:00:00,119.6,215.243,62.232,32.641 +2020-01-28 15:15:00,119.97,215.834,62.232,32.641 +2020-01-28 15:30:00,119.48,217.37400000000002,62.232,32.641 +2020-01-28 15:45:00,120.64,218.15,62.232,32.641 +2020-01-28 16:00:00,121.9,219.89,63.611999999999995,32.641 +2020-01-28 16:15:00,122.55,221.785,63.611999999999995,32.641 +2020-01-28 16:30:00,127.29,224.62599999999998,63.611999999999995,32.641 +2020-01-28 16:45:00,127.69,225.864,63.611999999999995,32.641 +2020-01-28 17:00:00,132.53,228.227,70.658,32.641 +2020-01-28 17:15:00,136.01,229.27,70.658,32.641 +2020-01-28 17:30:00,140.13,230.585,70.658,32.641 +2020-01-28 17:45:00,139.21,230.69,70.658,32.641 +2020-01-28 18:00:00,139.49,232.614,68.361,32.641 +2020-01-28 18:15:00,137.64,229.528,68.361,32.641 +2020-01-28 18:30:00,137.81,228.544,68.361,32.641 +2020-01-28 18:45:00,137.16,229.122,68.361,32.641 +2020-01-28 19:00:00,134.44,227.023,62.922,32.641 +2020-01-28 19:15:00,132.06,222.94400000000002,62.922,32.641 +2020-01-28 19:30:00,137.09,220.002,62.922,32.641 +2020-01-28 19:45:00,138.01,217.142,62.922,32.641 +2020-01-28 20:00:00,129.62,212.735,63.251999999999995,32.641 +2020-01-28 20:15:00,119.68,205.938,63.251999999999995,32.641 +2020-01-28 20:30:00,118.14,201.671,63.251999999999995,32.641 +2020-01-28 20:45:00,115.08,200.576,63.251999999999995,32.641 +2020-01-28 21:00:00,112.27,196.71099999999998,54.47,32.641 +2020-01-28 21:15:00,114.93,193.66400000000002,54.47,32.641 +2020-01-28 21:30:00,113.2,191.614,54.47,32.641 +2020-01-28 21:45:00,108.39,190.24,54.47,32.641 +2020-01-28 22:00:00,99.19,182.795,51.12,32.641 +2020-01-28 22:15:00,103.31,177.084,51.12,32.641 +2020-01-28 22:30:00,102.8,163.095,51.12,32.641 +2020-01-28 22:45:00,99.67,154.697,51.12,32.641 +2020-01-28 23:00:00,91.53,147.145,42.156000000000006,32.641 +2020-01-28 23:15:00,92.74,145.862,42.156000000000006,32.641 +2020-01-28 23:30:00,92.86,146.243,42.156000000000006,32.641 +2020-01-28 23:45:00,91.28,145.873,42.156000000000006,32.641 +2020-01-29 00:00:00,85.71,139.624,37.192,32.641 +2020-01-29 00:15:00,85.1,139.17700000000002,37.192,32.641 +2020-01-29 00:30:00,86.49,140.494,37.192,32.641 +2020-01-29 00:45:00,83.22,142.166,37.192,32.641 +2020-01-29 01:00:00,79.05,145.10299999999998,32.24,32.641 +2020-01-29 01:15:00,81.99,144.944,32.24,32.641 +2020-01-29 01:30:00,83.1,145.232,32.24,32.641 +2020-01-29 01:45:00,82.85,145.632,32.24,32.641 +2020-01-29 02:00:00,76.67,148.023,30.34,32.641 +2020-01-29 02:15:00,79.99,150.127,30.34,32.641 +2020-01-29 02:30:00,80.46,151.332,30.34,32.641 +2020-01-29 02:45:00,82.45,153.511,30.34,32.641 +2020-01-29 03:00:00,78.21,156.477,29.129,32.641 +2020-01-29 03:15:00,81.98,157.697,29.129,32.641 +2020-01-29 03:30:00,83.78,159.504,29.129,32.641 +2020-01-29 03:45:00,84.27,161.56799999999998,29.129,32.641 +2020-01-29 04:00:00,79.14,172.926,30.075,32.641 +2020-01-29 04:15:00,81.89,184.338,30.075,32.641 +2020-01-29 04:30:00,86.55,188.173,30.075,32.641 +2020-01-29 04:45:00,86.15,191.208,30.075,32.641 +2020-01-29 05:00:00,85.56,225.864,35.684,32.641 +2020-01-29 05:15:00,86.72,253.618,35.684,32.641 +2020-01-29 05:30:00,89.23,249.269,35.684,32.641 +2020-01-29 05:45:00,94.99,242.946,35.684,32.641 +2020-01-29 06:00:00,105.54,240.192,51.49,32.641 +2020-01-29 06:15:00,109.68,246.484,51.49,32.641 +2020-01-29 06:30:00,113.38,249.673,51.49,32.641 +2020-01-29 06:45:00,116.12,255.03099999999998,51.49,32.641 +2020-01-29 07:00:00,121.07,253.53,68.242,32.641 +2020-01-29 07:15:00,126.34,259.173,68.242,32.641 +2020-01-29 07:30:00,126.89,262.293,68.242,32.641 +2020-01-29 07:45:00,128.53,263.848,68.242,32.641 +2020-01-29 08:00:00,129.79,262.355,63.619,32.641 +2020-01-29 08:15:00,129.69,262.562,63.619,32.641 +2020-01-29 08:30:00,128.46,259.569,63.619,32.641 +2020-01-29 08:45:00,129.7,256.64,63.619,32.641 +2020-01-29 09:00:00,130.75,249.91400000000002,61.333,32.641 +2020-01-29 09:15:00,133.1,247.137,61.333,32.641 +2020-01-29 09:30:00,134.98,245.503,61.333,32.641 +2020-01-29 09:45:00,134.4,242.05900000000003,61.333,32.641 +2020-01-29 10:00:00,132.46,236.209,59.663000000000004,32.641 +2020-01-29 10:15:00,133.39,232.55200000000002,59.663000000000004,32.641 +2020-01-29 10:30:00,133.22,228.53099999999998,59.663000000000004,32.641 +2020-01-29 10:45:00,132.39,226.74400000000003,59.663000000000004,32.641 +2020-01-29 11:00:00,132.31,223.495,59.771,32.641 +2020-01-29 11:15:00,133.12,222.22,59.771,32.641 +2020-01-29 11:30:00,134.86,220.34599999999998,59.771,32.641 +2020-01-29 11:45:00,133.3,219.771,59.771,32.641 +2020-01-29 12:00:00,132.7,215.925,58.723,32.641 +2020-01-29 12:15:00,132.51,215.765,58.723,32.641 +2020-01-29 12:30:00,131.59,215.287,58.723,32.641 +2020-01-29 12:45:00,130.75,215.78900000000002,58.723,32.641 +2020-01-29 13:00:00,129.37,213.39,58.727,32.641 +2020-01-29 13:15:00,129.56,212.47400000000002,58.727,32.641 +2020-01-29 13:30:00,127.85,211.46099999999998,58.727,32.641 +2020-01-29 13:45:00,128.01,211.72799999999998,58.727,32.641 +2020-01-29 14:00:00,127.61,211.81799999999998,59.803999999999995,32.641 +2020-01-29 14:15:00,127.81,212.204,59.803999999999995,32.641 +2020-01-29 14:30:00,127.7,213.043,59.803999999999995,32.641 +2020-01-29 14:45:00,127.4,213.96900000000002,59.803999999999995,32.641 +2020-01-29 15:00:00,128.03,215.045,61.05,32.641 +2020-01-29 15:15:00,127.39,215.608,61.05,32.641 +2020-01-29 15:30:00,128.43,217.12,61.05,32.641 +2020-01-29 15:45:00,124.88,217.88,61.05,32.641 +2020-01-29 16:00:00,126.8,219.623,64.012,32.641 +2020-01-29 16:15:00,126.43,221.516,64.012,32.641 +2020-01-29 16:30:00,130.89,224.363,64.012,32.641 +2020-01-29 16:45:00,131.2,225.593,64.012,32.641 +2020-01-29 17:00:00,135.48,227.94799999999998,66.751,32.641 +2020-01-29 17:15:00,135.19,229.023,66.751,32.641 +2020-01-29 17:30:00,136.26,230.373,66.751,32.641 +2020-01-29 17:45:00,136.97,230.505,66.751,32.641 +2020-01-29 18:00:00,136.56,232.447,65.91199999999999,32.641 +2020-01-29 18:15:00,134.46,229.4,65.91199999999999,32.641 +2020-01-29 18:30:00,136.21,228.42,65.91199999999999,32.641 +2020-01-29 18:45:00,134.51,229.021,65.91199999999999,32.641 +2020-01-29 19:00:00,130.95,226.882,63.324,32.641 +2020-01-29 19:15:00,130.8,222.81099999999998,63.324,32.641 +2020-01-29 19:30:00,128.57,219.885,63.324,32.641 +2020-01-29 19:45:00,133.5,217.047,63.324,32.641 +2020-01-29 20:00:00,127.01,212.614,63.573,32.641 +2020-01-29 20:15:00,117.14,205.825,63.573,32.641 +2020-01-29 20:30:00,114.01,201.562,63.573,32.641 +2020-01-29 20:45:00,109.09,200.477,63.573,32.641 +2020-01-29 21:00:00,107.15,196.59400000000002,55.073,32.641 +2020-01-29 21:15:00,111.75,193.52900000000002,55.073,32.641 +2020-01-29 21:30:00,109.55,191.48,55.073,32.641 +2020-01-29 21:45:00,104.52,190.122,55.073,32.641 +2020-01-29 22:00:00,95.88,182.666,51.321999999999996,32.641 +2020-01-29 22:15:00,96.63,176.979,51.321999999999996,32.641 +2020-01-29 22:30:00,97.76,162.968,51.321999999999996,32.641 +2020-01-29 22:45:00,95.81,154.578,51.321999999999996,32.641 +2020-01-29 23:00:00,89.08,147.014,42.09,32.641 +2020-01-29 23:15:00,83.66,145.744,42.09,32.641 +2020-01-29 23:30:00,86.56,146.143,42.09,32.641 +2020-01-29 23:45:00,88.77,145.786,42.09,32.641 +2020-01-30 00:00:00,82.65,139.567,38.399,32.641 +2020-01-30 00:15:00,78.77,139.108,38.399,32.641 +2020-01-30 00:30:00,76.46,140.406,38.399,32.641 +2020-01-30 00:45:00,81.36,142.06799999999998,38.399,32.641 +2020-01-30 01:00:00,78.58,144.985,36.94,32.641 +2020-01-30 01:15:00,78.21,144.812,36.94,32.641 +2020-01-30 01:30:00,76.11,145.093,36.94,32.641 +2020-01-30 01:45:00,79.6,145.488,36.94,32.641 +2020-01-30 02:00:00,78.45,147.884,35.275,32.641 +2020-01-30 02:15:00,77.62,149.989,35.275,32.641 +2020-01-30 02:30:00,71.61,151.20600000000002,35.275,32.641 +2020-01-30 02:45:00,72.74,153.385,35.275,32.641 +2020-01-30 03:00:00,71.23,156.349,35.329,32.641 +2020-01-30 03:15:00,71.37,157.578,35.329,32.641 +2020-01-30 03:30:00,74.11,159.38299999999998,35.329,32.641 +2020-01-30 03:45:00,80.9,161.459,35.329,32.641 +2020-01-30 04:00:00,82.14,172.805,36.275,32.641 +2020-01-30 04:15:00,83.94,184.206,36.275,32.641 +2020-01-30 04:30:00,79.65,188.05,36.275,32.641 +2020-01-30 04:45:00,79.86,191.074,36.275,32.641 +2020-01-30 05:00:00,84.84,225.699,42.193999999999996,32.641 +2020-01-30 05:15:00,87.97,253.44799999999998,42.193999999999996,32.641 +2020-01-30 05:30:00,91.94,249.08700000000002,42.193999999999996,32.641 +2020-01-30 05:45:00,96.69,242.77599999999998,42.193999999999996,32.641 +2020-01-30 06:00:00,105.79,240.03900000000002,56.422,32.641 +2020-01-30 06:15:00,111.14,246.34400000000002,56.422,32.641 +2020-01-30 06:30:00,115.88,249.519,56.422,32.641 +2020-01-30 06:45:00,119.21,254.891,56.422,32.641 +2020-01-30 07:00:00,127.68,253.41299999999998,72.569,32.641 +2020-01-30 07:15:00,130.37,259.034,72.569,32.641 +2020-01-30 07:30:00,129.83,262.124,72.569,32.641 +2020-01-30 07:45:00,131.25,263.643,72.569,32.641 +2020-01-30 08:00:00,133.61,262.135,67.704,32.641 +2020-01-30 08:15:00,133.19,262.318,67.704,32.641 +2020-01-30 08:30:00,132.87,259.27099999999996,67.704,32.641 +2020-01-30 08:45:00,132.5,256.33299999999997,67.704,32.641 +2020-01-30 09:00:00,132.2,249.60299999999998,63.434,32.641 +2020-01-30 09:15:00,133.4,246.83,63.434,32.641 +2020-01-30 09:30:00,133.88,245.217,63.434,32.641 +2020-01-30 09:45:00,134.13,241.77200000000002,63.434,32.641 +2020-01-30 10:00:00,134.86,235.928,61.88399999999999,32.641 +2020-01-30 10:15:00,134.96,232.292,61.88399999999999,32.641 +2020-01-30 10:30:00,133.39,228.273,61.88399999999999,32.641 +2020-01-30 10:45:00,134.4,226.497,61.88399999999999,32.641 +2020-01-30 11:00:00,133.27,223.232,61.481,32.641 +2020-01-30 11:15:00,134.3,221.965,61.481,32.641 +2020-01-30 11:30:00,136.88,220.09599999999998,61.481,32.641 +2020-01-30 11:45:00,137.03,219.52900000000002,61.481,32.641 +2020-01-30 12:00:00,133.83,215.699,59.527,32.641 +2020-01-30 12:15:00,133.63,215.558,59.527,32.641 +2020-01-30 12:30:00,132.45,215.06,59.527,32.641 +2020-01-30 12:45:00,132.76,215.56,59.527,32.641 +2020-01-30 13:00:00,132.63,213.172,58.794,32.641 +2020-01-30 13:15:00,131.93,212.234,58.794,32.641 +2020-01-30 13:30:00,131.32,211.206,58.794,32.641 +2020-01-30 13:45:00,131.62,211.47099999999998,58.794,32.641 +2020-01-30 14:00:00,130.86,211.605,60.32,32.641 +2020-01-30 14:15:00,130.65,211.976,60.32,32.641 +2020-01-30 14:30:00,129.29,212.80599999999998,60.32,32.641 +2020-01-30 14:45:00,129.11,213.748,60.32,32.641 +2020-01-30 15:00:00,128.71,214.838,62.52,32.641 +2020-01-30 15:15:00,127.59,215.372,62.52,32.641 +2020-01-30 15:30:00,126.25,216.857,62.52,32.641 +2020-01-30 15:45:00,127.28,217.6,62.52,32.641 +2020-01-30 16:00:00,127.19,219.34400000000002,64.199,32.641 +2020-01-30 16:15:00,126.69,221.235,64.199,32.641 +2020-01-30 16:30:00,127.31,224.08700000000002,64.199,32.641 +2020-01-30 16:45:00,129.45,225.31,64.199,32.641 +2020-01-30 17:00:00,133.14,227.657,68.19800000000001,32.641 +2020-01-30 17:15:00,136.81,228.763,68.19800000000001,32.641 +2020-01-30 17:30:00,138.93,230.148,68.19800000000001,32.641 +2020-01-30 17:45:00,139.45,230.30700000000002,68.19800000000001,32.641 +2020-01-30 18:00:00,139.02,232.267,67.899,32.641 +2020-01-30 18:15:00,140.21,229.262,67.899,32.641 +2020-01-30 18:30:00,137.12,228.28400000000002,67.899,32.641 +2020-01-30 18:45:00,137.17,228.91099999999997,67.899,32.641 +2020-01-30 19:00:00,134.6,226.73,64.72399999999999,32.641 +2020-01-30 19:15:00,131.53,222.669,64.72399999999999,32.641 +2020-01-30 19:30:00,136.23,219.75900000000001,64.72399999999999,32.641 +2020-01-30 19:45:00,136.85,216.945,64.72399999999999,32.641 +2020-01-30 20:00:00,129.72,212.484,64.062,32.641 +2020-01-30 20:15:00,119.43,205.704,64.062,32.641 +2020-01-30 20:30:00,117.24,201.445,64.062,32.641 +2020-01-30 20:45:00,114.49,200.37099999999998,64.062,32.641 +2020-01-30 21:00:00,110.45,196.468,57.971000000000004,32.641 +2020-01-30 21:15:00,114.4,193.387,57.971000000000004,32.641 +2020-01-30 21:30:00,113.1,191.33700000000002,57.971000000000004,32.641 +2020-01-30 21:45:00,108.52,189.99599999999998,57.971000000000004,32.641 +2020-01-30 22:00:00,99.82,182.52700000000002,53.715,32.641 +2020-01-30 22:15:00,95.43,176.864,53.715,32.641 +2020-01-30 22:30:00,95.06,162.83100000000002,53.715,32.641 +2020-01-30 22:45:00,97.76,154.44899999999998,53.715,32.641 +2020-01-30 23:00:00,95.28,146.873,47.8,32.641 +2020-01-30 23:15:00,93.83,145.61700000000002,47.8,32.641 +2020-01-30 23:30:00,85.99,146.034,47.8,32.641 +2020-01-30 23:45:00,83.25,145.692,47.8,32.641 +2020-01-31 00:00:00,83.54,138.534,43.656000000000006,32.641 +2020-01-31 00:15:00,86.38,138.262,43.656000000000006,32.641 +2020-01-31 00:30:00,85.86,139.339,43.656000000000006,32.641 +2020-01-31 00:45:00,81.71,141.053,43.656000000000006,32.641 +2020-01-31 01:00:00,75.55,143.645,41.263000000000005,32.641 +2020-01-31 01:15:00,81.11,144.639,41.263000000000005,32.641 +2020-01-31 01:30:00,82.73,144.529,41.263000000000005,32.641 +2020-01-31 01:45:00,83.26,145.086,41.263000000000005,32.641 +2020-01-31 02:00:00,78.12,147.43200000000002,40.799,32.641 +2020-01-31 02:15:00,77.01,149.412,40.799,32.641 +2020-01-31 02:30:00,81.31,151.12,40.799,32.641 +2020-01-31 02:45:00,81.77,153.464,40.799,32.641 +2020-01-31 03:00:00,80.53,155.114,41.398,32.641 +2020-01-31 03:15:00,78.43,157.67700000000002,41.398,32.641 +2020-01-31 03:30:00,83.05,159.502,41.398,32.641 +2020-01-31 03:45:00,85.88,161.82299999999998,41.398,32.641 +2020-01-31 04:00:00,81.83,173.408,42.38,32.641 +2020-01-31 04:15:00,79.91,184.787,42.38,32.641 +2020-01-31 04:30:00,83.03,188.74200000000002,42.38,32.641 +2020-01-31 04:45:00,88.4,190.513,42.38,32.641 +2020-01-31 05:00:00,92.99,223.68099999999998,46.181000000000004,32.641 +2020-01-31 05:15:00,86.2,253.05200000000002,46.181000000000004,32.641 +2020-01-31 05:30:00,87.66,249.93200000000002,46.181000000000004,32.641 +2020-01-31 05:45:00,92.16,243.638,46.181000000000004,32.641 +2020-01-31 06:00:00,100.18,241.388,59.33,32.641 +2020-01-31 06:15:00,103.88,245.922,59.33,32.641 +2020-01-31 06:30:00,107.37,248.06099999999998,59.33,32.641 +2020-01-31 06:45:00,111.97,255.41,59.33,32.641 +2020-01-31 07:00:00,118.81,252.83900000000003,72.454,32.641 +2020-01-31 07:15:00,121.43,259.492,72.454,32.641 +2020-01-31 07:30:00,120.61,262.686,72.454,32.641 +2020-01-31 07:45:00,123.16,263.152,72.454,32.641 +2020-01-31 08:00:00,125.9,260.182,67.175,32.641 +2020-01-31 08:15:00,125.96,259.769,67.175,32.641 +2020-01-31 08:30:00,124.1,257.837,67.175,32.641 +2020-01-31 08:45:00,122.45,253.051,67.175,32.641 +2020-01-31 09:00:00,121.29,247.245,65.365,32.641 +2020-01-31 09:15:00,120.99,244.838,65.365,32.641 +2020-01-31 09:30:00,122.63,242.851,65.365,32.641 +2020-01-31 09:45:00,125.07,239.21400000000003,65.365,32.641 +2020-01-31 10:00:00,125.19,232.065,63.95,32.641 +2020-01-31 10:15:00,123.48,229.30700000000002,63.95,32.641 +2020-01-31 10:30:00,118.43,225.118,63.95,32.641 +2020-01-31 10:45:00,116.42,222.847,63.95,32.641 +2020-01-31 11:00:00,112.88,219.519,63.92100000000001,32.641 +2020-01-31 11:15:00,114.05,217.38099999999997,63.92100000000001,32.641 +2020-01-31 11:30:00,112.59,217.62,63.92100000000001,32.641 +2020-01-31 11:45:00,113.51,217.265,63.92100000000001,32.641 +2020-01-31 12:00:00,110.81,214.657,60.79600000000001,32.641 +2020-01-31 12:15:00,108.51,212.18099999999998,60.79600000000001,32.641 +2020-01-31 12:30:00,110.61,211.828,60.79600000000001,32.641 +2020-01-31 12:45:00,107.4,213.051,60.79600000000001,32.641 +2020-01-31 13:00:00,109.64,211.68400000000003,59.393,32.641 +2020-01-31 13:15:00,114.24,211.65400000000002,59.393,32.641 +2020-01-31 13:30:00,114.13,210.50400000000002,59.393,32.641 +2020-01-31 13:45:00,116.3,210.64700000000002,59.393,32.641 +2020-01-31 14:00:00,114.74,209.613,57.943999999999996,32.641 +2020-01-31 14:15:00,117.45,209.69099999999997,57.943999999999996,32.641 +2020-01-31 14:30:00,113.57,210.88299999999998,57.943999999999996,32.641 +2020-01-31 14:45:00,113.98,212.3,57.943999999999996,32.641 +2020-01-31 15:00:00,114.17,212.85,60.153999999999996,32.641 +2020-01-31 15:15:00,115.31,212.88099999999997,60.153999999999996,32.641 +2020-01-31 15:30:00,115.47,212.65400000000002,60.153999999999996,32.641 +2020-01-31 15:45:00,118.13,213.44,60.153999999999996,32.641 +2020-01-31 16:00:00,117.86,213.96900000000002,62.933,32.641 +2020-01-31 16:15:00,117.65,216.122,62.933,32.641 +2020-01-31 16:30:00,119.13,219.115,62.933,32.641 +2020-01-31 16:45:00,120.48,220.305,62.933,32.641 +2020-01-31 17:00:00,125.21,222.662,68.657,32.641 +2020-01-31 17:15:00,125.41,223.35,68.657,32.641 +2020-01-31 17:30:00,129.61,224.38299999999998,68.657,32.641 +2020-01-31 17:45:00,127.16,224.322,68.657,32.641 +2020-01-31 18:00:00,127.56,227.113,67.111,32.641 +2020-01-31 18:15:00,125.29,223.821,67.111,32.641 +2020-01-31 18:30:00,125.6,223.31,67.111,32.641 +2020-01-31 18:45:00,124.45,223.90200000000002,67.111,32.641 +2020-01-31 19:00:00,121.9,222.61900000000003,62.434,32.641 +2020-01-31 19:15:00,121.13,220.045,62.434,32.641 +2020-01-31 19:30:00,123.82,216.67700000000002,62.434,32.641 +2020-01-31 19:45:00,118.48,213.486,62.434,32.641 +2020-01-31 20:00:00,112.24,209.06799999999998,61.763000000000005,32.641 +2020-01-31 20:15:00,106.36,202.207,61.763000000000005,32.641 +2020-01-31 20:30:00,103.04,197.96599999999998,61.763000000000005,32.641 +2020-01-31 20:45:00,102.41,197.637,61.763000000000005,32.641 +2020-01-31 21:00:00,97.4,194.132,56.785,32.641 +2020-01-31 21:15:00,102.69,191.324,56.785,32.641 +2020-01-31 21:30:00,101.42,189.345,56.785,32.641 +2020-01-31 21:45:00,97.23,188.61700000000002,56.785,32.641 +2020-01-31 22:00:00,90.73,182.27200000000002,52.693000000000005,32.641 +2020-01-31 22:15:00,85.64,176.511,52.693000000000005,32.641 +2020-01-31 22:30:00,87.1,169.132,52.693000000000005,32.641 +2020-01-31 22:45:00,89.23,164.673,52.693000000000005,32.641 +2020-01-31 23:00:00,85.55,156.369,45.443999999999996,32.641 +2020-01-31 23:15:00,83.18,153.1,45.443999999999996,32.641 +2020-01-31 23:30:00,79.02,152.094,45.443999999999996,32.641 +2020-01-31 23:45:00,83.75,151.02700000000002,45.443999999999996,32.641 +2020-02-01 00:00:00,76.71,129.81,42.033,32.431999999999995 +2020-02-01 00:15:00,73.23,124.75399999999999,42.033,32.431999999999995 +2020-02-01 00:30:00,70.83,127.20700000000001,42.033,32.431999999999995 +2020-02-01 00:45:00,74.17,129.618,42.033,32.431999999999995 +2020-02-01 01:00:00,71.69,132.734,38.255,32.431999999999995 +2020-02-01 01:15:00,71.85,132.577,38.255,32.431999999999995 +2020-02-01 01:30:00,67.13,132.036,38.255,32.431999999999995 +2020-02-01 01:45:00,71.17,132.134,38.255,32.431999999999995 +2020-02-01 02:00:00,69.97,135.287,36.404,32.431999999999995 +2020-02-01 02:15:00,69.51,136.813,36.404,32.431999999999995 +2020-02-01 02:30:00,67.27,137.26,36.404,32.431999999999995 +2020-02-01 02:45:00,73.49,139.49,36.404,32.431999999999995 +2020-02-01 03:00:00,69.19,141.94,36.083,32.431999999999995 +2020-02-01 03:15:00,67.7,143.183,36.083,32.431999999999995 +2020-02-01 03:30:00,62.05,143.143,36.083,32.431999999999995 +2020-02-01 03:45:00,63.49,145.23,36.083,32.431999999999995 +2020-02-01 04:00:00,67.97,152.216,36.102,32.431999999999995 +2020-02-01 04:15:00,68.64,160.666,36.102,32.431999999999995 +2020-02-01 04:30:00,65.01,162.115,36.102,32.431999999999995 +2020-02-01 04:45:00,63.87,163.159,36.102,32.431999999999995 +2020-02-01 05:00:00,64.66,178.486,35.284,32.431999999999995 +2020-02-01 05:15:00,65.5,187.328,35.284,32.431999999999995 +2020-02-01 05:30:00,65.69,184.41,35.284,32.431999999999995 +2020-02-01 05:45:00,67.86,183.62,35.284,32.431999999999995 +2020-02-01 06:00:00,69.24,201.104,36.265,32.431999999999995 +2020-02-01 06:15:00,71.37,222.93900000000002,36.265,32.431999999999995 +2020-02-01 06:30:00,71.93,219.313,36.265,32.431999999999995 +2020-02-01 06:45:00,74.95,216.52599999999998,36.265,32.431999999999995 +2020-02-01 07:00:00,78.52,210.303,40.714,32.431999999999995 +2020-02-01 07:15:00,80.13,215.368,40.714,32.431999999999995 +2020-02-01 07:30:00,82.2,221.09,40.714,32.431999999999995 +2020-02-01 07:45:00,85.69,225.72099999999998,40.714,32.431999999999995 +2020-02-01 08:00:00,91.13,227.44099999999997,46.692,32.431999999999995 +2020-02-01 08:15:00,91.12,231.018,46.692,32.431999999999995 +2020-02-01 08:30:00,92.79,230.90099999999998,46.692,32.431999999999995 +2020-02-01 08:45:00,94.88,229.52900000000002,46.692,32.431999999999995 +2020-02-01 09:00:00,97.7,225.294,48.925,32.431999999999995 +2020-02-01 09:15:00,97.76,223.685,48.925,32.431999999999995 +2020-02-01 09:30:00,98.69,222.65200000000002,48.925,32.431999999999995 +2020-02-01 09:45:00,101.05,219.40099999999998,48.925,32.431999999999995 +2020-02-01 10:00:00,101.31,212.862,47.799,32.431999999999995 +2020-02-01 10:15:00,101.73,210.19,47.799,32.431999999999995 +2020-02-01 10:30:00,101.39,206.543,47.799,32.431999999999995 +2020-02-01 10:45:00,106.31,205.921,47.799,32.431999999999995 +2020-02-01 11:00:00,103.02,203.19299999999998,44.309,32.431999999999995 +2020-02-01 11:15:00,103.3,200.252,44.309,32.431999999999995 +2020-02-01 11:30:00,108.4,199.29,44.309,32.431999999999995 +2020-02-01 11:45:00,112.35,197.61900000000003,44.309,32.431999999999995 +2020-02-01 12:00:00,105.64,193.695,42.367,32.431999999999995 +2020-02-01 12:15:00,104.87,191.93599999999998,42.367,32.431999999999995 +2020-02-01 12:30:00,102.75,191.995,42.367,32.431999999999995 +2020-02-01 12:45:00,101.19,192.31900000000002,42.367,32.431999999999995 +2020-02-01 13:00:00,98.77,190.88400000000001,39.036,32.431999999999995 +2020-02-01 13:15:00,102.03,188.609,39.036,32.431999999999995 +2020-02-01 13:30:00,97.87,187.08700000000002,39.036,32.431999999999995 +2020-02-01 13:45:00,97.89,187.785,39.036,32.431999999999995 +2020-02-01 14:00:00,95.54,188.062,37.995,32.431999999999995 +2020-02-01 14:15:00,95.43,187.68599999999998,37.995,32.431999999999995 +2020-02-01 14:30:00,94.24,186.92700000000002,37.995,32.431999999999995 +2020-02-01 14:45:00,95.58,188.394,37.995,32.431999999999995 +2020-02-01 15:00:00,95.01,189.855,40.71,32.431999999999995 +2020-02-01 15:15:00,94.99,190.50799999999998,40.71,32.431999999999995 +2020-02-01 15:30:00,93.2,192.142,40.71,32.431999999999995 +2020-02-01 15:45:00,93.06,193.253,40.71,32.431999999999995 +2020-02-01 16:00:00,95.23,191.983,46.998000000000005,32.431999999999995 +2020-02-01 16:15:00,95.22,195.12099999999998,46.998000000000005,32.431999999999995 +2020-02-01 16:30:00,97.78,198.024,46.998000000000005,32.431999999999995 +2020-02-01 16:45:00,99.14,200.262,46.998000000000005,32.431999999999995 +2020-02-01 17:00:00,105.82,202.077,55.431000000000004,32.431999999999995 +2020-02-01 17:15:00,106.49,204.93599999999998,55.431000000000004,32.431999999999995 +2020-02-01 17:30:00,107.67,205.97799999999998,55.431000000000004,32.431999999999995 +2020-02-01 17:45:00,109.78,205.5,55.431000000000004,32.431999999999995 +2020-02-01 18:00:00,112.26,207.61599999999999,55.989,32.431999999999995 +2020-02-01 18:15:00,110.96,206.43400000000003,55.989,32.431999999999995 +2020-02-01 18:30:00,109.62,207.187,55.989,32.431999999999995 +2020-02-01 18:45:00,108.33,204.48,55.989,32.431999999999995 +2020-02-01 19:00:00,106.53,204.679,50.882,32.431999999999995 +2020-02-01 19:15:00,105.46,201.778,50.882,32.431999999999995 +2020-02-01 19:30:00,104.21,199.399,50.882,32.431999999999995 +2020-02-01 19:45:00,102.98,195.791,50.882,32.431999999999995 +2020-02-01 20:00:00,97.91,193.72299999999998,43.172,32.431999999999995 +2020-02-01 20:15:00,94.51,189.55700000000002,43.172,32.431999999999995 +2020-02-01 20:30:00,91.8,185.125,43.172,32.431999999999995 +2020-02-01 20:45:00,90.04,184.016,43.172,32.431999999999995 +2020-02-01 21:00:00,84.99,183.33900000000003,37.599000000000004,32.431999999999995 +2020-02-01 21:15:00,84.93,181.149,37.599000000000004,32.431999999999995 +2020-02-01 21:30:00,83.67,180.52,37.599000000000004,32.431999999999995 +2020-02-01 21:45:00,82.32,179.476,37.599000000000004,32.431999999999995 +2020-02-01 22:00:00,78.77,174.79,39.047,32.431999999999995 +2020-02-01 22:15:00,79.21,171.956,39.047,32.431999999999995 +2020-02-01 22:30:00,75.78,171.52200000000002,39.047,32.431999999999995 +2020-02-01 22:45:00,74.58,169.205,39.047,32.431999999999995 +2020-02-01 23:00:00,71.15,163.97299999999998,32.339,32.431999999999995 +2020-02-01 23:15:00,70.35,158.893,32.339,32.431999999999995 +2020-02-01 23:30:00,67.66,155.857,32.339,32.431999999999995 +2020-02-01 23:45:00,67.11,152.153,32.339,32.431999999999995 +2020-02-02 00:00:00,63.53,130.029,29.988000000000003,32.431999999999995 +2020-02-02 00:15:00,63.8,124.745,29.988000000000003,32.431999999999995 +2020-02-02 00:30:00,62.55,126.78,29.988000000000003,32.431999999999995 +2020-02-02 00:45:00,61.0,129.969,29.988000000000003,32.431999999999995 +2020-02-02 01:00:00,57.82,132.872,28.531999999999996,32.431999999999995 +2020-02-02 01:15:00,58.55,133.874,28.531999999999996,32.431999999999995 +2020-02-02 01:30:00,58.11,133.923,28.531999999999996,32.431999999999995 +2020-02-02 01:45:00,57.64,133.714,28.531999999999996,32.431999999999995 +2020-02-02 02:00:00,55.93,136.03,27.805999999999997,32.431999999999995 +2020-02-02 02:15:00,56.4,136.515,27.805999999999997,32.431999999999995 +2020-02-02 02:30:00,55.63,137.906,27.805999999999997,32.431999999999995 +2020-02-02 02:45:00,55.85,140.687,27.805999999999997,32.431999999999995 +2020-02-02 03:00:00,54.61,143.411,26.193,32.431999999999995 +2020-02-02 03:15:00,55.42,144.041,26.193,32.431999999999995 +2020-02-02 03:30:00,55.76,145.66,26.193,32.431999999999995 +2020-02-02 03:45:00,55.94,147.775,26.193,32.431999999999995 +2020-02-02 04:00:00,55.92,154.489,27.19,32.431999999999995 +2020-02-02 04:15:00,57.67,161.849,27.19,32.431999999999995 +2020-02-02 04:30:00,57.96,163.20600000000002,27.19,32.431999999999995 +2020-02-02 04:45:00,58.08,164.608,27.19,32.431999999999995 +2020-02-02 05:00:00,58.81,175.93599999999998,28.166999999999998,32.431999999999995 +2020-02-02 05:15:00,60.25,182.16099999999997,28.166999999999998,32.431999999999995 +2020-02-02 05:30:00,60.89,179.095,28.166999999999998,32.431999999999995 +2020-02-02 05:45:00,61.68,178.62599999999998,28.166999999999998,32.431999999999995 +2020-02-02 06:00:00,62.19,196.321,27.16,32.431999999999995 +2020-02-02 06:15:00,62.48,216.12400000000002,27.16,32.431999999999995 +2020-02-02 06:30:00,63.17,211.331,27.16,32.431999999999995 +2020-02-02 06:45:00,64.8,207.49599999999998,27.16,32.431999999999995 +2020-02-02 07:00:00,68.38,204.016,29.578000000000003,32.431999999999995 +2020-02-02 07:15:00,68.86,208.31099999999998,29.578000000000003,32.431999999999995 +2020-02-02 07:30:00,69.25,212.454,29.578000000000003,32.431999999999995 +2020-02-02 07:45:00,71.7,216.199,29.578000000000003,32.431999999999995 +2020-02-02 08:00:00,76.25,219.887,34.650999999999996,32.431999999999995 +2020-02-02 08:15:00,77.74,223.195,34.650999999999996,32.431999999999995 +2020-02-02 08:30:00,79.71,224.77,34.650999999999996,32.431999999999995 +2020-02-02 08:45:00,79.84,225.657,34.650999999999996,32.431999999999995 +2020-02-02 09:00:00,84.28,220.99099999999999,38.080999999999996,32.431999999999995 +2020-02-02 09:15:00,85.13,220.058,38.080999999999996,32.431999999999995 +2020-02-02 09:30:00,86.37,218.822,38.080999999999996,32.431999999999995 +2020-02-02 09:45:00,88.68,215.322,38.080999999999996,32.431999999999995 +2020-02-02 10:00:00,88.73,211.52200000000002,39.934,32.431999999999995 +2020-02-02 10:15:00,89.78,209.44799999999998,39.934,32.431999999999995 +2020-02-02 10:30:00,92.29,206.45,39.934,32.431999999999995 +2020-02-02 10:45:00,94.48,203.609,39.934,32.431999999999995 +2020-02-02 11:00:00,96.74,201.93599999999998,43.74100000000001,32.431999999999995 +2020-02-02 11:15:00,100.32,199.2,43.74100000000001,32.431999999999995 +2020-02-02 11:30:00,102.7,197.222,43.74100000000001,32.431999999999995 +2020-02-02 11:45:00,103.15,196.201,43.74100000000001,32.431999999999995 +2020-02-02 12:00:00,99.58,191.542,40.001999999999995,32.431999999999995 +2020-02-02 12:15:00,98.64,191.982,40.001999999999995,32.431999999999995 +2020-02-02 12:30:00,95.5,190.382,40.001999999999995,32.431999999999995 +2020-02-02 12:45:00,93.0,189.704,40.001999999999995,32.431999999999995 +2020-02-02 13:00:00,90.71,187.521,37.855,32.431999999999995 +2020-02-02 13:15:00,89.28,188.63099999999997,37.855,32.431999999999995 +2020-02-02 13:30:00,87.1,186.988,37.855,32.431999999999995 +2020-02-02 13:45:00,86.01,186.828,37.855,32.431999999999995 +2020-02-02 14:00:00,84.14,187.291,35.946999999999996,32.431999999999995 +2020-02-02 14:15:00,84.33,188.176,35.946999999999996,32.431999999999995 +2020-02-02 14:30:00,83.61,188.908,35.946999999999996,32.431999999999995 +2020-02-02 14:45:00,84.39,190.054,35.946999999999996,32.431999999999995 +2020-02-02 15:00:00,84.93,189.815,35.138000000000005,32.431999999999995 +2020-02-02 15:15:00,84.41,191.359,35.138000000000005,32.431999999999995 +2020-02-02 15:30:00,83.67,193.63299999999998,35.138000000000005,32.431999999999995 +2020-02-02 15:45:00,83.84,195.46,35.138000000000005,32.431999999999995 +2020-02-02 16:00:00,85.14,196.38099999999997,38.672,32.431999999999995 +2020-02-02 16:15:00,85.03,198.49599999999998,38.672,32.431999999999995 +2020-02-02 16:30:00,86.66,201.58599999999998,38.672,32.431999999999995 +2020-02-02 16:45:00,89.42,203.954,38.672,32.431999999999995 +2020-02-02 17:00:00,94.56,205.68900000000002,48.684,32.431999999999995 +2020-02-02 17:15:00,97.47,208.07299999999998,48.684,32.431999999999995 +2020-02-02 17:30:00,99.16,209.385,48.684,32.431999999999995 +2020-02-02 17:45:00,100.99,211.365,48.684,32.431999999999995 +2020-02-02 18:00:00,105.14,212.826,51.568999999999996,32.431999999999995 +2020-02-02 18:15:00,103.39,213.162,51.568999999999996,32.431999999999995 +2020-02-02 18:30:00,106.37,211.679,51.568999999999996,32.431999999999995 +2020-02-02 18:45:00,102.89,210.99400000000003,51.568999999999996,32.431999999999995 +2020-02-02 19:00:00,101.38,210.583,48.608000000000004,32.431999999999995 +2020-02-02 19:15:00,99.21,208.429,48.608000000000004,32.431999999999995 +2020-02-02 19:30:00,98.13,205.91099999999997,48.608000000000004,32.431999999999995 +2020-02-02 19:45:00,98.79,203.928,48.608000000000004,32.431999999999995 +2020-02-02 20:00:00,93.72,201.81799999999998,43.733999999999995,32.431999999999995 +2020-02-02 20:15:00,91.94,198.74400000000003,43.733999999999995,32.431999999999995 +2020-02-02 20:30:00,90.61,195.58700000000002,43.733999999999995,32.431999999999995 +2020-02-02 20:45:00,89.18,193.338,43.733999999999995,32.431999999999995 +2020-02-02 21:00:00,86.51,189.835,39.283,32.431999999999995 +2020-02-02 21:15:00,86.22,186.979,39.283,32.431999999999995 +2020-02-02 21:30:00,86.04,186.727,39.283,32.431999999999995 +2020-02-02 21:45:00,87.9,185.815,39.283,32.431999999999995 +2020-02-02 22:00:00,84.32,179.638,40.111,32.431999999999995 +2020-02-02 22:15:00,85.45,176.153,40.111,32.431999999999995 +2020-02-02 22:30:00,83.23,172.30900000000003,40.111,32.431999999999995 +2020-02-02 22:45:00,82.39,169.19,40.111,32.431999999999995 +2020-02-02 23:00:00,78.06,160.942,35.791,32.431999999999995 +2020-02-02 23:15:00,78.99,157.774,35.791,32.431999999999995 +2020-02-02 23:30:00,76.63,155.68200000000002,35.791,32.431999999999995 +2020-02-02 23:45:00,77.09,152.945,35.791,32.431999999999995 +2020-02-03 00:00:00,72.55,134.424,34.311,32.613 +2020-02-03 00:15:00,73.3,132.341,34.311,32.613 +2020-02-03 00:30:00,72.72,134.543,34.311,32.613 +2020-02-03 00:45:00,71.94,137.173,34.311,32.613 +2020-02-03 01:00:00,69.66,140.04,34.585,32.613 +2020-02-03 01:15:00,70.92,140.44,34.585,32.613 +2020-02-03 01:30:00,70.34,140.496,34.585,32.613 +2020-02-03 01:45:00,70.86,140.424,34.585,32.613 +2020-02-03 02:00:00,69.94,142.671,34.111,32.613 +2020-02-03 02:15:00,71.57,144.918,34.111,32.613 +2020-02-03 02:30:00,70.86,146.672,34.111,32.613 +2020-02-03 02:45:00,72.17,148.778,34.111,32.613 +2020-02-03 03:00:00,71.64,152.88299999999998,32.435,32.613 +2020-02-03 03:15:00,71.9,155.274,32.435,32.613 +2020-02-03 03:30:00,73.09,156.496,32.435,32.613 +2020-02-03 03:45:00,73.72,158.063,32.435,32.613 +2020-02-03 04:00:00,74.52,169.278,33.04,32.613 +2020-02-03 04:15:00,75.25,180.90200000000002,33.04,32.613 +2020-02-03 04:30:00,76.93,184.803,33.04,32.613 +2020-02-03 04:45:00,80.1,186.326,33.04,32.613 +2020-02-03 05:00:00,83.89,214.47799999999998,40.399,32.613 +2020-02-03 05:15:00,86.25,242.049,40.399,32.613 +2020-02-03 05:30:00,91.33,239.489,40.399,32.613 +2020-02-03 05:45:00,95.99,233.071,40.399,32.613 +2020-02-03 06:00:00,104.74,231.924,60.226000000000006,32.613 +2020-02-03 06:15:00,110.75,236.253,60.226000000000006,32.613 +2020-02-03 06:30:00,114.63,239.93900000000002,60.226000000000006,32.613 +2020-02-03 06:45:00,119.48,245.31099999999998,60.226000000000006,32.613 +2020-02-03 07:00:00,129.14,244.451,73.578,32.613 +2020-02-03 07:15:00,130.05,249.81900000000002,73.578,32.613 +2020-02-03 07:30:00,129.17,253.173,73.578,32.613 +2020-02-03 07:45:00,130.1,253.949,73.578,32.613 +2020-02-03 08:00:00,135.43,252.31099999999998,66.58,32.613 +2020-02-03 08:15:00,135.0,253.35,66.58,32.613 +2020-02-03 08:30:00,134.72,250.42,66.58,32.613 +2020-02-03 08:45:00,136.03,247.53799999999998,66.58,32.613 +2020-02-03 09:00:00,138.01,241.83900000000003,62.0,32.613 +2020-02-03 09:15:00,139.79,237.245,62.0,32.613 +2020-02-03 09:30:00,141.36,235.02900000000002,62.0,32.613 +2020-02-03 09:45:00,139.59,232.144,62.0,32.613 +2020-02-03 10:00:00,138.43,227.13400000000001,59.099,32.613 +2020-02-03 10:15:00,138.67,224.736,59.099,32.613 +2020-02-03 10:30:00,137.73,220.82299999999998,59.099,32.613 +2020-02-03 10:45:00,137.82,219.03,59.099,32.613 +2020-02-03 11:00:00,138.07,214.34599999999998,57.729,32.613 +2020-02-03 11:15:00,139.42,213.59,57.729,32.613 +2020-02-03 11:30:00,139.86,213.06599999999997,57.729,32.613 +2020-02-03 11:45:00,140.03,211.54,57.729,32.613 +2020-02-03 12:00:00,139.07,209.048,55.615,32.613 +2020-02-03 12:15:00,138.54,209.49200000000002,55.615,32.613 +2020-02-03 12:30:00,136.96,208.28099999999998,55.615,32.613 +2020-02-03 12:45:00,137.41,209.297,55.615,32.613 +2020-02-03 13:00:00,134.33,207.68,56.515,32.613 +2020-02-03 13:15:00,132.51,207.333,56.515,32.613 +2020-02-03 13:30:00,130.0,205.06400000000002,56.515,32.613 +2020-02-03 13:45:00,128.89,204.83,56.515,32.613 +2020-02-03 14:00:00,128.86,204.725,58.1,32.613 +2020-02-03 14:15:00,127.42,204.808,58.1,32.613 +2020-02-03 14:30:00,127.1,204.94799999999998,58.1,32.613 +2020-02-03 14:45:00,128.03,205.826,58.1,32.613 +2020-02-03 15:00:00,129.69,207.56599999999997,59.801,32.613 +2020-02-03 15:15:00,135.62,207.56099999999998,59.801,32.613 +2020-02-03 15:30:00,130.78,208.795,59.801,32.613 +2020-02-03 15:45:00,132.56,210.171,59.801,32.613 +2020-02-03 16:00:00,130.0,211.095,62.901,32.613 +2020-02-03 16:15:00,128.75,212.358,62.901,32.613 +2020-02-03 16:30:00,126.25,214.46200000000002,62.901,32.613 +2020-02-03 16:45:00,126.93,215.51,62.901,32.613 +2020-02-03 17:00:00,132.31,217.107,70.418,32.613 +2020-02-03 17:15:00,134.91,218.438,70.418,32.613 +2020-02-03 17:30:00,139.16,219.222,70.418,32.613 +2020-02-03 17:45:00,137.81,219.636,70.418,32.613 +2020-02-03 18:00:00,140.89,221.644,71.726,32.613 +2020-02-03 18:15:00,138.81,219.799,71.726,32.613 +2020-02-03 18:30:00,142.27,219.105,71.726,32.613 +2020-02-03 18:45:00,138.78,218.90900000000002,71.726,32.613 +2020-02-03 19:00:00,137.55,216.71,65.997,32.613 +2020-02-03 19:15:00,133.6,213.172,65.997,32.613 +2020-02-03 19:30:00,132.16,211.24599999999998,65.997,32.613 +2020-02-03 19:45:00,130.1,208.445,65.997,32.613 +2020-02-03 20:00:00,123.56,203.873,68.09100000000001,32.613 +2020-02-03 20:15:00,121.51,197.967,68.09100000000001,32.613 +2020-02-03 20:30:00,117.5,192.733,68.09100000000001,32.613 +2020-02-03 20:45:00,116.06,192.24400000000003,68.09100000000001,32.613 +2020-02-03 21:00:00,112.08,189.36900000000003,59.617,32.613 +2020-02-03 21:15:00,119.36,185.173,59.617,32.613 +2020-02-03 21:30:00,115.51,183.97799999999998,59.617,32.613 +2020-02-03 21:45:00,112.17,182.554,59.617,32.613 +2020-02-03 22:00:00,102.03,173.428,54.938,32.613 +2020-02-03 22:15:00,101.13,168.33,54.938,32.613 +2020-02-03 22:30:00,103.23,154.47899999999998,54.938,32.613 +2020-02-03 22:45:00,102.28,146.101,54.938,32.613 +2020-02-03 23:00:00,97.78,138.697,47.43,32.613 +2020-02-03 23:15:00,93.27,138.597,47.43,32.613 +2020-02-03 23:30:00,87.74,139.533,47.43,32.613 +2020-02-03 23:45:00,93.42,139.745,47.43,32.613 +2020-02-04 00:00:00,90.86,133.845,48.354,32.613 +2020-02-04 00:15:00,91.14,133.235,48.354,32.613 +2020-02-04 00:30:00,85.35,134.276,48.354,32.613 +2020-02-04 00:45:00,87.43,135.703,48.354,32.613 +2020-02-04 01:00:00,85.05,138.391,45.68600000000001,32.613 +2020-02-04 01:15:00,86.31,138.264,45.68600000000001,32.613 +2020-02-04 01:30:00,82.43,138.523,45.68600000000001,32.613 +2020-02-04 01:45:00,87.86,138.858,45.68600000000001,32.613 +2020-02-04 02:00:00,86.41,141.167,44.269,32.613 +2020-02-04 02:15:00,87.36,143.13,44.269,32.613 +2020-02-04 02:30:00,82.98,144.28,44.269,32.613 +2020-02-04 02:45:00,87.34,146.35399999999998,44.269,32.613 +2020-02-04 03:00:00,85.37,149.16899999999998,44.187,32.613 +2020-02-04 03:15:00,87.11,150.472,44.187,32.613 +2020-02-04 03:30:00,84.96,152.24200000000002,44.187,32.613 +2020-02-04 03:45:00,83.22,154.197,44.187,32.613 +2020-02-04 04:00:00,82.45,165.356,46.126999999999995,32.613 +2020-02-04 04:15:00,83.66,176.574,46.126999999999995,32.613 +2020-02-04 04:30:00,85.46,180.15099999999998,46.126999999999995,32.613 +2020-02-04 04:45:00,87.52,182.96599999999998,46.126999999999995,32.613 +2020-02-04 05:00:00,92.46,216.549,49.666000000000004,32.613 +2020-02-04 05:15:00,94.39,243.795,49.666000000000004,32.613 +2020-02-04 05:30:00,96.94,239.44099999999997,49.666000000000004,32.613 +2020-02-04 05:45:00,101.07,233.153,49.666000000000004,32.613 +2020-02-04 06:00:00,109.2,230.50799999999998,61.077,32.613 +2020-02-04 06:15:00,111.14,236.672,61.077,32.613 +2020-02-04 06:30:00,117.58,239.65099999999998,61.077,32.613 +2020-02-04 06:45:00,123.45,244.737,61.077,32.613 +2020-02-04 07:00:00,133.3,243.669,74.717,32.613 +2020-02-04 07:15:00,131.29,248.86900000000003,74.717,32.613 +2020-02-04 07:30:00,133.43,251.56799999999998,74.717,32.613 +2020-02-04 07:45:00,133.13,252.69799999999998,74.717,32.613 +2020-02-04 08:00:00,136.35,251.172,69.033,32.613 +2020-02-04 08:15:00,136.11,251.097,69.033,32.613 +2020-02-04 08:30:00,134.66,247.888,69.033,32.613 +2020-02-04 08:45:00,134.24,244.832,69.033,32.613 +2020-02-04 09:00:00,133.77,238.14700000000002,63.113,32.613 +2020-02-04 09:15:00,135.41,235.375,63.113,32.613 +2020-02-04 09:30:00,136.68,233.847,63.113,32.613 +2020-02-04 09:45:00,135.45,230.52900000000002,63.113,32.613 +2020-02-04 10:00:00,134.9,225.05700000000002,61.461999999999996,32.613 +2020-02-04 10:15:00,134.83,221.503,61.461999999999996,32.613 +2020-02-04 10:30:00,135.76,217.795,61.461999999999996,32.613 +2020-02-04 10:45:00,134.18,216.19,61.461999999999996,32.613 +2020-02-04 11:00:00,131.66,213.17,59.614,32.613 +2020-02-04 11:15:00,132.42,211.995,59.614,32.613 +2020-02-04 11:30:00,131.61,210.327,59.614,32.613 +2020-02-04 11:45:00,129.45,209.65900000000002,59.614,32.613 +2020-02-04 12:00:00,126.71,205.668,57.415,32.613 +2020-02-04 12:15:00,125.47,205.61599999999999,57.415,32.613 +2020-02-04 12:30:00,128.08,205.076,57.415,32.613 +2020-02-04 12:45:00,125.59,205.66400000000002,57.415,32.613 +2020-02-04 13:00:00,123.19,203.65400000000002,58.534,32.613 +2020-02-04 13:15:00,122.62,202.643,58.534,32.613 +2020-02-04 13:30:00,119.8,201.68,58.534,32.613 +2020-02-04 13:45:00,120.47,201.791,58.534,32.613 +2020-02-04 14:00:00,121.58,201.998,59.415,32.613 +2020-02-04 14:15:00,123.66,202.265,59.415,32.613 +2020-02-04 14:30:00,122.74,203.05599999999998,59.415,32.613 +2020-02-04 14:45:00,123.25,203.967,59.415,32.613 +2020-02-04 15:00:00,126.72,205.26,62.071999999999996,32.613 +2020-02-04 15:15:00,128.05,205.477,62.071999999999996,32.613 +2020-02-04 15:30:00,126.41,206.958,62.071999999999996,32.613 +2020-02-04 15:45:00,125.03,207.822,62.071999999999996,32.613 +2020-02-04 16:00:00,125.33,209.22799999999998,64.99,32.613 +2020-02-04 16:15:00,124.64,211.00400000000002,64.99,32.613 +2020-02-04 16:30:00,127.25,213.877,64.99,32.613 +2020-02-04 16:45:00,128.17,215.144,64.99,32.613 +2020-02-04 17:00:00,133.12,217.313,72.658,32.613 +2020-02-04 17:15:00,136.25,218.657,72.658,32.613 +2020-02-04 17:30:00,140.8,220.261,72.658,32.613 +2020-02-04 17:45:00,141.04,220.595,72.658,32.613 +2020-02-04 18:00:00,142.74,222.60299999999998,73.645,32.613 +2020-02-04 18:15:00,143.03,220.092,73.645,32.613 +2020-02-04 18:30:00,140.63,219.092,73.645,32.613 +2020-02-04 18:45:00,138.97,219.81400000000002,73.645,32.613 +2020-02-04 19:00:00,135.76,217.78,67.085,32.613 +2020-02-04 19:15:00,134.8,213.929,67.085,32.613 +2020-02-04 19:30:00,140.27,211.31400000000002,67.085,32.613 +2020-02-04 19:45:00,133.39,208.546,67.085,32.613 +2020-02-04 20:00:00,125.17,204.082,66.138,32.613 +2020-02-04 20:15:00,128.55,197.642,66.138,32.613 +2020-02-04 20:30:00,119.49,193.523,66.138,32.613 +2020-02-04 20:45:00,118.62,192.36700000000002,66.138,32.613 +2020-02-04 21:00:00,111.08,188.612,57.512,32.613 +2020-02-04 21:15:00,115.78,185.55599999999998,57.512,32.613 +2020-02-04 21:30:00,115.31,183.541,57.512,32.613 +2020-02-04 21:45:00,112.98,182.362,57.512,32.613 +2020-02-04 22:00:00,101.24,175.079,54.545,32.613 +2020-02-04 22:15:00,101.11,169.757,54.545,32.613 +2020-02-04 22:30:00,101.29,155.946,54.545,32.613 +2020-02-04 22:45:00,102.32,147.877,54.545,32.613 +2020-02-04 23:00:00,97.65,140.57399999999998,48.605,32.613 +2020-02-04 23:15:00,94.45,139.328,48.605,32.613 +2020-02-04 23:30:00,87.36,139.886,48.605,32.613 +2020-02-04 23:45:00,92.55,139.616,48.605,32.613 +2020-02-05 00:00:00,89.46,133.739,45.675,32.613 +2020-02-05 00:15:00,90.37,133.12,45.675,32.613 +2020-02-05 00:30:00,85.45,134.141,45.675,32.613 +2020-02-05 00:45:00,84.27,135.56,45.675,32.613 +2020-02-05 01:00:00,85.32,138.22299999999998,43.015,32.613 +2020-02-05 01:15:00,85.92,138.083,43.015,32.613 +2020-02-05 01:30:00,85.0,138.333,43.015,32.613 +2020-02-05 01:45:00,83.16,138.666,43.015,32.613 +2020-02-05 02:00:00,83.15,140.976,41.0,32.613 +2020-02-05 02:15:00,77.78,142.938,41.0,32.613 +2020-02-05 02:30:00,81.84,144.102,41.0,32.613 +2020-02-05 02:45:00,86.05,146.175,41.0,32.613 +2020-02-05 03:00:00,81.25,148.99200000000002,41.318000000000005,32.613 +2020-02-05 03:15:00,88.66,150.3,41.318000000000005,32.613 +2020-02-05 03:30:00,89.0,152.067,41.318000000000005,32.613 +2020-02-05 03:45:00,89.87,154.033,41.318000000000005,32.613 +2020-02-05 04:00:00,85.75,165.185,42.544,32.613 +2020-02-05 04:15:00,82.09,176.393,42.544,32.613 +2020-02-05 04:30:00,83.82,179.982,42.544,32.613 +2020-02-05 04:45:00,88.66,182.785,42.544,32.613 +2020-02-05 05:00:00,90.45,216.34400000000002,45.161,32.613 +2020-02-05 05:15:00,92.16,243.595,45.161,32.613 +2020-02-05 05:30:00,94.66,239.22400000000002,45.161,32.613 +2020-02-05 05:45:00,100.11,232.94299999999998,45.161,32.613 +2020-02-05 06:00:00,110.31,230.312,61.86600000000001,32.613 +2020-02-05 06:15:00,112.52,236.489,61.86600000000001,32.613 +2020-02-05 06:30:00,118.94,239.447,61.86600000000001,32.613 +2020-02-05 06:45:00,122.35,244.541,61.86600000000001,32.613 +2020-02-05 07:00:00,129.73,243.495,77.814,32.613 +2020-02-05 07:15:00,130.12,248.67,77.814,32.613 +2020-02-05 07:30:00,132.0,251.338,77.814,32.613 +2020-02-05 07:45:00,130.03,252.429,77.814,32.613 +2020-02-05 08:00:00,133.79,250.885,70.251,32.613 +2020-02-05 08:15:00,132.59,250.78599999999997,70.251,32.613 +2020-02-05 08:30:00,134.41,247.52200000000002,70.251,32.613 +2020-02-05 08:45:00,131.14,244.46200000000002,70.251,32.613 +2020-02-05 09:00:00,131.14,237.77700000000002,66.965,32.613 +2020-02-05 09:15:00,131.67,235.00900000000001,66.965,32.613 +2020-02-05 09:30:00,133.35,233.50099999999998,66.965,32.613 +2020-02-05 09:45:00,132.54,230.18200000000002,66.965,32.613 +2020-02-05 10:00:00,133.0,224.718,63.628,32.613 +2020-02-05 10:15:00,130.23,221.18900000000002,63.628,32.613 +2020-02-05 10:30:00,128.04,217.488,63.628,32.613 +2020-02-05 10:45:00,128.01,215.894,63.628,32.613 +2020-02-05 11:00:00,127.57,212.861,62.516999999999996,32.613 +2020-02-05 11:15:00,126.97,211.695,62.516999999999996,32.613 +2020-02-05 11:30:00,127.26,210.033,62.516999999999996,32.613 +2020-02-05 11:45:00,128.78,209.375,62.516999999999996,32.613 +2020-02-05 12:00:00,125.28,205.40099999999998,60.888999999999996,32.613 +2020-02-05 12:15:00,123.86,205.365,60.888999999999996,32.613 +2020-02-05 12:30:00,125.88,204.801,60.888999999999996,32.613 +2020-02-05 12:45:00,122.32,205.387,60.888999999999996,32.613 +2020-02-05 13:00:00,120.11,203.394,61.57899999999999,32.613 +2020-02-05 13:15:00,120.74,202.361,61.57899999999999,32.613 +2020-02-05 13:30:00,121.03,201.382,61.57899999999999,32.613 +2020-02-05 13:45:00,121.19,201.493,61.57899999999999,32.613 +2020-02-05 14:00:00,119.29,201.74900000000002,62.602,32.613 +2020-02-05 14:15:00,121.95,201.998,62.602,32.613 +2020-02-05 14:30:00,121.58,202.77700000000002,62.602,32.613 +2020-02-05 14:45:00,122.34,203.703,62.602,32.613 +2020-02-05 15:00:00,125.21,205.007,64.259,32.613 +2020-02-05 15:15:00,123.42,205.195,64.259,32.613 +2020-02-05 15:30:00,126.69,206.644,64.259,32.613 +2020-02-05 15:45:00,122.86,207.49099999999999,64.259,32.613 +2020-02-05 16:00:00,126.84,208.898,67.632,32.613 +2020-02-05 16:15:00,124.67,210.669,67.632,32.613 +2020-02-05 16:30:00,126.69,213.547,67.632,32.613 +2020-02-05 16:45:00,128.11,214.801,67.632,32.613 +2020-02-05 17:00:00,131.41,216.96400000000003,72.583,32.613 +2020-02-05 17:15:00,135.42,218.33599999999998,72.583,32.613 +2020-02-05 17:30:00,141.15,219.976,72.583,32.613 +2020-02-05 17:45:00,142.13,220.33599999999998,72.583,32.613 +2020-02-05 18:00:00,142.56,222.359,72.744,32.613 +2020-02-05 18:15:00,140.69,219.895,72.744,32.613 +2020-02-05 18:30:00,140.23,218.899,72.744,32.613 +2020-02-05 18:45:00,138.2,219.645,72.744,32.613 +2020-02-05 19:00:00,133.78,217.56900000000002,69.684,32.613 +2020-02-05 19:15:00,135.47,213.73,69.684,32.613 +2020-02-05 19:30:00,139.35,211.13400000000001,69.684,32.613 +2020-02-05 19:45:00,137.95,208.394,69.684,32.613 +2020-02-05 20:00:00,129.71,203.90200000000002,70.036,32.613 +2020-02-05 20:15:00,121.3,197.472,70.036,32.613 +2020-02-05 20:30:00,116.66,193.362,70.036,32.613 +2020-02-05 20:45:00,118.71,192.213,70.036,32.613 +2020-02-05 21:00:00,109.48,188.44,60.431999999999995,32.613 +2020-02-05 21:15:00,115.23,185.36900000000003,60.431999999999995,32.613 +2020-02-05 21:30:00,114.27,183.355,60.431999999999995,32.613 +2020-02-05 21:45:00,111.11,182.192,60.431999999999995,32.613 +2020-02-05 22:00:00,102.57,174.896,56.2,32.613 +2020-02-05 22:15:00,98.72,169.59799999999998,56.2,32.613 +2020-02-05 22:30:00,95.62,155.756,56.2,32.613 +2020-02-05 22:45:00,97.92,147.694,56.2,32.613 +2020-02-05 23:00:00,93.72,140.384,47.927,32.613 +2020-02-05 23:15:00,95.58,139.151,47.927,32.613 +2020-02-05 23:30:00,93.57,139.726,47.927,32.613 +2020-02-05 23:45:00,93.42,139.472,47.927,32.613 +2020-02-06 00:00:00,88.03,133.625,43.794,32.613 +2020-02-06 00:15:00,88.69,132.998,43.794,32.613 +2020-02-06 00:30:00,88.37,134.0,43.794,32.613 +2020-02-06 00:45:00,83.31,135.411,43.794,32.613 +2020-02-06 01:00:00,85.13,138.046,42.397,32.613 +2020-02-06 01:15:00,86.78,137.894,42.397,32.613 +2020-02-06 01:30:00,84.13,138.134,42.397,32.613 +2020-02-06 01:45:00,81.34,138.464,42.397,32.613 +2020-02-06 02:00:00,83.95,140.776,40.010999999999996,32.613 +2020-02-06 02:15:00,85.6,142.738,40.010999999999996,32.613 +2020-02-06 02:30:00,84.94,143.915,40.010999999999996,32.613 +2020-02-06 02:45:00,83.06,145.987,40.010999999999996,32.613 +2020-02-06 03:00:00,85.59,148.806,39.181,32.613 +2020-02-06 03:15:00,88.0,150.118,39.181,32.613 +2020-02-06 03:30:00,87.64,151.882,39.181,32.613 +2020-02-06 03:45:00,86.23,153.86,39.181,32.613 +2020-02-06 04:00:00,87.6,165.005,40.39,32.613 +2020-02-06 04:15:00,83.48,176.203,40.39,32.613 +2020-02-06 04:30:00,84.04,179.805,40.39,32.613 +2020-02-06 04:45:00,86.36,182.597,40.39,32.613 +2020-02-06 05:00:00,90.39,216.13299999999998,45.504,32.613 +2020-02-06 05:15:00,92.47,243.39,45.504,32.613 +2020-02-06 05:30:00,94.94,238.99900000000002,45.504,32.613 +2020-02-06 05:45:00,99.52,232.726,45.504,32.613 +2020-02-06 06:00:00,109.99,230.108,57.748000000000005,32.613 +2020-02-06 06:15:00,113.45,236.298,57.748000000000005,32.613 +2020-02-06 06:30:00,117.48,239.236,57.748000000000005,32.613 +2020-02-06 06:45:00,122.09,244.335,57.748000000000005,32.613 +2020-02-06 07:00:00,130.35,243.31099999999998,72.138,32.613 +2020-02-06 07:15:00,131.22,248.46200000000002,72.138,32.613 +2020-02-06 07:30:00,134.4,251.09599999999998,72.138,32.613 +2020-02-06 07:45:00,135.28,252.148,72.138,32.613 +2020-02-06 08:00:00,138.42,250.588,65.542,32.613 +2020-02-06 08:15:00,139.12,250.46400000000003,65.542,32.613 +2020-02-06 08:30:00,135.68,247.144,65.542,32.613 +2020-02-06 08:45:00,135.18,244.079,65.542,32.613 +2020-02-06 09:00:00,136.0,237.396,60.523,32.613 +2020-02-06 09:15:00,137.89,234.63099999999997,60.523,32.613 +2020-02-06 09:30:00,140.17,233.143,60.523,32.613 +2020-02-06 09:45:00,143.52,229.824,60.523,32.613 +2020-02-06 10:00:00,140.22,224.36900000000003,57.449,32.613 +2020-02-06 10:15:00,141.82,220.864,57.449,32.613 +2020-02-06 10:30:00,141.15,217.17,57.449,32.613 +2020-02-06 10:45:00,141.82,215.588,57.449,32.613 +2020-02-06 11:00:00,140.12,212.542,54.505,32.613 +2020-02-06 11:15:00,141.56,211.387,54.505,32.613 +2020-02-06 11:30:00,142.0,209.731,54.505,32.613 +2020-02-06 11:45:00,141.74,209.082,54.505,32.613 +2020-02-06 12:00:00,141.83,205.12400000000002,51.50899999999999,32.613 +2020-02-06 12:15:00,140.81,205.106,51.50899999999999,32.613 +2020-02-06 12:30:00,138.96,204.518,51.50899999999999,32.613 +2020-02-06 12:45:00,136.26,205.1,51.50899999999999,32.613 +2020-02-06 13:00:00,133.75,203.12599999999998,51.303999999999995,32.613 +2020-02-06 13:15:00,132.36,202.07,51.303999999999995,32.613 +2020-02-06 13:30:00,131.1,201.075,51.303999999999995,32.613 +2020-02-06 13:45:00,131.74,201.18599999999998,51.303999999999995,32.613 +2020-02-06 14:00:00,131.95,201.49400000000003,52.785,32.613 +2020-02-06 14:15:00,131.78,201.72299999999998,52.785,32.613 +2020-02-06 14:30:00,129.87,202.489,52.785,32.613 +2020-02-06 14:45:00,131.48,203.43099999999998,52.785,32.613 +2020-02-06 15:00:00,130.36,204.74599999999998,56.458999999999996,32.613 +2020-02-06 15:15:00,130.85,204.90400000000002,56.458999999999996,32.613 +2020-02-06 15:30:00,132.16,206.32,56.458999999999996,32.613 +2020-02-06 15:45:00,129.54,207.15200000000002,56.458999999999996,32.613 +2020-02-06 16:00:00,131.64,208.558,59.388000000000005,32.613 +2020-02-06 16:15:00,130.67,210.32299999999998,59.388000000000005,32.613 +2020-02-06 16:30:00,131.07,213.205,59.388000000000005,32.613 +2020-02-06 16:45:00,131.83,214.446,59.388000000000005,32.613 +2020-02-06 17:00:00,136.93,216.605,64.462,32.613 +2020-02-06 17:15:00,139.65,218.00400000000002,64.462,32.613 +2020-02-06 17:30:00,141.81,219.679,64.462,32.613 +2020-02-06 17:45:00,143.36,220.06400000000002,64.462,32.613 +2020-02-06 18:00:00,143.08,222.104,65.128,32.613 +2020-02-06 18:15:00,141.76,219.69,65.128,32.613 +2020-02-06 18:30:00,142.88,218.695,65.128,32.613 +2020-02-06 18:45:00,140.14,219.465,65.128,32.613 +2020-02-06 19:00:00,137.45,217.34799999999998,61.316,32.613 +2020-02-06 19:15:00,136.1,213.52,61.316,32.613 +2020-02-06 19:30:00,140.99,210.94400000000002,61.316,32.613 +2020-02-06 19:45:00,140.88,208.235,61.316,32.613 +2020-02-06 20:00:00,130.65,203.713,59.845,32.613 +2020-02-06 20:15:00,123.56,197.295,59.845,32.613 +2020-02-06 20:30:00,119.45,193.192,59.845,32.613 +2020-02-06 20:45:00,118.13,192.05,59.845,32.613 +2020-02-06 21:00:00,110.84,188.26,54.83,32.613 +2020-02-06 21:15:00,117.96,185.175,54.83,32.613 +2020-02-06 21:30:00,117.04,183.16,54.83,32.613 +2020-02-06 21:45:00,112.67,182.015,54.83,32.613 +2020-02-06 22:00:00,101.49,174.704,50.933,32.613 +2020-02-06 22:15:00,101.52,169.43,50.933,32.613 +2020-02-06 22:30:00,97.68,155.55700000000002,50.933,32.613 +2020-02-06 22:45:00,100.63,147.503,50.933,32.613 +2020-02-06 23:00:00,98.2,140.183,45.32899999999999,32.613 +2020-02-06 23:15:00,98.49,138.966,45.32899999999999,32.613 +2020-02-06 23:30:00,93.7,139.555,45.32899999999999,32.613 +2020-02-06 23:45:00,88.78,139.321,45.32899999999999,32.613 +2020-02-07 00:00:00,88.69,132.535,43.74,32.613 +2020-02-07 00:15:00,91.22,132.095,43.74,32.613 +2020-02-07 00:30:00,91.01,132.892,43.74,32.613 +2020-02-07 00:45:00,85.92,134.365,43.74,32.613 +2020-02-07 01:00:00,86.38,136.671,42.555,32.613 +2020-02-07 01:15:00,86.96,137.619,42.555,32.613 +2020-02-07 01:30:00,85.7,137.502,42.555,32.613 +2020-02-07 01:45:00,78.77,137.983,42.555,32.613 +2020-02-07 02:00:00,77.21,140.266,41.68600000000001,32.613 +2020-02-07 02:15:00,78.6,142.107,41.68600000000001,32.613 +2020-02-07 02:30:00,78.82,143.773,41.68600000000001,32.613 +2020-02-07 02:45:00,79.14,145.988,41.68600000000001,32.613 +2020-02-07 03:00:00,80.57,147.57399999999998,42.278999999999996,32.613 +2020-02-07 03:15:00,87.01,150.124,42.278999999999996,32.613 +2020-02-07 03:30:00,88.53,151.899,42.278999999999996,32.613 +2020-02-07 03:45:00,91.75,154.13299999999998,42.278999999999996,32.613 +2020-02-07 04:00:00,81.9,165.515,43.742,32.613 +2020-02-07 04:15:00,81.41,176.655,43.742,32.613 +2020-02-07 04:30:00,83.77,180.39,43.742,32.613 +2020-02-07 04:45:00,85.67,181.968,43.742,32.613 +2020-02-07 05:00:00,89.49,214.108,46.973,32.613 +2020-02-07 05:15:00,90.53,242.95,46.973,32.613 +2020-02-07 05:30:00,95.28,239.739,46.973,32.613 +2020-02-07 05:45:00,99.96,233.467,46.973,32.613 +2020-02-07 06:00:00,110.48,231.317,59.63399999999999,32.613 +2020-02-07 06:15:00,113.13,235.822,59.63399999999999,32.613 +2020-02-07 06:30:00,118.49,237.761,59.63399999999999,32.613 +2020-02-07 06:45:00,122.09,244.732,59.63399999999999,32.613 +2020-02-07 07:00:00,129.43,242.68599999999998,71.631,32.613 +2020-02-07 07:15:00,129.96,248.84,71.631,32.613 +2020-02-07 07:30:00,130.46,251.521,71.631,32.613 +2020-02-07 07:45:00,133.64,251.55700000000002,71.631,32.613 +2020-02-07 08:00:00,137.27,248.618,66.181,32.613 +2020-02-07 08:15:00,133.27,247.94099999999997,66.181,32.613 +2020-02-07 08:30:00,132.95,245.679,66.181,32.613 +2020-02-07 08:45:00,131.08,240.852,66.181,32.613 +2020-02-07 09:00:00,132.18,234.997,63.086000000000006,32.613 +2020-02-07 09:15:00,132.77,232.62599999999998,63.086000000000006,32.613 +2020-02-07 09:30:00,132.22,230.766,63.086000000000006,32.613 +2020-02-07 09:45:00,132.21,227.275,63.086000000000006,32.613 +2020-02-07 10:00:00,132.16,220.571,60.886,32.613 +2020-02-07 10:15:00,134.75,217.90599999999998,60.886,32.613 +2020-02-07 10:30:00,132.78,214.06599999999997,60.886,32.613 +2020-02-07 10:45:00,131.45,212.013,60.886,32.613 +2020-02-07 11:00:00,127.82,208.912,59.391000000000005,32.613 +2020-02-07 11:15:00,128.72,206.90200000000002,59.391000000000005,32.613 +2020-02-07 11:30:00,126.6,207.252,59.391000000000005,32.613 +2020-02-07 11:45:00,125.26,206.785,59.391000000000005,32.613 +2020-02-07 12:00:00,123.4,204.003,56.172,32.613 +2020-02-07 12:15:00,122.69,201.739,56.172,32.613 +2020-02-07 12:30:00,121.83,201.28900000000002,56.172,32.613 +2020-02-07 12:45:00,121.23,202.543,56.172,32.613 +2020-02-07 13:00:00,119.63,201.565,54.406000000000006,32.613 +2020-02-07 13:15:00,119.78,201.37599999999998,54.406000000000006,32.613 +2020-02-07 13:30:00,118.9,200.287,54.406000000000006,32.613 +2020-02-07 13:45:00,118.56,200.28799999999998,54.406000000000006,32.613 +2020-02-07 14:00:00,115.99,199.468,53.578,32.613 +2020-02-07 14:15:00,117.39,199.42700000000002,53.578,32.613 +2020-02-07 14:30:00,118.46,200.57,53.578,32.613 +2020-02-07 14:45:00,121.39,201.94799999999998,53.578,32.613 +2020-02-07 15:00:00,122.71,202.743,56.568999999999996,32.613 +2020-02-07 15:15:00,118.45,202.41299999999998,56.568999999999996,32.613 +2020-02-07 15:30:00,118.49,202.174,56.568999999999996,32.613 +2020-02-07 15:45:00,118.79,203.062,56.568999999999996,32.613 +2020-02-07 16:00:00,120.97,203.285,60.169,32.613 +2020-02-07 16:15:00,123.05,205.308,60.169,32.613 +2020-02-07 16:30:00,121.89,208.31900000000002,60.169,32.613 +2020-02-07 16:45:00,123.97,209.50400000000002,60.169,32.613 +2020-02-07 17:00:00,128.63,211.71099999999998,65.497,32.613 +2020-02-07 17:15:00,131.36,212.703,65.497,32.613 +2020-02-07 17:30:00,135.74,214.044,65.497,32.613 +2020-02-07 17:45:00,136.59,214.21599999999998,65.497,32.613 +2020-02-07 18:00:00,137.38,217.047,65.082,32.613 +2020-02-07 18:15:00,136.53,214.35,65.082,32.613 +2020-02-07 18:30:00,135.49,213.798,65.082,32.613 +2020-02-07 18:45:00,134.69,214.545,65.082,32.613 +2020-02-07 19:00:00,131.86,213.304,60.968,32.613 +2020-02-07 19:15:00,131.03,210.918,60.968,32.613 +2020-02-07 19:30:00,134.97,207.908,60.968,32.613 +2020-02-07 19:45:00,136.65,204.822,60.968,32.613 +2020-02-07 20:00:00,128.29,200.338,61.123000000000005,32.613 +2020-02-07 20:15:00,120.3,193.86,61.123000000000005,32.613 +2020-02-07 20:30:00,116.56,189.77,61.123000000000005,32.613 +2020-02-07 20:45:00,113.11,189.325,61.123000000000005,32.613 +2020-02-07 21:00:00,107.82,185.94299999999998,55.416000000000004,32.613 +2020-02-07 21:15:00,111.91,183.15400000000002,55.416000000000004,32.613 +2020-02-07 21:30:00,109.99,181.205,55.416000000000004,32.613 +2020-02-07 21:45:00,109.63,180.65099999999998,55.416000000000004,32.613 +2020-02-07 22:00:00,99.9,174.41299999999998,51.631,32.613 +2020-02-07 22:15:00,94.38,169.043,51.631,32.613 +2020-02-07 22:30:00,91.58,161.64,51.631,32.613 +2020-02-07 22:45:00,96.88,157.365,51.631,32.613 +2020-02-07 23:00:00,93.8,149.38299999999998,44.898,32.613 +2020-02-07 23:15:00,96.38,146.203,44.898,32.613 +2020-02-07 23:30:00,86.3,145.392,44.898,32.613 +2020-02-07 23:45:00,82.22,144.46200000000002,44.898,32.613 +2020-02-08 00:00:00,76.07,129.066,42.033,32.431999999999995 +2020-02-08 00:15:00,81.2,123.95200000000001,42.033,32.431999999999995 +2020-02-08 00:30:00,83.22,126.271,42.033,32.431999999999995 +2020-02-08 00:45:00,85.24,128.624,42.033,32.431999999999995 +2020-02-08 01:00:00,74.78,131.55700000000002,38.255,32.431999999999995 +2020-02-08 01:15:00,73.72,131.313,38.255,32.431999999999995 +2020-02-08 01:30:00,71.69,130.709,38.255,32.431999999999995 +2020-02-08 01:45:00,80.83,130.78799999999998,38.255,32.431999999999995 +2020-02-08 02:00:00,81.35,133.951,36.404,32.431999999999995 +2020-02-08 02:15:00,79.32,135.47799999999998,36.404,32.431999999999995 +2020-02-08 02:30:00,73.69,136.015,36.404,32.431999999999995 +2020-02-08 02:45:00,72.18,138.24,36.404,32.431999999999995 +2020-02-08 03:00:00,76.73,140.701,36.083,32.431999999999995 +2020-02-08 03:15:00,78.04,141.977,36.083,32.431999999999995 +2020-02-08 03:30:00,75.79,141.916,36.083,32.431999999999995 +2020-02-08 03:45:00,72.95,144.086,36.083,32.431999999999995 +2020-02-08 04:00:00,69.34,151.02,36.102,32.431999999999995 +2020-02-08 04:15:00,69.82,159.40200000000002,36.102,32.431999999999995 +2020-02-08 04:30:00,69.75,160.933,36.102,32.431999999999995 +2020-02-08 04:45:00,73.37,161.901,36.102,32.431999999999995 +2020-02-08 05:00:00,71.6,177.055,35.284,32.431999999999995 +2020-02-08 05:15:00,72.11,185.93599999999998,35.284,32.431999999999995 +2020-02-08 05:30:00,71.73,182.887,35.284,32.431999999999995 +2020-02-08 05:45:00,75.3,182.155,35.284,32.431999999999995 +2020-02-08 06:00:00,74.8,199.732,36.265,32.431999999999995 +2020-02-08 06:15:00,74.36,221.66,36.265,32.431999999999995 +2020-02-08 06:30:00,75.45,217.892,36.265,32.431999999999995 +2020-02-08 06:45:00,77.69,215.15,36.265,32.431999999999995 +2020-02-08 07:00:00,83.57,209.08700000000002,40.714,32.431999999999995 +2020-02-08 07:15:00,87.81,213.976,40.714,32.431999999999995 +2020-02-08 07:30:00,87.5,219.47400000000002,40.714,32.431999999999995 +2020-02-08 07:45:00,88.9,223.83700000000002,40.714,32.431999999999995 +2020-02-08 08:00:00,93.52,225.43400000000003,46.692,32.431999999999995 +2020-02-08 08:15:00,95.5,228.845,46.692,32.431999999999995 +2020-02-08 08:30:00,98.42,228.342,46.692,32.431999999999995 +2020-02-08 08:45:00,100.91,226.93599999999998,46.692,32.431999999999995 +2020-02-08 09:00:00,103.5,222.706,48.925,32.431999999999995 +2020-02-08 09:15:00,103.89,221.12,48.925,32.431999999999995 +2020-02-08 09:30:00,104.75,220.226,48.925,32.431999999999995 +2020-02-08 09:45:00,107.02,216.97299999999998,48.925,32.431999999999995 +2020-02-08 10:00:00,107.36,210.49,47.799,32.431999999999995 +2020-02-08 10:15:00,107.78,207.989,47.799,32.431999999999995 +2020-02-08 10:30:00,108.48,204.386,47.799,32.431999999999995 +2020-02-08 10:45:00,109.57,203.84799999999998,47.799,32.431999999999995 +2020-02-08 11:00:00,108.73,201.024,44.309,32.431999999999995 +2020-02-08 11:15:00,110.59,198.15400000000002,44.309,32.431999999999995 +2020-02-08 11:30:00,111.33,197.233,44.309,32.431999999999995 +2020-02-08 11:45:00,110.89,195.63099999999997,44.309,32.431999999999995 +2020-02-08 12:00:00,108.2,191.81599999999997,42.367,32.431999999999995 +2020-02-08 12:15:00,106.08,190.18,42.367,32.431999999999995 +2020-02-08 12:30:00,103.17,190.074,42.367,32.431999999999995 +2020-02-08 12:45:00,102.67,190.37599999999998,42.367,32.431999999999995 +2020-02-08 13:00:00,98.62,189.06799999999998,39.036,32.431999999999995 +2020-02-08 13:15:00,98.03,186.632,39.036,32.431999999999995 +2020-02-08 13:30:00,95.95,185.00099999999998,39.036,32.431999999999995 +2020-02-08 13:45:00,94.83,185.69799999999998,39.036,32.431999999999995 +2020-02-08 14:00:00,96.32,186.321,37.995,32.431999999999995 +2020-02-08 14:15:00,93.19,185.817,37.995,32.431999999999995 +2020-02-08 14:30:00,93.21,184.97400000000002,37.995,32.431999999999995 +2020-02-08 14:45:00,93.07,186.553,37.995,32.431999999999995 +2020-02-08 15:00:00,92.13,188.085,40.71,32.431999999999995 +2020-02-08 15:15:00,92.1,188.537,40.71,32.431999999999995 +2020-02-08 15:30:00,93.08,189.947,40.71,32.431999999999995 +2020-02-08 15:45:00,94.41,190.942,40.71,32.431999999999995 +2020-02-08 16:00:00,93.8,189.675,46.998000000000005,32.431999999999995 +2020-02-08 16:15:00,94.73,192.775,46.998000000000005,32.431999999999995 +2020-02-08 16:30:00,96.17,195.707,46.998000000000005,32.431999999999995 +2020-02-08 16:45:00,97.61,197.858,46.998000000000005,32.431999999999995 +2020-02-08 17:00:00,102.44,199.639,55.431000000000004,32.431999999999995 +2020-02-08 17:15:00,103.98,202.69299999999998,55.431000000000004,32.431999999999995 +2020-02-08 17:30:00,110.41,203.977,55.431000000000004,32.431999999999995 +2020-02-08 17:45:00,109.38,203.68,55.431000000000004,32.431999999999995 +2020-02-08 18:00:00,111.7,205.91099999999997,55.989,32.431999999999995 +2020-02-08 18:15:00,111.45,205.063,55.989,32.431999999999995 +2020-02-08 18:30:00,109.88,205.833,55.989,32.431999999999995 +2020-02-08 18:45:00,108.49,203.294,55.989,32.431999999999995 +2020-02-08 19:00:00,107.67,203.201,50.882,32.431999999999995 +2020-02-08 19:15:00,106.93,200.38299999999998,50.882,32.431999999999995 +2020-02-08 19:30:00,104.88,198.138,50.882,32.431999999999995 +2020-02-08 19:45:00,102.39,194.731,50.882,32.431999999999995 +2020-02-08 20:00:00,98.25,192.463,43.172,32.431999999999995 +2020-02-08 20:15:00,94.38,188.37400000000002,43.172,32.431999999999995 +2020-02-08 20:30:00,91.97,183.99599999999998,43.172,32.431999999999995 +2020-02-08 20:45:00,89.93,182.93900000000002,43.172,32.431999999999995 +2020-02-08 21:00:00,85.08,182.136,37.599000000000004,32.431999999999995 +2020-02-08 21:15:00,83.97,179.845,37.599000000000004,32.431999999999995 +2020-02-08 21:30:00,82.71,179.21599999999998,37.599000000000004,32.431999999999995 +2020-02-08 21:45:00,82.15,178.28900000000002,37.599000000000004,32.431999999999995 +2020-02-08 22:00:00,77.71,173.505,39.047,32.431999999999995 +2020-02-08 22:15:00,77.18,170.838,39.047,32.431999999999995 +2020-02-08 22:30:00,73.7,170.201,39.047,32.431999999999995 +2020-02-08 22:45:00,72.2,167.93,39.047,32.431999999999995 +2020-02-08 23:00:00,69.4,162.638,32.339,32.431999999999995 +2020-02-08 23:15:00,68.66,157.657,32.339,32.431999999999995 +2020-02-08 23:30:00,65.61,154.733,32.339,32.431999999999995 +2020-02-08 23:45:00,64.73,151.15200000000002,32.339,32.431999999999995 +2020-02-09 00:00:00,61.27,129.226,29.988000000000003,32.431999999999995 +2020-02-09 00:15:00,60.71,123.889,29.988000000000003,32.431999999999995 +2020-02-09 00:30:00,59.99,125.789,29.988000000000003,32.431999999999995 +2020-02-09 00:45:00,59.14,128.922,29.988000000000003,32.431999999999995 +2020-02-09 01:00:00,57.53,131.635,28.531999999999996,32.431999999999995 +2020-02-09 01:15:00,56.93,132.55,28.531999999999996,32.431999999999995 +2020-02-09 01:30:00,57.32,132.533,28.531999999999996,32.431999999999995 +2020-02-09 01:45:00,57.14,132.30700000000002,28.531999999999996,32.431999999999995 +2020-02-09 02:00:00,56.24,134.63,27.805999999999997,32.431999999999995 +2020-02-09 02:15:00,57.09,135.11700000000002,27.805999999999997,32.431999999999995 +2020-02-09 02:30:00,56.53,136.59799999999998,27.805999999999997,32.431999999999995 +2020-02-09 02:45:00,54.95,139.373,27.805999999999997,32.431999999999995 +2020-02-09 03:00:00,54.55,142.111,26.193,32.431999999999995 +2020-02-09 03:15:00,55.28,142.769,26.193,32.431999999999995 +2020-02-09 03:30:00,55.8,144.366,26.193,32.431999999999995 +2020-02-09 03:45:00,56.3,146.563,26.193,32.431999999999995 +2020-02-09 04:00:00,56.5,153.233,27.19,32.431999999999995 +2020-02-09 04:15:00,57.24,160.52700000000002,27.19,32.431999999999995 +2020-02-09 04:30:00,57.91,161.968,27.19,32.431999999999995 +2020-02-09 04:45:00,57.79,163.293,27.19,32.431999999999995 +2020-02-09 05:00:00,58.77,174.454,28.166999999999998,32.431999999999995 +2020-02-09 05:15:00,58.46,180.73,28.166999999999998,32.431999999999995 +2020-02-09 05:30:00,60.97,177.52599999999998,28.166999999999998,32.431999999999995 +2020-02-09 05:45:00,62.39,177.112,28.166999999999998,32.431999999999995 +2020-02-09 06:00:00,60.88,194.896,27.16,32.431999999999995 +2020-02-09 06:15:00,63.92,214.793,27.16,32.431999999999995 +2020-02-09 06:30:00,65.05,209.84799999999998,27.16,32.431999999999995 +2020-02-09 06:45:00,63.69,206.051,27.16,32.431999999999995 +2020-02-09 07:00:00,68.95,202.731,29.578000000000003,32.431999999999995 +2020-02-09 07:15:00,68.37,206.84900000000002,29.578000000000003,32.431999999999995 +2020-02-09 07:30:00,70.21,210.766,29.578000000000003,32.431999999999995 +2020-02-09 07:45:00,72.49,214.237,29.578000000000003,32.431999999999995 +2020-02-09 08:00:00,76.04,217.799,34.650999999999996,32.431999999999995 +2020-02-09 08:15:00,76.96,220.94,34.650999999999996,32.431999999999995 +2020-02-09 08:30:00,77.09,222.12400000000002,34.650999999999996,32.431999999999995 +2020-02-09 08:45:00,77.53,222.98,34.650999999999996,32.431999999999995 +2020-02-09 09:00:00,79.01,218.326,38.080999999999996,32.431999999999995 +2020-02-09 09:15:00,78.77,217.41299999999998,38.080999999999996,32.431999999999995 +2020-02-09 09:30:00,75.58,216.317,38.080999999999996,32.431999999999995 +2020-02-09 09:45:00,79.56,212.81599999999997,38.080999999999996,32.431999999999995 +2020-02-09 10:00:00,81.3,209.075,39.934,32.431999999999995 +2020-02-09 10:15:00,76.81,207.17700000000002,39.934,32.431999999999995 +2020-02-09 10:30:00,79.12,204.226,39.934,32.431999999999995 +2020-02-09 10:45:00,81.4,201.47099999999998,39.934,32.431999999999995 +2020-02-09 11:00:00,84.98,199.705,43.74100000000001,32.431999999999995 +2020-02-09 11:15:00,91.65,197.043,43.74100000000001,32.431999999999995 +2020-02-09 11:30:00,93.87,195.105,43.74100000000001,32.431999999999995 +2020-02-09 11:45:00,94.14,194.15599999999998,43.74100000000001,32.431999999999995 +2020-02-09 12:00:00,89.58,189.606,40.001999999999995,32.431999999999995 +2020-02-09 12:15:00,87.22,190.168,40.001999999999995,32.431999999999995 +2020-02-09 12:30:00,81.41,188.4,40.001999999999995,32.431999999999995 +2020-02-09 12:45:00,83.91,187.699,40.001999999999995,32.431999999999995 +2020-02-09 13:00:00,83.42,185.649,37.855,32.431999999999995 +2020-02-09 13:15:00,83.34,186.597,37.855,32.431999999999995 +2020-02-09 13:30:00,83.38,184.843,37.855,32.431999999999995 +2020-02-09 13:45:00,83.48,184.68400000000003,37.855,32.431999999999995 +2020-02-09 14:00:00,82.03,185.49900000000002,35.946999999999996,32.431999999999995 +2020-02-09 14:15:00,81.11,186.253,35.946999999999996,32.431999999999995 +2020-02-09 14:30:00,80.88,186.899,35.946999999999996,32.431999999999995 +2020-02-09 14:45:00,80.8,188.155,35.946999999999996,32.431999999999995 +2020-02-09 15:00:00,80.61,187.985,35.138000000000005,32.431999999999995 +2020-02-09 15:15:00,81.01,189.326,35.138000000000005,32.431999999999995 +2020-02-09 15:30:00,81.24,191.368,35.138000000000005,32.431999999999995 +2020-02-09 15:45:00,82.18,193.079,35.138000000000005,32.431999999999995 +2020-02-09 16:00:00,84.12,194.005,38.672,32.431999999999995 +2020-02-09 16:15:00,82.75,196.077,38.672,32.431999999999995 +2020-02-09 16:30:00,83.87,199.19400000000002,38.672,32.431999999999995 +2020-02-09 16:45:00,85.94,201.47099999999998,38.672,32.431999999999995 +2020-02-09 17:00:00,91.05,203.174,48.684,32.431999999999995 +2020-02-09 17:15:00,91.88,205.752,48.684,32.431999999999995 +2020-02-09 17:30:00,98.58,207.305,48.684,32.431999999999995 +2020-02-09 17:45:00,98.25,209.468,48.684,32.431999999999995 +2020-02-09 18:00:00,103.28,211.042,51.568999999999996,32.431999999999995 +2020-02-09 18:15:00,100.42,211.72099999999998,51.568999999999996,32.431999999999995 +2020-02-09 18:30:00,104.15,210.25400000000002,51.568999999999996,32.431999999999995 +2020-02-09 18:45:00,100.37,209.738,51.568999999999996,32.431999999999995 +2020-02-09 19:00:00,98.81,209.03599999999997,48.608000000000004,32.431999999999995 +2020-02-09 19:15:00,97.23,206.96599999999998,48.608000000000004,32.431999999999995 +2020-02-09 19:30:00,95.94,204.58599999999998,48.608000000000004,32.431999999999995 +2020-02-09 19:45:00,94.87,202.80900000000003,48.608000000000004,32.431999999999995 +2020-02-09 20:00:00,91.94,200.498,43.733999999999995,32.431999999999995 +2020-02-09 20:15:00,91.17,197.502,43.733999999999995,32.431999999999995 +2020-02-09 20:30:00,89.34,194.40599999999998,43.733999999999995,32.431999999999995 +2020-02-09 20:45:00,90.36,192.203,43.733999999999995,32.431999999999995 +2020-02-09 21:00:00,83.75,188.577,39.283,32.431999999999995 +2020-02-09 21:15:00,84.63,185.62,39.283,32.431999999999995 +2020-02-09 21:30:00,84.54,185.36900000000003,39.283,32.431999999999995 +2020-02-09 21:45:00,87.87,184.575,39.283,32.431999999999995 +2020-02-09 22:00:00,86.37,178.296,40.111,32.431999999999995 +2020-02-09 22:15:00,84.1,174.982,40.111,32.431999999999995 +2020-02-09 22:30:00,80.59,170.921,40.111,32.431999999999995 +2020-02-09 22:45:00,80.15,167.84900000000002,40.111,32.431999999999995 +2020-02-09 23:00:00,76.59,159.545,35.791,32.431999999999995 +2020-02-09 23:15:00,78.67,156.477,35.791,32.431999999999995 +2020-02-09 23:30:00,75.47,154.495,35.791,32.431999999999995 +2020-02-09 23:45:00,74.68,151.886,35.791,32.431999999999995 +2020-02-10 00:00:00,69.25,133.565,34.311,32.613 +2020-02-10 00:15:00,70.25,131.43200000000002,34.311,32.613 +2020-02-10 00:30:00,70.75,133.498,34.311,32.613 +2020-02-10 00:45:00,70.63,136.07399999999998,34.311,32.613 +2020-02-10 01:00:00,69.11,138.743,34.585,32.613 +2020-02-10 01:15:00,68.52,139.054,34.585,32.613 +2020-02-10 01:30:00,68.82,139.04399999999998,34.585,32.613 +2020-02-10 01:45:00,69.91,138.957,34.585,32.613 +2020-02-10 02:00:00,68.03,141.208,34.111,32.613 +2020-02-10 02:15:00,68.74,143.455,34.111,32.613 +2020-02-10 02:30:00,68.09,145.303,34.111,32.613 +2020-02-10 02:45:00,68.85,147.40200000000002,34.111,32.613 +2020-02-10 03:00:00,67.44,151.52200000000002,32.435,32.613 +2020-02-10 03:15:00,68.32,153.937,32.435,32.613 +2020-02-10 03:30:00,69.58,155.138,32.435,32.613 +2020-02-10 03:45:00,70.89,156.787,32.435,32.613 +2020-02-10 04:00:00,71.36,167.96099999999998,33.04,32.613 +2020-02-10 04:15:00,73.21,179.521,33.04,32.613 +2020-02-10 04:30:00,75.25,183.50900000000001,33.04,32.613 +2020-02-10 04:45:00,77.06,184.954,33.04,32.613 +2020-02-10 05:00:00,81.16,212.946,40.399,32.613 +2020-02-10 05:15:00,83.71,240.581,40.399,32.613 +2020-02-10 05:30:00,88.12,237.87599999999998,40.399,32.613 +2020-02-10 05:45:00,91.96,231.50799999999998,40.399,32.613 +2020-02-10 06:00:00,100.85,230.446,60.226000000000006,32.613 +2020-02-10 06:15:00,104.58,234.87099999999998,60.226000000000006,32.613 +2020-02-10 06:30:00,109.61,238.394,60.226000000000006,32.613 +2020-02-10 06:45:00,114.01,243.798,60.226000000000006,32.613 +2020-02-10 07:00:00,123.45,243.1,73.578,32.613 +2020-02-10 07:15:00,122.16,248.285,73.578,32.613 +2020-02-10 07:30:00,126.33,251.41,73.578,32.613 +2020-02-10 07:45:00,124.92,251.91099999999997,73.578,32.613 +2020-02-10 08:00:00,128.37,250.143,66.58,32.613 +2020-02-10 08:15:00,127.45,251.013,66.58,32.613 +2020-02-10 08:30:00,127.24,247.687,66.58,32.613 +2020-02-10 08:45:00,126.73,244.77900000000002,66.58,32.613 +2020-02-10 09:00:00,128.93,239.095,62.0,32.613 +2020-02-10 09:15:00,132.12,234.521,62.0,32.613 +2020-02-10 09:30:00,129.88,232.447,62.0,32.613 +2020-02-10 09:45:00,132.17,229.562,62.0,32.613 +2020-02-10 10:00:00,131.38,224.611,59.099,32.613 +2020-02-10 10:15:00,127.86,222.393,59.099,32.613 +2020-02-10 10:30:00,126.07,218.533,59.099,32.613 +2020-02-10 10:45:00,129.64,216.829,59.099,32.613 +2020-02-10 11:00:00,130.68,212.051,57.729,32.613 +2020-02-10 11:15:00,131.96,211.373,57.729,32.613 +2020-02-10 11:30:00,132.31,210.891,57.729,32.613 +2020-02-10 11:45:00,136.23,209.438,57.729,32.613 +2020-02-10 12:00:00,131.68,207.05599999999998,55.615,32.613 +2020-02-10 12:15:00,132.92,207.62,55.615,32.613 +2020-02-10 12:30:00,134.19,206.238,55.615,32.613 +2020-02-10 12:45:00,129.29,207.23,55.615,32.613 +2020-02-10 13:00:00,125.98,205.753,56.515,32.613 +2020-02-10 13:15:00,124.66,205.24,56.515,32.613 +2020-02-10 13:30:00,124.24,202.86,56.515,32.613 +2020-02-10 13:45:00,128.1,202.62900000000002,56.515,32.613 +2020-02-10 14:00:00,124.97,202.88400000000001,58.1,32.613 +2020-02-10 14:15:00,125.5,202.832,58.1,32.613 +2020-02-10 14:30:00,126.78,202.88299999999998,58.1,32.613 +2020-02-10 14:45:00,128.81,203.86900000000003,58.1,32.613 +2020-02-10 15:00:00,129.17,205.675,59.801,32.613 +2020-02-10 15:15:00,129.48,205.46599999999998,59.801,32.613 +2020-02-10 15:30:00,124.6,206.46400000000003,59.801,32.613 +2020-02-10 15:45:00,123.59,207.72099999999998,59.801,32.613 +2020-02-10 16:00:00,123.44,208.65,62.901,32.613 +2020-02-10 16:15:00,123.28,209.86700000000002,62.901,32.613 +2020-02-10 16:30:00,122.68,211.997,62.901,32.613 +2020-02-10 16:45:00,123.82,212.947,62.901,32.613 +2020-02-10 17:00:00,128.52,214.516,70.418,32.613 +2020-02-10 17:15:00,127.46,216.03799999999998,70.418,32.613 +2020-02-10 17:30:00,135.21,217.065,70.418,32.613 +2020-02-10 17:45:00,135.18,217.66099999999997,70.418,32.613 +2020-02-10 18:00:00,137.86,219.78099999999998,71.726,32.613 +2020-02-10 18:15:00,135.1,218.28900000000002,71.726,32.613 +2020-02-10 18:30:00,134.0,217.61,71.726,32.613 +2020-02-10 18:45:00,133.93,217.583,71.726,32.613 +2020-02-10 19:00:00,132.08,215.093,65.997,32.613 +2020-02-10 19:15:00,130.65,211.641,65.997,32.613 +2020-02-10 19:30:00,130.87,209.857,65.997,32.613 +2020-02-10 19:45:00,134.33,207.269,65.997,32.613 +2020-02-10 20:00:00,125.69,202.49400000000003,68.09100000000001,32.613 +2020-02-10 20:15:00,123.18,196.667,68.09100000000001,32.613 +2020-02-10 20:30:00,113.96,191.49900000000002,68.09100000000001,32.613 +2020-02-10 20:45:00,114.18,191.053,68.09100000000001,32.613 +2020-02-10 21:00:00,108.98,188.05599999999998,59.617,32.613 +2020-02-10 21:15:00,111.78,183.76,59.617,32.613 +2020-02-10 21:30:00,113.19,182.56599999999997,59.617,32.613 +2020-02-10 21:45:00,110.69,181.261,59.617,32.613 +2020-02-10 22:00:00,98.01,172.03099999999998,54.938,32.613 +2020-02-10 22:15:00,97.21,167.104,54.938,32.613 +2020-02-10 22:30:00,96.16,153.02700000000002,54.938,32.613 +2020-02-10 22:45:00,99.73,144.695,54.938,32.613 +2020-02-10 23:00:00,94.89,137.238,47.43,32.613 +2020-02-10 23:15:00,95.68,137.238,47.43,32.613 +2020-02-10 23:30:00,87.21,138.285,47.43,32.613 +2020-02-10 23:45:00,87.28,138.628,47.43,32.613 +2020-02-11 00:00:00,86.73,132.929,48.354,32.613 +2020-02-11 00:15:00,86.67,132.27200000000002,48.354,32.613 +2020-02-11 00:30:00,84.13,133.178,48.354,32.613 +2020-02-11 00:45:00,83.69,134.55200000000002,48.354,32.613 +2020-02-11 01:00:00,83.21,137.034,45.68600000000001,32.613 +2020-02-11 01:15:00,84.29,136.819,45.68600000000001,32.613 +2020-02-11 01:30:00,79.62,137.00799999999998,45.68600000000001,32.613 +2020-02-11 01:45:00,77.48,137.332,45.68600000000001,32.613 +2020-02-11 02:00:00,82.18,139.641,44.269,32.613 +2020-02-11 02:15:00,83.97,141.60399999999998,44.269,32.613 +2020-02-11 02:30:00,82.96,142.84799999999998,44.269,32.613 +2020-02-11 02:45:00,77.71,144.915,44.269,32.613 +2020-02-11 03:00:00,76.55,147.748,44.187,32.613 +2020-02-11 03:15:00,81.43,149.071,44.187,32.613 +2020-02-11 03:30:00,85.46,150.81799999999998,44.187,32.613 +2020-02-11 03:45:00,86.18,152.856,44.187,32.613 +2020-02-11 04:00:00,84.27,163.98,46.126999999999995,32.613 +2020-02-11 04:15:00,84.94,175.13400000000001,46.126999999999995,32.613 +2020-02-11 04:30:00,88.91,178.803,46.126999999999995,32.613 +2020-02-11 04:45:00,90.77,181.537,46.126999999999995,32.613 +2020-02-11 05:00:00,89.12,214.968,49.666000000000004,32.613 +2020-02-11 05:15:00,89.51,242.28799999999998,49.666000000000004,32.613 +2020-02-11 05:30:00,91.15,237.78400000000002,49.666000000000004,32.613 +2020-02-11 05:45:00,95.27,231.54,49.666000000000004,32.613 +2020-02-11 06:00:00,104.24,228.979,61.077,32.613 +2020-02-11 06:15:00,107.46,235.239,61.077,32.613 +2020-02-11 06:30:00,112.26,238.045,61.077,32.613 +2020-02-11 06:45:00,115.29,243.15599999999998,61.077,32.613 +2020-02-11 07:00:00,122.79,242.25,74.717,32.613 +2020-02-11 07:15:00,123.23,247.265,74.717,32.613 +2020-02-11 07:30:00,126.38,249.733,74.717,32.613 +2020-02-11 07:45:00,126.0,250.583,74.717,32.613 +2020-02-11 08:00:00,128.06,248.925,69.033,32.613 +2020-02-11 08:15:00,126.93,248.678,69.033,32.613 +2020-02-11 08:30:00,130.01,245.06900000000002,69.033,32.613 +2020-02-11 08:45:00,126.16,241.99200000000002,69.033,32.613 +2020-02-11 09:00:00,127.51,235.327,63.113,32.613 +2020-02-11 09:15:00,129.21,232.574,63.113,32.613 +2020-02-11 09:30:00,129.61,231.18599999999998,63.113,32.613 +2020-02-11 09:45:00,128.56,227.87099999999998,63.113,32.613 +2020-02-11 10:00:00,125.66,222.46099999999998,61.461999999999996,32.613 +2020-02-11 10:15:00,124.7,219.09099999999998,61.461999999999996,32.613 +2020-02-11 10:30:00,123.35,215.44,61.461999999999996,32.613 +2020-02-11 10:45:00,125.93,213.925,61.461999999999996,32.613 +2020-02-11 11:00:00,119.21,210.815,59.614,32.613 +2020-02-11 11:15:00,119.93,209.72,59.614,32.613 +2020-02-11 11:30:00,119.99,208.093,59.614,32.613 +2020-02-11 11:45:00,121.04,207.5,59.614,32.613 +2020-02-11 12:00:00,115.58,203.62099999999998,57.415,32.613 +2020-02-11 12:15:00,116.2,203.688,57.415,32.613 +2020-02-11 12:30:00,121.86,202.972,57.415,32.613 +2020-02-11 12:45:00,119.33,203.535,57.415,32.613 +2020-02-11 13:00:00,115.97,201.671,58.534,32.613 +2020-02-11 13:15:00,115.16,200.493,58.534,32.613 +2020-02-11 13:30:00,116.39,199.42,58.534,32.613 +2020-02-11 13:45:00,118.38,199.53400000000002,58.534,32.613 +2020-02-11 14:00:00,114.61,200.108,59.415,32.613 +2020-02-11 14:15:00,116.91,200.239,59.415,32.613 +2020-02-11 14:30:00,117.94,200.933,59.415,32.613 +2020-02-11 14:45:00,120.5,201.953,59.415,32.613 +2020-02-11 15:00:00,122.94,203.31099999999998,62.071999999999996,32.613 +2020-02-11 15:15:00,119.07,203.321,62.071999999999996,32.613 +2020-02-11 15:30:00,121.84,204.55900000000003,62.071999999999996,32.613 +2020-02-11 15:45:00,120.06,205.305,62.071999999999996,32.613 +2020-02-11 16:00:00,122.44,206.71400000000003,64.99,32.613 +2020-02-11 16:15:00,120.88,208.44099999999997,64.99,32.613 +2020-02-11 16:30:00,120.1,211.33900000000003,64.99,32.613 +2020-02-11 16:45:00,121.9,212.503,64.99,32.613 +2020-02-11 17:00:00,126.56,214.648,72.658,32.613 +2020-02-11 17:15:00,130.27,216.179,72.658,32.613 +2020-02-11 17:30:00,133.97,218.02700000000002,72.658,32.613 +2020-02-11 17:45:00,133.32,218.545,72.658,32.613 +2020-02-11 18:00:00,135.96,220.662,73.645,32.613 +2020-02-11 18:15:00,134.35,218.513,73.645,32.613 +2020-02-11 18:30:00,137.41,217.52700000000002,73.645,32.613 +2020-02-11 18:45:00,134.9,218.418,73.645,32.613 +2020-02-11 19:00:00,133.28,216.093,67.085,32.613 +2020-02-11 19:15:00,131.07,212.33,67.085,32.613 +2020-02-11 19:30:00,128.99,209.861,67.085,32.613 +2020-02-11 19:45:00,130.79,207.312,67.085,32.613 +2020-02-11 20:00:00,123.69,202.644,66.138,32.613 +2020-02-11 20:15:00,117.11,196.28599999999997,66.138,32.613 +2020-02-11 20:30:00,115.68,192.236,66.138,32.613 +2020-02-11 20:45:00,116.21,191.12,66.138,32.613 +2020-02-11 21:00:00,108.07,187.245,57.512,32.613 +2020-02-11 21:15:00,113.35,184.08900000000003,57.512,32.613 +2020-02-11 21:30:00,113.57,182.076,57.512,32.613 +2020-02-11 21:45:00,109.47,181.015,57.512,32.613 +2020-02-11 22:00:00,100.17,173.62599999999998,54.545,32.613 +2020-02-11 22:15:00,99.34,168.476,54.545,32.613 +2020-02-11 22:30:00,95.56,154.42700000000002,54.545,32.613 +2020-02-11 22:45:00,96.63,146.406,54.545,32.613 +2020-02-11 23:00:00,97.75,139.053,48.605,32.613 +2020-02-11 23:15:00,94.85,137.909,48.605,32.613 +2020-02-11 23:30:00,89.6,138.576,48.605,32.613 +2020-02-11 23:45:00,87.08,138.44,48.605,32.613 +2020-02-12 00:00:00,83.69,132.765,45.675,32.613 +2020-02-12 00:15:00,81.35,132.10399999999998,45.675,32.613 +2020-02-12 00:30:00,81.62,132.99,45.675,32.613 +2020-02-12 00:45:00,87.28,134.359,45.675,32.613 +2020-02-12 01:00:00,85.43,136.80700000000002,43.015,32.613 +2020-02-12 01:15:00,86.31,136.579,43.015,32.613 +2020-02-12 01:30:00,81.81,136.75799999999998,43.015,32.613 +2020-02-12 01:45:00,85.94,137.08,43.015,32.613 +2020-02-12 02:00:00,84.66,139.388,41.0,32.613 +2020-02-12 02:15:00,84.18,141.351,41.0,32.613 +2020-02-12 02:30:00,80.13,142.608,41.0,32.613 +2020-02-12 02:45:00,89.1,144.674,41.0,32.613 +2020-02-12 03:00:00,85.93,147.511,41.318000000000005,32.613 +2020-02-12 03:15:00,86.61,148.835,41.318000000000005,32.613 +2020-02-12 03:30:00,84.88,150.577,41.318000000000005,32.613 +2020-02-12 03:45:00,88.19,152.627,41.318000000000005,32.613 +2020-02-12 04:00:00,87.93,163.749,42.544,32.613 +2020-02-12 04:15:00,84.22,174.896,42.544,32.613 +2020-02-12 04:30:00,85.75,178.579,42.544,32.613 +2020-02-12 04:45:00,92.54,181.30200000000002,42.544,32.613 +2020-02-12 05:00:00,97.5,214.71400000000003,45.161,32.613 +2020-02-12 05:15:00,96.39,242.053,45.161,32.613 +2020-02-12 05:30:00,93.69,237.52200000000002,45.161,32.613 +2020-02-12 05:45:00,97.9,231.282,45.161,32.613 +2020-02-12 06:00:00,105.8,228.73,61.86600000000001,32.613 +2020-02-12 06:15:00,112.45,235.005,61.86600000000001,32.613 +2020-02-12 06:30:00,115.07,237.782,61.86600000000001,32.613 +2020-02-12 06:45:00,119.42,242.891,61.86600000000001,32.613 +2020-02-12 07:00:00,125.77,242.01,77.814,32.613 +2020-02-12 07:15:00,125.17,246.997,77.814,32.613 +2020-02-12 07:30:00,125.81,249.429,77.814,32.613 +2020-02-12 07:45:00,127.26,250.237,77.814,32.613 +2020-02-12 08:00:00,129.7,248.55900000000003,70.251,32.613 +2020-02-12 08:15:00,127.18,248.287,70.251,32.613 +2020-02-12 08:30:00,128.56,244.618,70.251,32.613 +2020-02-12 08:45:00,125.84,241.541,70.251,32.613 +2020-02-12 09:00:00,125.04,234.88099999999997,66.965,32.613 +2020-02-12 09:15:00,127.84,232.13,66.965,32.613 +2020-02-12 09:30:00,126.14,230.763,66.965,32.613 +2020-02-12 09:45:00,126.04,227.449,66.965,32.613 +2020-02-12 10:00:00,123.44,222.047,63.628,32.613 +2020-02-12 10:15:00,124.2,218.708,63.628,32.613 +2020-02-12 10:30:00,123.23,215.067,63.628,32.613 +2020-02-12 10:45:00,121.64,213.56599999999997,63.628,32.613 +2020-02-12 11:00:00,120.34,210.44400000000002,62.516999999999996,32.613 +2020-02-12 11:15:00,122.82,209.362,62.516999999999996,32.613 +2020-02-12 11:30:00,118.97,207.74099999999999,62.516999999999996,32.613 +2020-02-12 11:45:00,119.71,207.16,62.516999999999996,32.613 +2020-02-12 12:00:00,116.2,203.298,60.888999999999996,32.613 +2020-02-12 12:15:00,117.0,203.38,60.888999999999996,32.613 +2020-02-12 12:30:00,116.14,202.638,60.888999999999996,32.613 +2020-02-12 12:45:00,117.32,203.196,60.888999999999996,32.613 +2020-02-12 13:00:00,114.38,201.357,61.57899999999999,32.613 +2020-02-12 13:15:00,115.3,200.15400000000002,61.57899999999999,32.613 +2020-02-12 13:30:00,113.12,199.065,61.57899999999999,32.613 +2020-02-12 13:45:00,117.39,199.18,61.57899999999999,32.613 +2020-02-12 14:00:00,117.12,199.81,62.602,32.613 +2020-02-12 14:15:00,116.5,199.92,62.602,32.613 +2020-02-12 14:30:00,117.79,200.59799999999998,62.602,32.613 +2020-02-12 14:45:00,117.74,201.63299999999998,62.602,32.613 +2020-02-12 15:00:00,118.94,202.998,64.259,32.613 +2020-02-12 15:15:00,120.51,202.979,64.259,32.613 +2020-02-12 15:30:00,117.33,204.179,64.259,32.613 +2020-02-12 15:45:00,117.84,204.908,64.259,32.613 +2020-02-12 16:00:00,118.53,206.317,67.632,32.613 +2020-02-12 16:15:00,121.72,208.03400000000002,67.632,32.613 +2020-02-12 16:30:00,120.12,210.935,67.632,32.613 +2020-02-12 16:45:00,121.93,212.081,67.632,32.613 +2020-02-12 17:00:00,125.32,214.22400000000002,72.583,32.613 +2020-02-12 17:15:00,126.29,215.782,72.583,32.613 +2020-02-12 17:30:00,131.21,217.66400000000002,72.583,32.613 +2020-02-12 17:45:00,134.28,218.209,72.583,32.613 +2020-02-12 18:00:00,137.59,220.34099999999998,72.744,32.613 +2020-02-12 18:15:00,135.78,218.24900000000002,72.744,32.613 +2020-02-12 18:30:00,137.74,217.264,72.744,32.613 +2020-02-12 18:45:00,138.12,218.18099999999998,72.744,32.613 +2020-02-12 19:00:00,134.84,215.812,69.684,32.613 +2020-02-12 19:15:00,131.69,212.063,69.684,32.613 +2020-02-12 19:30:00,130.22,209.61700000000002,69.684,32.613 +2020-02-12 19:45:00,132.79,207.10299999999998,69.684,32.613 +2020-02-12 20:00:00,122.05,202.40400000000002,70.036,32.613 +2020-02-12 20:15:00,117.75,196.06,70.036,32.613 +2020-02-12 20:30:00,118.3,192.02200000000002,70.036,32.613 +2020-02-12 20:45:00,115.96,190.91099999999997,70.036,32.613 +2020-02-12 21:00:00,112.05,187.018,60.431999999999995,32.613 +2020-02-12 21:15:00,114.02,183.84900000000002,60.431999999999995,32.613 +2020-02-12 21:30:00,113.2,181.83599999999998,60.431999999999995,32.613 +2020-02-12 21:45:00,108.74,180.792,60.431999999999995,32.613 +2020-02-12 22:00:00,99.68,173.387,56.2,32.613 +2020-02-12 22:15:00,100.84,168.261,56.2,32.613 +2020-02-12 22:30:00,105.11,154.175,56.2,32.613 +2020-02-12 22:45:00,104.72,146.158,56.2,32.613 +2020-02-12 23:00:00,97.34,138.80100000000002,47.927,32.613 +2020-02-12 23:15:00,89.87,137.672,47.927,32.613 +2020-02-12 23:30:00,93.4,138.35399999999998,47.927,32.613 +2020-02-12 23:45:00,93.96,138.24,47.927,32.613 +2020-02-13 00:00:00,89.19,132.594,43.794,32.613 +2020-02-13 00:15:00,88.82,131.93,43.794,32.613 +2020-02-13 00:30:00,89.62,132.795,43.794,32.613 +2020-02-13 00:45:00,88.21,134.158,43.794,32.613 +2020-02-13 01:00:00,82.63,136.571,42.397,32.613 +2020-02-13 01:15:00,82.36,136.33,42.397,32.613 +2020-02-13 01:30:00,84.87,136.498,42.397,32.613 +2020-02-13 01:45:00,85.58,136.821,42.397,32.613 +2020-02-13 02:00:00,81.21,139.126,40.010999999999996,32.613 +2020-02-13 02:15:00,80.58,141.09,40.010999999999996,32.613 +2020-02-13 02:30:00,84.33,142.36,40.010999999999996,32.613 +2020-02-13 02:45:00,85.66,144.425,40.010999999999996,32.613 +2020-02-13 03:00:00,84.48,147.266,39.181,32.613 +2020-02-13 03:15:00,84.82,148.589,39.181,32.613 +2020-02-13 03:30:00,87.48,150.329,39.181,32.613 +2020-02-13 03:45:00,87.5,152.391,39.181,32.613 +2020-02-13 04:00:00,84.29,163.512,40.39,32.613 +2020-02-13 04:15:00,82.01,174.649,40.39,32.613 +2020-02-13 04:30:00,82.76,178.34900000000002,40.39,32.613 +2020-02-13 04:45:00,84.79,181.05900000000003,40.39,32.613 +2020-02-13 05:00:00,88.04,214.454,45.504,32.613 +2020-02-13 05:15:00,91.08,241.812,45.504,32.613 +2020-02-13 05:30:00,93.75,237.25400000000002,45.504,32.613 +2020-02-13 05:45:00,99.4,231.018,45.504,32.613 +2020-02-13 06:00:00,105.36,228.475,57.748000000000005,32.613 +2020-02-13 06:15:00,108.99,234.764,57.748000000000005,32.613 +2020-02-13 06:30:00,113.77,237.51,57.748000000000005,32.613 +2020-02-13 06:45:00,118.6,242.61700000000002,57.748000000000005,32.613 +2020-02-13 07:00:00,125.32,241.76,72.138,32.613 +2020-02-13 07:15:00,124.14,246.71900000000002,72.138,32.613 +2020-02-13 07:30:00,127.06,249.11700000000002,72.138,32.613 +2020-02-13 07:45:00,125.97,249.882,72.138,32.613 +2020-02-13 08:00:00,128.01,248.18200000000002,65.542,32.613 +2020-02-13 08:15:00,126.31,247.885,65.542,32.613 +2020-02-13 08:30:00,125.65,244.155,65.542,32.613 +2020-02-13 08:45:00,126.67,241.079,65.542,32.613 +2020-02-13 09:00:00,125.47,234.424,60.523,32.613 +2020-02-13 09:15:00,126.89,231.675,60.523,32.613 +2020-02-13 09:30:00,129.73,230.329,60.523,32.613 +2020-02-13 09:45:00,129.88,227.015,60.523,32.613 +2020-02-13 10:00:00,131.24,221.625,57.449,32.613 +2020-02-13 10:15:00,135.05,218.315,57.449,32.613 +2020-02-13 10:30:00,132.88,214.68400000000003,57.449,32.613 +2020-02-13 10:45:00,133.42,213.19799999999998,57.449,32.613 +2020-02-13 11:00:00,134.39,210.065,54.505,32.613 +2020-02-13 11:15:00,130.99,208.99599999999998,54.505,32.613 +2020-02-13 11:30:00,130.46,207.382,54.505,32.613 +2020-02-13 11:45:00,131.92,206.813,54.505,32.613 +2020-02-13 12:00:00,132.04,202.96599999999998,51.50899999999999,32.613 +2020-02-13 12:15:00,132.58,203.065,51.50899999999999,32.613 +2020-02-13 12:30:00,130.4,202.295,51.50899999999999,32.613 +2020-02-13 12:45:00,130.32,202.84900000000002,51.50899999999999,32.613 +2020-02-13 13:00:00,128.38,201.03599999999997,51.303999999999995,32.613 +2020-02-13 13:15:00,128.1,199.808,51.303999999999995,32.613 +2020-02-13 13:30:00,127.22,198.702,51.303999999999995,32.613 +2020-02-13 13:45:00,127.79,198.81900000000002,51.303999999999995,32.613 +2020-02-13 14:00:00,128.62,199.505,52.785,32.613 +2020-02-13 14:15:00,126.84,199.595,52.785,32.613 +2020-02-13 14:30:00,126.35,200.25599999999997,52.785,32.613 +2020-02-13 14:45:00,128.53,201.30599999999998,52.785,32.613 +2020-02-13 15:00:00,129.93,202.678,56.458999999999996,32.613 +2020-02-13 15:15:00,128.24,202.62900000000002,56.458999999999996,32.613 +2020-02-13 15:30:00,126.87,203.79,56.458999999999996,32.613 +2020-02-13 15:45:00,125.33,204.50099999999998,56.458999999999996,32.613 +2020-02-13 16:00:00,127.19,205.91099999999997,59.388000000000005,32.613 +2020-02-13 16:15:00,124.76,207.618,59.388000000000005,32.613 +2020-02-13 16:30:00,125.2,210.52200000000002,59.388000000000005,32.613 +2020-02-13 16:45:00,126.24,211.65,59.388000000000005,32.613 +2020-02-13 17:00:00,130.87,213.791,64.462,32.613 +2020-02-13 17:15:00,132.84,215.37400000000002,64.462,32.613 +2020-02-13 17:30:00,137.45,217.291,64.462,32.613 +2020-02-13 17:45:00,138.46,217.863,64.462,32.613 +2020-02-13 18:00:00,138.81,220.00900000000001,65.128,32.613 +2020-02-13 18:15:00,139.01,217.975,65.128,32.613 +2020-02-13 18:30:00,137.4,216.99200000000002,65.128,32.613 +2020-02-13 18:45:00,137.2,217.93200000000002,65.128,32.613 +2020-02-13 19:00:00,135.8,215.521,61.316,32.613 +2020-02-13 19:15:00,131.56,211.78799999999998,61.316,32.613 +2020-02-13 19:30:00,134.76,209.36599999999999,61.316,32.613 +2020-02-13 19:45:00,138.85,206.887,61.316,32.613 +2020-02-13 20:00:00,128.5,202.157,59.845,32.613 +2020-02-13 20:15:00,118.6,195.827,59.845,32.613 +2020-02-13 20:30:00,113.73,191.801,59.845,32.613 +2020-02-13 20:45:00,114.63,190.69299999999998,59.845,32.613 +2020-02-13 21:00:00,108.1,186.78400000000002,54.83,32.613 +2020-02-13 21:15:00,111.9,183.602,54.83,32.613 +2020-02-13 21:30:00,111.83,181.59,54.83,32.613 +2020-02-13 21:45:00,111.1,180.563,54.83,32.613 +2020-02-13 22:00:00,98.11,173.14,50.933,32.613 +2020-02-13 22:15:00,97.59,168.041,50.933,32.613 +2020-02-13 22:30:00,96.24,153.912,50.933,32.613 +2020-02-13 22:45:00,91.94,145.90200000000002,50.933,32.613 +2020-02-13 23:00:00,90.76,138.541,45.32899999999999,32.613 +2020-02-13 23:15:00,94.89,137.42700000000002,45.32899999999999,32.613 +2020-02-13 23:30:00,92.24,138.123,45.32899999999999,32.613 +2020-02-13 23:45:00,90.49,138.031,45.32899999999999,32.613 +2020-02-14 00:00:00,80.41,131.44799999999998,43.74,32.613 +2020-02-14 00:15:00,85.51,130.975,43.74,32.613 +2020-02-14 00:30:00,87.17,131.634,43.74,32.613 +2020-02-14 00:45:00,86.07,133.062,43.74,32.613 +2020-02-14 01:00:00,76.17,135.139,42.555,32.613 +2020-02-14 01:15:00,80.38,135.996,42.555,32.613 +2020-02-14 01:30:00,83.38,135.805,42.555,32.613 +2020-02-14 01:45:00,82.94,136.282,42.555,32.613 +2020-02-14 02:00:00,77.22,138.555,41.68600000000001,32.613 +2020-02-14 02:15:00,79.25,140.39600000000002,41.68600000000001,32.613 +2020-02-14 02:30:00,82.1,142.158,41.68600000000001,32.613 +2020-02-14 02:45:00,84.42,144.364,41.68600000000001,32.613 +2020-02-14 03:00:00,82.15,145.974,42.278999999999996,32.613 +2020-02-14 03:15:00,82.27,148.532,42.278999999999996,32.613 +2020-02-14 03:30:00,85.54,150.282,42.278999999999996,32.613 +2020-02-14 03:45:00,84.15,152.6,42.278999999999996,32.613 +2020-02-14 04:00:00,80.78,163.96400000000003,43.742,32.613 +2020-02-14 04:15:00,81.02,175.043,43.742,32.613 +2020-02-14 04:30:00,81.93,178.87900000000002,43.742,32.613 +2020-02-14 04:45:00,84.27,180.37400000000002,43.742,32.613 +2020-02-14 05:00:00,87.26,212.38099999999997,46.973,32.613 +2020-02-14 05:15:00,88.2,241.33599999999998,46.973,32.613 +2020-02-14 05:30:00,91.52,237.951,46.973,32.613 +2020-02-14 05:45:00,95.37,231.71099999999998,46.973,32.613 +2020-02-14 06:00:00,102.89,229.63400000000001,59.63399999999999,32.613 +2020-02-14 06:15:00,106.98,234.238,59.63399999999999,32.613 +2020-02-14 06:30:00,110.84,235.976,59.63399999999999,32.613 +2020-02-14 06:45:00,116.04,242.94799999999998,59.63399999999999,32.613 +2020-02-14 07:00:00,122.39,241.06900000000002,71.631,32.613 +2020-02-14 07:15:00,121.91,247.02900000000002,71.631,32.613 +2020-02-14 07:30:00,125.24,249.47099999999998,71.631,32.613 +2020-02-14 07:45:00,123.6,249.21599999999998,71.631,32.613 +2020-02-14 08:00:00,125.27,246.135,66.181,32.613 +2020-02-14 08:15:00,123.65,245.28400000000002,66.181,32.613 +2020-02-14 08:30:00,123.84,242.607,66.181,32.613 +2020-02-14 08:45:00,123.1,237.773,66.181,32.613 +2020-02-14 09:00:00,121.88,231.95,63.086000000000006,32.613 +2020-02-14 09:15:00,127.03,229.597,63.086000000000006,32.613 +2020-02-14 09:30:00,125.21,227.877,63.086000000000006,32.613 +2020-02-14 09:45:00,125.55,224.393,63.086000000000006,32.613 +2020-02-14 10:00:00,120.82,217.755,60.886,32.613 +2020-02-14 10:15:00,120.02,215.28900000000002,60.886,32.613 +2020-02-14 10:30:00,120.71,211.518,60.886,32.613 +2020-02-14 10:45:00,119.38,209.56,60.886,32.613 +2020-02-14 11:00:00,118.72,206.375,59.391000000000005,32.613 +2020-02-14 11:15:00,118.29,204.453,59.391000000000005,32.613 +2020-02-14 11:30:00,117.37,204.847,59.391000000000005,32.613 +2020-02-14 11:45:00,114.65,204.46,59.391000000000005,32.613 +2020-02-14 12:00:00,113.88,201.791,56.172,32.613 +2020-02-14 12:15:00,113.37,199.643,56.172,32.613 +2020-02-14 12:30:00,111.06,199.00900000000001,56.172,32.613 +2020-02-14 12:45:00,114.09,200.232,56.172,32.613 +2020-02-14 13:00:00,109.71,199.421,54.406000000000006,32.613 +2020-02-14 13:15:00,109.48,199.05900000000003,54.406000000000006,32.613 +2020-02-14 13:30:00,108.28,197.857,54.406000000000006,32.613 +2020-02-14 13:45:00,110.11,197.86599999999999,54.406000000000006,32.613 +2020-02-14 14:00:00,112.37,197.43200000000002,53.578,32.613 +2020-02-14 14:15:00,116.73,197.24900000000002,53.578,32.613 +2020-02-14 14:30:00,116.58,198.28400000000002,53.578,32.613 +2020-02-14 14:45:00,115.29,199.769,53.578,32.613 +2020-02-14 15:00:00,113.44,200.61700000000002,56.568999999999996,32.613 +2020-02-14 15:15:00,112.25,200.078,56.568999999999996,32.613 +2020-02-14 15:30:00,111.0,199.579,56.568999999999996,32.613 +2020-02-14 15:45:00,113.13,200.34599999999998,56.568999999999996,32.613 +2020-02-14 16:00:00,114.03,200.571,60.169,32.613 +2020-02-14 16:15:00,115.56,202.53400000000002,60.169,32.613 +2020-02-14 16:30:00,117.3,205.56599999999997,60.169,32.613 +2020-02-14 16:45:00,117.51,206.63299999999998,60.169,32.613 +2020-02-14 17:00:00,123.02,208.825,65.497,32.613 +2020-02-14 17:15:00,123.09,209.998,65.497,32.613 +2020-02-14 17:30:00,125.89,211.582,65.497,32.613 +2020-02-14 17:45:00,129.33,211.94,65.497,32.613 +2020-02-14 18:00:00,131.38,214.87599999999998,65.082,32.613 +2020-02-14 18:15:00,129.52,212.56799999999998,65.082,32.613 +2020-02-14 18:30:00,129.37,212.02700000000002,65.082,32.613 +2020-02-14 18:45:00,130.15,212.945,65.082,32.613 +2020-02-14 19:00:00,127.94,211.408,60.968,32.613 +2020-02-14 19:15:00,128.26,209.11900000000003,60.968,32.613 +2020-02-14 19:30:00,129.49,206.266,60.968,32.613 +2020-02-14 19:45:00,134.25,203.418,60.968,32.613 +2020-02-14 20:00:00,123.84,198.725,61.123000000000005,32.613 +2020-02-14 20:15:00,113.6,192.33599999999998,61.123000000000005,32.613 +2020-02-14 20:30:00,111.96,188.327,61.123000000000005,32.613 +2020-02-14 20:45:00,109.54,187.91299999999998,61.123000000000005,32.613 +2020-02-14 21:00:00,103.24,184.41400000000002,55.416000000000004,32.613 +2020-02-14 21:15:00,107.66,181.52900000000002,55.416000000000004,32.613 +2020-02-14 21:30:00,106.5,179.583,55.416000000000004,32.613 +2020-02-14 21:45:00,100.0,179.14700000000002,55.416000000000004,32.613 +2020-02-14 22:00:00,92.22,172.795,51.631,32.613 +2020-02-14 22:15:00,89.89,167.59900000000002,51.631,32.613 +2020-02-14 22:30:00,87.77,159.931,51.631,32.613 +2020-02-14 22:45:00,89.65,155.7,51.631,32.613 +2020-02-14 23:00:00,81.69,147.679,44.898,32.613 +2020-02-14 23:15:00,87.18,144.605,44.898,32.613 +2020-02-14 23:30:00,86.71,143.9,44.898,32.613 +2020-02-14 23:45:00,86.41,143.115,44.898,32.613 +2020-02-15 00:00:00,79.08,116.521,42.033,32.431999999999995 +2020-02-15 00:15:00,72.83,111.295,42.033,32.431999999999995 +2020-02-15 00:30:00,73.01,112.603,42.033,32.431999999999995 +2020-02-15 00:45:00,78.83,113.965,42.033,32.431999999999995 +2020-02-15 01:00:00,77.6,116.42,38.255,32.431999999999995 +2020-02-15 01:15:00,76.74,116.64299999999999,38.255,32.431999999999995 +2020-02-15 01:30:00,71.1,116.18299999999999,38.255,32.431999999999995 +2020-02-15 01:45:00,68.6,116.26,38.255,32.431999999999995 +2020-02-15 02:00:00,68.99,119.05,36.404,32.431999999999995 +2020-02-15 02:15:00,77.1,119.95700000000001,36.404,32.431999999999995 +2020-02-15 02:30:00,76.22,120.164,36.404,32.431999999999995 +2020-02-15 02:45:00,74.46,122.105,36.404,32.431999999999995 +2020-02-15 03:00:00,68.01,124.14200000000001,36.083,32.431999999999995 +2020-02-15 03:15:00,70.9,125.476,36.083,32.431999999999995 +2020-02-15 03:30:00,76.79,125.72399999999999,36.083,32.431999999999995 +2020-02-15 03:45:00,72.61,127.546,36.083,32.431999999999995 +2020-02-15 04:00:00,70.23,135.398,36.102,32.431999999999995 +2020-02-15 04:15:00,69.29,144.376,36.102,32.431999999999995 +2020-02-15 04:30:00,69.13,144.93200000000002,36.102,32.431999999999995 +2020-02-15 04:45:00,70.59,145.541,36.102,32.431999999999995 +2020-02-15 05:00:00,72.01,161.107,35.284,32.431999999999995 +2020-02-15 05:15:00,73.57,171.671,35.284,32.431999999999995 +2020-02-15 05:30:00,71.26,168.453,35.284,32.431999999999995 +2020-02-15 05:45:00,73.74,166.71900000000002,35.284,32.431999999999995 +2020-02-15 06:00:00,74.58,183.00599999999997,36.265,32.431999999999995 +2020-02-15 06:15:00,75.42,203.584,36.265,32.431999999999995 +2020-02-15 06:30:00,76.43,199.912,36.265,32.431999999999995 +2020-02-15 06:45:00,78.71,196.593,36.265,32.431999999999995 +2020-02-15 07:00:00,80.29,192.623,40.714,32.431999999999995 +2020-02-15 07:15:00,81.02,196.359,40.714,32.431999999999995 +2020-02-15 07:30:00,83.66,200.294,40.714,32.431999999999995 +2020-02-15 07:45:00,87.0,203.146,40.714,32.431999999999995 +2020-02-15 08:00:00,90.02,204.477,46.692,32.431999999999995 +2020-02-15 08:15:00,90.93,206.66299999999998,46.692,32.431999999999995 +2020-02-15 08:30:00,92.08,205.672,46.692,32.431999999999995 +2020-02-15 08:45:00,93.93,203.412,46.692,32.431999999999995 +2020-02-15 09:00:00,96.51,198.43,48.925,32.431999999999995 +2020-02-15 09:15:00,95.91,196.627,48.925,32.431999999999995 +2020-02-15 09:30:00,96.0,195.517,48.925,32.431999999999995 +2020-02-15 09:45:00,96.39,192.588,48.925,32.431999999999995 +2020-02-15 10:00:00,96.58,187.47799999999998,47.799,32.431999999999995 +2020-02-15 10:15:00,96.18,184.49900000000002,47.799,32.431999999999995 +2020-02-15 10:30:00,95.93,182.03599999999997,47.799,32.431999999999995 +2020-02-15 10:45:00,96.38,181.78799999999998,47.799,32.431999999999995 +2020-02-15 11:00:00,98.86,180.104,44.309,32.431999999999995 +2020-02-15 11:15:00,100.22,177.608,44.309,32.431999999999995 +2020-02-15 11:30:00,99.83,177.332,44.309,32.431999999999995 +2020-02-15 11:45:00,98.79,175.109,44.309,32.431999999999995 +2020-02-15 12:00:00,99.97,170.31400000000002,42.367,32.431999999999995 +2020-02-15 12:15:00,98.06,168.968,42.367,32.431999999999995 +2020-02-15 12:30:00,94.95,168.99599999999998,42.367,32.431999999999995 +2020-02-15 12:45:00,93.22,169.708,42.367,32.431999999999995 +2020-02-15 13:00:00,91.16,169.28,39.036,32.431999999999995 +2020-02-15 13:15:00,90.52,166.933,39.036,32.431999999999995 +2020-02-15 13:30:00,89.43,165.641,39.036,32.431999999999995 +2020-02-15 13:45:00,90.29,165.61900000000003,39.036,32.431999999999995 +2020-02-15 14:00:00,88.86,166.04,37.995,32.431999999999995 +2020-02-15 14:15:00,88.98,165.236,37.995,32.431999999999995 +2020-02-15 14:30:00,89.39,164.611,37.995,32.431999999999995 +2020-02-15 14:45:00,92.56,165.851,37.995,32.431999999999995 +2020-02-15 15:00:00,90.72,167.85299999999998,40.71,32.431999999999995 +2020-02-15 15:15:00,93.83,167.59599999999998,40.71,32.431999999999995 +2020-02-15 15:30:00,90.34,169.00400000000002,40.71,32.431999999999995 +2020-02-15 15:45:00,89.96,170.202,40.71,32.431999999999995 +2020-02-15 16:00:00,91.14,169.48,46.998000000000005,32.431999999999995 +2020-02-15 16:15:00,90.96,172.19400000000002,46.998000000000005,32.431999999999995 +2020-02-15 16:30:00,91.81,175.07299999999998,46.998000000000005,32.431999999999995 +2020-02-15 16:45:00,93.73,177.238,46.998000000000005,32.431999999999995 +2020-02-15 17:00:00,98.33,178.832,55.431000000000004,32.431999999999995 +2020-02-15 17:15:00,99.99,182.15599999999998,55.431000000000004,32.431999999999995 +2020-02-15 17:30:00,103.94,184.06900000000002,55.431000000000004,32.431999999999995 +2020-02-15 17:45:00,107.33,184.25099999999998,55.431000000000004,32.431999999999995 +2020-02-15 18:00:00,110.74,186.635,55.989,32.431999999999995 +2020-02-15 18:15:00,108.62,187.128,55.989,32.431999999999995 +2020-02-15 18:30:00,109.33,187.54,55.989,32.431999999999995 +2020-02-15 18:45:00,107.71,185.11,55.989,32.431999999999995 +2020-02-15 19:00:00,106.51,186.041,50.882,32.431999999999995 +2020-02-15 19:15:00,104.82,183.521,50.882,32.431999999999995 +2020-02-15 19:30:00,103.08,182.215,50.882,32.431999999999995 +2020-02-15 19:45:00,102.01,178.392,50.882,32.431999999999995 +2020-02-15 20:00:00,96.44,176.037,43.172,32.431999999999995 +2020-02-15 20:15:00,92.99,172.542,43.172,32.431999999999995 +2020-02-15 20:30:00,90.07,168.233,43.172,32.431999999999995 +2020-02-15 20:45:00,89.39,166.56,43.172,32.431999999999995 +2020-02-15 21:00:00,84.61,166.14,37.599000000000004,32.431999999999995 +2020-02-15 21:15:00,83.31,164.019,37.599000000000004,32.431999999999995 +2020-02-15 21:30:00,82.48,163.214,37.599000000000004,32.431999999999995 +2020-02-15 21:45:00,81.66,162.619,37.599000000000004,32.431999999999995 +2020-02-15 22:00:00,77.87,157.94799999999998,39.047,32.431999999999995 +2020-02-15 22:15:00,76.64,155.707,39.047,32.431999999999995 +2020-02-15 22:30:00,74.14,154.209,39.047,32.431999999999995 +2020-02-15 22:45:00,73.06,152.016,39.047,32.431999999999995 +2020-02-15 23:00:00,69.29,147.38299999999998,32.339,32.431999999999995 +2020-02-15 23:15:00,69.86,142.295,32.339,32.431999999999995 +2020-02-15 23:30:00,65.81,140.22,32.339,32.431999999999995 +2020-02-15 23:45:00,64.83,137.039,32.339,32.431999999999995 +2020-02-16 00:00:00,59.92,116.76700000000001,29.988000000000003,32.431999999999995 +2020-02-16 00:15:00,60.99,111.206,29.988000000000003,32.431999999999995 +2020-02-16 00:30:00,60.45,112.125,29.988000000000003,32.431999999999995 +2020-02-16 00:45:00,59.8,114.161,29.988000000000003,32.431999999999995 +2020-02-16 01:00:00,56.82,116.45,28.531999999999996,32.431999999999995 +2020-02-16 01:15:00,58.25,117.669,28.531999999999996,32.431999999999995 +2020-02-16 01:30:00,57.58,117.70200000000001,28.531999999999996,32.431999999999995 +2020-02-16 01:45:00,56.97,117.46600000000001,28.531999999999996,32.431999999999995 +2020-02-16 02:00:00,55.01,119.524,27.805999999999997,32.431999999999995 +2020-02-16 02:15:00,55.92,119.61399999999999,27.805999999999997,32.431999999999995 +2020-02-16 02:30:00,55.77,120.67200000000001,27.805999999999997,32.431999999999995 +2020-02-16 02:45:00,55.31,123.05799999999999,27.805999999999997,32.431999999999995 +2020-02-16 03:00:00,54.28,125.42200000000001,26.193,32.431999999999995 +2020-02-16 03:15:00,56.05,126.24799999999999,26.193,32.431999999999995 +2020-02-16 03:30:00,55.3,127.836,26.193,32.431999999999995 +2020-02-16 03:45:00,55.85,129.576,26.193,32.431999999999995 +2020-02-16 04:00:00,55.44,137.189,27.19,32.431999999999995 +2020-02-16 04:15:00,56.58,145.164,27.19,32.431999999999995 +2020-02-16 04:30:00,56.86,145.828,27.19,32.431999999999995 +2020-02-16 04:45:00,58.08,146.68,27.19,32.431999999999995 +2020-02-16 05:00:00,58.38,158.819,28.166999999999998,32.431999999999995 +2020-02-16 05:15:00,58.85,167.02900000000002,28.166999999999998,32.431999999999995 +2020-02-16 05:30:00,59.76,163.615,28.166999999999998,32.431999999999995 +2020-02-16 05:45:00,60.47,162.11700000000002,28.166999999999998,32.431999999999995 +2020-02-16 06:00:00,61.37,178.252,27.16,32.431999999999995 +2020-02-16 06:15:00,64.85,197.18900000000002,27.16,32.431999999999995 +2020-02-16 06:30:00,63.55,192.395,27.16,32.431999999999995 +2020-02-16 06:45:00,65.16,188.02,27.16,32.431999999999995 +2020-02-16 07:00:00,68.28,186.46,29.578000000000003,32.431999999999995 +2020-02-16 07:15:00,67.41,189.298,29.578000000000003,32.431999999999995 +2020-02-16 07:30:00,69.82,192.037,29.578000000000003,32.431999999999995 +2020-02-16 07:45:00,72.92,194.109,29.578000000000003,32.431999999999995 +2020-02-16 08:00:00,74.96,197.226,34.650999999999996,32.431999999999995 +2020-02-16 08:15:00,75.92,199.338,34.650999999999996,32.431999999999995 +2020-02-16 08:30:00,77.55,199.925,34.650999999999996,32.431999999999995 +2020-02-16 08:45:00,79.55,199.627,34.650999999999996,32.431999999999995 +2020-02-16 09:00:00,81.5,194.25599999999997,38.080999999999996,32.431999999999995 +2020-02-16 09:15:00,82.17,192.979,38.080999999999996,32.431999999999995 +2020-02-16 09:30:00,83.33,191.74900000000002,38.080999999999996,32.431999999999995 +2020-02-16 09:45:00,84.69,188.739,38.080999999999996,32.431999999999995 +2020-02-16 10:00:00,85.76,186.095,39.934,32.431999999999995 +2020-02-16 10:15:00,85.84,183.66400000000002,39.934,32.431999999999995 +2020-02-16 10:30:00,89.06,181.803,39.934,32.431999999999995 +2020-02-16 10:45:00,89.12,179.768,39.934,32.431999999999995 +2020-02-16 11:00:00,92.11,178.949,43.74100000000001,32.431999999999995 +2020-02-16 11:15:00,95.2,176.582,43.74100000000001,32.431999999999995 +2020-02-16 11:30:00,96.92,175.487,43.74100000000001,32.431999999999995 +2020-02-16 11:45:00,95.96,173.87099999999998,43.74100000000001,32.431999999999995 +2020-02-16 12:00:00,93.67,168.581,40.001999999999995,32.431999999999995 +2020-02-16 12:15:00,92.58,169.065,40.001999999999995,32.431999999999995 +2020-02-16 12:30:00,89.3,167.671,40.001999999999995,32.431999999999995 +2020-02-16 12:45:00,90.13,167.426,40.001999999999995,32.431999999999995 +2020-02-16 13:00:00,84.56,166.325,37.855,32.431999999999995 +2020-02-16 13:15:00,84.84,166.843,37.855,32.431999999999995 +2020-02-16 13:30:00,82.46,165.296,37.855,32.431999999999995 +2020-02-16 13:45:00,82.47,164.67700000000002,37.855,32.431999999999995 +2020-02-16 14:00:00,80.52,165.43,35.946999999999996,32.431999999999995 +2020-02-16 14:15:00,80.46,165.77599999999998,35.946999999999996,32.431999999999995 +2020-02-16 14:30:00,80.76,166.28,35.946999999999996,32.431999999999995 +2020-02-16 14:45:00,80.96,167.1,35.946999999999996,32.431999999999995 +2020-02-16 15:00:00,81.11,167.64,35.138000000000005,32.431999999999995 +2020-02-16 15:15:00,79.85,168.058,35.138000000000005,32.431999999999995 +2020-02-16 15:30:00,79.76,169.99,35.138000000000005,32.431999999999995 +2020-02-16 15:45:00,80.59,171.847,35.138000000000005,32.431999999999995 +2020-02-16 16:00:00,82.28,172.89,38.672,32.431999999999995 +2020-02-16 16:15:00,81.81,174.72099999999998,38.672,32.431999999999995 +2020-02-16 16:30:00,81.95,177.882,38.672,32.431999999999995 +2020-02-16 16:45:00,84.44,180.15599999999998,38.672,32.431999999999995 +2020-02-16 17:00:00,88.25,181.767,48.684,32.431999999999995 +2020-02-16 17:15:00,89.74,184.832,48.684,32.431999999999995 +2020-02-16 17:30:00,92.59,187.075,48.684,32.431999999999995 +2020-02-16 17:45:00,97.75,189.489,48.684,32.431999999999995 +2020-02-16 18:00:00,103.5,191.37400000000002,51.568999999999996,32.431999999999995 +2020-02-16 18:15:00,103.77,193.201,51.568999999999996,32.431999999999995 +2020-02-16 18:30:00,103.86,191.58,51.568999999999996,32.431999999999995 +2020-02-16 18:45:00,101.12,190.97400000000002,51.568999999999996,32.431999999999995 +2020-02-16 19:00:00,100.36,191.597,48.608000000000004,32.431999999999995 +2020-02-16 19:15:00,97.84,189.642,48.608000000000004,32.431999999999995 +2020-02-16 19:30:00,100.63,188.19299999999998,48.608000000000004,32.431999999999995 +2020-02-16 19:45:00,103.68,185.798,48.608000000000004,32.431999999999995 +2020-02-16 20:00:00,99.8,183.389,43.733999999999995,32.431999999999995 +2020-02-16 20:15:00,93.26,180.862,43.733999999999995,32.431999999999995 +2020-02-16 20:30:00,92.18,177.79,43.733999999999995,32.431999999999995 +2020-02-16 20:45:00,90.05,174.91400000000002,43.733999999999995,32.431999999999995 +2020-02-16 21:00:00,87.92,171.957,39.283,32.431999999999995 +2020-02-16 21:15:00,88.88,169.209,39.283,32.431999999999995 +2020-02-16 21:30:00,93.52,168.675,39.283,32.431999999999995 +2020-02-16 21:45:00,95.15,168.239,39.283,32.431999999999995 +2020-02-16 22:00:00,90.26,162.445,40.111,32.431999999999995 +2020-02-16 22:15:00,89.37,159.447,40.111,32.431999999999995 +2020-02-16 22:30:00,90.03,154.82,40.111,32.431999999999995 +2020-02-16 22:45:00,90.58,151.776,40.111,32.431999999999995 +2020-02-16 23:00:00,86.82,144.405,35.791,32.431999999999995 +2020-02-16 23:15:00,85.0,141.168,35.791,32.431999999999995 +2020-02-16 23:30:00,86.25,139.881,35.791,32.431999999999995 +2020-02-16 23:45:00,84.99,137.593,35.791,32.431999999999995 +2020-02-17 00:00:00,81.05,120.693,34.311,32.613 +2020-02-17 00:15:00,80.07,118.045,34.311,32.613 +2020-02-17 00:30:00,81.59,119.06200000000001,34.311,32.613 +2020-02-17 00:45:00,81.45,120.56,34.311,32.613 +2020-02-17 01:00:00,76.96,122.838,34.585,32.613 +2020-02-17 01:15:00,76.08,123.53,34.585,32.613 +2020-02-17 01:30:00,79.32,123.61200000000001,34.585,32.613 +2020-02-17 01:45:00,79.06,123.48700000000001,34.585,32.613 +2020-02-17 02:00:00,75.2,125.52799999999999,34.111,32.613 +2020-02-17 02:15:00,77.83,127.016,34.111,32.613 +2020-02-17 02:30:00,78.92,128.418,34.111,32.613 +2020-02-17 02:45:00,80.45,130.196,34.111,32.613 +2020-02-17 03:00:00,74.01,133.811,32.435,32.613 +2020-02-17 03:15:00,76.74,136.253,32.435,32.613 +2020-02-17 03:30:00,81.58,137.588,32.435,32.613 +2020-02-17 03:45:00,81.79,138.791,32.435,32.613 +2020-02-17 04:00:00,80.2,150.708,33.04,32.613 +2020-02-17 04:15:00,77.79,162.775,33.04,32.613 +2020-02-17 04:30:00,85.23,165.579,33.04,32.613 +2020-02-17 04:45:00,86.55,166.584,33.04,32.613 +2020-02-17 05:00:00,89.34,194.2,40.399,32.613 +2020-02-17 05:15:00,84.87,222.275,40.399,32.613 +2020-02-17 05:30:00,88.55,219.07,40.399,32.613 +2020-02-17 05:45:00,93.5,212.00799999999998,40.399,32.613 +2020-02-17 06:00:00,101.21,210.477,60.226000000000006,32.613 +2020-02-17 06:15:00,106.95,214.78900000000002,60.226000000000006,32.613 +2020-02-17 06:30:00,112.4,217.851,60.226000000000006,32.613 +2020-02-17 06:45:00,116.6,222.146,60.226000000000006,32.613 +2020-02-17 07:00:00,122.33,222.924,73.578,32.613 +2020-02-17 07:15:00,122.39,226.99400000000003,73.578,32.613 +2020-02-17 07:30:00,122.25,228.925,73.578,32.613 +2020-02-17 07:45:00,124.77,228.46599999999998,73.578,32.613 +2020-02-17 08:00:00,129.11,226.765,66.58,32.613 +2020-02-17 08:15:00,127.63,226.747,66.58,32.613 +2020-02-17 08:30:00,123.89,223.28599999999997,66.58,32.613 +2020-02-17 08:45:00,122.13,219.752,66.58,32.613 +2020-02-17 09:00:00,124.23,213.396,62.0,32.613 +2020-02-17 09:15:00,125.02,208.692,62.0,32.613 +2020-02-17 09:30:00,125.32,206.49,62.0,32.613 +2020-02-17 09:45:00,122.32,203.72299999999998,62.0,32.613 +2020-02-17 10:00:00,121.29,200.037,59.099,32.613 +2020-02-17 10:15:00,125.81,197.31599999999997,59.099,32.613 +2020-02-17 10:30:00,124.92,194.606,59.099,32.613 +2020-02-17 10:45:00,127.08,193.273,59.099,32.613 +2020-02-17 11:00:00,122.8,189.91400000000002,57.729,32.613 +2020-02-17 11:15:00,123.95,189.315,57.729,32.613 +2020-02-17 11:30:00,125.08,189.584,57.729,32.613 +2020-02-17 11:45:00,121.96,187.597,57.729,32.613 +2020-02-17 12:00:00,120.65,183.946,55.615,32.613 +2020-02-17 12:15:00,122.1,184.45,55.615,32.613 +2020-02-17 12:30:00,120.86,183.24200000000002,55.615,32.613 +2020-02-17 12:45:00,120.22,184.47099999999998,55.615,32.613 +2020-02-17 13:00:00,118.27,183.98,56.515,32.613 +2020-02-17 13:15:00,119.03,183.113,56.515,32.613 +2020-02-17 13:30:00,118.32,181.043,56.515,32.613 +2020-02-17 13:45:00,117.07,180.481,56.515,32.613 +2020-02-17 14:00:00,115.45,180.667,58.1,32.613 +2020-02-17 14:15:00,118.18,180.386,58.1,32.613 +2020-02-17 14:30:00,118.2,180.34400000000002,58.1,32.613 +2020-02-17 14:45:00,115.44,181.199,58.1,32.613 +2020-02-17 15:00:00,117.71,183.47299999999998,59.801,32.613 +2020-02-17 15:15:00,119.93,182.456,59.801,32.613 +2020-02-17 15:30:00,119.86,183.56599999999997,59.801,32.613 +2020-02-17 15:45:00,115.36,184.96400000000003,59.801,32.613 +2020-02-17 16:00:00,116.04,186.22400000000002,62.901,32.613 +2020-02-17 16:15:00,117.83,187.304,62.901,32.613 +2020-02-17 16:30:00,118.12,189.513,62.901,32.613 +2020-02-17 16:45:00,120.01,190.609,62.901,32.613 +2020-02-17 17:00:00,123.07,192.03900000000002,70.418,32.613 +2020-02-17 17:15:00,128.21,194.203,70.418,32.613 +2020-02-17 17:30:00,127.74,195.93200000000002,70.418,32.613 +2020-02-17 17:45:00,131.41,196.90400000000002,70.418,32.613 +2020-02-17 18:00:00,133.59,199.207,71.726,32.613 +2020-02-17 18:15:00,132.22,198.90099999999998,71.726,32.613 +2020-02-17 18:30:00,134.7,197.90400000000002,71.726,32.613 +2020-02-17 18:45:00,132.33,198.072,71.726,32.613 +2020-02-17 19:00:00,129.8,197.084,65.997,32.613 +2020-02-17 19:15:00,128.73,194.02,65.997,32.613 +2020-02-17 19:30:00,129.34,193.07299999999998,65.997,32.613 +2020-02-17 19:45:00,129.08,189.891,65.997,32.613 +2020-02-17 20:00:00,119.74,185.122,68.09100000000001,32.613 +2020-02-17 20:15:00,116.95,180.209,68.09100000000001,32.613 +2020-02-17 20:30:00,113.25,175.343,68.09100000000001,32.613 +2020-02-17 20:45:00,111.85,174.063,68.09100000000001,32.613 +2020-02-17 21:00:00,106.25,171.602,59.617,32.613 +2020-02-17 21:15:00,111.38,167.703,59.617,32.613 +2020-02-17 21:30:00,111.19,166.368,59.617,32.613 +2020-02-17 21:45:00,107.47,165.453,59.617,32.613 +2020-02-17 22:00:00,100.26,156.791,54.938,32.613 +2020-02-17 22:15:00,96.86,152.559,54.938,32.613 +2020-02-17 22:30:00,93.99,138.485,54.938,32.613 +2020-02-17 22:45:00,92.23,130.718,54.938,32.613 +2020-02-17 23:00:00,85.74,124.1,47.43,32.613 +2020-02-17 23:15:00,87.58,123.447,47.43,32.613 +2020-02-17 23:30:00,86.64,124.87200000000001,47.43,32.613 +2020-02-17 23:45:00,94.27,125.181,47.43,32.613 +2020-02-18 00:00:00,90.43,119.78299999999999,48.354,32.613 +2020-02-18 00:15:00,87.49,118.541,48.354,32.613 +2020-02-18 00:30:00,81.76,118.62799999999999,48.354,32.613 +2020-02-18 00:45:00,80.22,119.175,48.354,32.613 +2020-02-18 01:00:00,84.53,121.22,45.68600000000001,32.613 +2020-02-18 01:15:00,84.82,121.465,45.68600000000001,32.613 +2020-02-18 01:30:00,82.61,121.7,45.68600000000001,32.613 +2020-02-18 01:45:00,78.21,121.87,45.68600000000001,32.613 +2020-02-18 02:00:00,82.26,123.896,44.269,32.613 +2020-02-18 02:15:00,84.76,125.286,44.269,32.613 +2020-02-18 02:30:00,83.23,126.115,44.269,32.613 +2020-02-18 02:45:00,78.58,127.90799999999999,44.269,32.613 +2020-02-18 03:00:00,79.52,130.341,44.187,32.613 +2020-02-18 03:15:00,84.9,131.954,44.187,32.613 +2020-02-18 03:30:00,86.95,133.75799999999998,44.187,32.613 +2020-02-18 03:45:00,85.47,135.149,44.187,32.613 +2020-02-18 04:00:00,80.95,146.857,46.126999999999995,32.613 +2020-02-18 04:15:00,86.03,158.57399999999998,46.126999999999995,32.613 +2020-02-18 04:30:00,90.64,161.08700000000002,46.126999999999995,32.613 +2020-02-18 04:45:00,93.17,163.274,46.126999999999995,32.613 +2020-02-18 05:00:00,95.59,195.803,49.666000000000004,32.613 +2020-02-18 05:15:00,96.2,223.696,49.666000000000004,32.613 +2020-02-18 05:30:00,101.55,218.959,49.666000000000004,32.613 +2020-02-18 05:45:00,105.28,211.90200000000002,49.666000000000004,32.613 +2020-02-18 06:00:00,111.44,209.226,61.077,32.613 +2020-02-18 06:15:00,110.26,215.14700000000002,61.077,32.613 +2020-02-18 06:30:00,113.34,217.54,61.077,32.613 +2020-02-18 06:45:00,118.15,221.449,61.077,32.613 +2020-02-18 07:00:00,124.13,222.07,74.717,32.613 +2020-02-18 07:15:00,123.32,225.953,74.717,32.613 +2020-02-18 07:30:00,123.67,227.312,74.717,32.613 +2020-02-18 07:45:00,124.55,227.015,74.717,32.613 +2020-02-18 08:00:00,129.02,225.399,69.033,32.613 +2020-02-18 08:15:00,128.96,224.354,69.033,32.613 +2020-02-18 08:30:00,129.77,220.667,69.033,32.613 +2020-02-18 08:45:00,127.77,216.868,69.033,32.613 +2020-02-18 09:00:00,129.25,209.703,63.113,32.613 +2020-02-18 09:15:00,133.26,206.56900000000002,63.113,32.613 +2020-02-18 09:30:00,129.51,205.06,63.113,32.613 +2020-02-18 09:45:00,127.42,202.097,63.113,32.613 +2020-02-18 10:00:00,123.91,197.831,61.461999999999996,32.613 +2020-02-18 10:15:00,124.56,194.101,61.461999999999996,32.613 +2020-02-18 10:30:00,124.88,191.581,61.461999999999996,32.613 +2020-02-18 10:45:00,127.18,190.551,61.461999999999996,32.613 +2020-02-18 11:00:00,126.45,188.638,59.614,32.613 +2020-02-18 11:15:00,127.78,187.733,59.614,32.613 +2020-02-18 11:30:00,124.02,186.854,59.614,32.613 +2020-02-18 11:45:00,124.8,185.56099999999998,59.614,32.613 +2020-02-18 12:00:00,119.33,180.585,57.415,32.613 +2020-02-18 12:15:00,122.01,180.7,57.415,32.613 +2020-02-18 12:30:00,121.83,180.183,57.415,32.613 +2020-02-18 12:45:00,118.63,181.13099999999997,57.415,32.613 +2020-02-18 13:00:00,114.34,180.268,58.534,32.613 +2020-02-18 13:15:00,116.38,179.06400000000002,58.534,32.613 +2020-02-18 13:30:00,116.49,178.125,58.534,32.613 +2020-02-18 13:45:00,118.33,177.733,58.534,32.613 +2020-02-18 14:00:00,120.0,178.25900000000001,59.415,32.613 +2020-02-18 14:15:00,120.28,178.108,59.415,32.613 +2020-02-18 14:30:00,120.23,178.66400000000002,59.415,32.613 +2020-02-18 14:45:00,123.79,179.44299999999998,59.415,32.613 +2020-02-18 15:00:00,121.51,181.301,62.071999999999996,32.613 +2020-02-18 15:15:00,120.26,180.593,62.071999999999996,32.613 +2020-02-18 15:30:00,122.85,181.87900000000002,62.071999999999996,32.613 +2020-02-18 15:45:00,121.26,182.873,62.071999999999996,32.613 +2020-02-18 16:00:00,122.32,184.453,64.99,32.613 +2020-02-18 16:15:00,121.34,185.988,64.99,32.613 +2020-02-18 16:30:00,122.1,188.827,64.99,32.613 +2020-02-18 16:45:00,124.58,190.19,64.99,32.613 +2020-02-18 17:00:00,130.96,192.155,72.658,32.613 +2020-02-18 17:15:00,129.03,194.37,72.658,32.613 +2020-02-18 17:30:00,131.39,196.77700000000002,72.658,32.613 +2020-02-18 17:45:00,134.3,197.639,72.658,32.613 +2020-02-18 18:00:00,137.03,199.84099999999998,73.645,32.613 +2020-02-18 18:15:00,135.62,199.113,73.645,32.613 +2020-02-18 18:30:00,134.52,197.81,73.645,32.613 +2020-02-18 18:45:00,134.65,198.78099999999998,73.645,32.613 +2020-02-18 19:00:00,130.82,197.82299999999998,67.085,32.613 +2020-02-18 19:15:00,130.82,194.50099999999998,67.085,32.613 +2020-02-18 19:30:00,138.13,192.917,67.085,32.613 +2020-02-18 19:45:00,138.56,189.81,67.085,32.613 +2020-02-18 20:00:00,126.03,185.172,66.138,32.613 +2020-02-18 20:15:00,119.06,179.63099999999997,66.138,32.613 +2020-02-18 20:30:00,114.73,175.77599999999998,66.138,32.613 +2020-02-18 20:45:00,113.6,173.94299999999998,66.138,32.613 +2020-02-18 21:00:00,107.89,170.787,57.512,32.613 +2020-02-18 21:15:00,114.35,167.757,57.512,32.613 +2020-02-18 21:30:00,113.36,165.707,57.512,32.613 +2020-02-18 21:45:00,111.8,165.03599999999997,57.512,32.613 +2020-02-18 22:00:00,100.85,158.043,54.545,32.613 +2020-02-18 22:15:00,99.95,153.578,54.545,32.613 +2020-02-18 22:30:00,100.92,139.555,54.545,32.613 +2020-02-18 22:45:00,102.09,132.065,54.545,32.613 +2020-02-18 23:00:00,97.23,125.461,48.605,32.613 +2020-02-18 23:15:00,94.48,123.948,48.605,32.613 +2020-02-18 23:30:00,89.92,125.03,48.605,32.613 +2020-02-18 23:45:00,91.19,124.929,48.605,32.613 +2020-02-19 00:00:00,90.06,119.571,45.675,32.613 +2020-02-19 00:15:00,88.18,118.331,45.675,32.613 +2020-02-19 00:30:00,88.53,118.399,45.675,32.613 +2020-02-19 00:45:00,85.32,118.94200000000001,45.675,32.613 +2020-02-19 01:00:00,85.6,120.95299999999999,43.015,32.613 +2020-02-19 01:15:00,87.17,121.185,43.015,32.613 +2020-02-19 01:30:00,86.58,121.40700000000001,43.015,32.613 +2020-02-19 01:45:00,82.82,121.579,43.015,32.613 +2020-02-19 02:00:00,84.77,123.601,41.0,32.613 +2020-02-19 02:15:00,86.47,124.98700000000001,41.0,32.613 +2020-02-19 02:30:00,83.73,125.83200000000001,41.0,32.613 +2020-02-19 02:45:00,83.36,127.624,41.0,32.613 +2020-02-19 03:00:00,86.31,130.064,41.318000000000005,32.613 +2020-02-19 03:15:00,87.22,131.672,41.318000000000005,32.613 +2020-02-19 03:30:00,83.52,133.47,41.318000000000005,32.613 +2020-02-19 03:45:00,86.28,134.872,41.318000000000005,32.613 +2020-02-19 04:00:00,81.82,146.582,42.544,32.613 +2020-02-19 04:15:00,86.0,158.291,42.544,32.613 +2020-02-19 04:30:00,91.07,160.819,42.544,32.613 +2020-02-19 04:45:00,93.83,162.994,42.544,32.613 +2020-02-19 05:00:00,95.03,195.505,45.161,32.613 +2020-02-19 05:15:00,92.61,223.41400000000002,45.161,32.613 +2020-02-19 05:30:00,95.38,218.649,45.161,32.613 +2020-02-19 05:45:00,101.64,211.597,45.161,32.613 +2020-02-19 06:00:00,106.45,208.93200000000002,61.86600000000001,32.613 +2020-02-19 06:15:00,110.83,214.862,61.86600000000001,32.613 +2020-02-19 06:30:00,114.29,217.22099999999998,61.86600000000001,32.613 +2020-02-19 06:45:00,118.12,221.122,61.86600000000001,32.613 +2020-02-19 07:00:00,124.11,221.766,77.814,32.613 +2020-02-19 07:15:00,126.22,225.61900000000003,77.814,32.613 +2020-02-19 07:30:00,128.75,226.94299999999998,77.814,32.613 +2020-02-19 07:45:00,127.24,226.606,77.814,32.613 +2020-02-19 08:00:00,130.4,224.96900000000002,70.251,32.613 +2020-02-19 08:15:00,128.37,223.905,70.251,32.613 +2020-02-19 08:30:00,132.94,220.16299999999998,70.251,32.613 +2020-02-19 08:45:00,130.84,216.36900000000003,70.251,32.613 +2020-02-19 09:00:00,133.42,209.21200000000002,66.965,32.613 +2020-02-19 09:15:00,135.01,206.08,66.965,32.613 +2020-02-19 09:30:00,136.59,204.59099999999998,66.965,32.613 +2020-02-19 09:45:00,138.7,201.63400000000001,66.965,32.613 +2020-02-19 10:00:00,134.17,197.37900000000002,63.628,32.613 +2020-02-19 10:15:00,135.78,193.68099999999998,63.628,32.613 +2020-02-19 10:30:00,135.61,191.174,63.628,32.613 +2020-02-19 10:45:00,136.33,190.16,63.628,32.613 +2020-02-19 11:00:00,134.83,188.235,62.516999999999996,32.613 +2020-02-19 11:15:00,132.88,187.34599999999998,62.516999999999996,32.613 +2020-02-19 11:30:00,130.69,186.47299999999998,62.516999999999996,32.613 +2020-02-19 11:45:00,126.99,185.19299999999998,62.516999999999996,32.613 +2020-02-19 12:00:00,126.05,180.234,60.888999999999996,32.613 +2020-02-19 12:15:00,122.3,180.364,60.888999999999996,32.613 +2020-02-19 12:30:00,119.64,179.81799999999998,60.888999999999996,32.613 +2020-02-19 12:45:00,118.58,180.763,60.888999999999996,32.613 +2020-02-19 13:00:00,115.52,179.93,61.57899999999999,32.613 +2020-02-19 13:15:00,116.57,178.705,61.57899999999999,32.613 +2020-02-19 13:30:00,113.94,177.752,61.57899999999999,32.613 +2020-02-19 13:45:00,115.97,177.36,61.57899999999999,32.613 +2020-02-19 14:00:00,114.21,177.94400000000002,62.602,32.613 +2020-02-19 14:15:00,115.49,177.77200000000002,62.602,32.613 +2020-02-19 14:30:00,115.54,178.308,62.602,32.613 +2020-02-19 14:45:00,118.46,179.09900000000002,62.602,32.613 +2020-02-19 15:00:00,119.72,180.96599999999998,64.259,32.613 +2020-02-19 15:15:00,122.91,180.229,64.259,32.613 +2020-02-19 15:30:00,120.97,181.476,64.259,32.613 +2020-02-19 15:45:00,121.61,182.453,64.259,32.613 +2020-02-19 16:00:00,124.31,184.03400000000002,67.632,32.613 +2020-02-19 16:15:00,123.96,185.55599999999998,67.632,32.613 +2020-02-19 16:30:00,123.8,188.396,67.632,32.613 +2020-02-19 16:45:00,124.67,189.734,67.632,32.613 +2020-02-19 17:00:00,129.4,191.702,72.583,32.613 +2020-02-19 17:15:00,130.26,193.933,72.583,32.613 +2020-02-19 17:30:00,133.14,196.37,72.583,32.613 +2020-02-19 17:45:00,133.67,197.252,72.583,32.613 +2020-02-19 18:00:00,138.63,199.46599999999998,72.744,32.613 +2020-02-19 18:15:00,135.29,198.795,72.744,32.613 +2020-02-19 18:30:00,137.36,197.49099999999999,72.744,32.613 +2020-02-19 18:45:00,134.04,198.484,72.744,32.613 +2020-02-19 19:00:00,131.09,197.487,69.684,32.613 +2020-02-19 19:15:00,130.73,194.18,69.684,32.613 +2020-02-19 19:30:00,136.9,192.62099999999998,69.684,32.613 +2020-02-19 19:45:00,137.66,189.549,69.684,32.613 +2020-02-19 20:00:00,126.33,184.882,70.036,32.613 +2020-02-19 20:15:00,117.21,179.354,70.036,32.613 +2020-02-19 20:30:00,116.21,175.517,70.036,32.613 +2020-02-19 20:45:00,114.1,173.687,70.036,32.613 +2020-02-19 21:00:00,108.92,170.518,60.431999999999995,32.613 +2020-02-19 21:15:00,114.41,167.477,60.431999999999995,32.613 +2020-02-19 21:30:00,113.68,165.428,60.431999999999995,32.613 +2020-02-19 21:45:00,113.09,164.775,60.431999999999995,32.613 +2020-02-19 22:00:00,101.66,157.768,56.2,32.613 +2020-02-19 22:15:00,97.67,153.327,56.2,32.613 +2020-02-19 22:30:00,95.3,139.265,56.2,32.613 +2020-02-19 22:45:00,95.0,131.78,56.2,32.613 +2020-02-19 23:00:00,94.34,125.169,47.927,32.613 +2020-02-19 23:15:00,97.09,123.671,47.927,32.613 +2020-02-19 23:30:00,95.13,124.764,47.927,32.613 +2020-02-19 23:45:00,91.62,124.68700000000001,47.927,32.613 +2020-02-20 00:00:00,84.69,119.354,43.794,32.613 +2020-02-20 00:15:00,87.96,118.113,43.794,32.613 +2020-02-20 00:30:00,88.54,118.163,43.794,32.613 +2020-02-20 00:45:00,87.1,118.70299999999999,43.794,32.613 +2020-02-20 01:00:00,80.45,120.679,42.397,32.613 +2020-02-20 01:15:00,79.74,120.897,42.397,32.613 +2020-02-20 01:30:00,79.29,121.10799999999999,42.397,32.613 +2020-02-20 01:45:00,84.73,121.281,42.397,32.613 +2020-02-20 02:00:00,85.65,123.29799999999999,40.010999999999996,32.613 +2020-02-20 02:15:00,86.23,124.682,40.010999999999996,32.613 +2020-02-20 02:30:00,82.4,125.541,40.010999999999996,32.613 +2020-02-20 02:45:00,80.94,127.333,40.010999999999996,32.613 +2020-02-20 03:00:00,86.23,129.78,39.181,32.613 +2020-02-20 03:15:00,87.78,131.382,39.181,32.613 +2020-02-20 03:30:00,86.6,133.175,39.181,32.613 +2020-02-20 03:45:00,82.73,134.589,39.181,32.613 +2020-02-20 04:00:00,84.08,146.3,40.39,32.613 +2020-02-20 04:15:00,82.86,158.0,40.39,32.613 +2020-02-20 04:30:00,83.77,160.543,40.39,32.613 +2020-02-20 04:45:00,86.58,162.708,40.39,32.613 +2020-02-20 05:00:00,97.42,195.2,45.504,32.613 +2020-02-20 05:15:00,99.75,223.127,45.504,32.613 +2020-02-20 05:30:00,97.65,218.333,45.504,32.613 +2020-02-20 05:45:00,102.13,211.28599999999997,45.504,32.613 +2020-02-20 06:00:00,106.74,208.63,57.748000000000005,32.613 +2020-02-20 06:15:00,110.76,214.57,57.748000000000005,32.613 +2020-02-20 06:30:00,114.34,216.894,57.748000000000005,32.613 +2020-02-20 06:45:00,118.21,220.787,57.748000000000005,32.613 +2020-02-20 07:00:00,123.28,221.452,72.138,32.613 +2020-02-20 07:15:00,123.1,225.27700000000002,72.138,32.613 +2020-02-20 07:30:00,125.66,226.56400000000002,72.138,32.613 +2020-02-20 07:45:00,125.06,226.188,72.138,32.613 +2020-02-20 08:00:00,127.31,224.528,65.542,32.613 +2020-02-20 08:15:00,125.33,223.446,65.542,32.613 +2020-02-20 08:30:00,126.93,219.65,65.542,32.613 +2020-02-20 08:45:00,122.41,215.863,65.542,32.613 +2020-02-20 09:00:00,122.95,208.71200000000002,60.523,32.613 +2020-02-20 09:15:00,123.48,205.582,60.523,32.613 +2020-02-20 09:30:00,123.66,204.113,60.523,32.613 +2020-02-20 09:45:00,123.38,201.16299999999998,60.523,32.613 +2020-02-20 10:00:00,123.79,196.918,57.449,32.613 +2020-02-20 10:15:00,122.44,193.252,57.449,32.613 +2020-02-20 10:30:00,120.99,190.75900000000001,57.449,32.613 +2020-02-20 10:45:00,121.04,189.761,57.449,32.613 +2020-02-20 11:00:00,118.26,187.826,54.505,32.613 +2020-02-20 11:15:00,118.28,186.951,54.505,32.613 +2020-02-20 11:30:00,118.21,186.08599999999998,54.505,32.613 +2020-02-20 11:45:00,117.86,184.82,54.505,32.613 +2020-02-20 12:00:00,116.74,179.87599999999998,51.50899999999999,32.613 +2020-02-20 12:15:00,118.18,180.021,51.50899999999999,32.613 +2020-02-20 12:30:00,118.55,179.447,51.50899999999999,32.613 +2020-02-20 12:45:00,118.61,180.388,51.50899999999999,32.613 +2020-02-20 13:00:00,114.9,179.585,51.303999999999995,32.613 +2020-02-20 13:15:00,115.25,178.33700000000002,51.303999999999995,32.613 +2020-02-20 13:30:00,112.8,177.372,51.303999999999995,32.613 +2020-02-20 13:45:00,116.14,176.981,51.303999999999995,32.613 +2020-02-20 14:00:00,111.39,177.622,52.785,32.613 +2020-02-20 14:15:00,115.64,177.429,52.785,32.613 +2020-02-20 14:30:00,114.88,177.945,52.785,32.613 +2020-02-20 14:45:00,116.2,178.748,52.785,32.613 +2020-02-20 15:00:00,118.06,180.623,56.458999999999996,32.613 +2020-02-20 15:15:00,115.92,179.859,56.458999999999996,32.613 +2020-02-20 15:30:00,115.99,181.065,56.458999999999996,32.613 +2020-02-20 15:45:00,117.51,182.02599999999998,56.458999999999996,32.613 +2020-02-20 16:00:00,117.85,183.607,59.388000000000005,32.613 +2020-02-20 16:15:00,118.3,185.11700000000002,59.388000000000005,32.613 +2020-02-20 16:30:00,122.42,187.958,59.388000000000005,32.613 +2020-02-20 16:45:00,122.43,189.268,59.388000000000005,32.613 +2020-02-20 17:00:00,124.49,191.24099999999999,64.462,32.613 +2020-02-20 17:15:00,125.36,193.489,64.462,32.613 +2020-02-20 17:30:00,131.6,195.954,64.462,32.613 +2020-02-20 17:45:00,131.89,196.857,64.462,32.613 +2020-02-20 18:00:00,138.28,199.081,65.128,32.613 +2020-02-20 18:15:00,136.37,198.468,65.128,32.613 +2020-02-20 18:30:00,138.13,197.16400000000002,65.128,32.613 +2020-02-20 18:45:00,136.19,198.179,65.128,32.613 +2020-02-20 19:00:00,132.24,197.143,61.316,32.613 +2020-02-20 19:15:00,136.06,193.85,61.316,32.613 +2020-02-20 19:30:00,140.35,192.31599999999997,61.316,32.613 +2020-02-20 19:45:00,136.18,189.28099999999998,61.316,32.613 +2020-02-20 20:00:00,124.63,184.584,59.845,32.613 +2020-02-20 20:15:00,117.47,179.071,59.845,32.613 +2020-02-20 20:30:00,115.88,175.25,59.845,32.613 +2020-02-20 20:45:00,113.56,173.425,59.845,32.613 +2020-02-20 21:00:00,109.82,170.24099999999999,54.83,32.613 +2020-02-20 21:15:00,107.49,167.19099999999997,54.83,32.613 +2020-02-20 21:30:00,105.41,165.142,54.83,32.613 +2020-02-20 21:45:00,107.31,164.50900000000001,54.83,32.613 +2020-02-20 22:00:00,98.64,157.486,50.933,32.613 +2020-02-20 22:15:00,102.93,153.07,50.933,32.613 +2020-02-20 22:30:00,103.82,138.967,50.933,32.613 +2020-02-20 22:45:00,105.37,131.486,50.933,32.613 +2020-02-20 23:00:00,94.47,124.87,45.32899999999999,32.613 +2020-02-20 23:15:00,90.46,123.387,45.32899999999999,32.613 +2020-02-20 23:30:00,88.59,124.492,45.32899999999999,32.613 +2020-02-20 23:45:00,88.87,124.436,45.32899999999999,32.613 +2020-02-21 00:00:00,84.07,118.053,43.74,32.613 +2020-02-21 00:15:00,90.99,117.012,43.74,32.613 +2020-02-21 00:30:00,90.98,116.915,43.74,32.613 +2020-02-21 00:45:00,87.5,117.566,43.74,32.613 +2020-02-21 01:00:00,80.73,119.194,42.555,32.613 +2020-02-21 01:15:00,82.69,120.294,42.555,32.613 +2020-02-21 01:30:00,78.88,120.279,42.555,32.613 +2020-02-21 01:45:00,79.85,120.557,42.555,32.613 +2020-02-21 02:00:00,79.02,122.663,41.68600000000001,32.613 +2020-02-21 02:15:00,86.13,123.93,41.68600000000001,32.613 +2020-02-21 02:30:00,87.4,125.331,41.68600000000001,32.613 +2020-02-21 02:45:00,87.01,127.15700000000001,41.68600000000001,32.613 +2020-02-21 03:00:00,80.72,128.641,42.278999999999996,32.613 +2020-02-21 03:15:00,80.62,131.14600000000002,42.278999999999996,32.613 +2020-02-21 03:30:00,82.45,132.915,42.278999999999996,32.613 +2020-02-21 03:45:00,82.52,134.672,42.278999999999996,32.613 +2020-02-21 04:00:00,83.5,146.612,43.742,32.613 +2020-02-21 04:15:00,88.63,158.04399999999998,43.742,32.613 +2020-02-21 04:30:00,94.41,160.84,43.742,32.613 +2020-02-21 04:45:00,95.37,161.859,43.742,32.613 +2020-02-21 05:00:00,94.16,193.079,46.973,32.613 +2020-02-21 05:15:00,92.16,222.521,46.973,32.613 +2020-02-21 05:30:00,94.26,218.762,46.973,32.613 +2020-02-21 05:45:00,99.52,211.65099999999998,46.973,32.613 +2020-02-21 06:00:00,108.6,209.43200000000002,59.63399999999999,32.613 +2020-02-21 06:15:00,111.42,213.935,59.63399999999999,32.613 +2020-02-21 06:30:00,115.46,215.37400000000002,59.63399999999999,32.613 +2020-02-21 06:45:00,117.72,220.855,59.63399999999999,32.613 +2020-02-21 07:00:00,122.43,220.72799999999998,71.631,32.613 +2020-02-21 07:15:00,123.45,225.54,71.631,32.613 +2020-02-21 07:30:00,123.07,226.58900000000003,71.631,32.613 +2020-02-21 07:45:00,124.6,225.299,71.631,32.613 +2020-02-21 08:00:00,127.22,222.543,66.181,32.613 +2020-02-21 08:15:00,125.37,221.072,66.181,32.613 +2020-02-21 08:30:00,126.6,218.17700000000002,66.181,32.613 +2020-02-21 08:45:00,123.25,212.84599999999998,66.181,32.613 +2020-02-21 09:00:00,125.59,206.114,63.086000000000006,32.613 +2020-02-21 09:15:00,125.25,203.579,63.086000000000006,32.613 +2020-02-21 09:30:00,126.21,201.69799999999998,63.086000000000006,32.613 +2020-02-21 09:45:00,128.09,198.653,63.086000000000006,32.613 +2020-02-21 10:00:00,122.61,193.30700000000002,60.886,32.613 +2020-02-21 10:15:00,124.67,190.35299999999998,60.886,32.613 +2020-02-21 10:30:00,125.15,187.81599999999997,60.886,32.613 +2020-02-21 10:45:00,124.81,186.39700000000002,60.886,32.613 +2020-02-21 11:00:00,120.18,184.442,59.391000000000005,32.613 +2020-02-21 11:15:00,120.77,182.68200000000002,59.391000000000005,32.613 +2020-02-21 11:30:00,120.33,183.52599999999998,59.391000000000005,32.613 +2020-02-21 11:45:00,120.04,182.298,59.391000000000005,32.613 +2020-02-21 12:00:00,118.73,178.43900000000002,56.172,32.613 +2020-02-21 12:15:00,118.32,176.53799999999998,56.172,32.613 +2020-02-21 12:30:00,117.18,176.092,56.172,32.613 +2020-02-21 12:45:00,119.34,177.515,56.172,32.613 +2020-02-21 13:00:00,112.77,177.671,54.406000000000006,32.613 +2020-02-21 13:15:00,112.5,177.203,54.406000000000006,32.613 +2020-02-21 13:30:00,111.04,176.26,54.406000000000006,32.613 +2020-02-21 13:45:00,111.47,175.815,54.406000000000006,32.613 +2020-02-21 14:00:00,109.79,175.38299999999998,53.578,32.613 +2020-02-21 14:15:00,110.7,175.00599999999997,53.578,32.613 +2020-02-21 14:30:00,110.76,176.04,53.578,32.613 +2020-02-21 14:45:00,110.95,177.13099999999997,53.578,32.613 +2020-02-21 15:00:00,117.42,178.542,56.568999999999996,32.613 +2020-02-21 15:15:00,120.19,177.31599999999997,56.568999999999996,32.613 +2020-02-21 15:30:00,119.17,176.987,56.568999999999996,32.613 +2020-02-21 15:45:00,115.35,178.092,56.568999999999996,32.613 +2020-02-21 16:00:00,117.52,178.511,60.169,32.613 +2020-02-21 16:15:00,115.01,180.301,60.169,32.613 +2020-02-21 16:30:00,116.7,183.232,60.169,32.613 +2020-02-21 16:45:00,119.87,184.386,60.169,32.613 +2020-02-21 17:00:00,123.86,186.592,65.497,32.613 +2020-02-21 17:15:00,126.26,188.45,65.497,32.613 +2020-02-21 17:30:00,126.38,190.63400000000001,65.497,32.613 +2020-02-21 17:45:00,128.7,191.324,65.497,32.613 +2020-02-21 18:00:00,133.63,194.234,65.082,32.613 +2020-02-21 18:15:00,132.24,193.282,65.082,32.613 +2020-02-21 18:30:00,135.09,192.361,65.082,32.613 +2020-02-21 18:45:00,134.34,193.4,65.082,32.613 +2020-02-21 19:00:00,132.35,193.229,60.968,32.613 +2020-02-21 19:15:00,136.76,191.30200000000002,60.968,32.613 +2020-02-21 19:30:00,136.33,189.387,60.968,32.613 +2020-02-21 19:45:00,136.31,185.921,60.968,32.613 +2020-02-21 20:00:00,121.81,181.247,61.123000000000005,32.613 +2020-02-21 20:15:00,119.79,175.767,61.123000000000005,32.613 +2020-02-21 20:30:00,114.65,171.91400000000002,61.123000000000005,32.613 +2020-02-21 20:45:00,115.4,170.627,61.123000000000005,32.613 +2020-02-21 21:00:00,106.52,167.949,55.416000000000004,32.613 +2020-02-21 21:15:00,104.0,165.347,55.416000000000004,32.613 +2020-02-21 21:30:00,102.97,163.342,55.416000000000004,32.613 +2020-02-21 21:45:00,103.72,163.263,55.416000000000004,32.613 +2020-02-21 22:00:00,97.94,157.187,51.631,32.613 +2020-02-21 22:15:00,94.54,152.662,51.631,32.613 +2020-02-21 22:30:00,92.36,144.856,51.631,32.613 +2020-02-21 22:45:00,96.36,140.861,51.631,32.613 +2020-02-21 23:00:00,96.29,133.83,44.898,32.613 +2020-02-21 23:15:00,93.93,130.423,44.898,32.613 +2020-02-21 23:30:00,87.56,130.07399999999998,44.898,32.613 +2020-02-21 23:45:00,86.86,129.387,44.898,32.613 +2020-02-22 00:00:00,80.69,115.046,42.033,32.431999999999995 +2020-02-22 00:15:00,85.92,109.822,42.033,32.431999999999995 +2020-02-22 00:30:00,86.31,111.001,42.033,32.431999999999995 +2020-02-22 00:45:00,83.99,112.337,42.033,32.431999999999995 +2020-02-22 01:00:00,75.49,114.553,38.255,32.431999999999995 +2020-02-22 01:15:00,77.66,114.685,38.255,32.431999999999995 +2020-02-22 01:30:00,74.25,114.135,38.255,32.431999999999995 +2020-02-22 01:45:00,78.27,114.225,38.255,32.431999999999995 +2020-02-22 02:00:00,80.13,116.98200000000001,36.404,32.431999999999995 +2020-02-22 02:15:00,81.83,117.868,36.404,32.431999999999995 +2020-02-22 02:30:00,77.76,118.184,36.404,32.431999999999995 +2020-02-22 02:45:00,77.53,120.12299999999999,36.404,32.431999999999995 +2020-02-22 03:00:00,76.75,122.20700000000001,36.083,32.431999999999995 +2020-02-22 03:15:00,80.16,123.5,36.083,32.431999999999995 +2020-02-22 03:30:00,80.23,123.714,36.083,32.431999999999995 +2020-02-22 03:45:00,78.0,125.616,36.083,32.431999999999995 +2020-02-22 04:00:00,72.92,133.475,36.102,32.431999999999995 +2020-02-22 04:15:00,73.17,142.395,36.102,32.431999999999995 +2020-02-22 04:30:00,73.24,143.054,36.102,32.431999999999995 +2020-02-22 04:45:00,74.17,143.582,36.102,32.431999999999995 +2020-02-22 05:00:00,74.51,159.02200000000002,35.284,32.431999999999995 +2020-02-22 05:15:00,75.89,169.7,35.284,32.431999999999995 +2020-02-22 05:30:00,75.3,166.28599999999997,35.284,32.431999999999995 +2020-02-22 05:45:00,76.79,164.58599999999998,35.284,32.431999999999995 +2020-02-22 06:00:00,78.47,180.94,36.265,32.431999999999995 +2020-02-22 06:15:00,79.02,201.58900000000003,36.265,32.431999999999995 +2020-02-22 06:30:00,79.89,197.68200000000002,36.265,32.431999999999995 +2020-02-22 06:45:00,80.02,194.308,36.265,32.431999999999995 +2020-02-22 07:00:00,81.54,190.49099999999999,40.714,32.431999999999995 +2020-02-22 07:15:00,85.98,194.023,40.714,32.431999999999995 +2020-02-22 07:30:00,86.18,197.71,40.714,32.431999999999995 +2020-02-22 07:45:00,87.86,200.28400000000002,40.714,32.431999999999995 +2020-02-22 08:00:00,89.81,201.467,46.692,32.431999999999995 +2020-02-22 08:15:00,91.73,203.52200000000002,46.692,32.431999999999995 +2020-02-22 08:30:00,98.01,202.149,46.692,32.431999999999995 +2020-02-22 08:45:00,99.24,199.93,46.692,32.431999999999995 +2020-02-22 09:00:00,102.43,194.99599999999998,48.925,32.431999999999995 +2020-02-22 09:15:00,102.12,193.207,48.925,32.431999999999995 +2020-02-22 09:30:00,102.49,192.239,48.925,32.431999999999995 +2020-02-22 09:45:00,99.78,189.347,48.925,32.431999999999995 +2020-02-22 10:00:00,95.56,184.31099999999998,47.799,32.431999999999995 +2020-02-22 10:15:00,99.82,181.56099999999998,47.799,32.431999999999995 +2020-02-22 10:30:00,100.4,179.19,47.799,32.431999999999995 +2020-02-22 10:45:00,99.55,179.048,47.799,32.431999999999995 +2020-02-22 11:00:00,95.77,177.29,44.309,32.431999999999995 +2020-02-22 11:15:00,95.07,174.896,44.309,32.431999999999995 +2020-02-22 11:30:00,94.71,174.668,44.309,32.431999999999995 +2020-02-22 11:45:00,97.76,172.54,44.309,32.431999999999995 +2020-02-22 12:00:00,92.83,167.857,42.367,32.431999999999995 +2020-02-22 12:15:00,92.41,166.61599999999999,42.367,32.431999999999995 +2020-02-22 12:30:00,88.04,166.44400000000002,42.367,32.431999999999995 +2020-02-22 12:45:00,86.69,167.13400000000001,42.367,32.431999999999995 +2020-02-22 13:00:00,82.02,166.915,39.036,32.431999999999995 +2020-02-22 13:15:00,85.74,164.417,39.036,32.431999999999995 +2020-02-22 13:30:00,79.5,163.02700000000002,39.036,32.431999999999995 +2020-02-22 13:45:00,80.82,163.011,39.036,32.431999999999995 +2020-02-22 14:00:00,78.34,163.83100000000002,37.995,32.431999999999995 +2020-02-22 14:15:00,78.85,162.882,37.995,32.431999999999995 +2020-02-22 14:30:00,80.14,162.118,37.995,32.431999999999995 +2020-02-22 14:45:00,81.7,163.444,37.995,32.431999999999995 +2020-02-22 15:00:00,81.24,165.505,40.71,32.431999999999995 +2020-02-22 15:15:00,82.52,165.051,40.71,32.431999999999995 +2020-02-22 15:30:00,83.61,166.18200000000002,40.71,32.431999999999995 +2020-02-22 15:45:00,85.83,167.262,40.71,32.431999999999995 +2020-02-22 16:00:00,87.34,166.548,46.998000000000005,32.431999999999995 +2020-02-22 16:15:00,91.33,169.175,46.998000000000005,32.431999999999995 +2020-02-22 16:30:00,93.53,172.06,46.998000000000005,32.431999999999995 +2020-02-22 16:45:00,92.11,174.048,46.998000000000005,32.431999999999995 +2020-02-22 17:00:00,99.16,175.66,55.431000000000004,32.431999999999995 +2020-02-22 17:15:00,98.14,179.105,55.431000000000004,32.431999999999995 +2020-02-22 17:30:00,103.35,181.222,55.431000000000004,32.431999999999995 +2020-02-22 17:45:00,104.88,181.547,55.431000000000004,32.431999999999995 +2020-02-22 18:00:00,113.78,184.007,55.989,32.431999999999995 +2020-02-22 18:15:00,112.69,184.90400000000002,55.989,32.431999999999995 +2020-02-22 18:30:00,112.95,185.31,55.989,32.431999999999995 +2020-02-22 18:45:00,112.81,183.035,55.989,32.431999999999995 +2020-02-22 19:00:00,111.89,183.692,50.882,32.431999999999995 +2020-02-22 19:15:00,112.4,181.27599999999998,50.882,32.431999999999995 +2020-02-22 19:30:00,107.62,180.136,50.882,32.431999999999995 +2020-02-22 19:45:00,106.47,176.567,50.882,32.431999999999995 +2020-02-22 20:00:00,101.54,174.00799999999998,43.172,32.431999999999995 +2020-02-22 20:15:00,99.49,170.608,43.172,32.431999999999995 +2020-02-22 20:30:00,96.48,166.41400000000002,43.172,32.431999999999995 +2020-02-22 20:45:00,98.36,164.771,43.172,32.431999999999995 +2020-02-22 21:00:00,91.47,164.25099999999998,37.599000000000004,32.431999999999995 +2020-02-22 21:15:00,90.24,162.063,37.599000000000004,32.431999999999995 +2020-02-22 21:30:00,88.52,161.263,37.599000000000004,32.431999999999995 +2020-02-22 21:45:00,88.73,160.796,37.599000000000004,32.431999999999995 +2020-02-22 22:00:00,85.26,156.02100000000002,39.047,32.431999999999995 +2020-02-22 22:15:00,84.4,153.955,39.047,32.431999999999995 +2020-02-22 22:30:00,81.79,152.175,39.047,32.431999999999995 +2020-02-22 22:45:00,81.3,150.017,39.047,32.431999999999995 +2020-02-22 23:00:00,77.58,145.344,32.339,32.431999999999995 +2020-02-22 23:15:00,77.79,140.363,32.339,32.431999999999995 +2020-02-22 23:30:00,74.96,138.365,32.339,32.431999999999995 +2020-02-22 23:45:00,73.63,135.339,32.339,32.431999999999995 +2020-02-23 00:00:00,70.14,115.244,29.988000000000003,32.431999999999995 +2020-02-23 00:15:00,69.61,109.686,29.988000000000003,32.431999999999995 +2020-02-23 00:30:00,69.59,110.479,29.988000000000003,32.431999999999995 +2020-02-23 00:45:00,69.38,112.488,29.988000000000003,32.431999999999995 +2020-02-23 01:00:00,65.46,114.53399999999999,28.531999999999996,32.431999999999995 +2020-02-23 01:15:00,66.66,115.661,28.531999999999996,32.431999999999995 +2020-02-23 01:30:00,65.52,115.603,28.531999999999996,32.431999999999995 +2020-02-23 01:45:00,65.26,115.382,28.531999999999996,32.431999999999995 +2020-02-23 02:00:00,63.83,117.405,27.805999999999997,32.431999999999995 +2020-02-23 02:15:00,64.64,117.473,27.805999999999997,32.431999999999995 +2020-02-23 02:30:00,63.77,118.64200000000001,27.805999999999997,32.431999999999995 +2020-02-23 02:45:00,64.98,121.022,27.805999999999997,32.431999999999995 +2020-02-23 03:00:00,63.94,123.436,26.193,32.431999999999995 +2020-02-23 03:15:00,64.7,124.21799999999999,26.193,32.431999999999995 +2020-02-23 03:30:00,64.1,125.774,26.193,32.431999999999995 +2020-02-23 03:45:00,64.57,127.594,26.193,32.431999999999995 +2020-02-23 04:00:00,64.91,135.216,27.19,32.431999999999995 +2020-02-23 04:15:00,64.97,143.135,27.19,32.431999999999995 +2020-02-23 04:30:00,65.65,143.904,27.19,32.431999999999995 +2020-02-23 04:45:00,66.48,144.673,27.19,32.431999999999995 +2020-02-23 05:00:00,67.26,156.69,28.166999999999998,32.431999999999995 +2020-02-23 05:15:00,67.76,165.021,28.166999999999998,32.431999999999995 +2020-02-23 05:30:00,68.15,161.405,28.166999999999998,32.431999999999995 +2020-02-23 05:45:00,68.8,159.939,28.166999999999998,32.431999999999995 +2020-02-23 06:00:00,69.41,176.139,27.16,32.431999999999995 +2020-02-23 06:15:00,69.86,195.146,27.16,32.431999999999995 +2020-02-23 06:30:00,70.71,190.112,27.16,32.431999999999995 +2020-02-23 06:45:00,69.95,185.675,27.16,32.431999999999995 +2020-02-23 07:00:00,72.62,184.268,29.578000000000003,32.431999999999995 +2020-02-23 07:15:00,73.12,186.9,29.578000000000003,32.431999999999995 +2020-02-23 07:30:00,74.51,189.389,29.578000000000003,32.431999999999995 +2020-02-23 07:45:00,76.29,191.18200000000002,29.578000000000003,32.431999999999995 +2020-02-23 08:00:00,78.45,194.149,34.650999999999996,32.431999999999995 +2020-02-23 08:15:00,78.33,196.13,34.650999999999996,32.431999999999995 +2020-02-23 08:30:00,78.73,196.333,34.650999999999996,32.431999999999995 +2020-02-23 08:45:00,79.07,196.078,34.650999999999996,32.431999999999995 +2020-02-23 09:00:00,78.69,190.75900000000001,38.080999999999996,32.431999999999995 +2020-02-23 09:15:00,78.31,189.495,38.080999999999996,32.431999999999995 +2020-02-23 09:30:00,78.05,188.407,38.080999999999996,32.431999999999995 +2020-02-23 09:45:00,77.75,185.43599999999998,38.080999999999996,32.431999999999995 +2020-02-23 10:00:00,76.21,182.868,39.934,32.431999999999995 +2020-02-23 10:15:00,75.97,180.67,39.934,32.431999999999995 +2020-02-23 10:30:00,75.82,178.90400000000002,39.934,32.431999999999995 +2020-02-23 10:45:00,76.78,176.976,39.934,32.431999999999995 +2020-02-23 11:00:00,77.47,176.084,43.74100000000001,32.431999999999995 +2020-02-23 11:15:00,80.97,173.822,43.74100000000001,32.431999999999995 +2020-02-23 11:30:00,81.83,172.77700000000002,43.74100000000001,32.431999999999995 +2020-02-23 11:45:00,84.37,171.255,43.74100000000001,32.431999999999995 +2020-02-23 12:00:00,80.33,166.08,40.001999999999995,32.431999999999995 +2020-02-23 12:15:00,75.8,166.668,40.001999999999995,32.431999999999995 +2020-02-23 12:30:00,75.85,165.06900000000002,40.001999999999995,32.431999999999995 +2020-02-23 12:45:00,72.42,164.801,40.001999999999995,32.431999999999995 +2020-02-23 13:00:00,71.1,163.916,37.855,32.431999999999995 +2020-02-23 13:15:00,69.74,164.28099999999998,37.855,32.431999999999995 +2020-02-23 13:30:00,66.49,162.636,37.855,32.431999999999995 +2020-02-23 13:45:00,65.1,162.02700000000002,37.855,32.431999999999995 +2020-02-23 14:00:00,63.85,163.18200000000002,35.946999999999996,32.431999999999995 +2020-02-23 14:15:00,67.63,163.381,35.946999999999996,32.431999999999995 +2020-02-23 14:30:00,65.18,163.741,35.946999999999996,32.431999999999995 +2020-02-23 14:45:00,66.34,164.646,35.946999999999996,32.431999999999995 +2020-02-23 15:00:00,67.38,165.24599999999998,35.138000000000005,32.431999999999995 +2020-02-23 15:15:00,71.0,165.46400000000003,35.138000000000005,32.431999999999995 +2020-02-23 15:30:00,70.06,167.115,35.138000000000005,32.431999999999995 +2020-02-23 15:45:00,71.4,168.856,35.138000000000005,32.431999999999995 +2020-02-23 16:00:00,77.36,169.905,38.672,32.431999999999995 +2020-02-23 16:15:00,75.53,171.645,38.672,32.431999999999995 +2020-02-23 16:30:00,77.71,174.812,38.672,32.431999999999995 +2020-02-23 16:45:00,81.19,176.903,38.672,32.431999999999995 +2020-02-23 17:00:00,88.54,178.53599999999997,48.684,32.431999999999995 +2020-02-23 17:15:00,86.32,181.71900000000002,48.684,32.431999999999995 +2020-02-23 17:30:00,90.78,184.165,48.684,32.431999999999995 +2020-02-23 17:45:00,96.64,186.72099999999998,48.684,32.431999999999995 +2020-02-23 18:00:00,104.83,188.683,51.568999999999996,32.431999999999995 +2020-02-23 18:15:00,107.06,190.919,51.568999999999996,32.431999999999995 +2020-02-23 18:30:00,107.23,189.291,51.568999999999996,32.431999999999995 +2020-02-23 18:45:00,103.85,188.84,51.568999999999996,32.431999999999995 +2020-02-23 19:00:00,101.61,189.18900000000002,48.608000000000004,32.431999999999995 +2020-02-23 19:15:00,102.96,187.34,48.608000000000004,32.431999999999995 +2020-02-23 19:30:00,99.45,186.058,48.608000000000004,32.431999999999995 +2020-02-23 19:45:00,100.59,183.923,48.608000000000004,32.431999999999995 +2020-02-23 20:00:00,105.69,181.30900000000003,43.733999999999995,32.431999999999995 +2020-02-23 20:15:00,97.93,178.87599999999998,43.733999999999995,32.431999999999995 +2020-02-23 20:30:00,93.18,175.926,43.733999999999995,32.431999999999995 +2020-02-23 20:45:00,98.42,173.078,43.733999999999995,32.431999999999995 +2020-02-23 21:00:00,92.64,170.021,39.283,32.431999999999995 +2020-02-23 21:15:00,88.96,167.207,39.283,32.431999999999995 +2020-02-23 21:30:00,90.04,166.68,39.283,32.431999999999995 +2020-02-23 21:45:00,93.24,166.37099999999998,39.283,32.431999999999995 +2020-02-23 22:00:00,98.28,160.47,40.111,32.431999999999995 +2020-02-23 22:15:00,92.33,157.649,40.111,32.431999999999995 +2020-02-23 22:30:00,94.51,152.731,40.111,32.431999999999995 +2020-02-23 22:45:00,91.38,149.721,40.111,32.431999999999995 +2020-02-23 23:00:00,86.18,142.312,35.791,32.431999999999995 +2020-02-23 23:15:00,83.82,139.184,35.791,32.431999999999995 +2020-02-23 23:30:00,84.12,137.974,35.791,32.431999999999995 +2020-02-23 23:45:00,89.48,135.843,35.791,32.431999999999995 +2020-02-24 00:00:00,87.4,119.12100000000001,34.311,32.613 +2020-02-24 00:15:00,85.71,116.48200000000001,34.311,32.613 +2020-02-24 00:30:00,79.25,117.37,34.311,32.613 +2020-02-24 00:45:00,80.11,118.845,34.311,32.613 +2020-02-24 01:00:00,75.53,120.87299999999999,34.585,32.613 +2020-02-24 01:15:00,82.25,121.47399999999999,34.585,32.613 +2020-02-24 01:30:00,82.39,121.463,34.585,32.613 +2020-02-24 01:45:00,83.21,121.355,34.585,32.613 +2020-02-24 02:00:00,78.47,123.35799999999999,34.111,32.613 +2020-02-24 02:15:00,81.62,124.824,34.111,32.613 +2020-02-24 02:30:00,83.49,126.336,34.111,32.613 +2020-02-24 02:45:00,85.86,128.109,34.111,32.613 +2020-02-24 03:00:00,77.68,131.778,32.435,32.613 +2020-02-24 03:15:00,84.84,134.17,32.435,32.613 +2020-02-24 03:30:00,85.17,135.471,32.435,32.613 +2020-02-24 03:45:00,85.91,136.755,32.435,32.613 +2020-02-24 04:00:00,83.63,148.685,33.04,32.613 +2020-02-24 04:15:00,83.89,160.695,33.04,32.613 +2020-02-24 04:30:00,89.11,163.607,33.04,32.613 +2020-02-24 04:45:00,86.29,164.52900000000002,33.04,32.613 +2020-02-24 05:00:00,90.81,192.02599999999998,40.399,32.613 +2020-02-24 05:15:00,91.34,220.23,40.399,32.613 +2020-02-24 05:30:00,95.51,216.81900000000002,40.399,32.613 +2020-02-24 05:45:00,100.68,209.787,40.399,32.613 +2020-02-24 06:00:00,112.19,208.31799999999998,60.226000000000006,32.613 +2020-02-24 06:15:00,114.53,212.699,60.226000000000006,32.613 +2020-02-24 06:30:00,121.55,215.514,60.226000000000006,32.613 +2020-02-24 06:45:00,120.58,219.74099999999999,60.226000000000006,32.613 +2020-02-24 07:00:00,127.09,220.673,73.578,32.613 +2020-02-24 07:15:00,128.48,224.535,73.578,32.613 +2020-02-24 07:30:00,128.83,226.213,73.578,32.613 +2020-02-24 07:45:00,128.43,225.47400000000002,73.578,32.613 +2020-02-24 08:00:00,132.65,223.62099999999998,66.58,32.613 +2020-02-24 08:15:00,130.35,223.472,66.58,32.613 +2020-02-24 08:30:00,132.82,219.62400000000002,66.58,32.613 +2020-02-24 08:45:00,128.9,216.138,66.58,32.613 +2020-02-24 09:00:00,130.95,209.83700000000002,62.0,32.613 +2020-02-24 09:15:00,129.17,205.146,62.0,32.613 +2020-02-24 09:30:00,128.34,203.085,62.0,32.613 +2020-02-24 09:45:00,127.77,200.359,62.0,32.613 +2020-02-24 10:00:00,125.53,196.74900000000002,59.099,32.613 +2020-02-24 10:15:00,127.37,194.266,59.099,32.613 +2020-02-24 10:30:00,123.36,191.65599999999998,59.099,32.613 +2020-02-24 10:45:00,127.17,190.43200000000002,59.099,32.613 +2020-02-24 11:00:00,122.95,187.0,57.729,32.613 +2020-02-24 11:15:00,126.73,186.50900000000001,57.729,32.613 +2020-02-24 11:30:00,127.56,186.827,57.729,32.613 +2020-02-24 11:45:00,123.32,184.93599999999998,57.729,32.613 +2020-02-24 12:00:00,122.5,181.40099999999998,55.615,32.613 +2020-02-24 12:15:00,126.78,182.00599999999997,55.615,32.613 +2020-02-24 12:30:00,127.67,180.593,55.615,32.613 +2020-02-24 12:45:00,124.86,181.798,55.615,32.613 +2020-02-24 13:00:00,122.88,181.528,56.515,32.613 +2020-02-24 13:15:00,123.82,180.50599999999997,56.515,32.613 +2020-02-24 13:30:00,125.65,178.34,56.515,32.613 +2020-02-24 13:45:00,128.04,177.78599999999997,56.515,32.613 +2020-02-24 14:00:00,130.27,178.38099999999997,58.1,32.613 +2020-02-24 14:15:00,128.28,177.951,58.1,32.613 +2020-02-24 14:30:00,131.05,177.762,58.1,32.613 +2020-02-24 14:45:00,130.12,178.701,58.1,32.613 +2020-02-24 15:00:00,130.46,181.032,59.801,32.613 +2020-02-24 15:15:00,131.88,179.815,59.801,32.613 +2020-02-24 15:30:00,129.22,180.639,59.801,32.613 +2020-02-24 15:45:00,129.42,181.919,59.801,32.613 +2020-02-24 16:00:00,129.55,183.18599999999998,62.901,32.613 +2020-02-24 16:15:00,129.72,184.172,62.901,32.613 +2020-02-24 16:30:00,127.89,186.385,62.901,32.613 +2020-02-24 16:45:00,129.96,187.296,62.901,32.613 +2020-02-24 17:00:00,133.58,188.75,70.418,32.613 +2020-02-24 17:15:00,136.65,191.028,70.418,32.613 +2020-02-24 17:30:00,139.14,192.96,70.418,32.613 +2020-02-24 17:45:00,138.19,194.074,70.418,32.613 +2020-02-24 18:00:00,144.78,196.45,71.726,32.613 +2020-02-24 18:15:00,140.22,196.56,71.726,32.613 +2020-02-24 18:30:00,140.06,195.55599999999998,71.726,32.613 +2020-02-24 18:45:00,141.43,195.878,71.726,32.613 +2020-02-24 19:00:00,139.29,194.61599999999999,65.997,32.613 +2020-02-24 19:15:00,138.24,191.66,65.997,32.613 +2020-02-24 19:30:00,144.32,190.885,65.997,32.613 +2020-02-24 19:45:00,141.12,187.96599999999998,65.997,32.613 +2020-02-24 20:00:00,133.01,182.99099999999999,68.09100000000001,32.613 +2020-02-24 20:15:00,125.91,178.175,68.09100000000001,32.613 +2020-02-24 20:30:00,120.23,173.433,68.09100000000001,32.613 +2020-02-24 20:45:00,121.43,172.179,68.09100000000001,32.613 +2020-02-24 21:00:00,114.0,169.61900000000003,59.617,32.613 +2020-02-24 21:15:00,117.58,165.65599999999998,59.617,32.613 +2020-02-24 21:30:00,117.76,164.327,59.617,32.613 +2020-02-24 21:45:00,115.93,163.54,59.617,32.613 +2020-02-24 22:00:00,105.83,154.77,54.938,32.613 +2020-02-24 22:15:00,101.05,150.715,54.938,32.613 +2020-02-24 22:30:00,99.67,136.342,54.938,32.613 +2020-02-24 22:45:00,98.93,128.609,54.938,32.613 +2020-02-24 23:00:00,95.34,121.956,47.43,32.613 +2020-02-24 23:15:00,94.94,121.412,47.43,32.613 +2020-02-24 23:30:00,89.82,122.914,47.43,32.613 +2020-02-24 23:45:00,89.72,123.382,47.43,32.613 +2020-02-25 00:00:00,92.74,118.161,48.354,32.613 +2020-02-25 00:15:00,93.6,116.93299999999999,48.354,32.613 +2020-02-25 00:30:00,94.11,116.89200000000001,48.354,32.613 +2020-02-25 00:45:00,89.59,117.417,48.354,32.613 +2020-02-25 01:00:00,84.84,119.20700000000001,45.68600000000001,32.613 +2020-02-25 01:15:00,86.12,119.359,45.68600000000001,32.613 +2020-02-25 01:30:00,82.8,119.501,45.68600000000001,32.613 +2020-02-25 01:45:00,83.08,119.69,45.68600000000001,32.613 +2020-02-25 02:00:00,84.51,121.675,44.269,32.613 +2020-02-25 02:15:00,87.23,123.042,44.269,32.613 +2020-02-25 02:30:00,89.0,123.98200000000001,44.269,32.613 +2020-02-25 02:45:00,93.75,125.771,44.269,32.613 +2020-02-25 03:00:00,88.41,128.25799999999998,44.187,32.613 +2020-02-25 03:15:00,89.23,129.819,44.187,32.613 +2020-02-25 03:30:00,85.06,131.588,44.187,32.613 +2020-02-25 03:45:00,89.04,133.059,44.187,32.613 +2020-02-25 04:00:00,86.27,144.786,46.126999999999995,32.613 +2020-02-25 04:15:00,86.74,156.446,46.126999999999995,32.613 +2020-02-25 04:30:00,88.33,159.06799999999998,46.126999999999995,32.613 +2020-02-25 04:45:00,91.62,161.172,46.126999999999995,32.613 +2020-02-25 05:00:00,97.3,193.585,49.666000000000004,32.613 +2020-02-25 05:15:00,96.94,221.614,49.666000000000004,32.613 +2020-02-25 05:30:00,101.12,216.666,49.666000000000004,32.613 +2020-02-25 05:45:00,105.3,209.638,49.666000000000004,32.613 +2020-02-25 06:00:00,116.13,207.021,61.077,32.613 +2020-02-25 06:15:00,118.32,213.012,61.077,32.613 +2020-02-25 06:30:00,121.9,215.149,61.077,32.613 +2020-02-25 06:45:00,123.41,218.986,61.077,32.613 +2020-02-25 07:00:00,130.25,219.761,74.717,32.613 +2020-02-25 07:15:00,129.83,223.43400000000003,74.717,32.613 +2020-02-25 07:30:00,131.97,224.53900000000002,74.717,32.613 +2020-02-25 07:45:00,129.74,223.96,74.717,32.613 +2020-02-25 08:00:00,132.69,222.18900000000002,69.033,32.613 +2020-02-25 08:15:00,133.52,221.015,69.033,32.613 +2020-02-25 08:30:00,129.26,216.93599999999998,69.033,32.613 +2020-02-25 08:45:00,128.0,213.18900000000002,69.033,32.613 +2020-02-25 09:00:00,134.63,206.084,63.113,32.613 +2020-02-25 09:15:00,134.74,202.96200000000002,63.113,32.613 +2020-02-25 09:30:00,137.02,201.59400000000002,63.113,32.613 +2020-02-25 09:45:00,135.12,198.675,63.113,32.613 +2020-02-25 10:00:00,127.82,194.487,61.461999999999996,32.613 +2020-02-25 10:15:00,127.61,190.99599999999998,61.461999999999996,32.613 +2020-02-25 10:30:00,124.56,188.578,61.461999999999996,32.613 +2020-02-25 10:45:00,124.47,187.65900000000002,61.461999999999996,32.613 +2020-02-25 11:00:00,120.98,185.67700000000002,59.614,32.613 +2020-02-25 11:15:00,121.56,184.88099999999997,59.614,32.613 +2020-02-25 11:30:00,122.41,184.05200000000002,59.614,32.613 +2020-02-25 11:45:00,121.67,182.856,59.614,32.613 +2020-02-25 12:00:00,121.07,177.997,57.415,32.613 +2020-02-25 12:15:00,120.61,178.21200000000002,57.415,32.613 +2020-02-25 12:30:00,117.95,177.486,57.415,32.613 +2020-02-25 12:45:00,118.6,178.41099999999997,57.415,32.613 +2020-02-25 13:00:00,116.09,177.77200000000002,58.534,32.613 +2020-02-25 13:15:00,116.32,176.41299999999998,58.534,32.613 +2020-02-25 13:30:00,118.93,175.377,58.534,32.613 +2020-02-25 13:45:00,116.93,174.995,58.534,32.613 +2020-02-25 14:00:00,119.27,175.935,59.415,32.613 +2020-02-25 14:15:00,117.11,175.63400000000001,59.415,32.613 +2020-02-25 14:30:00,118.09,176.03900000000002,59.415,32.613 +2020-02-25 14:45:00,119.19,176.899,59.415,32.613 +2020-02-25 15:00:00,121.2,178.81400000000002,62.071999999999996,32.613 +2020-02-25 15:15:00,119.3,177.905,62.071999999999996,32.613 +2020-02-25 15:30:00,119.76,178.90200000000002,62.071999999999996,32.613 +2020-02-25 15:45:00,121.39,179.77700000000002,62.071999999999996,32.613 +2020-02-25 16:00:00,123.07,181.363,64.99,32.613 +2020-02-25 16:15:00,125.94,182.801,64.99,32.613 +2020-02-25 16:30:00,123.48,185.644,64.99,32.613 +2020-02-25 16:45:00,125.86,186.815,64.99,32.613 +2020-02-25 17:00:00,133.26,188.80900000000003,72.658,32.613 +2020-02-25 17:15:00,133.66,191.135,72.658,32.613 +2020-02-25 17:30:00,136.58,193.74400000000003,72.658,32.613 +2020-02-25 17:45:00,134.98,194.747,72.658,32.613 +2020-02-25 18:00:00,142.9,197.021,73.645,32.613 +2020-02-25 18:15:00,140.35,196.71400000000003,73.645,32.613 +2020-02-25 18:30:00,140.6,195.40400000000002,73.645,32.613 +2020-02-25 18:45:00,140.89,196.53,73.645,32.613 +2020-02-25 19:00:00,138.89,195.298,67.085,32.613 +2020-02-25 19:15:00,137.3,192.084,67.085,32.613 +2020-02-25 19:30:00,143.66,190.675,67.085,32.613 +2020-02-25 19:45:00,146.82,187.835,67.085,32.613 +2020-02-25 20:00:00,137.15,182.99099999999999,66.138,32.613 +2020-02-25 20:15:00,124.2,177.548,66.138,32.613 +2020-02-25 20:30:00,125.13,173.822,66.138,32.613 +2020-02-25 20:45:00,122.61,172.012,66.138,32.613 +2020-02-25 21:00:00,114.79,168.75900000000001,57.512,32.613 +2020-02-25 21:15:00,117.9,165.666,57.512,32.613 +2020-02-25 21:30:00,118.78,163.622,57.512,32.613 +2020-02-25 21:45:00,115.59,163.079,57.512,32.613 +2020-02-25 22:00:00,105.29,155.977,54.545,32.613 +2020-02-25 22:15:00,105.73,151.689,54.545,32.613 +2020-02-25 22:30:00,100.32,137.36,54.545,32.613 +2020-02-25 22:45:00,103.78,129.903,54.545,32.613 +2020-02-25 23:00:00,103.37,123.266,48.605,32.613 +2020-02-25 23:15:00,102.21,121.863,48.605,32.613 +2020-02-25 23:30:00,94.38,123.02,48.605,32.613 +2020-02-25 23:45:00,93.16,123.08200000000001,48.605,32.613 +2020-02-26 00:00:00,84.21,117.904,45.675,32.613 +2020-02-26 00:15:00,85.32,116.678,45.675,32.613 +2020-02-26 00:30:00,85.53,116.619,45.675,32.613 +2020-02-26 00:45:00,85.09,117.14299999999999,45.675,32.613 +2020-02-26 01:00:00,86.21,118.89200000000001,43.015,32.613 +2020-02-26 01:15:00,91.24,119.031,43.015,32.613 +2020-02-26 01:30:00,91.43,119.15899999999999,43.015,32.613 +2020-02-26 01:45:00,91.86,119.352,43.015,32.613 +2020-02-26 02:00:00,88.93,121.329,41.0,32.613 +2020-02-26 02:15:00,90.95,122.693,41.0,32.613 +2020-02-26 02:30:00,91.59,123.649,41.0,32.613 +2020-02-26 02:45:00,90.28,125.43700000000001,41.0,32.613 +2020-02-26 03:00:00,90.74,127.93299999999999,41.318000000000005,32.613 +2020-02-26 03:15:00,95.62,129.485,41.318000000000005,32.613 +2020-02-26 03:30:00,92.99,131.248,41.318000000000005,32.613 +2020-02-26 03:45:00,89.88,132.731,41.318000000000005,32.613 +2020-02-26 04:00:00,92.31,144.463,42.544,32.613 +2020-02-26 04:15:00,89.03,156.114,42.544,32.613 +2020-02-26 04:30:00,88.74,158.754,42.544,32.613 +2020-02-26 04:45:00,89.87,160.845,42.544,32.613 +2020-02-26 05:00:00,94.12,193.24400000000003,45.161,32.613 +2020-02-26 05:15:00,95.66,221.297,45.161,32.613 +2020-02-26 05:30:00,99.71,216.31599999999997,45.161,32.613 +2020-02-26 05:45:00,104.02,209.291,45.161,32.613 +2020-02-26 06:00:00,116.54,206.68099999999998,61.86600000000001,32.613 +2020-02-26 06:15:00,119.33,212.68099999999998,61.86600000000001,32.613 +2020-02-26 06:30:00,124.91,214.77900000000002,61.86600000000001,32.613 +2020-02-26 06:45:00,124.25,218.601,61.86600000000001,32.613 +2020-02-26 07:00:00,132.05,219.398,77.814,32.613 +2020-02-26 07:15:00,131.93,223.041,77.814,32.613 +2020-02-26 07:30:00,130.23,224.108,77.814,32.613 +2020-02-26 07:45:00,131.9,223.488,77.814,32.613 +2020-02-26 08:00:00,135.76,221.69299999999998,70.251,32.613 +2020-02-26 08:15:00,134.68,220.50099999999998,70.251,32.613 +2020-02-26 08:30:00,134.89,216.365,70.251,32.613 +2020-02-26 08:45:00,132.39,212.62900000000002,70.251,32.613 +2020-02-26 09:00:00,132.02,205.533,66.965,32.613 +2020-02-26 09:15:00,137.94,202.412,66.965,32.613 +2020-02-26 09:30:00,138.13,201.06400000000002,66.965,32.613 +2020-02-26 09:45:00,140.89,198.15200000000002,66.965,32.613 +2020-02-26 10:00:00,135.54,193.976,63.628,32.613 +2020-02-26 10:15:00,133.05,190.52200000000002,63.628,32.613 +2020-02-26 10:30:00,129.5,188.12099999999998,63.628,32.613 +2020-02-26 10:45:00,130.84,187.217,63.628,32.613 +2020-02-26 11:00:00,122.72,185.227,62.516999999999996,32.613 +2020-02-26 11:15:00,122.07,184.449,62.516999999999996,32.613 +2020-02-26 11:30:00,120.82,183.62599999999998,62.516999999999996,32.613 +2020-02-26 11:45:00,120.99,182.445,62.516999999999996,32.613 +2020-02-26 12:00:00,121.64,177.602,60.888999999999996,32.613 +2020-02-26 12:15:00,119.41,177.832,60.888999999999996,32.613 +2020-02-26 12:30:00,119.83,177.074,60.888999999999996,32.613 +2020-02-26 12:45:00,121.37,177.99599999999998,60.888999999999996,32.613 +2020-02-26 13:00:00,118.3,177.392,61.57899999999999,32.613 +2020-02-26 13:15:00,121.25,176.01,61.57899999999999,32.613 +2020-02-26 13:30:00,119.3,174.96,61.57899999999999,32.613 +2020-02-26 13:45:00,117.57,174.58,61.57899999999999,32.613 +2020-02-26 14:00:00,120.42,175.582,62.602,32.613 +2020-02-26 14:15:00,120.08,175.25900000000001,62.602,32.613 +2020-02-26 14:30:00,127.13,175.64,62.602,32.613 +2020-02-26 14:45:00,123.18,176.512,62.602,32.613 +2020-02-26 15:00:00,124.28,178.433,64.259,32.613 +2020-02-26 15:15:00,121.09,177.495,64.259,32.613 +2020-02-26 15:30:00,121.21,178.44799999999998,64.259,32.613 +2020-02-26 15:45:00,124.4,179.30599999999998,64.259,32.613 +2020-02-26 16:00:00,123.44,180.894,67.632,32.613 +2020-02-26 16:15:00,128.2,182.315,67.632,32.613 +2020-02-26 16:30:00,126.24,185.15900000000002,67.632,32.613 +2020-02-26 16:45:00,129.82,186.3,67.632,32.613 +2020-02-26 17:00:00,132.44,188.3,72.583,32.613 +2020-02-26 17:15:00,132.18,190.64,72.583,32.613 +2020-02-26 17:30:00,138.18,193.27700000000002,72.583,32.613 +2020-02-26 17:45:00,137.44,194.299,72.583,32.613 +2020-02-26 18:00:00,144.08,196.584,72.744,32.613 +2020-02-26 18:15:00,142.07,196.34,72.744,32.613 +2020-02-26 18:30:00,143.05,195.028,72.744,32.613 +2020-02-26 18:45:00,143.25,196.175,72.744,32.613 +2020-02-26 19:00:00,142.61,194.905,69.684,32.613 +2020-02-26 19:15:00,143.22,191.708,69.684,32.613 +2020-02-26 19:30:00,148.53,190.325,69.684,32.613 +2020-02-26 19:45:00,145.96,187.525,69.684,32.613 +2020-02-26 20:00:00,134.53,182.65200000000002,70.036,32.613 +2020-02-26 20:15:00,128.04,177.22299999999998,70.036,32.613 +2020-02-26 20:30:00,123.73,173.518,70.036,32.613 +2020-02-26 20:45:00,122.17,171.71,70.036,32.613 +2020-02-26 21:00:00,116.79,168.44400000000002,60.431999999999995,32.613 +2020-02-26 21:15:00,121.15,165.343,60.431999999999995,32.613 +2020-02-26 21:30:00,120.23,163.3,60.431999999999995,32.613 +2020-02-26 21:45:00,113.68,162.774,60.431999999999995,32.613 +2020-02-26 22:00:00,107.74,155.657,56.2,32.613 +2020-02-26 22:15:00,104.57,151.393,56.2,32.613 +2020-02-26 22:30:00,101.62,137.017,56.2,32.613 +2020-02-26 22:45:00,102.65,129.564,56.2,32.613 +2020-02-26 23:00:00,96.31,122.925,47.927,32.613 +2020-02-26 23:15:00,102.68,121.538,47.927,32.613 +2020-02-26 23:30:00,99.27,122.704,47.927,32.613 +2020-02-26 23:45:00,95.89,122.791,47.927,32.613 +2020-02-27 00:00:00,88.18,117.63799999999999,43.794,32.613 +2020-02-27 00:15:00,88.97,116.417,43.794,32.613 +2020-02-27 00:30:00,90.67,116.34,43.794,32.613 +2020-02-27 00:45:00,87.97,116.863,43.794,32.613 +2020-02-27 01:00:00,93.08,118.571,42.397,32.613 +2020-02-27 01:15:00,91.83,118.697,42.397,32.613 +2020-02-27 01:30:00,90.3,118.811,42.397,32.613 +2020-02-27 01:45:00,86.08,119.008,42.397,32.613 +2020-02-27 02:00:00,85.82,120.977,40.010999999999996,32.613 +2020-02-27 02:15:00,93.94,122.337,40.010999999999996,32.613 +2020-02-27 02:30:00,91.53,123.309,40.010999999999996,32.613 +2020-02-27 02:45:00,92.99,125.09700000000001,40.010999999999996,32.613 +2020-02-27 03:00:00,90.02,127.602,39.181,32.613 +2020-02-27 03:15:00,93.1,129.143,39.181,32.613 +2020-02-27 03:30:00,93.75,130.901,39.181,32.613 +2020-02-27 03:45:00,90.45,132.39600000000002,39.181,32.613 +2020-02-27 04:00:00,93.08,144.13299999999998,40.39,32.613 +2020-02-27 04:15:00,98.7,155.77700000000002,40.39,32.613 +2020-02-27 04:30:00,96.19,158.435,40.39,32.613 +2020-02-27 04:45:00,92.23,160.512,40.39,32.613 +2020-02-27 05:00:00,95.1,192.89700000000002,45.504,32.613 +2020-02-27 05:15:00,97.21,220.97400000000002,45.504,32.613 +2020-02-27 05:30:00,102.16,215.96099999999998,45.504,32.613 +2020-02-27 05:45:00,104.54,208.938,45.504,32.613 +2020-02-27 06:00:00,114.7,206.334,57.748000000000005,32.613 +2020-02-27 06:15:00,118.52,212.34400000000002,57.748000000000005,32.613 +2020-02-27 06:30:00,121.44,214.40099999999998,57.748000000000005,32.613 +2020-02-27 06:45:00,123.95,218.209,57.748000000000005,32.613 +2020-02-27 07:00:00,127.56,219.028,72.138,32.613 +2020-02-27 07:15:00,129.28,222.639,72.138,32.613 +2020-02-27 07:30:00,129.58,223.67,72.138,32.613 +2020-02-27 07:45:00,132.15,223.00799999999998,72.138,32.613 +2020-02-27 08:00:00,134.97,221.19,65.542,32.613 +2020-02-27 08:15:00,134.2,219.979,65.542,32.613 +2020-02-27 08:30:00,131.35,215.787,65.542,32.613 +2020-02-27 08:45:00,126.9,212.06,65.542,32.613 +2020-02-27 09:00:00,124.52,204.975,60.523,32.613 +2020-02-27 09:15:00,124.46,201.856,60.523,32.613 +2020-02-27 09:30:00,126.69,200.528,60.523,32.613 +2020-02-27 09:45:00,127.92,197.623,60.523,32.613 +2020-02-27 10:00:00,125.86,193.459,57.449,32.613 +2020-02-27 10:15:00,123.44,190.041,57.449,32.613 +2020-02-27 10:30:00,125.61,187.658,57.449,32.613 +2020-02-27 10:45:00,126.43,186.771,57.449,32.613 +2020-02-27 11:00:00,122.4,184.77200000000002,54.505,32.613 +2020-02-27 11:15:00,127.71,184.00900000000001,54.505,32.613 +2020-02-27 11:30:00,120.16,183.195,54.505,32.613 +2020-02-27 11:45:00,118.8,182.02900000000002,54.505,32.613 +2020-02-27 12:00:00,117.29,177.203,51.50899999999999,32.613 +2020-02-27 12:15:00,116.79,177.446,51.50899999999999,32.613 +2020-02-27 12:30:00,112.3,176.657,51.50899999999999,32.613 +2020-02-27 12:45:00,115.74,177.57299999999998,51.50899999999999,32.613 +2020-02-27 13:00:00,112.3,177.00599999999997,51.303999999999995,32.613 +2020-02-27 13:15:00,119.69,175.601,51.303999999999995,32.613 +2020-02-27 13:30:00,117.02,174.53799999999998,51.303999999999995,32.613 +2020-02-27 13:45:00,114.97,174.16,51.303999999999995,32.613 +2020-02-27 14:00:00,115.47,175.22400000000002,52.785,32.613 +2020-02-27 14:15:00,115.4,174.87900000000002,52.785,32.613 +2020-02-27 14:30:00,119.74,175.236,52.785,32.613 +2020-02-27 14:45:00,121.19,176.11900000000003,52.785,32.613 +2020-02-27 15:00:00,123.14,178.046,56.458999999999996,32.613 +2020-02-27 15:15:00,116.9,177.079,56.458999999999996,32.613 +2020-02-27 15:30:00,124.45,177.988,56.458999999999996,32.613 +2020-02-27 15:45:00,126.19,178.828,56.458999999999996,32.613 +2020-02-27 16:00:00,128.02,180.417,59.388000000000005,32.613 +2020-02-27 16:15:00,126.16,181.822,59.388000000000005,32.613 +2020-02-27 16:30:00,128.55,184.666,59.388000000000005,32.613 +2020-02-27 16:45:00,130.21,185.77599999999998,59.388000000000005,32.613 +2020-02-27 17:00:00,134.52,187.782,64.462,32.613 +2020-02-27 17:15:00,134.4,190.137,64.462,32.613 +2020-02-27 17:30:00,136.14,192.80200000000002,64.462,32.613 +2020-02-27 17:45:00,140.64,193.84400000000002,64.462,32.613 +2020-02-27 18:00:00,148.99,196.137,65.128,32.613 +2020-02-27 18:15:00,149.31,195.958,65.128,32.613 +2020-02-27 18:30:00,144.63,194.645,65.128,32.613 +2020-02-27 18:45:00,147.88,195.813,65.128,32.613 +2020-02-27 19:00:00,146.09,194.50400000000002,61.316,32.613 +2020-02-27 19:15:00,145.64,191.324,61.316,32.613 +2020-02-27 19:30:00,135.54,189.967,61.316,32.613 +2020-02-27 19:45:00,136.53,187.209,61.316,32.613 +2020-02-27 20:00:00,133.14,182.305,59.845,32.613 +2020-02-27 20:15:00,130.96,176.892,59.845,32.613 +2020-02-27 20:30:00,127.67,173.207,59.845,32.613 +2020-02-27 20:45:00,123.1,171.40099999999998,59.845,32.613 +2020-02-27 21:00:00,118.51,168.123,54.83,32.613 +2020-02-27 21:15:00,116.64,165.014,54.83,32.613 +2020-02-27 21:30:00,116.78,162.971,54.83,32.613 +2020-02-27 21:45:00,116.76,162.464,54.83,32.613 +2020-02-27 22:00:00,106.08,155.33,50.933,32.613 +2020-02-27 22:15:00,104.2,151.092,50.933,32.613 +2020-02-27 22:30:00,106.52,136.667,50.933,32.613 +2020-02-27 22:45:00,108.8,129.218,50.933,32.613 +2020-02-27 23:00:00,101.23,122.57600000000001,45.32899999999999,32.613 +2020-02-27 23:15:00,100.86,121.205,45.32899999999999,32.613 +2020-02-27 23:30:00,97.66,122.382,45.32899999999999,32.613 +2020-02-27 23:45:00,99.21,122.494,45.32899999999999,32.613 +2020-02-28 00:00:00,95.24,116.29,43.74,32.613 +2020-02-28 00:15:00,90.0,115.273,43.74,32.613 +2020-02-28 00:30:00,93.54,115.04799999999999,43.74,32.613 +2020-02-28 00:45:00,93.7,115.684,43.74,32.613 +2020-02-28 01:00:00,86.92,117.04,42.555,32.613 +2020-02-28 01:15:00,89.35,118.04700000000001,42.555,32.613 +2020-02-28 01:30:00,90.93,117.935,42.555,32.613 +2020-02-28 01:45:00,91.4,118.236,42.555,32.613 +2020-02-28 02:00:00,88.43,120.294,41.68600000000001,32.613 +2020-02-28 02:15:00,90.06,121.537,41.68600000000001,32.613 +2020-02-28 02:30:00,89.98,123.05,41.68600000000001,32.613 +2020-02-28 02:45:00,91.98,124.87200000000001,41.68600000000001,32.613 +2020-02-28 03:00:00,85.75,126.415,42.278999999999996,32.613 +2020-02-28 03:15:00,90.33,128.856,42.278999999999996,32.613 +2020-02-28 03:30:00,91.82,130.59,42.278999999999996,32.613 +2020-02-28 03:45:00,92.28,132.428,42.278999999999996,32.613 +2020-02-28 04:00:00,93.53,144.399,43.742,32.613 +2020-02-28 04:15:00,94.58,155.774,43.742,32.613 +2020-02-28 04:30:00,96.07,158.686,43.742,32.613 +2020-02-28 04:45:00,88.54,159.619,43.742,32.613 +2020-02-28 05:00:00,92.18,190.735,46.973,32.613 +2020-02-28 05:15:00,94.1,220.334,46.973,32.613 +2020-02-28 05:30:00,99.17,216.352,46.973,32.613 +2020-02-28 05:45:00,101.77,209.26,46.973,32.613 +2020-02-28 06:00:00,114.52,207.092,59.63399999999999,32.613 +2020-02-28 06:15:00,116.78,211.665,59.63399999999999,32.613 +2020-02-28 06:30:00,121.82,212.828,59.63399999999999,32.613 +2020-02-28 06:45:00,121.41,218.22,59.63399999999999,32.613 +2020-02-28 07:00:00,126.51,218.248,71.631,32.613 +2020-02-28 07:15:00,130.43,222.845,71.631,32.613 +2020-02-28 07:30:00,132.73,223.635,71.631,32.613 +2020-02-28 07:45:00,131.7,222.05599999999998,71.631,32.613 +2020-02-28 08:00:00,137.02,219.141,66.181,32.613 +2020-02-28 08:15:00,131.96,217.541,66.181,32.613 +2020-02-28 08:30:00,136.24,214.248,66.181,32.613 +2020-02-28 08:45:00,130.35,208.982,66.181,32.613 +2020-02-28 09:00:00,130.85,202.32,63.086000000000006,32.613 +2020-02-28 09:15:00,128.0,199.795,63.086000000000006,32.613 +2020-02-28 09:30:00,131.49,198.054,63.086000000000006,32.613 +2020-02-28 09:45:00,128.77,195.05700000000002,63.086000000000006,32.613 +2020-02-28 10:00:00,128.05,189.793,60.886,32.613 +2020-02-28 10:15:00,129.03,187.09,60.886,32.613 +2020-02-28 10:30:00,125.57,184.666,60.886,32.613 +2020-02-28 10:45:00,132.11,183.361,60.886,32.613 +2020-02-28 11:00:00,130.54,181.342,59.391000000000005,32.613 +2020-02-28 11:15:00,127.61,179.697,59.391000000000005,32.613 +2020-02-28 11:30:00,127.14,180.593,59.391000000000005,32.613 +2020-02-28 11:45:00,124.9,179.465,59.391000000000005,32.613 +2020-02-28 12:00:00,122.01,175.725,56.172,32.613 +2020-02-28 12:15:00,126.66,173.922,56.172,32.613 +2020-02-28 12:30:00,120.31,173.257,56.172,32.613 +2020-02-28 12:45:00,122.17,174.65400000000002,56.172,32.613 +2020-02-28 13:00:00,118.8,175.051,54.406000000000006,32.613 +2020-02-28 13:15:00,122.05,174.425,54.406000000000006,32.613 +2020-02-28 13:30:00,121.88,173.385,54.406000000000006,32.613 +2020-02-28 13:45:00,125.01,172.955,54.406000000000006,32.613 +2020-02-28 14:00:00,121.64,172.949,53.578,32.613 +2020-02-28 14:15:00,117.04,172.418,53.578,32.613 +2020-02-28 14:30:00,115.28,173.28900000000002,53.578,32.613 +2020-02-28 14:45:00,116.76,174.46099999999998,53.578,32.613 +2020-02-28 15:00:00,112.41,175.921,56.568999999999996,32.613 +2020-02-28 15:15:00,116.08,174.49099999999999,56.568999999999996,32.613 +2020-02-28 15:30:00,121.56,173.861,56.568999999999996,32.613 +2020-02-28 15:45:00,123.51,174.84599999999998,56.568999999999996,32.613 +2020-02-28 16:00:00,121.03,175.273,60.169,32.613 +2020-02-28 16:15:00,119.77,176.956,60.169,32.613 +2020-02-28 16:30:00,124.79,179.888,60.169,32.613 +2020-02-28 16:45:00,130.53,180.83700000000002,60.169,32.613 +2020-02-28 17:00:00,134.83,183.079,65.497,32.613 +2020-02-28 17:15:00,137.27,185.04,65.497,32.613 +2020-02-28 17:30:00,137.09,187.424,65.497,32.613 +2020-02-28 17:45:00,141.86,188.253,65.497,32.613 +2020-02-28 18:00:00,148.36,191.23,65.082,32.613 +2020-02-28 18:15:00,143.47,190.71599999999998,65.082,32.613 +2020-02-28 18:30:00,142.41,189.78599999999997,65.082,32.613 +2020-02-28 18:45:00,144.94,190.97799999999998,65.082,32.613 +2020-02-28 19:00:00,139.83,190.535,60.968,32.613 +2020-02-28 19:15:00,136.32,188.72099999999998,60.968,32.613 +2020-02-28 19:30:00,138.61,186.987,60.968,32.613 +2020-02-28 19:45:00,138.84,183.80200000000002,60.968,32.613 +2020-02-28 20:00:00,130.09,178.919,61.123000000000005,32.613 +2020-02-28 20:15:00,127.16,173.543,61.123000000000005,32.613 +2020-02-28 20:30:00,122.02,169.83,61.123000000000005,32.613 +2020-02-28 20:45:00,118.31,168.558,61.123000000000005,32.613 +2020-02-28 21:00:00,117.05,165.787,55.416000000000004,32.613 +2020-02-28 21:15:00,114.42,163.128,55.416000000000004,32.613 +2020-02-28 21:30:00,108.61,161.128,55.416000000000004,32.613 +2020-02-28 21:45:00,103.76,161.17600000000002,55.416000000000004,32.613 +2020-02-28 22:00:00,96.68,154.987,51.631,32.613 +2020-02-28 22:15:00,95.08,150.64,51.631,32.613 +2020-02-28 22:30:00,100.47,142.507,51.631,32.613 +2020-02-28 22:45:00,100.52,138.542,51.631,32.613 +2020-02-28 23:00:00,93.73,131.487,44.898,32.613 +2020-02-28 23:15:00,87.45,128.192,44.898,32.613 +2020-02-28 23:30:00,82.28,127.915,44.898,32.613 +2020-02-28 23:45:00,85.88,127.398,44.898,32.613 +2020-02-29 00:00:00,82.24,113.23700000000001,42.033,32.431999999999995 +2020-02-29 00:15:00,85.01,108.041,42.033,32.431999999999995 +2020-02-29 00:30:00,80.52,109.09299999999999,42.033,32.431999999999995 +2020-02-29 00:45:00,80.2,110.416,42.033,32.431999999999995 +2020-02-29 01:00:00,76.23,112.354,38.255,32.431999999999995 +2020-02-29 01:15:00,75.84,112.39200000000001,38.255,32.431999999999995 +2020-02-29 01:30:00,74.66,111.743,38.255,32.431999999999995 +2020-02-29 01:45:00,74.31,111.86,38.255,32.431999999999995 +2020-02-29 02:00:00,77.97,114.565,36.404,32.431999999999995 +2020-02-29 02:15:00,79.13,115.428,36.404,32.431999999999995 +2020-02-29 02:30:00,76.43,115.85600000000001,36.404,32.431999999999995 +2020-02-29 02:45:00,74.31,117.789,36.404,32.431999999999995 +2020-02-29 03:00:00,72.93,119.935,36.083,32.431999999999995 +2020-02-29 03:15:00,71.88,121.161,36.083,32.431999999999995 +2020-02-29 03:30:00,71.31,121.338,36.083,32.431999999999995 +2020-02-29 03:45:00,71.98,123.323,36.083,32.431999999999995 +2020-02-29 04:00:00,72.01,131.215,36.102,32.431999999999995 +2020-02-29 04:15:00,72.24,140.079,36.102,32.431999999999995 +2020-02-29 04:30:00,72.51,140.856,36.102,32.431999999999995 +2020-02-29 04:45:00,73.77,141.296,36.102,32.431999999999995 +2020-02-29 05:00:00,73.63,156.637,35.284,32.431999999999995 +2020-02-29 05:15:00,74.75,167.479,35.284,32.431999999999995 +2020-02-29 05:30:00,75.74,163.838,35.284,32.431999999999995 +2020-02-29 05:45:00,77.0,162.155,35.284,32.431999999999995 +2020-02-29 06:00:00,77.1,178.55700000000002,36.265,32.431999999999995 +2020-02-29 06:15:00,78.45,199.275,36.265,32.431999999999995 +2020-02-29 06:30:00,77.71,195.088,36.265,32.431999999999995 +2020-02-29 06:45:00,79.47,191.61700000000002,36.265,32.431999999999995 +2020-02-29 07:00:00,82.82,187.955,40.714,32.431999999999995 +2020-02-29 07:15:00,85.08,191.27,40.714,32.431999999999995 +2020-02-29 07:30:00,87.37,194.697,40.714,32.431999999999995 +2020-02-29 07:45:00,90.24,196.983,40.714,32.431999999999995 +2020-02-29 08:00:00,94.26,198.00400000000002,46.692,32.431999999999995 +2020-02-29 08:15:00,93.79,199.93099999999998,46.692,32.431999999999995 +2020-02-29 08:30:00,95.57,198.158,46.692,32.431999999999995 +2020-02-29 08:45:00,97.68,196.007,46.692,32.431999999999995 +2020-02-29 09:00:00,99.64,191.146,48.925,32.431999999999995 +2020-02-29 09:15:00,101.64,189.36700000000002,48.925,32.431999999999995 +2020-02-29 09:30:00,101.61,188.537,48.925,32.431999999999995 +2020-02-29 09:45:00,103.34,185.695,48.925,32.431999999999995 +2020-02-29 10:00:00,103.19,180.743,47.799,32.431999999999995 +2020-02-29 10:15:00,103.4,178.24599999999998,47.799,32.431999999999995 +2020-02-29 10:30:00,100.05,175.993,47.799,32.431999999999995 +2020-02-29 10:45:00,101.34,175.965,47.799,32.431999999999995 +2020-02-29 11:00:00,102.61,174.145,44.309,32.431999999999995 +2020-02-29 11:15:00,104.3,171.86900000000003,44.309,32.431999999999995 +2020-02-29 11:30:00,107.59,171.69299999999998,44.309,32.431999999999995 +2020-02-29 11:45:00,110.39,169.666,44.309,32.431999999999995 +2020-02-29 12:00:00,107.28,165.10299999999998,42.367,32.431999999999995 +2020-02-29 12:15:00,108.46,163.957,42.367,32.431999999999995 +2020-02-29 12:30:00,105.82,163.566,42.367,32.431999999999995 +2020-02-29 12:45:00,103.19,164.22799999999998,42.367,32.431999999999995 +2020-02-29 13:00:00,97.8,164.255,39.036,32.431999999999995 +2020-02-29 13:15:00,97.25,161.597,39.036,32.431999999999995 +2020-02-29 13:30:00,95.99,160.111,39.036,32.431999999999995 +2020-02-29 13:45:00,96.33,160.111,39.036,32.431999999999995 +2020-02-29 14:00:00,93.08,161.362,37.995,32.431999999999995 +2020-02-29 14:15:00,93.55,160.257,37.995,32.431999999999995 +2020-02-29 14:30:00,93.15,159.326,37.995,32.431999999999995 +2020-02-29 14:45:00,93.28,160.732,37.995,32.431999999999995 +2020-02-29 15:00:00,92.46,162.841,40.71,32.431999999999995 +2020-02-29 15:15:00,91.85,162.183,40.71,32.431999999999995 +2020-02-29 15:30:00,89.88,163.00799999999998,40.71,32.431999999999995 +2020-02-29 15:45:00,88.72,163.968,40.71,32.431999999999995 +2020-02-29 16:00:00,88.8,163.262,46.998000000000005,32.431999999999995 +2020-02-29 16:15:00,87.64,165.77700000000002,46.998000000000005,32.431999999999995 +2020-02-29 16:30:00,89.92,168.66400000000002,46.998000000000005,32.431999999999995 +2020-02-29 16:45:00,92.75,170.44299999999998,46.998000000000005,32.431999999999995 +2020-02-29 17:00:00,98.43,172.093,55.431000000000004,32.431999999999995 +2020-02-29 17:15:00,99.1,175.64,55.431000000000004,32.431999999999995 +2020-02-29 17:30:00,102.07,177.953,55.431000000000004,32.431999999999995 +2020-02-29 17:45:00,105.39,178.418,55.431000000000004,32.431999999999995 +2020-02-29 18:00:00,110.77,180.94299999999998,55.989,32.431999999999995 +2020-02-29 18:15:00,113.04,182.285,55.989,32.431999999999995 +2020-02-29 18:30:00,114.76,182.68,55.989,32.431999999999995 +2020-02-29 18:45:00,114.16,180.55700000000002,55.989,32.431999999999995 +2020-02-29 19:00:00,112.1,180.942,50.882,32.431999999999995 +2020-02-29 19:15:00,110.76,178.642,50.882,32.431999999999995 +2020-02-29 19:30:00,109.34,177.685,50.882,32.431999999999995 +2020-02-29 19:45:00,110.83,174.40099999999998,50.882,32.431999999999995 +2020-02-29 20:00:00,103.05,171.63400000000001,43.172,32.431999999999995 +2020-02-29 20:15:00,100.21,168.33700000000002,43.172,32.431999999999995 +2020-02-29 20:30:00,97.84,164.28599999999997,43.172,32.431999999999995 +2020-02-29 20:45:00,96.65,162.657,43.172,32.431999999999995 +2020-02-29 21:00:00,90.85,162.047,37.599000000000004,32.431999999999995 +2020-02-29 21:15:00,91.68,159.80200000000002,37.599000000000004,32.431999999999995 +2020-02-29 21:30:00,89.56,159.00799999999998,37.599000000000004,32.431999999999995 +2020-02-29 21:45:00,89.3,158.668,37.599000000000004,32.431999999999995 +2020-02-29 22:00:00,85.48,153.77700000000002,39.047,32.431999999999995 +2020-02-29 22:15:00,84.96,151.89,39.047,32.431999999999995 +2020-02-29 22:30:00,79.47,149.774,39.047,32.431999999999995 +2020-02-29 22:45:00,81.08,147.64600000000002,39.047,32.431999999999995 +2020-02-29 23:00:00,77.22,142.952,32.339,32.431999999999995 +2020-02-29 23:15:00,77.22,138.085,32.339,32.431999999999995 +2020-02-29 23:30:00,75.06,136.158,32.339,32.431999999999995 +2020-02-29 23:45:00,73.54,133.304,32.339,32.431999999999995 +2020-03-01 00:00:00,68.61,114.87200000000001,20.007,31.988000000000003 +2020-03-01 00:15:00,70.53,108.667,20.007,31.988000000000003 +2020-03-01 00:30:00,66.5,108.73299999999999,20.007,31.988000000000003 +2020-03-01 00:45:00,67.58,110.04899999999999,20.007,31.988000000000003 +2020-03-01 01:00:00,65.55,112.008,17.378,31.988000000000003 +2020-03-01 01:15:00,65.75,113.44200000000001,17.378,31.988000000000003 +2020-03-01 01:30:00,65.35,113.366,17.378,31.988000000000003 +2020-03-01 01:45:00,65.61,113.147,17.378,31.988000000000003 +2020-03-01 02:00:00,63.96,115.15,16.145,31.988000000000003 +2020-03-01 02:15:00,64.55,114.846,16.145,31.988000000000003 +2020-03-01 02:30:00,62.76,115.854,16.145,31.988000000000003 +2020-03-01 02:45:00,63.36,118.155,16.145,31.988000000000003 +2020-03-01 03:00:00,62.76,120.616,15.427999999999999,31.988000000000003 +2020-03-01 03:15:00,63.56,121.51,15.427999999999999,31.988000000000003 +2020-03-01 03:30:00,63.25,123.181,15.427999999999999,31.988000000000003 +2020-03-01 03:45:00,63.21,124.824,15.427999999999999,31.988000000000003 +2020-03-01 04:00:00,62.91,133.92700000000002,16.663,31.988000000000003 +2020-03-01 04:15:00,63.35,143.066,16.663,31.988000000000003 +2020-03-01 04:30:00,63.9,143.304,16.663,31.988000000000003 +2020-03-01 04:45:00,63.94,143.76,16.663,31.988000000000003 +2020-03-01 05:00:00,66.16,157.566,17.271,31.988000000000003 +2020-03-01 05:15:00,66.79,168.065,17.271,31.988000000000003 +2020-03-01 05:30:00,66.13,163.91,17.271,31.988000000000003 +2020-03-01 05:45:00,66.68,161.535,17.271,31.988000000000003 +2020-03-01 06:00:00,67.98,178.033,17.612000000000002,31.988000000000003 +2020-03-01 06:15:00,67.63,197.967,17.612000000000002,31.988000000000003 +2020-03-01 06:30:00,66.96,192.521,17.612000000000002,31.988000000000003 +2020-03-01 06:45:00,67.33,187.15599999999998,17.612000000000002,31.988000000000003 +2020-03-01 07:00:00,69.6,187.088,20.88,31.988000000000003 +2020-03-01 07:15:00,70.32,188.924,20.88,31.988000000000003 +2020-03-01 07:30:00,72.12,190.649,20.88,31.988000000000003 +2020-03-01 07:45:00,75.07,191.512,20.88,31.988000000000003 +2020-03-01 08:00:00,79.08,194.41400000000002,25.861,31.988000000000003 +2020-03-01 08:15:00,78.76,195.868,25.861,31.988000000000003 +2020-03-01 08:30:00,77.97,195.63400000000001,25.861,31.988000000000003 +2020-03-01 08:45:00,78.47,194.578,25.861,31.988000000000003 +2020-03-01 09:00:00,77.62,188.77599999999998,27.921999999999997,31.988000000000003 +2020-03-01 09:15:00,77.96,187.282,27.921999999999997,31.988000000000003 +2020-03-01 09:30:00,80.81,186.24200000000002,27.921999999999997,31.988000000000003 +2020-03-01 09:45:00,87.16,183.475,27.921999999999997,31.988000000000003 +2020-03-01 10:00:00,87.22,182.304,29.048000000000002,31.988000000000003 +2020-03-01 10:15:00,85.78,180.335,29.048000000000002,31.988000000000003 +2020-03-01 10:30:00,84.44,178.554,29.048000000000002,31.988000000000003 +2020-03-01 10:45:00,83.32,176.795,29.048000000000002,31.988000000000003 +2020-03-01 11:00:00,83.84,174.87900000000002,32.02,31.988000000000003 +2020-03-01 11:15:00,81.49,172.76,32.02,31.988000000000003 +2020-03-01 11:30:00,83.37,172.18400000000003,32.02,31.988000000000003 +2020-03-01 11:45:00,82.81,171.415,32.02,31.988000000000003 +2020-03-01 12:00:00,79.55,165.91400000000002,28.55,31.988000000000003 +2020-03-01 12:15:00,76.4,166.451,28.55,31.988000000000003 +2020-03-01 12:30:00,72.82,165.06900000000002,28.55,31.988000000000003 +2020-03-01 12:45:00,71.89,164.695,28.55,31.988000000000003 +2020-03-01 13:00:00,68.82,163.696,25.601999999999997,31.988000000000003 +2020-03-01 13:15:00,67.4,163.767,25.601999999999997,31.988000000000003 +2020-03-01 13:30:00,66.06,161.689,25.601999999999997,31.988000000000003 +2020-03-01 13:45:00,65.28,160.939,25.601999999999997,31.988000000000003 +2020-03-01 14:00:00,62.31,162.632,23.916999999999998,31.988000000000003 +2020-03-01 14:15:00,63.97,162.403,23.916999999999998,31.988000000000003 +2020-03-01 14:30:00,64.32,162.54399999999998,23.916999999999998,31.988000000000003 +2020-03-01 14:45:00,66.22,163.375,23.916999999999998,31.988000000000003 +2020-03-01 15:00:00,66.27,163.856,24.064,31.988000000000003 +2020-03-01 15:15:00,67.29,163.786,24.064,31.988000000000003 +2020-03-01 15:30:00,68.58,164.68200000000002,24.064,31.988000000000003 +2020-03-01 15:45:00,70.54,165.767,24.064,31.988000000000003 +2020-03-01 16:00:00,73.29,168.68,28.189,31.988000000000003 +2020-03-01 16:15:00,73.42,170.81400000000002,28.189,31.988000000000003 +2020-03-01 16:30:00,75.75,173.27599999999998,28.189,31.988000000000003 +2020-03-01 16:45:00,78.52,174.707,28.189,31.988000000000003 +2020-03-01 17:00:00,83.11,177.141,37.576,31.988000000000003 +2020-03-01 17:15:00,85.27,180.53900000000002,37.576,31.988000000000003 +2020-03-01 17:30:00,89.46,183.257,37.576,31.988000000000003 +2020-03-01 17:45:00,92.14,185.89,37.576,31.988000000000003 +2020-03-01 18:00:00,100.96,188.957,42.669,31.988000000000003 +2020-03-01 18:15:00,103.27,191.891,42.669,31.988000000000003 +2020-03-01 18:30:00,103.76,190.107,42.669,31.988000000000003 +2020-03-01 18:45:00,102.03,190.166,42.669,31.988000000000003 +2020-03-01 19:00:00,101.46,190.812,43.538999999999994,31.988000000000003 +2020-03-01 19:15:00,100.17,188.94400000000002,43.538999999999994,31.988000000000003 +2020-03-01 19:30:00,98.2,188.227,43.538999999999994,31.988000000000003 +2020-03-01 19:45:00,96.97,185.736,43.538999999999994,31.988000000000003 +2020-03-01 20:00:00,93.91,182.748,37.330999999999996,31.988000000000003 +2020-03-01 20:15:00,93.83,180.495,37.330999999999996,31.988000000000003 +2020-03-01 20:30:00,95.91,177.46200000000002,37.330999999999996,31.988000000000003 +2020-03-01 20:45:00,97.34,173.905,37.330999999999996,31.988000000000003 +2020-03-01 21:00:00,94.5,170.889,33.856,31.988000000000003 +2020-03-01 21:15:00,89.25,168.035,33.856,31.988000000000003 +2020-03-01 21:30:00,88.14,167.192,33.856,31.988000000000003 +2020-03-01 21:45:00,89.58,167.22400000000002,33.856,31.988000000000003 +2020-03-01 22:00:00,93.6,161.214,34.711999999999996,31.988000000000003 +2020-03-01 22:15:00,95.33,158.43200000000002,34.711999999999996,31.988000000000003 +2020-03-01 22:30:00,91.18,152.474,34.711999999999996,31.988000000000003 +2020-03-01 22:45:00,83.29,149.13299999999998,34.711999999999996,31.988000000000003 +2020-03-01 23:00:00,79.65,141.811,29.698,31.988000000000003 +2020-03-01 23:15:00,80.74,138.316,29.698,31.988000000000003 +2020-03-01 23:30:00,78.27,137.503,29.698,31.988000000000003 +2020-03-01 23:45:00,78.56,135.459,29.698,31.988000000000003 +2020-03-02 00:00:00,73.51,118.774,29.983,32.166 +2020-03-02 00:15:00,74.57,115.516,29.983,32.166 +2020-03-02 00:30:00,75.5,115.632,29.983,32.166 +2020-03-02 00:45:00,73.67,116.381,29.983,32.166 +2020-03-02 01:00:00,70.05,118.355,29.122,32.166 +2020-03-02 01:15:00,71.82,119.274,29.122,32.166 +2020-03-02 01:30:00,71.29,119.28299999999999,29.122,32.166 +2020-03-02 01:45:00,72.24,119.164,29.122,32.166 +2020-03-02 02:00:00,70.05,121.193,28.676,32.166 +2020-03-02 02:15:00,71.47,122.126,28.676,32.166 +2020-03-02 02:30:00,70.78,123.491,28.676,32.166 +2020-03-02 02:45:00,71.48,125.18299999999999,28.676,32.166 +2020-03-02 03:00:00,72.12,128.914,26.552,32.166 +2020-03-02 03:15:00,72.82,131.45600000000002,26.552,32.166 +2020-03-02 03:30:00,79.44,132.96200000000002,26.552,32.166 +2020-03-02 03:45:00,81.71,134.03,26.552,32.166 +2020-03-02 04:00:00,82.58,147.67700000000002,27.44,32.166 +2020-03-02 04:15:00,77.82,161.142,27.44,32.166 +2020-03-02 04:30:00,78.23,163.398,27.44,32.166 +2020-03-02 04:45:00,79.38,164.046,27.44,32.166 +2020-03-02 05:00:00,83.72,193.69400000000002,36.825,32.166 +2020-03-02 05:15:00,86.57,224.705,36.825,32.166 +2020-03-02 05:30:00,91.53,220.55200000000002,36.825,32.166 +2020-03-02 05:45:00,96.44,212.40900000000002,36.825,32.166 +2020-03-02 06:00:00,105.65,210.571,56.589,32.166 +2020-03-02 06:15:00,110.56,215.197,56.589,32.166 +2020-03-02 06:30:00,114.34,217.84599999999998,56.589,32.166 +2020-03-02 06:45:00,116.99,221.528,56.589,32.166 +2020-03-02 07:00:00,121.98,223.831,67.49,32.166 +2020-03-02 07:15:00,122.83,227.138,67.49,32.166 +2020-03-02 07:30:00,126.26,227.96599999999998,67.49,32.166 +2020-03-02 07:45:00,128.48,226.40400000000002,67.49,32.166 +2020-03-02 08:00:00,132.44,224.445,60.028,32.166 +2020-03-02 08:15:00,131.31,223.685,60.028,32.166 +2020-03-02 08:30:00,130.5,219.38,60.028,32.166 +2020-03-02 08:45:00,131.43,215.199,60.028,32.166 +2020-03-02 09:00:00,130.52,208.362,55.018,32.166 +2020-03-02 09:15:00,130.5,203.305,55.018,32.166 +2020-03-02 09:30:00,131.0,201.21099999999998,55.018,32.166 +2020-03-02 09:45:00,131.43,198.43,55.018,32.166 +2020-03-02 10:00:00,129.42,196.28799999999998,51.183,32.166 +2020-03-02 10:15:00,130.46,194.028,51.183,32.166 +2020-03-02 10:30:00,130.39,191.372,51.183,32.166 +2020-03-02 10:45:00,130.17,190.128,51.183,32.166 +2020-03-02 11:00:00,128.8,185.782,50.065,32.166 +2020-03-02 11:15:00,130.97,185.44099999999997,50.065,32.166 +2020-03-02 11:30:00,128.5,186.283,50.065,32.166 +2020-03-02 11:45:00,124.59,185.206,50.065,32.166 +2020-03-02 12:00:00,122.07,181.143,48.141999999999996,32.166 +2020-03-02 12:15:00,122.75,181.709,48.141999999999996,32.166 +2020-03-02 12:30:00,118.19,180.388,48.141999999999996,32.166 +2020-03-02 12:45:00,115.54,181.465,48.141999999999996,32.166 +2020-03-02 13:00:00,116.29,181.18,47.887,32.166 +2020-03-02 13:15:00,111.2,179.791,47.887,32.166 +2020-03-02 13:30:00,110.44,177.218,47.887,32.166 +2020-03-02 13:45:00,110.06,176.62,47.887,32.166 +2020-03-02 14:00:00,109.1,177.69,48.571000000000005,32.166 +2020-03-02 14:15:00,110.15,176.90200000000002,48.571000000000005,32.166 +2020-03-02 14:30:00,109.82,176.476,48.571000000000005,32.166 +2020-03-02 14:45:00,112.38,177.563,48.571000000000005,32.166 +2020-03-02 15:00:00,111.64,179.74599999999998,49.937,32.166 +2020-03-02 15:15:00,113.26,178.19299999999998,49.937,32.166 +2020-03-02 15:30:00,112.72,178.34599999999998,49.937,32.166 +2020-03-02 15:45:00,112.81,178.925,49.937,32.166 +2020-03-02 16:00:00,116.61,182.183,52.963,32.166 +2020-03-02 16:15:00,115.99,183.57299999999998,52.963,32.166 +2020-03-02 16:30:00,117.48,185.021,52.963,32.166 +2020-03-02 16:45:00,119.0,185.275,52.963,32.166 +2020-03-02 17:00:00,121.05,187.44,61.163999999999994,32.166 +2020-03-02 17:15:00,122.51,189.97299999999998,61.163999999999994,32.166 +2020-03-02 17:30:00,125.34,192.14,61.163999999999994,32.166 +2020-03-02 17:45:00,127.45,193.295,61.163999999999994,32.166 +2020-03-02 18:00:00,135.41,196.68599999999998,63.788999999999994,32.166 +2020-03-02 18:15:00,136.15,197.328,63.788999999999994,32.166 +2020-03-02 18:30:00,135.28,196.08900000000003,63.788999999999994,32.166 +2020-03-02 18:45:00,134.36,197.226,63.788999999999994,32.166 +2020-03-02 19:00:00,134.74,196.25,63.913000000000004,32.166 +2020-03-02 19:15:00,130.7,193.407,63.913000000000004,32.166 +2020-03-02 19:30:00,131.09,193.16099999999997,63.913000000000004,32.166 +2020-03-02 19:45:00,129.93,189.833,63.913000000000004,32.166 +2020-03-02 20:00:00,119.75,184.363,65.44,32.166 +2020-03-02 20:15:00,118.41,179.88299999999998,65.44,32.166 +2020-03-02 20:30:00,115.33,175.128,65.44,32.166 +2020-03-02 20:45:00,114.14,173.17700000000002,65.44,32.166 +2020-03-02 21:00:00,109.07,170.59400000000002,59.117,32.166 +2020-03-02 21:15:00,111.01,166.644,59.117,32.166 +2020-03-02 21:30:00,109.45,165.047,59.117,32.166 +2020-03-02 21:45:00,107.16,164.585,59.117,32.166 +2020-03-02 22:00:00,100.92,155.52,52.301,32.166 +2020-03-02 22:15:00,97.41,151.71,52.301,32.166 +2020-03-02 22:30:00,99.9,135.923,52.301,32.166 +2020-03-02 22:45:00,100.06,127.88600000000001,52.301,32.166 +2020-03-02 23:00:00,94.69,121.306,44.373000000000005,32.166 +2020-03-02 23:15:00,88.97,120.22200000000001,44.373000000000005,32.166 +2020-03-02 23:30:00,89.49,122.09700000000001,44.373000000000005,32.166 +2020-03-02 23:45:00,90.65,122.587,44.373000000000005,32.166 +2020-03-03 00:00:00,87.69,117.507,44.647,32.166 +2020-03-03 00:15:00,84.07,115.73299999999999,44.647,32.166 +2020-03-03 00:30:00,82.45,115.021,44.647,32.166 +2020-03-03 00:45:00,85.5,114.936,44.647,32.166 +2020-03-03 01:00:00,80.31,116.613,41.433,32.166 +2020-03-03 01:15:00,82.7,117.10600000000001,41.433,32.166 +2020-03-03 01:30:00,78.1,117.24600000000001,41.433,32.166 +2020-03-03 01:45:00,76.78,117.35700000000001,41.433,32.166 +2020-03-03 02:00:00,76.01,119.30799999999999,39.909,32.166 +2020-03-03 02:15:00,76.06,120.281,39.909,32.166 +2020-03-03 02:30:00,76.62,121.045,39.909,32.166 +2020-03-03 02:45:00,81.97,122.792,39.909,32.166 +2020-03-03 03:00:00,84.87,125.315,39.14,32.166 +2020-03-03 03:15:00,86.31,127.15700000000001,39.14,32.166 +2020-03-03 03:30:00,84.73,129.112,39.14,32.166 +2020-03-03 03:45:00,85.5,130.22899999999998,39.14,32.166 +2020-03-03 04:00:00,87.66,143.525,40.015,32.166 +2020-03-03 04:15:00,81.89,156.649,40.015,32.166 +2020-03-03 04:30:00,83.96,158.612,40.015,32.166 +2020-03-03 04:45:00,89.14,160.464,40.015,32.166 +2020-03-03 05:00:00,95.89,195.05900000000003,44.93600000000001,32.166 +2020-03-03 05:15:00,98.73,225.97400000000002,44.93600000000001,32.166 +2020-03-03 05:30:00,97.84,220.359,44.93600000000001,32.166 +2020-03-03 05:45:00,100.51,212.12900000000002,44.93600000000001,32.166 +2020-03-03 06:00:00,109.28,209.315,57.271,32.166 +2020-03-03 06:15:00,112.89,215.521,57.271,32.166 +2020-03-03 06:30:00,115.24,217.46900000000002,57.271,32.166 +2020-03-03 06:45:00,116.92,220.65599999999998,57.271,32.166 +2020-03-03 07:00:00,122.15,222.825,68.352,32.166 +2020-03-03 07:15:00,122.86,225.91299999999998,68.352,32.166 +2020-03-03 07:30:00,126.57,226.18099999999998,68.352,32.166 +2020-03-03 07:45:00,127.79,224.65400000000002,68.352,32.166 +2020-03-03 08:00:00,131.05,222.767,60.717,32.166 +2020-03-03 08:15:00,131.16,220.958,60.717,32.166 +2020-03-03 08:30:00,135.43,216.447,60.717,32.166 +2020-03-03 08:45:00,132.45,211.908,60.717,32.166 +2020-03-03 09:00:00,132.71,204.32299999999998,54.603,32.166 +2020-03-03 09:15:00,132.5,200.79,54.603,32.166 +2020-03-03 09:30:00,134.11,199.454,54.603,32.166 +2020-03-03 09:45:00,133.41,196.642,54.603,32.166 +2020-03-03 10:00:00,133.09,193.778,52.308,32.166 +2020-03-03 10:15:00,132.43,190.52200000000002,52.308,32.166 +2020-03-03 10:30:00,133.18,188.062,52.308,32.166 +2020-03-03 10:45:00,133.76,187.234,52.308,32.166 +2020-03-03 11:00:00,132.89,184.308,51.838,32.166 +2020-03-03 11:15:00,134.16,183.71599999999998,51.838,32.166 +2020-03-03 11:30:00,133.77,183.303,51.838,32.166 +2020-03-03 11:45:00,134.04,182.864,51.838,32.166 +2020-03-03 12:00:00,134.11,177.479,50.375,32.166 +2020-03-03 12:15:00,134.12,177.7,50.375,32.166 +2020-03-03 12:30:00,134.26,177.143,50.375,32.166 +2020-03-03 12:45:00,136.24,178.02,50.375,32.166 +2020-03-03 13:00:00,132.14,177.34099999999998,50.735,32.166 +2020-03-03 13:15:00,140.28,175.817,50.735,32.166 +2020-03-03 13:30:00,140.27,174.355,50.735,32.166 +2020-03-03 13:45:00,138.43,173.81900000000002,50.735,32.166 +2020-03-03 14:00:00,134.59,175.283,50.946000000000005,32.166 +2020-03-03 14:15:00,133.48,174.597,50.946000000000005,32.166 +2020-03-03 14:30:00,134.65,174.787,50.946000000000005,32.166 +2020-03-03 14:45:00,139.12,175.71,50.946000000000005,32.166 +2020-03-03 15:00:00,138.89,177.46400000000003,53.18,32.166 +2020-03-03 15:15:00,136.59,176.313,53.18,32.166 +2020-03-03 15:30:00,133.42,176.609,53.18,32.166 +2020-03-03 15:45:00,137.07,176.822,53.18,32.166 +2020-03-03 16:00:00,138.31,180.324,54.928999999999995,32.166 +2020-03-03 16:15:00,137.5,182.165,54.928999999999995,32.166 +2020-03-03 16:30:00,135.91,184.196,54.928999999999995,32.166 +2020-03-03 16:45:00,136.81,184.774,54.928999999999995,32.166 +2020-03-03 17:00:00,138.14,187.50099999999998,60.913000000000004,32.166 +2020-03-03 17:15:00,138.47,190.11700000000002,60.913000000000004,32.166 +2020-03-03 17:30:00,137.24,192.908,60.913000000000004,32.166 +2020-03-03 17:45:00,134.06,193.915,60.913000000000004,32.166 +2020-03-03 18:00:00,143.82,197.111,62.214,32.166 +2020-03-03 18:15:00,146.3,197.49,62.214,32.166 +2020-03-03 18:30:00,146.08,195.918,62.214,32.166 +2020-03-03 18:45:00,142.83,197.834,62.214,32.166 +2020-03-03 19:00:00,139.19,196.77599999999998,62.38,32.166 +2020-03-03 19:15:00,140.37,193.695,62.38,32.166 +2020-03-03 19:30:00,138.01,192.799,62.38,32.166 +2020-03-03 19:45:00,138.66,189.58599999999998,62.38,32.166 +2020-03-03 20:00:00,130.23,184.28,65.018,32.166 +2020-03-03 20:15:00,123.06,179.03400000000002,65.018,32.166 +2020-03-03 20:30:00,123.81,175.297,65.018,32.166 +2020-03-03 20:45:00,121.62,172.838,65.018,32.166 +2020-03-03 21:00:00,117.63,169.65599999999998,56.416000000000004,32.166 +2020-03-03 21:15:00,108.75,166.425,56.416000000000004,32.166 +2020-03-03 21:30:00,108.4,164.136,56.416000000000004,32.166 +2020-03-03 21:45:00,110.03,163.938,56.416000000000004,32.166 +2020-03-03 22:00:00,107.4,156.55,52.846000000000004,32.166 +2020-03-03 22:15:00,105.11,152.477,52.846000000000004,32.166 +2020-03-03 22:30:00,98.01,136.759,52.846000000000004,32.166 +2020-03-03 22:45:00,100.15,128.994,52.846000000000004,32.166 +2020-03-03 23:00:00,96.08,122.352,44.435,32.166 +2020-03-03 23:15:00,96.98,120.572,44.435,32.166 +2020-03-03 23:30:00,89.43,122.104,44.435,32.166 +2020-03-03 23:45:00,87.12,122.21,44.435,32.166 +2020-03-04 00:00:00,83.92,117.189,42.527,32.166 +2020-03-04 00:15:00,81.31,115.42299999999999,42.527,32.166 +2020-03-04 00:30:00,85.2,114.69200000000001,42.527,32.166 +2020-03-04 00:45:00,87.61,114.60600000000001,42.527,32.166 +2020-03-04 01:00:00,83.3,116.243,38.655,32.166 +2020-03-04 01:15:00,83.96,116.72,38.655,32.166 +2020-03-04 01:30:00,76.44,116.84299999999999,38.655,32.166 +2020-03-04 01:45:00,77.77,116.96,38.655,32.166 +2020-03-04 02:00:00,77.27,118.90100000000001,36.912,32.166 +2020-03-04 02:15:00,79.12,119.867,36.912,32.166 +2020-03-04 02:30:00,84.05,120.65,36.912,32.166 +2020-03-04 02:45:00,84.7,122.397,36.912,32.166 +2020-03-04 03:00:00,84.41,124.934,36.98,32.166 +2020-03-04 03:15:00,86.26,126.76,36.98,32.166 +2020-03-04 03:30:00,87.42,128.709,36.98,32.166 +2020-03-04 03:45:00,87.57,129.838,36.98,32.166 +2020-03-04 04:00:00,83.7,143.138,38.052,32.166 +2020-03-04 04:15:00,88.51,156.252,38.052,32.166 +2020-03-04 04:30:00,91.89,158.232,38.052,32.166 +2020-03-04 04:45:00,93.1,160.07,38.052,32.166 +2020-03-04 05:00:00,91.47,194.645,42.455,32.166 +2020-03-04 05:15:00,91.38,225.574,42.455,32.166 +2020-03-04 05:30:00,94.97,219.925,42.455,32.166 +2020-03-04 05:45:00,95.93,211.703,42.455,32.166 +2020-03-04 06:00:00,106.42,208.898,57.986000000000004,32.166 +2020-03-04 06:15:00,112.03,215.11,57.986000000000004,32.166 +2020-03-04 06:30:00,114.0,217.014,57.986000000000004,32.166 +2020-03-04 06:45:00,116.7,220.18200000000002,57.986000000000004,32.166 +2020-03-04 07:00:00,122.52,222.373,71.868,32.166 +2020-03-04 07:15:00,122.72,225.428,71.868,32.166 +2020-03-04 07:30:00,125.92,225.655,71.868,32.166 +2020-03-04 07:45:00,128.12,224.088,71.868,32.166 +2020-03-04 08:00:00,130.83,222.176,62.225,32.166 +2020-03-04 08:15:00,130.93,220.354,62.225,32.166 +2020-03-04 08:30:00,131.84,215.78599999999997,62.225,32.166 +2020-03-04 08:45:00,131.99,211.262,62.225,32.166 +2020-03-04 09:00:00,133.12,203.688,58.802,32.166 +2020-03-04 09:15:00,134.1,200.15599999999998,58.802,32.166 +2020-03-04 09:30:00,135.91,198.843,58.802,32.166 +2020-03-04 09:45:00,136.01,196.042,58.802,32.166 +2020-03-04 10:00:00,135.9,193.19299999999998,54.122,32.166 +2020-03-04 10:15:00,137.47,189.97799999999998,54.122,32.166 +2020-03-04 10:30:00,136.84,187.53799999999998,54.122,32.166 +2020-03-04 10:45:00,137.85,186.72799999999998,54.122,32.166 +2020-03-04 11:00:00,137.84,183.793,54.368,32.166 +2020-03-04 11:15:00,140.09,183.22099999999998,54.368,32.166 +2020-03-04 11:30:00,140.75,182.81599999999997,54.368,32.166 +2020-03-04 11:45:00,141.22,182.395,54.368,32.166 +2020-03-04 12:00:00,138.17,177.02900000000002,52.74,32.166 +2020-03-04 12:15:00,140.05,177.263,52.74,32.166 +2020-03-04 12:30:00,136.13,176.671,52.74,32.166 +2020-03-04 12:45:00,137.21,177.545,52.74,32.166 +2020-03-04 13:00:00,141.05,176.90599999999998,52.544,32.166 +2020-03-04 13:15:00,139.87,175.361,52.544,32.166 +2020-03-04 13:30:00,125.18,173.886,52.544,32.166 +2020-03-04 13:45:00,130.05,173.352,52.544,32.166 +2020-03-04 14:00:00,125.22,174.88400000000001,53.602,32.166 +2020-03-04 14:15:00,123.48,174.174,53.602,32.166 +2020-03-04 14:30:00,126.37,174.335,53.602,32.166 +2020-03-04 14:45:00,123.47,175.268,53.602,32.166 +2020-03-04 15:00:00,121.69,177.032,55.59,32.166 +2020-03-04 15:15:00,118.75,175.852,55.59,32.166 +2020-03-04 15:30:00,116.12,176.09900000000002,55.59,32.166 +2020-03-04 15:45:00,120.18,176.293,55.59,32.166 +2020-03-04 16:00:00,123.92,179.798,57.586999999999996,32.166 +2020-03-04 16:15:00,124.85,181.62099999999998,57.586999999999996,32.166 +2020-03-04 16:30:00,120.88,183.65099999999998,57.586999999999996,32.166 +2020-03-04 16:45:00,124.56,184.18900000000002,57.586999999999996,32.166 +2020-03-04 17:00:00,128.84,186.928,62.111999999999995,32.166 +2020-03-04 17:15:00,130.98,189.553,62.111999999999995,32.166 +2020-03-04 17:30:00,131.7,192.36900000000003,62.111999999999995,32.166 +2020-03-04 17:45:00,131.94,193.391,62.111999999999995,32.166 +2020-03-04 18:00:00,138.98,196.595,64.605,32.166 +2020-03-04 18:15:00,143.1,197.04,64.605,32.166 +2020-03-04 18:30:00,145.58,195.46400000000003,64.605,32.166 +2020-03-04 18:45:00,139.57,197.40099999999998,64.605,32.166 +2020-03-04 19:00:00,139.2,196.305,65.55199999999999,32.166 +2020-03-04 19:15:00,137.12,193.24200000000002,65.55199999999999,32.166 +2020-03-04 19:30:00,134.82,192.373,65.55199999999999,32.166 +2020-03-04 19:45:00,128.94,189.204,65.55199999999999,32.166 +2020-03-04 20:00:00,125.39,183.868,66.778,32.166 +2020-03-04 20:15:00,122.78,178.637,66.778,32.166 +2020-03-04 20:30:00,118.75,174.926,66.778,32.166 +2020-03-04 20:45:00,115.74,172.472,66.778,32.166 +2020-03-04 21:00:00,113.84,169.278,56.103,32.166 +2020-03-04 21:15:00,111.66,166.042,56.103,32.166 +2020-03-04 21:30:00,107.31,163.753,56.103,32.166 +2020-03-04 21:45:00,108.44,163.576,56.103,32.166 +2020-03-04 22:00:00,103.17,156.173,51.371,32.166 +2020-03-04 22:15:00,102.87,152.128,51.371,32.166 +2020-03-04 22:30:00,93.83,136.359,51.371,32.166 +2020-03-04 22:45:00,94.61,128.59799999999998,51.371,32.166 +2020-03-04 23:00:00,93.52,121.95,42.798,32.166 +2020-03-04 23:15:00,93.24,120.189,42.798,32.166 +2020-03-04 23:30:00,86.27,121.729,42.798,32.166 +2020-03-04 23:45:00,85.18,121.86,42.798,32.166 +2020-03-05 00:00:00,79.97,116.865,39.069,32.166 +2020-03-05 00:15:00,84.04,115.10700000000001,39.069,32.166 +2020-03-05 00:30:00,86.08,114.35799999999999,39.069,32.166 +2020-03-05 00:45:00,83.85,114.272,39.069,32.166 +2020-03-05 01:00:00,75.83,115.867,37.043,32.166 +2020-03-05 01:15:00,75.46,116.329,37.043,32.166 +2020-03-05 01:30:00,75.14,116.435,37.043,32.166 +2020-03-05 01:45:00,77.65,116.557,37.043,32.166 +2020-03-05 02:00:00,79.69,118.488,34.625,32.166 +2020-03-05 02:15:00,80.69,119.448,34.625,32.166 +2020-03-05 02:30:00,78.05,120.249,34.625,32.166 +2020-03-05 02:45:00,76.94,121.99700000000001,34.625,32.166 +2020-03-05 03:00:00,77.22,124.545,33.812,32.166 +2020-03-05 03:15:00,82.54,126.35600000000001,33.812,32.166 +2020-03-05 03:30:00,83.02,128.299,33.812,32.166 +2020-03-05 03:45:00,80.83,129.442,33.812,32.166 +2020-03-05 04:00:00,78.0,142.746,35.236999999999995,32.166 +2020-03-05 04:15:00,80.68,155.84799999999998,35.236999999999995,32.166 +2020-03-05 04:30:00,86.04,157.845,35.236999999999995,32.166 +2020-03-05 04:45:00,87.84,159.67,35.236999999999995,32.166 +2020-03-05 05:00:00,88.83,194.22299999999998,40.375,32.166 +2020-03-05 05:15:00,87.34,225.169,40.375,32.166 +2020-03-05 05:30:00,90.9,219.488,40.375,32.166 +2020-03-05 05:45:00,95.15,211.271,40.375,32.166 +2020-03-05 06:00:00,105.43,208.47400000000002,52.316,32.166 +2020-03-05 06:15:00,108.76,214.69299999999998,52.316,32.166 +2020-03-05 06:30:00,115.44,216.55200000000002,52.316,32.166 +2020-03-05 06:45:00,115.49,219.701,52.316,32.166 +2020-03-05 07:00:00,122.69,221.91299999999998,64.115,32.166 +2020-03-05 07:15:00,123.41,224.935,64.115,32.166 +2020-03-05 07:30:00,125.69,225.12099999999998,64.115,32.166 +2020-03-05 07:45:00,128.28,223.512,64.115,32.166 +2020-03-05 08:00:00,131.57,221.575,55.033,32.166 +2020-03-05 08:15:00,127.69,219.74099999999999,55.033,32.166 +2020-03-05 08:30:00,127.62,215.11599999999999,55.033,32.166 +2020-03-05 08:45:00,124.84,210.609,55.033,32.166 +2020-03-05 09:00:00,122.67,203.046,49.411,32.166 +2020-03-05 09:15:00,118.75,199.516,49.411,32.166 +2020-03-05 09:30:00,118.32,198.22400000000002,49.411,32.166 +2020-03-05 09:45:00,118.04,195.43599999999998,49.411,32.166 +2020-03-05 10:00:00,117.58,192.6,45.82899999999999,32.166 +2020-03-05 10:15:00,119.95,189.429,45.82899999999999,32.166 +2020-03-05 10:30:00,123.89,187.00799999999998,45.82899999999999,32.166 +2020-03-05 10:45:00,127.92,186.218,45.82899999999999,32.166 +2020-03-05 11:00:00,124.66,183.275,44.333,32.166 +2020-03-05 11:15:00,129.25,182.722,44.333,32.166 +2020-03-05 11:30:00,117.7,182.325,44.333,32.166 +2020-03-05 11:45:00,115.76,181.92,44.333,32.166 +2020-03-05 12:00:00,113.86,176.574,42.95,32.166 +2020-03-05 12:15:00,115.98,176.822,42.95,32.166 +2020-03-05 12:30:00,123.05,176.19299999999998,42.95,32.166 +2020-03-05 12:45:00,119.31,177.06400000000002,42.95,32.166 +2020-03-05 13:00:00,116.22,176.468,42.489,32.166 +2020-03-05 13:15:00,117.03,174.90099999999998,42.489,32.166 +2020-03-05 13:30:00,114.34,173.41299999999998,42.489,32.166 +2020-03-05 13:45:00,112.98,172.882,42.489,32.166 +2020-03-05 14:00:00,120.48,174.481,43.448,32.166 +2020-03-05 14:15:00,125.43,173.747,43.448,32.166 +2020-03-05 14:30:00,124.36,173.87900000000002,43.448,32.166 +2020-03-05 14:45:00,124.56,174.821,43.448,32.166 +2020-03-05 15:00:00,120.75,176.59599999999998,45.994,32.166 +2020-03-05 15:15:00,117.53,175.38400000000001,45.994,32.166 +2020-03-05 15:30:00,125.28,175.582,45.994,32.166 +2020-03-05 15:45:00,123.67,175.75900000000001,45.994,32.166 +2020-03-05 16:00:00,124.31,179.269,48.167,32.166 +2020-03-05 16:15:00,121.46,181.071,48.167,32.166 +2020-03-05 16:30:00,123.02,183.101,48.167,32.166 +2020-03-05 16:45:00,120.81,183.59900000000002,48.167,32.166 +2020-03-05 17:00:00,126.49,186.34900000000002,52.637,32.166 +2020-03-05 17:15:00,122.58,188.981,52.637,32.166 +2020-03-05 17:30:00,129.9,191.82299999999998,52.637,32.166 +2020-03-05 17:45:00,131.12,192.859,52.637,32.166 +2020-03-05 18:00:00,136.47,196.07,55.739,32.166 +2020-03-05 18:15:00,136.58,196.582,55.739,32.166 +2020-03-05 18:30:00,142.41,195.00400000000002,55.739,32.166 +2020-03-05 18:45:00,143.42,196.96200000000002,55.739,32.166 +2020-03-05 19:00:00,139.76,195.826,56.36600000000001,32.166 +2020-03-05 19:15:00,135.79,192.78099999999998,56.36600000000001,32.166 +2020-03-05 19:30:00,135.89,191.94099999999997,56.36600000000001,32.166 +2020-03-05 19:45:00,135.03,188.817,56.36600000000001,32.166 +2020-03-05 20:00:00,127.56,183.449,56.338,32.166 +2020-03-05 20:15:00,124.1,178.235,56.338,32.166 +2020-03-05 20:30:00,121.73,174.549,56.338,32.166 +2020-03-05 20:45:00,117.92,172.1,56.338,32.166 +2020-03-05 21:00:00,109.2,168.894,49.894,32.166 +2020-03-05 21:15:00,112.76,165.65400000000002,49.894,32.166 +2020-03-05 21:30:00,110.85,163.363,49.894,32.166 +2020-03-05 21:45:00,109.7,163.209,49.894,32.166 +2020-03-05 22:00:00,101.13,155.79,46.687,32.166 +2020-03-05 22:15:00,103.31,151.773,46.687,32.166 +2020-03-05 22:30:00,97.08,135.952,46.687,32.166 +2020-03-05 22:45:00,93.57,128.194,46.687,32.166 +2020-03-05 23:00:00,85.09,121.542,39.211,32.166 +2020-03-05 23:15:00,84.64,119.8,39.211,32.166 +2020-03-05 23:30:00,87.76,121.34700000000001,39.211,32.166 +2020-03-05 23:45:00,89.95,121.505,39.211,32.166 +2020-03-06 00:00:00,84.63,115.272,36.616,32.166 +2020-03-06 00:15:00,80.02,113.741,36.616,32.166 +2020-03-06 00:30:00,78.58,112.88,36.616,32.166 +2020-03-06 00:45:00,79.97,112.955,36.616,32.166 +2020-03-06 01:00:00,81.81,114.15899999999999,33.799,32.166 +2020-03-06 01:15:00,83.15,115.40899999999999,33.799,32.166 +2020-03-06 01:30:00,81.42,115.375,33.799,32.166 +2020-03-06 01:45:00,77.29,115.573,33.799,32.166 +2020-03-06 02:00:00,77.39,117.697,32.968,32.166 +2020-03-06 02:15:00,82.75,118.53200000000001,32.968,32.166 +2020-03-06 02:30:00,81.3,119.965,32.968,32.166 +2020-03-06 02:45:00,79.68,121.664,32.968,32.166 +2020-03-06 03:00:00,75.05,123.374,33.533,32.166 +2020-03-06 03:15:00,80.07,125.906,33.533,32.166 +2020-03-06 03:30:00,83.67,127.795,33.533,32.166 +2020-03-06 03:45:00,79.45,129.38,33.533,32.166 +2020-03-06 04:00:00,79.38,142.92700000000002,36.102,32.166 +2020-03-06 04:15:00,84.68,155.572,36.102,32.166 +2020-03-06 04:30:00,86.79,157.934,36.102,32.166 +2020-03-06 04:45:00,89.75,158.566,36.102,32.166 +2020-03-06 05:00:00,92.61,191.81799999999998,42.423,32.166 +2020-03-06 05:15:00,88.55,224.35,42.423,32.166 +2020-03-06 05:30:00,92.57,219.692,42.423,32.166 +2020-03-06 05:45:00,95.18,211.36,42.423,32.166 +2020-03-06 06:00:00,105.52,209.019,55.38,32.166 +2020-03-06 06:15:00,109.46,213.859,55.38,32.166 +2020-03-06 06:30:00,110.06,214.83900000000003,55.38,32.166 +2020-03-06 06:45:00,112.44,219.50599999999997,55.38,32.166 +2020-03-06 07:00:00,119.09,221.028,65.929,32.166 +2020-03-06 07:15:00,120.34,225.11900000000003,65.929,32.166 +2020-03-06 07:30:00,122.16,224.828,65.929,32.166 +2020-03-06 07:45:00,120.89,222.30200000000002,65.929,32.166 +2020-03-06 08:00:00,121.85,219.385,57.336999999999996,32.166 +2020-03-06 08:15:00,119.68,217.25400000000002,57.336999999999996,32.166 +2020-03-06 08:30:00,118.65,213.497,57.336999999999996,32.166 +2020-03-06 08:45:00,117.07,207.47099999999998,57.336999999999996,32.166 +2020-03-06 09:00:00,115.36,200.05700000000002,54.226000000000006,32.166 +2020-03-06 09:15:00,114.99,197.328,54.226000000000006,32.166 +2020-03-06 09:30:00,115.37,195.553,54.226000000000006,32.166 +2020-03-06 09:45:00,113.84,192.722,54.226000000000006,32.166 +2020-03-06 10:00:00,112.66,188.787,51.298,32.166 +2020-03-06 10:15:00,112.59,186.301,51.298,32.166 +2020-03-06 10:30:00,112.69,183.907,51.298,32.166 +2020-03-06 10:45:00,112.62,182.697,51.298,32.166 +2020-03-06 11:00:00,113.85,179.75400000000002,50.839,32.166 +2020-03-06 11:15:00,114.18,178.215,50.839,32.166 +2020-03-06 11:30:00,118.13,179.46599999999998,50.839,32.166 +2020-03-06 11:45:00,115.78,178.99900000000002,50.839,32.166 +2020-03-06 12:00:00,110.8,174.774,47.976000000000006,32.166 +2020-03-06 12:15:00,112.96,172.933,47.976000000000006,32.166 +2020-03-06 12:30:00,107.04,172.435,47.976000000000006,32.166 +2020-03-06 12:45:00,113.31,173.696,47.976000000000006,32.166 +2020-03-06 13:00:00,113.56,174.122,46.299,32.166 +2020-03-06 13:15:00,112.83,173.34400000000002,46.299,32.166 +2020-03-06 13:30:00,105.26,171.96599999999998,46.299,32.166 +2020-03-06 13:45:00,104.96,171.417,46.299,32.166 +2020-03-06 14:00:00,107.2,171.88099999999997,44.971000000000004,32.166 +2020-03-06 14:15:00,110.28,171.00900000000001,44.971000000000004,32.166 +2020-03-06 14:30:00,113.01,171.803,44.971000000000004,32.166 +2020-03-06 14:45:00,113.67,172.955,44.971000000000004,32.166 +2020-03-06 15:00:00,114.93,174.264,47.48,32.166 +2020-03-06 15:15:00,115.31,172.56799999999998,47.48,32.166 +2020-03-06 15:30:00,123.49,171.176,47.48,32.166 +2020-03-06 15:45:00,122.8,171.57299999999998,47.48,32.166 +2020-03-06 16:00:00,120.52,173.84599999999998,50.648,32.166 +2020-03-06 16:15:00,118.33,175.97,50.648,32.166 +2020-03-06 16:30:00,123.41,178.06799999999998,50.648,32.166 +2020-03-06 16:45:00,122.27,178.315,50.648,32.166 +2020-03-06 17:00:00,123.95,181.48,56.251000000000005,32.166 +2020-03-06 17:15:00,125.14,183.697,56.251000000000005,32.166 +2020-03-06 17:30:00,127.64,186.27200000000002,56.251000000000005,32.166 +2020-03-06 17:45:00,129.37,187.075,56.251000000000005,32.166 +2020-03-06 18:00:00,132.2,190.96,58.982,32.166 +2020-03-06 18:15:00,134.17,191.049,58.982,32.166 +2020-03-06 18:30:00,139.97,189.84,58.982,32.166 +2020-03-06 18:45:00,137.26,191.862,58.982,32.166 +2020-03-06 19:00:00,138.5,191.671,57.293,32.166 +2020-03-06 19:15:00,136.65,190.049,57.293,32.166 +2020-03-06 19:30:00,132.19,188.83900000000003,57.293,32.166 +2020-03-06 19:45:00,128.54,185.19400000000002,57.293,32.166 +2020-03-06 20:00:00,118.1,179.83599999999998,59.433,32.166 +2020-03-06 20:15:00,120.22,174.738,59.433,32.166 +2020-03-06 20:30:00,116.22,170.981,59.433,32.166 +2020-03-06 20:45:00,116.32,168.983,59.433,32.166 +2020-03-06 21:00:00,108.24,166.416,52.153999999999996,32.166 +2020-03-06 21:15:00,107.96,163.793,52.153999999999996,32.166 +2020-03-06 21:30:00,103.98,161.531,52.153999999999996,32.166 +2020-03-06 21:45:00,100.92,161.953,52.153999999999996,32.166 +2020-03-06 22:00:00,101.26,155.461,47.125,32.166 +2020-03-06 22:15:00,98.2,151.312,47.125,32.166 +2020-03-06 22:30:00,91.48,142.222,47.125,32.166 +2020-03-06 22:45:00,86.47,138.014,47.125,32.166 +2020-03-06 23:00:00,80.32,131.118,41.236000000000004,32.166 +2020-03-06 23:15:00,86.56,127.30799999999999,41.236000000000004,32.166 +2020-03-06 23:30:00,86.05,127.225,41.236000000000004,32.166 +2020-03-06 23:45:00,84.17,126.74600000000001,41.236000000000004,32.166 +2020-03-07 00:00:00,73.36,112.361,36.484,31.988000000000003 +2020-03-07 00:15:00,72.69,106.662,36.484,31.988000000000003 +2020-03-07 00:30:00,73.63,106.99600000000001,36.484,31.988000000000003 +2020-03-07 00:45:00,79.66,107.662,36.484,31.988000000000003 +2020-03-07 01:00:00,75.3,109.476,32.391999999999996,31.988000000000003 +2020-03-07 01:15:00,77.06,109.846,32.391999999999996,31.988000000000003 +2020-03-07 01:30:00,71.97,109.189,32.391999999999996,31.988000000000003 +2020-03-07 01:45:00,68.58,109.354,32.391999999999996,31.988000000000003 +2020-03-07 02:00:00,73.65,112.00200000000001,30.194000000000003,31.988000000000003 +2020-03-07 02:15:00,74.65,112.369,30.194000000000003,31.988000000000003 +2020-03-07 02:30:00,69.35,112.65100000000001,30.194000000000003,31.988000000000003 +2020-03-07 02:45:00,68.86,114.555,30.194000000000003,31.988000000000003 +2020-03-07 03:00:00,66.7,116.7,29.677,31.988000000000003 +2020-03-07 03:15:00,65.75,117.959,29.677,31.988000000000003 +2020-03-07 03:30:00,70.1,118.37299999999999,29.677,31.988000000000003 +2020-03-07 03:45:00,72.68,120.28200000000001,29.677,31.988000000000003 +2020-03-07 04:00:00,68.24,129.653,29.616,31.988000000000003 +2020-03-07 04:15:00,67.2,139.745,29.616,31.988000000000003 +2020-03-07 04:30:00,67.39,139.829,29.616,31.988000000000003 +2020-03-07 04:45:00,67.69,140.015,29.616,31.988000000000003 +2020-03-07 05:00:00,67.99,156.97299999999998,29.625,31.988000000000003 +2020-03-07 05:15:00,67.86,169.953,29.625,31.988000000000003 +2020-03-07 05:30:00,67.93,165.817,29.625,31.988000000000003 +2020-03-07 05:45:00,70.0,163.291,29.625,31.988000000000003 +2020-03-07 06:00:00,71.25,180.298,30.551,31.988000000000003 +2020-03-07 06:15:00,72.04,201.768,30.551,31.988000000000003 +2020-03-07 06:30:00,72.04,197.2,30.551,31.988000000000003 +2020-03-07 06:45:00,72.96,192.865,30.551,31.988000000000003 +2020-03-07 07:00:00,76.51,190.575,34.865,31.988000000000003 +2020-03-07 07:15:00,78.94,193.25799999999998,34.865,31.988000000000003 +2020-03-07 07:30:00,81.32,195.71099999999998,34.865,31.988000000000003 +2020-03-07 07:45:00,83.9,197.054,34.865,31.988000000000003 +2020-03-07 08:00:00,88.57,197.97299999999998,41.456,31.988000000000003 +2020-03-07 08:15:00,89.24,199.271,41.456,31.988000000000003 +2020-03-07 08:30:00,90.68,196.99599999999998,41.456,31.988000000000003 +2020-03-07 08:45:00,94.24,194.13099999999997,41.456,31.988000000000003 +2020-03-07 09:00:00,96.14,188.805,43.001999999999995,31.988000000000003 +2020-03-07 09:15:00,98.61,186.863,43.001999999999995,31.988000000000003 +2020-03-07 09:30:00,102.12,186.041,43.001999999999995,31.988000000000003 +2020-03-07 09:45:00,98.59,183.317,43.001999999999995,31.988000000000003 +2020-03-07 10:00:00,95.01,179.757,42.047,31.988000000000003 +2020-03-07 10:15:00,98.58,177.519,42.047,31.988000000000003 +2020-03-07 10:30:00,95.97,175.25900000000001,42.047,31.988000000000003 +2020-03-07 10:45:00,94.89,175.265,42.047,31.988000000000003 +2020-03-07 11:00:00,95.66,172.479,39.894,31.988000000000003 +2020-03-07 11:15:00,96.05,170.417,39.894,31.988000000000003 +2020-03-07 11:30:00,95.32,170.642,39.894,31.988000000000003 +2020-03-07 11:45:00,95.01,169.364,39.894,31.988000000000003 +2020-03-07 12:00:00,95.78,164.368,38.122,31.988000000000003 +2020-03-07 12:15:00,92.41,163.267,38.122,31.988000000000003 +2020-03-07 12:30:00,86.18,163.02,38.122,31.988000000000003 +2020-03-07 12:45:00,85.12,163.636,38.122,31.988000000000003 +2020-03-07 13:00:00,87.26,163.61,34.645,31.988000000000003 +2020-03-07 13:15:00,86.32,160.77,34.645,31.988000000000003 +2020-03-07 13:30:00,84.06,158.97299999999998,34.645,31.988000000000003 +2020-03-07 13:45:00,83.33,158.702,34.645,31.988000000000003 +2020-03-07 14:00:00,77.3,160.404,33.739000000000004,31.988000000000003 +2020-03-07 14:15:00,74.26,158.83,33.739000000000004,31.988000000000003 +2020-03-07 14:30:00,74.73,157.79399999999998,33.739000000000004,31.988000000000003 +2020-03-07 14:45:00,79.24,159.227,33.739000000000004,31.988000000000003 +2020-03-07 15:00:00,79.87,161.2,35.908,31.988000000000003 +2020-03-07 15:15:00,82.29,160.34,35.908,31.988000000000003 +2020-03-07 15:30:00,82.88,160.406,35.908,31.988000000000003 +2020-03-07 15:45:00,83.16,160.681,35.908,31.988000000000003 +2020-03-07 16:00:00,82.92,162.039,39.249,31.988000000000003 +2020-03-07 16:15:00,82.39,164.894,39.249,31.988000000000003 +2020-03-07 16:30:00,84.42,166.97,39.249,31.988000000000003 +2020-03-07 16:45:00,86.48,168.02700000000002,39.249,31.988000000000003 +2020-03-07 17:00:00,89.54,170.46900000000002,46.045,31.988000000000003 +2020-03-07 17:15:00,90.25,174.037,46.045,31.988000000000003 +2020-03-07 17:30:00,93.17,176.52700000000002,46.045,31.988000000000003 +2020-03-07 17:45:00,96.07,177.01,46.045,31.988000000000003 +2020-03-07 18:00:00,104.2,180.562,48.238,31.988000000000003 +2020-03-07 18:15:00,106.48,182.665,48.238,31.988000000000003 +2020-03-07 18:30:00,104.75,182.90599999999998,48.238,31.988000000000003 +2020-03-07 18:45:00,105.22,181.287,48.238,31.988000000000003 +2020-03-07 19:00:00,104.77,181.752,46.785,31.988000000000003 +2020-03-07 19:15:00,101.74,179.548,46.785,31.988000000000003 +2020-03-07 19:30:00,103.23,179.188,46.785,31.988000000000003 +2020-03-07 19:45:00,99.14,175.61599999999999,46.785,31.988000000000003 +2020-03-07 20:00:00,94.25,172.472,39.830999999999996,31.988000000000003 +2020-03-07 20:15:00,90.48,169.38,39.830999999999996,31.988000000000003 +2020-03-07 20:30:00,88.69,165.206,39.830999999999996,31.988000000000003 +2020-03-07 20:45:00,89.27,163.041,39.830999999999996,31.988000000000003 +2020-03-07 21:00:00,82.31,162.475,34.063,31.988000000000003 +2020-03-07 21:15:00,82.13,160.234,34.063,31.988000000000003 +2020-03-07 21:30:00,81.1,159.189,34.063,31.988000000000003 +2020-03-07 21:45:00,79.97,159.173,34.063,31.988000000000003 +2020-03-07 22:00:00,79.99,153.97299999999998,34.455999999999996,31.988000000000003 +2020-03-07 22:15:00,77.88,152.298,34.455999999999996,31.988000000000003 +2020-03-07 22:30:00,73.56,149.155,34.455999999999996,31.988000000000003 +2020-03-07 22:45:00,72.67,146.81,34.455999999999996,31.988000000000003 +2020-03-07 23:00:00,68.96,142.2,27.840999999999998,31.988000000000003 +2020-03-07 23:15:00,69.09,136.869,27.840999999999998,31.988000000000003 +2020-03-07 23:30:00,65.76,135.38299999999998,27.840999999999998,31.988000000000003 +2020-03-07 23:45:00,64.86,132.614,27.840999999999998,31.988000000000003 +2020-03-08 00:00:00,61.0,112.605,20.007,31.988000000000003 +2020-03-08 00:15:00,61.21,106.455,20.007,31.988000000000003 +2020-03-08 00:30:00,60.15,106.39200000000001,20.007,31.988000000000003 +2020-03-08 00:45:00,60.07,107.709,20.007,31.988000000000003 +2020-03-08 01:00:00,56.47,109.37899999999999,17.378,31.988000000000003 +2020-03-08 01:15:00,57.99,110.706,17.378,31.988000000000003 +2020-03-08 01:30:00,57.0,110.509,17.378,31.988000000000003 +2020-03-08 01:45:00,57.02,110.329,17.378,31.988000000000003 +2020-03-08 02:00:00,55.64,112.262,16.145,31.988000000000003 +2020-03-08 02:15:00,56.91,111.91,16.145,31.988000000000003 +2020-03-08 02:30:00,56.07,113.05,16.145,31.988000000000003 +2020-03-08 02:45:00,56.9,115.353,16.145,31.988000000000003 +2020-03-08 03:00:00,55.26,117.9,15.427999999999999,31.988000000000003 +2020-03-08 03:15:00,55.5,118.68299999999999,15.427999999999999,31.988000000000003 +2020-03-08 03:30:00,53.73,120.31200000000001,15.427999999999999,31.988000000000003 +2020-03-08 03:45:00,55.64,122.04700000000001,15.427999999999999,31.988000000000003 +2020-03-08 04:00:00,55.63,131.18200000000002,16.663,31.988000000000003 +2020-03-08 04:15:00,54.6,140.243,16.663,31.988000000000003 +2020-03-08 04:30:00,57.94,140.602,16.663,31.988000000000003 +2020-03-08 04:45:00,58.3,140.964,16.663,31.988000000000003 +2020-03-08 05:00:00,57.65,154.621,17.271,31.988000000000003 +2020-03-08 05:15:00,59.1,165.231,17.271,31.988000000000003 +2020-03-08 05:30:00,56.61,160.846,17.271,31.988000000000003 +2020-03-08 05:45:00,59.93,158.515,17.271,31.988000000000003 +2020-03-08 06:00:00,60.8,175.07299999999998,17.612000000000002,31.988000000000003 +2020-03-08 06:15:00,60.98,195.047,17.612000000000002,31.988000000000003 +2020-03-08 06:30:00,59.58,189.28799999999998,17.612000000000002,31.988000000000003 +2020-03-08 06:45:00,60.73,183.79,17.612000000000002,31.988000000000003 +2020-03-08 07:00:00,64.07,183.87099999999998,20.88,31.988000000000003 +2020-03-08 07:15:00,64.46,185.475,20.88,31.988000000000003 +2020-03-08 07:30:00,67.14,186.912,20.88,31.988000000000003 +2020-03-08 07:45:00,68.83,187.49099999999999,20.88,31.988000000000003 +2020-03-08 08:00:00,70.55,190.217,25.861,31.988000000000003 +2020-03-08 08:15:00,68.92,191.58599999999998,25.861,31.988000000000003 +2020-03-08 08:30:00,68.33,190.949,25.861,31.988000000000003 +2020-03-08 08:45:00,68.61,190.00599999999997,25.861,31.988000000000003 +2020-03-08 09:00:00,68.42,184.287,27.921999999999997,31.988000000000003 +2020-03-08 09:15:00,68.88,182.803,27.921999999999997,31.988000000000003 +2020-03-08 09:30:00,66.98,181.915,27.921999999999997,31.988000000000003 +2020-03-08 09:45:00,66.66,179.234,27.921999999999997,31.988000000000003 +2020-03-08 10:00:00,63.08,178.157,29.048000000000002,31.988000000000003 +2020-03-08 10:15:00,65.17,176.486,29.048000000000002,31.988000000000003 +2020-03-08 10:30:00,66.69,174.85,29.048000000000002,31.988000000000003 +2020-03-08 10:45:00,68.39,173.22299999999998,29.048000000000002,31.988000000000003 +2020-03-08 11:00:00,71.32,171.245,32.02,31.988000000000003 +2020-03-08 11:15:00,68.73,169.267,32.02,31.988000000000003 +2020-03-08 11:30:00,77.87,168.745,32.02,31.988000000000003 +2020-03-08 11:45:00,71.63,168.09599999999998,32.02,31.988000000000003 +2020-03-08 12:00:00,69.65,162.733,28.55,31.988000000000003 +2020-03-08 12:15:00,64.14,163.364,28.55,31.988000000000003 +2020-03-08 12:30:00,62.02,161.72799999999998,28.55,31.988000000000003 +2020-03-08 12:45:00,62.53,161.333,28.55,31.988000000000003 +2020-03-08 13:00:00,59.46,160.626,25.601999999999997,31.988000000000003 +2020-03-08 13:15:00,59.09,160.545,25.601999999999997,31.988000000000003 +2020-03-08 13:30:00,59.19,158.379,25.601999999999997,31.988000000000003 +2020-03-08 13:45:00,58.93,157.644,25.601999999999997,31.988000000000003 +2020-03-08 14:00:00,57.84,159.814,23.916999999999998,31.988000000000003 +2020-03-08 14:15:00,58.46,159.417,23.916999999999998,31.988000000000003 +2020-03-08 14:30:00,62.93,159.34799999999998,23.916999999999998,31.988000000000003 +2020-03-08 14:45:00,64.38,160.249,23.916999999999998,31.988000000000003 +2020-03-08 15:00:00,64.9,160.799,24.064,31.988000000000003 +2020-03-08 15:15:00,65.65,160.517,24.064,31.988000000000003 +2020-03-08 15:30:00,65.05,161.069,24.064,31.988000000000003 +2020-03-08 15:45:00,64.57,162.025,24.064,31.988000000000003 +2020-03-08 16:00:00,67.33,164.97099999999998,28.189,31.988000000000003 +2020-03-08 16:15:00,68.58,166.96400000000003,28.189,31.988000000000003 +2020-03-08 16:30:00,68.93,169.421,28.189,31.988000000000003 +2020-03-08 16:45:00,71.27,170.572,28.189,31.988000000000003 +2020-03-08 17:00:00,75.95,173.09099999999998,37.576,31.988000000000003 +2020-03-08 17:15:00,76.2,176.542,37.576,31.988000000000003 +2020-03-08 17:30:00,80.05,179.435,37.576,31.988000000000003 +2020-03-08 17:45:00,83.25,182.171,37.576,31.988000000000003 +2020-03-08 18:00:00,91.18,185.28900000000002,42.669,31.988000000000003 +2020-03-08 18:15:00,96.39,188.69400000000002,42.669,31.988000000000003 +2020-03-08 18:30:00,101.38,186.88299999999998,42.669,31.988000000000003 +2020-03-08 18:45:00,99.75,187.09,42.669,31.988000000000003 +2020-03-08 19:00:00,97.87,187.46400000000003,43.538999999999994,31.988000000000003 +2020-03-08 19:15:00,93.75,185.72400000000002,43.538999999999994,31.988000000000003 +2020-03-08 19:30:00,94.16,185.204,43.538999999999994,31.988000000000003 +2020-03-08 19:45:00,89.68,183.025,43.538999999999994,31.988000000000003 +2020-03-08 20:00:00,86.68,179.81799999999998,37.330999999999996,31.988000000000003 +2020-03-08 20:15:00,86.27,177.678,37.330999999999996,31.988000000000003 +2020-03-08 20:30:00,83.87,174.827,37.330999999999996,31.988000000000003 +2020-03-08 20:45:00,83.17,171.297,37.330999999999996,31.988000000000003 +2020-03-08 21:00:00,79.53,168.202,33.856,31.988000000000003 +2020-03-08 21:15:00,80.3,165.315,33.856,31.988000000000003 +2020-03-08 21:30:00,80.55,164.47400000000002,33.856,31.988000000000003 +2020-03-08 21:45:00,85.28,164.655,33.856,31.988000000000003 +2020-03-08 22:00:00,87.48,158.54,34.711999999999996,31.988000000000003 +2020-03-08 22:15:00,88.51,155.951,34.711999999999996,31.988000000000003 +2020-03-08 22:30:00,83.88,149.629,34.711999999999996,31.988000000000003 +2020-03-08 22:45:00,79.79,146.309,34.711999999999996,31.988000000000003 +2020-03-08 23:00:00,77.62,138.95,29.698,31.988000000000003 +2020-03-08 23:15:00,82.04,135.591,29.698,31.988000000000003 +2020-03-08 23:30:00,82.56,134.83100000000002,29.698,31.988000000000003 +2020-03-08 23:45:00,79.6,132.972,29.698,31.988000000000003 +2020-03-09 00:00:00,69.92,116.464,29.983,32.166 +2020-03-09 00:15:00,69.51,113.265,29.983,32.166 +2020-03-09 00:30:00,71.32,113.25399999999999,29.983,32.166 +2020-03-09 00:45:00,76.73,114.006,29.983,32.166 +2020-03-09 01:00:00,74.19,115.686,29.122,32.166 +2020-03-09 01:15:00,73.87,116.49700000000001,29.122,32.166 +2020-03-09 01:30:00,67.47,116.385,29.122,32.166 +2020-03-09 01:45:00,74.37,116.307,29.122,32.166 +2020-03-09 02:00:00,73.55,118.26100000000001,28.676,32.166 +2020-03-09 02:15:00,73.53,119.147,28.676,32.166 +2020-03-09 02:30:00,68.13,120.645,28.676,32.166 +2020-03-09 02:45:00,75.19,122.339,28.676,32.166 +2020-03-09 03:00:00,75.27,126.15799999999999,26.552,32.166 +2020-03-09 03:15:00,74.56,128.586,26.552,32.166 +2020-03-09 03:30:00,73.02,130.048,26.552,32.166 +2020-03-09 03:45:00,77.49,131.209,26.552,32.166 +2020-03-09 04:00:00,77.93,144.89,27.44,32.166 +2020-03-09 04:15:00,76.54,158.27700000000002,27.44,32.166 +2020-03-09 04:30:00,74.57,160.655,27.44,32.166 +2020-03-09 04:45:00,74.33,161.21,27.44,32.166 +2020-03-09 05:00:00,77.36,190.71,36.825,32.166 +2020-03-09 05:15:00,83.26,221.83599999999998,36.825,32.166 +2020-03-09 05:30:00,86.0,217.45,36.825,32.166 +2020-03-09 05:45:00,90.09,209.351,36.825,32.166 +2020-03-09 06:00:00,98.46,207.56799999999998,56.589,32.166 +2020-03-09 06:15:00,105.15,212.236,56.589,32.166 +2020-03-09 06:30:00,108.02,214.56799999999998,56.589,32.166 +2020-03-09 06:45:00,110.16,218.112,56.589,32.166 +2020-03-09 07:00:00,117.31,220.562,67.49,32.166 +2020-03-09 07:15:00,118.79,223.637,67.49,32.166 +2020-03-09 07:30:00,122.33,224.175,67.49,32.166 +2020-03-09 07:45:00,125.44,222.329,67.49,32.166 +2020-03-09 08:00:00,129.3,220.19400000000002,60.028,32.166 +2020-03-09 08:15:00,131.05,219.35,60.028,32.166 +2020-03-09 08:30:00,131.67,214.641,60.028,32.166 +2020-03-09 08:45:00,131.5,210.576,60.028,32.166 +2020-03-09 09:00:00,130.44,203.824,55.018,32.166 +2020-03-09 09:15:00,134.15,198.77900000000002,55.018,32.166 +2020-03-09 09:30:00,133.09,196.834,55.018,32.166 +2020-03-09 09:45:00,134.3,194.14,55.018,32.166 +2020-03-09 10:00:00,134.13,192.09400000000002,51.183,32.166 +2020-03-09 10:15:00,134.12,190.137,51.183,32.166 +2020-03-09 10:30:00,134.67,187.627,51.183,32.166 +2020-03-09 10:45:00,135.0,186.516,51.183,32.166 +2020-03-09 11:00:00,136.7,182.109,50.065,32.166 +2020-03-09 11:15:00,135.2,181.91099999999997,50.065,32.166 +2020-03-09 11:30:00,135.31,182.80700000000002,50.065,32.166 +2020-03-09 11:45:00,135.27,181.85299999999998,50.065,32.166 +2020-03-09 12:00:00,136.04,177.92700000000002,48.141999999999996,32.166 +2020-03-09 12:15:00,132.64,178.58700000000002,48.141999999999996,32.166 +2020-03-09 12:30:00,129.11,177.00799999999998,48.141999999999996,32.166 +2020-03-09 12:45:00,129.79,178.063,48.141999999999996,32.166 +2020-03-09 13:00:00,129.66,178.076,47.887,32.166 +2020-03-09 13:15:00,129.83,176.532,47.887,32.166 +2020-03-09 13:30:00,129.35,173.873,47.887,32.166 +2020-03-09 13:45:00,131.32,173.292,47.887,32.166 +2020-03-09 14:00:00,126.29,174.843,48.571000000000005,32.166 +2020-03-09 14:15:00,126.44,173.885,48.571000000000005,32.166 +2020-03-09 14:30:00,128.22,173.24599999999998,48.571000000000005,32.166 +2020-03-09 14:45:00,126.5,174.40099999999998,48.571000000000005,32.166 +2020-03-09 15:00:00,128.64,176.653,49.937,32.166 +2020-03-09 15:15:00,125.18,174.889,49.937,32.166 +2020-03-09 15:30:00,124.09,174.695,49.937,32.166 +2020-03-09 15:45:00,123.67,175.143,49.937,32.166 +2020-03-09 16:00:00,123.86,178.435,52.963,32.166 +2020-03-09 16:15:00,124.07,179.68099999999998,52.963,32.166 +2020-03-09 16:30:00,120.57,181.12400000000002,52.963,32.166 +2020-03-09 16:45:00,120.89,181.093,52.963,32.166 +2020-03-09 17:00:00,124.22,183.347,61.163999999999994,32.166 +2020-03-09 17:15:00,125.08,185.929,61.163999999999994,32.166 +2020-03-09 17:30:00,129.2,188.269,61.163999999999994,32.166 +2020-03-09 17:45:00,128.13,189.52599999999998,61.163999999999994,32.166 +2020-03-09 18:00:00,133.54,192.96599999999998,63.788999999999994,32.166 +2020-03-09 18:15:00,133.96,194.083,63.788999999999994,32.166 +2020-03-09 18:30:00,130.46,192.81599999999997,63.788999999999994,32.166 +2020-03-09 18:45:00,129.43,194.101,63.788999999999994,32.166 +2020-03-09 19:00:00,129.65,192.855,63.913000000000004,32.166 +2020-03-09 19:15:00,125.51,190.139,63.913000000000004,32.166 +2020-03-09 19:30:00,133.89,190.092,63.913000000000004,32.166 +2020-03-09 19:45:00,132.07,187.08,63.913000000000004,32.166 +2020-03-09 20:00:00,119.33,181.389,65.44,32.166 +2020-03-09 20:15:00,115.81,177.02200000000002,65.44,32.166 +2020-03-09 20:30:00,115.88,172.455,65.44,32.166 +2020-03-09 20:45:00,115.72,170.53,65.44,32.166 +2020-03-09 21:00:00,105.11,167.868,59.117,32.166 +2020-03-09 21:15:00,99.97,163.887,59.117,32.166 +2020-03-09 21:30:00,98.25,162.292,59.117,32.166 +2020-03-09 21:45:00,97.32,161.97799999999998,59.117,32.166 +2020-03-09 22:00:00,92.52,152.808,52.301,32.166 +2020-03-09 22:15:00,93.86,149.191,52.301,32.166 +2020-03-09 22:30:00,94.64,133.034,52.301,32.166 +2020-03-09 22:45:00,93.49,125.016,52.301,32.166 +2020-03-09 23:00:00,89.93,118.40299999999999,44.373000000000005,32.166 +2020-03-09 23:15:00,83.28,117.455,44.373000000000005,32.166 +2020-03-09 23:30:00,82.01,119.383,44.373000000000005,32.166 +2020-03-09 23:45:00,79.61,120.059,44.373000000000005,32.166 +2020-03-10 00:00:00,79.63,115.156,44.647,32.166 +2020-03-10 00:15:00,81.95,113.444,44.647,32.166 +2020-03-10 00:30:00,80.93,112.60600000000001,44.647,32.166 +2020-03-10 00:45:00,77.16,112.525,44.647,32.166 +2020-03-10 01:00:00,70.56,113.905,41.433,32.166 +2020-03-10 01:15:00,76.76,114.289,41.433,32.166 +2020-03-10 01:30:00,76.51,114.307,41.433,32.166 +2020-03-10 01:45:00,79.3,114.46,41.433,32.166 +2020-03-10 02:00:00,73.31,116.336,39.909,32.166 +2020-03-10 02:15:00,73.05,117.262,39.909,32.166 +2020-03-10 02:30:00,69.81,118.15700000000001,39.909,32.166 +2020-03-10 02:45:00,71.57,119.90700000000001,39.909,32.166 +2020-03-10 03:00:00,71.1,122.52,39.14,32.166 +2020-03-10 03:15:00,78.8,124.244,39.14,32.166 +2020-03-10 03:30:00,81.43,126.156,39.14,32.166 +2020-03-10 03:45:00,78.81,127.365,39.14,32.166 +2020-03-10 04:00:00,75.34,140.69799999999998,40.015,32.166 +2020-03-10 04:15:00,74.56,153.744,40.015,32.166 +2020-03-10 04:30:00,80.72,155.83,40.015,32.166 +2020-03-10 04:45:00,85.85,157.588,40.015,32.166 +2020-03-10 05:00:00,89.69,192.037,44.93600000000001,32.166 +2020-03-10 05:15:00,87.41,223.071,44.93600000000001,32.166 +2020-03-10 05:30:00,94.15,217.21900000000002,44.93600000000001,32.166 +2020-03-10 05:45:00,99.31,209.03400000000002,44.93600000000001,32.166 +2020-03-10 06:00:00,108.19,206.273,57.271,32.166 +2020-03-10 06:15:00,106.67,212.52,57.271,32.166 +2020-03-10 06:30:00,105.56,214.145,57.271,32.166 +2020-03-10 06:45:00,110.72,217.18900000000002,57.271,32.166 +2020-03-10 07:00:00,114.85,219.50599999999997,68.352,32.166 +2020-03-10 07:15:00,122.4,222.36,68.352,32.166 +2020-03-10 07:30:00,126.34,222.338,68.352,32.166 +2020-03-10 07:45:00,126.87,220.52700000000002,68.352,32.166 +2020-03-10 08:00:00,121.17,218.46200000000002,60.717,32.166 +2020-03-10 08:15:00,121.73,216.57,60.717,32.166 +2020-03-10 08:30:00,123.57,211.655,60.717,32.166 +2020-03-10 08:45:00,119.88,207.236,60.717,32.166 +2020-03-10 09:00:00,120.24,199.739,54.603,32.166 +2020-03-10 09:15:00,125.53,196.215,54.603,32.166 +2020-03-10 09:30:00,125.18,195.02900000000002,54.603,32.166 +2020-03-10 09:45:00,117.63,192.30599999999998,54.603,32.166 +2020-03-10 10:00:00,122.65,189.53900000000002,52.308,32.166 +2020-03-10 10:15:00,122.53,186.588,52.308,32.166 +2020-03-10 10:30:00,122.83,184.278,52.308,32.166 +2020-03-10 10:45:00,123.85,183.583,52.308,32.166 +2020-03-10 11:00:00,124.85,180.6,51.838,32.166 +2020-03-10 11:15:00,124.63,180.15099999999998,51.838,32.166 +2020-03-10 11:30:00,120.0,179.793,51.838,32.166 +2020-03-10 11:45:00,112.97,179.477,51.838,32.166 +2020-03-10 12:00:00,116.62,174.23,50.375,32.166 +2020-03-10 12:15:00,124.04,174.543,50.375,32.166 +2020-03-10 12:30:00,134.05,173.727,50.375,32.166 +2020-03-10 12:45:00,127.79,174.581,50.375,32.166 +2020-03-10 13:00:00,126.21,174.203,50.735,32.166 +2020-03-10 13:15:00,131.91,172.525,50.735,32.166 +2020-03-10 13:30:00,125.28,170.977,50.735,32.166 +2020-03-10 13:45:00,123.2,170.459,50.735,32.166 +2020-03-10 14:00:00,122.54,172.40599999999998,50.946000000000005,32.166 +2020-03-10 14:15:00,121.23,171.549,50.946000000000005,32.166 +2020-03-10 14:30:00,124.05,171.524,50.946000000000005,32.166 +2020-03-10 14:45:00,124.51,172.513,50.946000000000005,32.166 +2020-03-10 15:00:00,131.26,174.335,53.18,32.166 +2020-03-10 15:15:00,123.68,172.97299999999998,53.18,32.166 +2020-03-10 15:30:00,117.32,172.918,53.18,32.166 +2020-03-10 15:45:00,118.3,173.00099999999998,53.18,32.166 +2020-03-10 16:00:00,122.14,176.535,54.928999999999995,32.166 +2020-03-10 16:15:00,120.8,178.231,54.928999999999995,32.166 +2020-03-10 16:30:00,123.17,180.257,54.928999999999995,32.166 +2020-03-10 16:45:00,120.87,180.545,54.928999999999995,32.166 +2020-03-10 17:00:00,119.71,183.364,60.913000000000004,32.166 +2020-03-10 17:15:00,128.27,186.02700000000002,60.913000000000004,32.166 +2020-03-10 17:30:00,128.23,188.989,60.913000000000004,32.166 +2020-03-10 17:45:00,132.22,190.09599999999998,60.913000000000004,32.166 +2020-03-10 18:00:00,133.86,193.33900000000003,62.214,32.166 +2020-03-10 18:15:00,138.61,194.197,62.214,32.166 +2020-03-10 18:30:00,141.3,192.59599999999998,62.214,32.166 +2020-03-10 18:45:00,137.8,194.65900000000002,62.214,32.166 +2020-03-10 19:00:00,133.97,193.333,62.38,32.166 +2020-03-10 19:15:00,133.09,190.37900000000002,62.38,32.166 +2020-03-10 19:30:00,134.88,189.68400000000003,62.38,32.166 +2020-03-10 19:45:00,130.24,186.79,62.38,32.166 +2020-03-10 20:00:00,124.19,181.265,65.018,32.166 +2020-03-10 20:15:00,120.85,176.13299999999998,65.018,32.166 +2020-03-10 20:30:00,119.04,172.58599999999998,65.018,32.166 +2020-03-10 20:45:00,115.01,170.15200000000002,65.018,32.166 +2020-03-10 21:00:00,110.97,166.892,56.416000000000004,32.166 +2020-03-10 21:15:00,109.67,163.63299999999998,56.416000000000004,32.166 +2020-03-10 21:30:00,108.54,161.345,56.416000000000004,32.166 +2020-03-10 21:45:00,105.86,161.29399999999998,56.416000000000004,32.166 +2020-03-10 22:00:00,98.25,153.8,52.846000000000004,32.166 +2020-03-10 22:15:00,100.43,149.92,52.846000000000004,32.166 +2020-03-10 22:30:00,97.18,133.826,52.846000000000004,32.166 +2020-03-10 22:45:00,91.4,126.08,52.846000000000004,32.166 +2020-03-10 23:00:00,89.03,119.40799999999999,44.435,32.166 +2020-03-10 23:15:00,91.06,117.764,44.435,32.166 +2020-03-10 23:30:00,88.04,119.34700000000001,44.435,32.166 +2020-03-10 23:45:00,81.48,119.641,44.435,32.166 +2020-03-11 00:00:00,75.53,114.79700000000001,42.527,32.166 +2020-03-11 00:15:00,80.59,113.096,42.527,32.166 +2020-03-11 00:30:00,83.26,112.241,42.527,32.166 +2020-03-11 00:45:00,83.5,112.162,42.527,32.166 +2020-03-11 01:00:00,75.29,113.49700000000001,38.655,32.166 +2020-03-11 01:15:00,74.08,113.866,38.655,32.166 +2020-03-11 01:30:00,75.7,113.865,38.655,32.166 +2020-03-11 01:45:00,74.92,114.025,38.655,32.166 +2020-03-11 02:00:00,74.13,115.889,36.912,32.166 +2020-03-11 02:15:00,81.05,116.80799999999999,36.912,32.166 +2020-03-11 02:30:00,80.33,117.723,36.912,32.166 +2020-03-11 02:45:00,79.51,119.47200000000001,36.912,32.166 +2020-03-11 03:00:00,75.06,122.09899999999999,36.98,32.166 +2020-03-11 03:15:00,75.42,123.804,36.98,32.166 +2020-03-11 03:30:00,76.42,125.709,36.98,32.166 +2020-03-11 03:45:00,80.52,126.93299999999999,36.98,32.166 +2020-03-11 04:00:00,84.62,140.27200000000002,38.052,32.166 +2020-03-11 04:15:00,85.72,153.308,38.052,32.166 +2020-03-11 04:30:00,81.45,155.411,38.052,32.166 +2020-03-11 04:45:00,84.39,157.156,38.052,32.166 +2020-03-11 05:00:00,91.07,191.585,42.455,32.166 +2020-03-11 05:15:00,95.13,222.638,42.455,32.166 +2020-03-11 05:30:00,98.48,216.752,42.455,32.166 +2020-03-11 05:45:00,96.19,208.571,42.455,32.166 +2020-03-11 06:00:00,104.58,205.817,57.986000000000004,32.166 +2020-03-11 06:15:00,107.88,212.06900000000002,57.986000000000004,32.166 +2020-03-11 06:30:00,110.59,213.645,57.986000000000004,32.166 +2020-03-11 06:45:00,113.78,216.666,57.986000000000004,32.166 +2020-03-11 07:00:00,118.72,219.00400000000002,71.868,32.166 +2020-03-11 07:15:00,127.45,221.824,71.868,32.166 +2020-03-11 07:30:00,131.59,221.76,71.868,32.166 +2020-03-11 07:45:00,132.28,219.90900000000002,71.868,32.166 +2020-03-11 08:00:00,129.65,217.81900000000002,62.225,32.166 +2020-03-11 08:15:00,131.66,215.915,62.225,32.166 +2020-03-11 08:30:00,130.64,210.942,62.225,32.166 +2020-03-11 08:45:00,131.27,206.542,62.225,32.166 +2020-03-11 09:00:00,134.97,199.05900000000003,58.802,32.166 +2020-03-11 09:15:00,139.13,195.53599999999997,58.802,32.166 +2020-03-11 09:30:00,141.53,194.37099999999998,58.802,32.166 +2020-03-11 09:45:00,138.32,191.662,58.802,32.166 +2020-03-11 10:00:00,134.68,188.90900000000002,54.122,32.166 +2020-03-11 10:15:00,135.2,186.00400000000002,54.122,32.166 +2020-03-11 10:30:00,134.26,183.71599999999998,54.122,32.166 +2020-03-11 10:45:00,133.84,183.042,54.122,32.166 +2020-03-11 11:00:00,133.84,180.051,54.368,32.166 +2020-03-11 11:15:00,136.34,179.623,54.368,32.166 +2020-03-11 11:30:00,134.04,179.273,54.368,32.166 +2020-03-11 11:45:00,134.3,178.97400000000002,54.368,32.166 +2020-03-11 12:00:00,136.13,173.747,52.74,32.166 +2020-03-11 12:15:00,139.12,174.074,52.74,32.166 +2020-03-11 12:30:00,137.79,173.21900000000002,52.74,32.166 +2020-03-11 12:45:00,132.99,174.07,52.74,32.166 +2020-03-11 13:00:00,129.17,173.736,52.544,32.166 +2020-03-11 13:15:00,130.87,172.03599999999997,52.544,32.166 +2020-03-11 13:30:00,132.75,170.476,52.544,32.166 +2020-03-11 13:45:00,134.79,169.96099999999998,52.544,32.166 +2020-03-11 14:00:00,129.47,171.979,53.602,32.166 +2020-03-11 14:15:00,126.72,171.09799999999998,53.602,32.166 +2020-03-11 14:30:00,128.13,171.03900000000002,53.602,32.166 +2020-03-11 14:45:00,124.89,172.03799999999998,53.602,32.166 +2020-03-11 15:00:00,129.33,173.86900000000003,55.59,32.166 +2020-03-11 15:15:00,130.78,172.476,55.59,32.166 +2020-03-11 15:30:00,132.29,172.37,55.59,32.166 +2020-03-11 15:45:00,130.2,172.433,55.59,32.166 +2020-03-11 16:00:00,126.12,175.97400000000002,57.586999999999996,32.166 +2020-03-11 16:15:00,122.31,177.64700000000002,57.586999999999996,32.166 +2020-03-11 16:30:00,122.71,179.671,57.586999999999996,32.166 +2020-03-11 16:45:00,123.16,179.915,57.586999999999996,32.166 +2020-03-11 17:00:00,125.12,182.75099999999998,62.111999999999995,32.166 +2020-03-11 17:15:00,128.58,185.418,62.111999999999995,32.166 +2020-03-11 17:30:00,127.81,188.40200000000002,62.111999999999995,32.166 +2020-03-11 17:45:00,130.41,189.523,62.111999999999995,32.166 +2020-03-11 18:00:00,142.61,192.77200000000002,64.605,32.166 +2020-03-11 18:15:00,140.13,193.701,64.605,32.166 +2020-03-11 18:30:00,143.86,192.095,64.605,32.166 +2020-03-11 18:45:00,134.22,194.179,64.605,32.166 +2020-03-11 19:00:00,135.54,192.81400000000002,65.55199999999999,32.166 +2020-03-11 19:15:00,132.58,189.88,65.55199999999999,32.166 +2020-03-11 19:30:00,135.41,189.21400000000003,65.55199999999999,32.166 +2020-03-11 19:45:00,134.32,186.36900000000003,65.55199999999999,32.166 +2020-03-11 20:00:00,127.68,180.81099999999998,66.778,32.166 +2020-03-11 20:15:00,121.46,175.696,66.778,32.166 +2020-03-11 20:30:00,114.11,172.178,66.778,32.166 +2020-03-11 20:45:00,112.76,169.74599999999998,66.778,32.166 +2020-03-11 21:00:00,103.63,166.476,56.103,32.166 +2020-03-11 21:15:00,103.76,163.215,56.103,32.166 +2020-03-11 21:30:00,107.96,160.925,56.103,32.166 +2020-03-11 21:45:00,109.11,160.89700000000002,56.103,32.166 +2020-03-11 22:00:00,106.52,153.386,51.371,32.166 +2020-03-11 22:15:00,101.13,149.535,51.371,32.166 +2020-03-11 22:30:00,100.38,133.38299999999998,51.371,32.166 +2020-03-11 22:45:00,98.36,125.639,51.371,32.166 +2020-03-11 23:00:00,94.04,118.964,42.798,32.166 +2020-03-11 23:15:00,90.56,117.34100000000001,42.798,32.166 +2020-03-11 23:30:00,90.47,118.929,42.798,32.166 +2020-03-11 23:45:00,92.85,119.251,42.798,32.166 +2020-03-12 00:00:00,90.26,114.434,39.069,32.166 +2020-03-12 00:15:00,83.72,112.744,39.069,32.166 +2020-03-12 00:30:00,85.51,111.87,39.069,32.166 +2020-03-12 00:45:00,85.74,111.79299999999999,39.069,32.166 +2020-03-12 01:00:00,82.15,113.084,37.043,32.166 +2020-03-12 01:15:00,81.61,113.43700000000001,37.043,32.166 +2020-03-12 01:30:00,83.56,113.417,37.043,32.166 +2020-03-12 01:45:00,83.38,113.586,37.043,32.166 +2020-03-12 02:00:00,79.78,115.43700000000001,34.625,32.166 +2020-03-12 02:15:00,79.93,116.348,34.625,32.166 +2020-03-12 02:30:00,80.09,117.28200000000001,34.625,32.166 +2020-03-12 02:45:00,83.47,119.03299999999999,34.625,32.166 +2020-03-12 03:00:00,79.75,121.67200000000001,33.812,32.166 +2020-03-12 03:15:00,83.53,123.359,33.812,32.166 +2020-03-12 03:30:00,85.78,125.258,33.812,32.166 +2020-03-12 03:45:00,86.38,126.495,33.812,32.166 +2020-03-12 04:00:00,83.32,139.841,35.236999999999995,32.166 +2020-03-12 04:15:00,85.25,152.864,35.236999999999995,32.166 +2020-03-12 04:30:00,89.13,154.987,35.236999999999995,32.166 +2020-03-12 04:45:00,87.85,156.718,35.236999999999995,32.166 +2020-03-12 05:00:00,87.66,191.12900000000002,40.375,32.166 +2020-03-12 05:15:00,89.16,222.2,40.375,32.166 +2020-03-12 05:30:00,92.45,216.27900000000002,40.375,32.166 +2020-03-12 05:45:00,94.7,208.10299999999998,40.375,32.166 +2020-03-12 06:00:00,106.37,205.355,52.316,32.166 +2020-03-12 06:15:00,109.43,211.613,52.316,32.166 +2020-03-12 06:30:00,110.52,213.139,52.316,32.166 +2020-03-12 06:45:00,112.03,216.136,52.316,32.166 +2020-03-12 07:00:00,115.95,218.495,64.115,32.166 +2020-03-12 07:15:00,118.25,221.282,64.115,32.166 +2020-03-12 07:30:00,121.19,221.176,64.115,32.166 +2020-03-12 07:45:00,121.25,219.28400000000002,64.115,32.166 +2020-03-12 08:00:00,121.28,217.168,55.033,32.166 +2020-03-12 08:15:00,120.35,215.253,55.033,32.166 +2020-03-12 08:30:00,121.03,210.222,55.033,32.166 +2020-03-12 08:45:00,122.01,205.84099999999998,55.033,32.166 +2020-03-12 09:00:00,121.47,198.373,49.411,32.166 +2020-03-12 09:15:00,118.49,194.851,49.411,32.166 +2020-03-12 09:30:00,119.35,193.707,49.411,32.166 +2020-03-12 09:45:00,117.69,191.012,49.411,32.166 +2020-03-12 10:00:00,118.5,188.274,45.82899999999999,32.166 +2020-03-12 10:15:00,119.49,185.41400000000002,45.82899999999999,32.166 +2020-03-12 10:30:00,118.65,183.149,45.82899999999999,32.166 +2020-03-12 10:45:00,120.54,182.495,45.82899999999999,32.166 +2020-03-12 11:00:00,120.4,179.497,44.333,32.166 +2020-03-12 11:15:00,120.2,179.092,44.333,32.166 +2020-03-12 11:30:00,120.88,178.748,44.333,32.166 +2020-03-12 11:45:00,118.34,178.46900000000002,44.333,32.166 +2020-03-12 12:00:00,117.08,173.262,42.95,32.166 +2020-03-12 12:15:00,116.25,173.6,42.95,32.166 +2020-03-12 12:30:00,115.75,172.706,42.95,32.166 +2020-03-12 12:45:00,109.47,173.554,42.95,32.166 +2020-03-12 13:00:00,108.09,173.266,42.489,32.166 +2020-03-12 13:15:00,115.15,171.544,42.489,32.166 +2020-03-12 13:30:00,115.05,169.97099999999998,42.489,32.166 +2020-03-12 13:45:00,111.73,169.46099999999998,42.489,32.166 +2020-03-12 14:00:00,112.25,171.55,43.448,32.166 +2020-03-12 14:15:00,113.98,170.643,43.448,32.166 +2020-03-12 14:30:00,117.08,170.551,43.448,32.166 +2020-03-12 14:45:00,119.53,171.55900000000003,43.448,32.166 +2020-03-12 15:00:00,118.97,173.39700000000002,45.994,32.166 +2020-03-12 15:15:00,118.68,171.975,45.994,32.166 +2020-03-12 15:30:00,117.13,171.81599999999997,45.994,32.166 +2020-03-12 15:45:00,119.59,171.862,45.994,32.166 +2020-03-12 16:00:00,119.06,175.407,48.167,32.166 +2020-03-12 16:15:00,118.71,177.05700000000002,48.167,32.166 +2020-03-12 16:30:00,116.06,179.081,48.167,32.166 +2020-03-12 16:45:00,116.15,179.28099999999998,48.167,32.166 +2020-03-12 17:00:00,117.1,182.13099999999997,52.637,32.166 +2020-03-12 17:15:00,117.47,184.803,52.637,32.166 +2020-03-12 17:30:00,124.27,187.81,52.637,32.166 +2020-03-12 17:45:00,130.48,188.94400000000002,52.637,32.166 +2020-03-12 18:00:00,135.41,192.19799999999998,55.739,32.166 +2020-03-12 18:15:00,134.81,193.199,55.739,32.166 +2020-03-12 18:30:00,135.02,191.58900000000003,55.739,32.166 +2020-03-12 18:45:00,141.44,193.692,55.739,32.166 +2020-03-12 19:00:00,139.92,192.29,56.36600000000001,32.166 +2020-03-12 19:15:00,135.83,189.375,56.36600000000001,32.166 +2020-03-12 19:30:00,133.89,188.739,56.36600000000001,32.166 +2020-03-12 19:45:00,136.57,185.94099999999997,56.36600000000001,32.166 +2020-03-12 20:00:00,127.47,180.352,56.338,32.166 +2020-03-12 20:15:00,117.38,175.25400000000002,56.338,32.166 +2020-03-12 20:30:00,114.25,171.765,56.338,32.166 +2020-03-12 20:45:00,118.49,169.33599999999998,56.338,32.166 +2020-03-12 21:00:00,110.15,166.05599999999998,49.894,32.166 +2020-03-12 21:15:00,109.53,162.791,49.894,32.166 +2020-03-12 21:30:00,99.6,160.502,49.894,32.166 +2020-03-12 21:45:00,103.89,160.494,49.894,32.166 +2020-03-12 22:00:00,96.79,152.968,46.687,32.166 +2020-03-12 22:15:00,101.17,149.144,46.687,32.166 +2020-03-12 22:30:00,99.91,132.936,46.687,32.166 +2020-03-12 22:45:00,97.37,125.19200000000001,46.687,32.166 +2020-03-12 23:00:00,90.19,118.515,39.211,32.166 +2020-03-12 23:15:00,91.27,116.912,39.211,32.166 +2020-03-12 23:30:00,89.72,118.506,39.211,32.166 +2020-03-12 23:45:00,90.08,118.85600000000001,39.211,32.166 +2020-03-13 00:00:00,79.34,112.801,36.616,32.166 +2020-03-13 00:15:00,81.17,111.344,36.616,32.166 +2020-03-13 00:30:00,84.9,110.35700000000001,36.616,32.166 +2020-03-13 00:45:00,84.62,110.445,36.616,32.166 +2020-03-13 01:00:00,76.48,111.34,33.799,32.166 +2020-03-13 01:15:00,75.14,112.479,33.799,32.166 +2020-03-13 01:30:00,75.63,112.32,33.799,32.166 +2020-03-13 01:45:00,78.52,112.565,33.799,32.166 +2020-03-13 02:00:00,80.3,114.60600000000001,32.968,32.166 +2020-03-13 02:15:00,78.45,115.39399999999999,32.968,32.166 +2020-03-13 02:30:00,75.39,116.959,32.968,32.166 +2020-03-13 02:45:00,74.75,118.661,32.968,32.166 +2020-03-13 03:00:00,80.8,120.464,33.533,32.166 +2020-03-13 03:15:00,81.43,122.869,33.533,32.166 +2020-03-13 03:30:00,80.63,124.714,33.533,32.166 +2020-03-13 03:45:00,76.96,126.39299999999999,33.533,32.166 +2020-03-13 04:00:00,77.78,139.984,36.102,32.166 +2020-03-13 04:15:00,76.01,152.549,36.102,32.166 +2020-03-13 04:30:00,78.16,155.04,36.102,32.166 +2020-03-13 04:45:00,80.35,155.576,36.102,32.166 +2020-03-13 05:00:00,83.6,188.688,42.423,32.166 +2020-03-13 05:15:00,86.08,221.351,42.423,32.166 +2020-03-13 05:30:00,89.34,216.452,42.423,32.166 +2020-03-13 05:45:00,93.73,208.157,42.423,32.166 +2020-03-13 06:00:00,103.64,205.862,55.38,32.166 +2020-03-13 06:15:00,107.57,210.74,55.38,32.166 +2020-03-13 06:30:00,110.64,211.385,55.38,32.166 +2020-03-13 06:45:00,114.48,215.893,55.38,32.166 +2020-03-13 07:00:00,119.66,217.563,65.929,32.166 +2020-03-13 07:15:00,122.04,221.418,65.929,32.166 +2020-03-13 07:30:00,125.84,220.833,65.929,32.166 +2020-03-13 07:45:00,127.92,218.025,65.929,32.166 +2020-03-13 08:00:00,130.98,214.92700000000002,57.336999999999996,32.166 +2020-03-13 08:15:00,131.41,212.718,57.336999999999996,32.166 +2020-03-13 08:30:00,133.25,208.553,57.336999999999996,32.166 +2020-03-13 08:45:00,132.73,202.65900000000002,57.336999999999996,32.166 +2020-03-13 09:00:00,131.91,195.34099999999998,54.226000000000006,32.166 +2020-03-13 09:15:00,133.92,192.61900000000003,54.226000000000006,32.166 +2020-03-13 09:30:00,134.68,190.99200000000002,54.226000000000006,32.166 +2020-03-13 09:45:00,135.73,188.257,54.226000000000006,32.166 +2020-03-13 10:00:00,134.92,184.42,51.298,32.166 +2020-03-13 10:15:00,135.4,182.248,51.298,32.166 +2020-03-13 10:30:00,134.47,180.012,51.298,32.166 +2020-03-13 10:45:00,135.19,178.93900000000002,51.298,32.166 +2020-03-13 11:00:00,135.89,175.94299999999998,50.839,32.166 +2020-03-13 11:15:00,138.7,174.553,50.839,32.166 +2020-03-13 11:30:00,137.93,175.858,50.839,32.166 +2020-03-13 11:45:00,136.68,175.517,50.839,32.166 +2020-03-13 12:00:00,135.2,171.43099999999998,47.976000000000006,32.166 +2020-03-13 12:15:00,133.97,169.679,47.976000000000006,32.166 +2020-03-13 12:30:00,132.57,168.91400000000002,47.976000000000006,32.166 +2020-03-13 12:45:00,133.27,170.15200000000002,47.976000000000006,32.166 +2020-03-13 13:00:00,131.19,170.889,46.299,32.166 +2020-03-13 13:15:00,131.49,169.957,46.299,32.166 +2020-03-13 13:30:00,130.77,168.495,46.299,32.166 +2020-03-13 13:45:00,129.96,167.967,46.299,32.166 +2020-03-13 14:00:00,127.92,168.924,44.971000000000004,32.166 +2020-03-13 14:15:00,127.13,167.878,44.971000000000004,32.166 +2020-03-13 14:30:00,128.48,168.445,44.971000000000004,32.166 +2020-03-13 14:45:00,133.54,169.662,44.971000000000004,32.166 +2020-03-13 15:00:00,132.39,171.033,47.48,32.166 +2020-03-13 15:15:00,129.23,169.125,47.48,32.166 +2020-03-13 15:30:00,123.71,167.375,47.48,32.166 +2020-03-13 15:45:00,127.43,167.642,47.48,32.166 +2020-03-13 16:00:00,125.11,169.949,50.648,32.166 +2020-03-13 16:15:00,125.31,171.919,50.648,32.166 +2020-03-13 16:30:00,131.08,174.01,50.648,32.166 +2020-03-13 16:45:00,131.36,173.954,50.648,32.166 +2020-03-13 17:00:00,133.17,177.222,56.251000000000005,32.166 +2020-03-13 17:15:00,127.14,179.476,56.251000000000005,32.166 +2020-03-13 17:30:00,131.27,182.215,56.251000000000005,32.166 +2020-03-13 17:45:00,129.12,183.114,56.251000000000005,32.166 +2020-03-13 18:00:00,131.24,187.041,58.982,32.166 +2020-03-13 18:15:00,135.17,187.62099999999998,58.982,32.166 +2020-03-13 18:30:00,139.57,186.37900000000002,58.982,32.166 +2020-03-13 18:45:00,139.51,188.546,58.982,32.166 +2020-03-13 19:00:00,133.36,188.09,57.293,32.166 +2020-03-13 19:15:00,128.85,186.59799999999998,57.293,32.166 +2020-03-13 19:30:00,132.49,185.59400000000002,57.293,32.166 +2020-03-13 19:45:00,126.88,182.27900000000002,57.293,32.166 +2020-03-13 20:00:00,119.57,176.699,59.433,32.166 +2020-03-13 20:15:00,115.51,171.71900000000002,59.433,32.166 +2020-03-13 20:30:00,114.29,168.16099999999997,59.433,32.166 +2020-03-13 20:45:00,118.37,166.18200000000002,59.433,32.166 +2020-03-13 21:00:00,112.96,163.543,52.153999999999996,32.166 +2020-03-13 21:15:00,110.25,160.898,52.153999999999996,32.166 +2020-03-13 21:30:00,97.71,158.635,52.153999999999996,32.166 +2020-03-13 21:45:00,100.27,159.203,52.153999999999996,32.166 +2020-03-13 22:00:00,97.93,152.60299999999998,47.125,32.166 +2020-03-13 22:15:00,92.71,148.64700000000002,47.125,32.166 +2020-03-13 22:30:00,96.04,139.16299999999998,47.125,32.166 +2020-03-13 22:45:00,96.36,134.97,47.125,32.166 +2020-03-13 23:00:00,92.48,128.05200000000002,41.236000000000004,32.166 +2020-03-13 23:15:00,86.81,124.382,41.236000000000004,32.166 +2020-03-13 23:30:00,83.75,124.344,41.236000000000004,32.166 +2020-03-13 23:45:00,80.73,124.059,41.236000000000004,32.166 +2020-03-14 00:00:00,77.88,109.852,36.484,31.988000000000003 +2020-03-14 00:15:00,83.78,104.229,36.484,31.988000000000003 +2020-03-14 00:30:00,82.86,104.44,36.484,31.988000000000003 +2020-03-14 00:45:00,81.65,105.12,36.484,31.988000000000003 +2020-03-14 01:00:00,70.41,106.62100000000001,32.391999999999996,31.988000000000003 +2020-03-14 01:15:00,73.58,106.88,32.391999999999996,31.988000000000003 +2020-03-14 01:30:00,71.51,106.098,32.391999999999996,31.988000000000003 +2020-03-14 01:45:00,71.4,106.311,32.391999999999996,31.988000000000003 +2020-03-14 02:00:00,70.84,108.875,30.194000000000003,31.988000000000003 +2020-03-14 02:15:00,70.05,109.193,30.194000000000003,31.988000000000003 +2020-03-14 02:30:00,68.78,109.60700000000001,30.194000000000003,31.988000000000003 +2020-03-14 02:45:00,70.66,111.51299999999999,30.194000000000003,31.988000000000003 +2020-03-14 03:00:00,74.8,113.75299999999999,29.677,31.988000000000003 +2020-03-14 03:15:00,77.05,114.881,29.677,31.988000000000003 +2020-03-14 03:30:00,75.79,115.251,29.677,31.988000000000003 +2020-03-14 03:45:00,74.51,117.256,29.677,31.988000000000003 +2020-03-14 04:00:00,73.14,126.67299999999999,29.616,31.988000000000003 +2020-03-14 04:15:00,76.99,136.685,29.616,31.988000000000003 +2020-03-14 04:30:00,79.29,136.898,29.616,31.988000000000003 +2020-03-14 04:45:00,79.22,136.989,29.616,31.988000000000003 +2020-03-14 05:00:00,73.08,153.809,29.625,31.988000000000003 +2020-03-14 05:15:00,73.44,166.923,29.625,31.988000000000003 +2020-03-14 05:30:00,78.54,162.54399999999998,29.625,31.988000000000003 +2020-03-14 05:45:00,82.22,160.054,29.625,31.988000000000003 +2020-03-14 06:00:00,85.25,177.106,30.551,31.988000000000003 +2020-03-14 06:15:00,78.08,198.613,30.551,31.988000000000003 +2020-03-14 06:30:00,81.79,193.703,30.551,31.988000000000003 +2020-03-14 06:45:00,83.22,189.206,30.551,31.988000000000003 +2020-03-14 07:00:00,89.51,187.062,34.865,31.988000000000003 +2020-03-14 07:15:00,91.04,189.51,34.865,31.988000000000003 +2020-03-14 07:30:00,91.15,191.669,34.865,31.988000000000003 +2020-03-14 07:45:00,90.05,192.73,34.865,31.988000000000003 +2020-03-14 08:00:00,95.18,193.46599999999998,41.456,31.988000000000003 +2020-03-14 08:15:00,100.55,194.687,41.456,31.988000000000003 +2020-03-14 08:30:00,104.99,192.00400000000002,41.456,31.988000000000003 +2020-03-14 08:45:00,103.55,189.274,41.456,31.988000000000003 +2020-03-14 09:00:00,103.12,184.047,43.001999999999995,31.988000000000003 +2020-03-14 09:15:00,109.55,182.112,43.001999999999995,31.988000000000003 +2020-03-14 09:30:00,113.29,181.43599999999998,43.001999999999995,31.988000000000003 +2020-03-14 09:45:00,114.02,178.81,43.001999999999995,31.988000000000003 +2020-03-14 10:00:00,110.04,175.35,42.047,31.988000000000003 +2020-03-14 10:15:00,109.56,173.428,42.047,31.988000000000003 +2020-03-14 10:30:00,115.95,171.328,42.047,31.988000000000003 +2020-03-14 10:45:00,117.43,171.47400000000002,42.047,31.988000000000003 +2020-03-14 11:00:00,116.25,168.637,39.894,31.988000000000003 +2020-03-14 11:15:00,113.56,166.72400000000002,39.894,31.988000000000003 +2020-03-14 11:30:00,120.33,167.00400000000002,39.894,31.988000000000003 +2020-03-14 11:45:00,122.42,165.852,39.894,31.988000000000003 +2020-03-14 12:00:00,112.56,160.997,38.122,31.988000000000003 +2020-03-14 12:15:00,110.8,159.982,38.122,31.988000000000003 +2020-03-14 12:30:00,105.88,159.467,38.122,31.988000000000003 +2020-03-14 12:45:00,104.72,160.058,38.122,31.988000000000003 +2020-03-14 13:00:00,102.48,160.347,34.645,31.988000000000003 +2020-03-14 13:15:00,101.32,157.35299999999998,34.645,31.988000000000003 +2020-03-14 13:30:00,99.94,155.47299999999998,34.645,31.988000000000003 +2020-03-14 13:45:00,98.58,155.225,34.645,31.988000000000003 +2020-03-14 14:00:00,96.86,157.421,33.739000000000004,31.988000000000003 +2020-03-14 14:15:00,96.28,155.673,33.739000000000004,31.988000000000003 +2020-03-14 14:30:00,96.6,154.406,33.739000000000004,31.988000000000003 +2020-03-14 14:45:00,97.2,155.903,33.739000000000004,31.988000000000003 +2020-03-14 15:00:00,96.67,157.937,35.908,31.988000000000003 +2020-03-14 15:15:00,95.72,156.866,35.908,31.988000000000003 +2020-03-14 15:30:00,95.92,156.571,35.908,31.988000000000003 +2020-03-14 15:45:00,95.09,156.715,35.908,31.988000000000003 +2020-03-14 16:00:00,95.88,158.109,39.249,31.988000000000003 +2020-03-14 16:15:00,95.55,160.806,39.249,31.988000000000003 +2020-03-14 16:30:00,95.24,162.874,39.249,31.988000000000003 +2020-03-14 16:45:00,95.42,163.626,39.249,31.988000000000003 +2020-03-14 17:00:00,99.69,166.172,46.045,31.988000000000003 +2020-03-14 17:15:00,99.32,169.774,46.045,31.988000000000003 +2020-03-14 17:30:00,101.07,172.42700000000002,46.045,31.988000000000003 +2020-03-14 17:45:00,102.78,173.00599999999997,46.045,31.988000000000003 +2020-03-14 18:00:00,107.78,176.59599999999998,48.238,31.988000000000003 +2020-03-14 18:15:00,108.04,179.19299999999998,48.238,31.988000000000003 +2020-03-14 18:30:00,111.45,179.40099999999998,48.238,31.988000000000003 +2020-03-14 18:45:00,111.41,177.924,48.238,31.988000000000003 +2020-03-14 19:00:00,111.15,178.12599999999998,46.785,31.988000000000003 +2020-03-14 19:15:00,109.51,176.055,46.785,31.988000000000003 +2020-03-14 19:30:00,108.23,175.903,46.785,31.988000000000003 +2020-03-14 19:45:00,106.14,172.66299999999998,46.785,31.988000000000003 +2020-03-14 20:00:00,101.01,169.296,39.830999999999996,31.988000000000003 +2020-03-14 20:15:00,97.68,166.32299999999998,39.830999999999996,31.988000000000003 +2020-03-14 20:30:00,93.02,162.352,39.830999999999996,31.988000000000003 +2020-03-14 20:45:00,93.19,160.203,39.830999999999996,31.988000000000003 +2020-03-14 21:00:00,89.17,159.56799999999998,34.063,31.988000000000003 +2020-03-14 21:15:00,86.26,157.30700000000002,34.063,31.988000000000003 +2020-03-14 21:30:00,89.1,156.261,34.063,31.988000000000003 +2020-03-14 21:45:00,87.56,156.39,34.063,31.988000000000003 +2020-03-14 22:00:00,82.87,151.08100000000002,34.455999999999996,31.988000000000003 +2020-03-14 22:15:00,83.61,149.59799999999998,34.455999999999996,31.988000000000003 +2020-03-14 22:30:00,77.72,146.056,34.455999999999996,31.988000000000003 +2020-03-14 22:45:00,79.25,143.726,34.455999999999996,31.988000000000003 +2020-03-14 23:00:00,75.28,139.096,27.840999999999998,31.988000000000003 +2020-03-14 23:15:00,72.99,133.905,27.840999999999998,31.988000000000003 +2020-03-14 23:30:00,72.93,132.463,27.840999999999998,31.988000000000003 +2020-03-14 23:45:00,71.64,129.89,27.840999999999998,31.988000000000003 +2020-03-15 00:00:00,66.91,110.05799999999999,20.007,31.988000000000003 +2020-03-15 00:15:00,67.0,103.98700000000001,20.007,31.988000000000003 +2020-03-15 00:30:00,66.54,103.802,20.007,31.988000000000003 +2020-03-15 00:45:00,65.35,105.135,20.007,31.988000000000003 +2020-03-15 01:00:00,62.97,106.49,17.378,31.988000000000003 +2020-03-15 01:15:00,63.28,107.705,17.378,31.988000000000003 +2020-03-15 01:30:00,60.74,107.381,17.378,31.988000000000003 +2020-03-15 01:45:00,63.48,107.251,17.378,31.988000000000003 +2020-03-15 02:00:00,61.21,109.098,16.145,31.988000000000003 +2020-03-15 02:15:00,61.18,108.698,16.145,31.988000000000003 +2020-03-15 02:30:00,60.17,109.969,16.145,31.988000000000003 +2020-03-15 02:45:00,61.4,112.275,16.145,31.988000000000003 +2020-03-15 03:00:00,60.81,114.917,15.427999999999999,31.988000000000003 +2020-03-15 03:15:00,61.75,115.56700000000001,15.427999999999999,31.988000000000003 +2020-03-15 03:30:00,61.32,117.15100000000001,15.427999999999999,31.988000000000003 +2020-03-15 03:45:00,62.75,118.98200000000001,15.427999999999999,31.988000000000003 +2020-03-15 04:00:00,62.97,128.166,16.663,31.988000000000003 +2020-03-15 04:15:00,63.98,137.14700000000002,16.663,31.988000000000003 +2020-03-15 04:30:00,63.98,137.636,16.663,31.988000000000003 +2020-03-15 04:45:00,63.9,137.90200000000002,16.663,31.988000000000003 +2020-03-15 05:00:00,63.92,151.424,17.271,31.988000000000003 +2020-03-15 05:15:00,62.64,162.171,17.271,31.988000000000003 +2020-03-15 05:30:00,63.97,157.541,17.271,31.988000000000003 +2020-03-15 05:45:00,64.06,155.245,17.271,31.988000000000003 +2020-03-15 06:00:00,63.06,171.845,17.612000000000002,31.988000000000003 +2020-03-15 06:15:00,61.28,191.857,17.612000000000002,31.988000000000003 +2020-03-15 06:30:00,58.8,185.75099999999998,17.612000000000002,31.988000000000003 +2020-03-15 06:45:00,61.36,180.08599999999998,17.612000000000002,31.988000000000003 +2020-03-15 07:00:00,63.14,180.313,20.88,31.988000000000003 +2020-03-15 07:15:00,62.27,181.68,20.88,31.988000000000003 +2020-03-15 07:30:00,66.04,182.82299999999998,20.88,31.988000000000003 +2020-03-15 07:45:00,68.94,183.12,20.88,31.988000000000003 +2020-03-15 08:00:00,71.34,185.66299999999998,25.861,31.988000000000003 +2020-03-15 08:15:00,73.91,186.957,25.861,31.988000000000003 +2020-03-15 08:30:00,74.04,185.912,25.861,31.988000000000003 +2020-03-15 08:45:00,74.07,185.106,25.861,31.988000000000003 +2020-03-15 09:00:00,74.58,179.49,27.921999999999997,31.988000000000003 +2020-03-15 09:15:00,73.36,178.012,27.921999999999997,31.988000000000003 +2020-03-15 09:30:00,75.48,177.27,27.921999999999997,31.988000000000003 +2020-03-15 09:45:00,75.64,174.68599999999998,27.921999999999997,31.988000000000003 +2020-03-15 10:00:00,75.27,173.71099999999998,29.048000000000002,31.988000000000003 +2020-03-15 10:15:00,74.22,172.36,29.048000000000002,31.988000000000003 +2020-03-15 10:30:00,73.91,170.886,29.048000000000002,31.988000000000003 +2020-03-15 10:45:00,74.56,169.398,29.048000000000002,31.988000000000003 +2020-03-15 11:00:00,77.3,167.37099999999998,32.02,31.988000000000003 +2020-03-15 11:15:00,80.08,165.544,32.02,31.988000000000003 +2020-03-15 11:30:00,82.09,165.077,32.02,31.988000000000003 +2020-03-15 11:45:00,79.85,164.555,32.02,31.988000000000003 +2020-03-15 12:00:00,78.18,159.333,28.55,31.988000000000003 +2020-03-15 12:15:00,79.4,160.05,28.55,31.988000000000003 +2020-03-15 12:30:00,76.27,158.143,28.55,31.988000000000003 +2020-03-15 12:45:00,75.58,157.72299999999998,28.55,31.988000000000003 +2020-03-15 13:00:00,71.35,157.334,25.601999999999997,31.988000000000003 +2020-03-15 13:15:00,71.38,157.099,25.601999999999997,31.988000000000003 +2020-03-15 13:30:00,70.92,154.85,25.601999999999997,31.988000000000003 +2020-03-15 13:45:00,72.05,154.139,25.601999999999997,31.988000000000003 +2020-03-15 14:00:00,72.64,156.80700000000002,23.916999999999998,31.988000000000003 +2020-03-15 14:15:00,71.93,156.235,23.916999999999998,31.988000000000003 +2020-03-15 14:30:00,71.59,155.931,23.916999999999998,31.988000000000003 +2020-03-15 14:45:00,71.9,156.895,23.916999999999998,31.988000000000003 +2020-03-15 15:00:00,73.94,157.505,24.064,31.988000000000003 +2020-03-15 15:15:00,72.87,157.013,24.064,31.988000000000003 +2020-03-15 15:30:00,73.03,157.2,24.064,31.988000000000003 +2020-03-15 15:45:00,74.33,158.025,24.064,31.988000000000003 +2020-03-15 16:00:00,76.14,161.00799999999998,28.189,31.988000000000003 +2020-03-15 16:15:00,77.61,162.841,28.189,31.988000000000003 +2020-03-15 16:30:00,78.45,165.28900000000002,28.189,31.988000000000003 +2020-03-15 16:45:00,83.21,166.13099999999997,28.189,31.988000000000003 +2020-03-15 17:00:00,85.3,168.75799999999998,37.576,31.988000000000003 +2020-03-15 17:15:00,86.88,172.24,37.576,31.988000000000003 +2020-03-15 17:30:00,88.48,175.292,37.576,31.988000000000003 +2020-03-15 17:45:00,89.58,178.122,37.576,31.988000000000003 +2020-03-15 18:00:00,97.68,181.27700000000002,42.669,31.988000000000003 +2020-03-15 18:15:00,98.02,185.18,42.669,31.988000000000003 +2020-03-15 18:30:00,103.29,183.335,42.669,31.988000000000003 +2020-03-15 18:45:00,103.7,183.683,42.669,31.988000000000003 +2020-03-15 19:00:00,105.01,183.797,43.538999999999994,31.988000000000003 +2020-03-15 19:15:00,102.92,182.188,43.538999999999994,31.988000000000003 +2020-03-15 19:30:00,101.41,181.878,43.538999999999994,31.988000000000003 +2020-03-15 19:45:00,99.98,180.03400000000002,43.538999999999994,31.988000000000003 +2020-03-15 20:00:00,98.07,176.605,37.330999999999996,31.988000000000003 +2020-03-15 20:15:00,98.05,174.584,37.330999999999996,31.988000000000003 +2020-03-15 20:30:00,102.06,171.94,37.330999999999996,31.988000000000003 +2020-03-15 20:45:00,101.51,168.424,37.330999999999996,31.988000000000003 +2020-03-15 21:00:00,92.01,165.261,33.856,31.988000000000003 +2020-03-15 21:15:00,96.09,162.356,33.856,31.988000000000003 +2020-03-15 21:30:00,95.45,161.513,33.856,31.988000000000003 +2020-03-15 21:45:00,98.39,161.839,33.856,31.988000000000003 +2020-03-15 22:00:00,96.08,155.615,34.711999999999996,31.988000000000003 +2020-03-15 22:15:00,93.54,153.219,34.711999999999996,31.988000000000003 +2020-03-15 22:30:00,88.54,146.49200000000002,34.711999999999996,31.988000000000003 +2020-03-15 22:45:00,88.54,143.184,34.711999999999996,31.988000000000003 +2020-03-15 23:00:00,90.01,135.808,29.698,31.988000000000003 +2020-03-15 23:15:00,90.99,132.591,29.698,31.988000000000003 +2020-03-15 23:30:00,88.54,131.872,29.698,31.988000000000003 +2020-03-15 23:45:00,84.36,130.211,29.698,31.988000000000003 +2020-03-16 00:00:00,75.75,98.40299999999999,29.983,32.166 +2020-03-16 00:15:00,83.47,96.28299999999999,29.983,32.166 +2020-03-16 00:30:00,84.42,95.56,29.983,32.166 +2020-03-16 00:45:00,82.25,95.21799999999999,29.983,32.166 +2020-03-16 01:00:00,77.57,96.861,29.122,32.166 +2020-03-16 01:15:00,80.67,97.205,29.122,32.166 +2020-03-16 01:30:00,81.83,96.76299999999999,29.122,32.166 +2020-03-16 01:45:00,81.92,96.56,29.122,32.166 +2020-03-16 02:00:00,76.07,98.52600000000001,28.676,32.166 +2020-03-16 02:15:00,79.7,98.59200000000001,28.676,32.166 +2020-03-16 02:30:00,80.65,100.493,28.676,32.166 +2020-03-16 02:45:00,81.61,101.565,28.676,32.166 +2020-03-16 03:00:00,77.46,105.42399999999999,26.552,32.166 +2020-03-16 03:15:00,79.91,107.682,26.552,32.166 +2020-03-16 03:30:00,82.47,108.404,26.552,32.166 +2020-03-16 03:45:00,82.23,109.36399999999999,26.552,32.166 +2020-03-16 04:00:00,80.26,122.883,27.44,32.166 +2020-03-16 04:15:00,78.08,136.07299999999998,27.44,32.166 +2020-03-16 04:30:00,78.99,137.483,27.44,32.166 +2020-03-16 04:45:00,83.48,138.162,27.44,32.166 +2020-03-16 05:00:00,85.58,168.11,36.825,32.166 +2020-03-16 05:15:00,89.84,199.683,36.825,32.166 +2020-03-16 05:30:00,94.03,193.502,36.825,32.166 +2020-03-16 05:45:00,98.78,184.55700000000002,36.825,32.166 +2020-03-16 06:00:00,104.82,183.747,56.589,32.166 +2020-03-16 06:15:00,108.78,188.298,56.589,32.166 +2020-03-16 06:30:00,111.69,189.327,56.589,32.166 +2020-03-16 06:45:00,114.28,191.799,56.589,32.166 +2020-03-16 07:00:00,119.75,194.726,67.49,32.166 +2020-03-16 07:15:00,118.99,196.967,67.49,32.166 +2020-03-16 07:30:00,121.18,196.90599999999998,67.49,32.166 +2020-03-16 07:45:00,121.94,194.672,67.49,32.166 +2020-03-16 08:00:00,124.7,193.282,60.028,32.166 +2020-03-16 08:15:00,122.52,192.105,60.028,32.166 +2020-03-16 08:30:00,121.4,187.63,60.028,32.166 +2020-03-16 08:45:00,119.86,184.179,60.028,32.166 +2020-03-16 09:00:00,117.82,178.373,55.018,32.166 +2020-03-16 09:15:00,116.63,174.179,55.018,32.166 +2020-03-16 09:30:00,117.73,173.43099999999998,55.018,32.166 +2020-03-16 09:45:00,115.81,171.18599999999998,55.018,32.166 +2020-03-16 10:00:00,113.41,168.915,51.183,32.166 +2020-03-16 10:15:00,116.0,167.81400000000002,51.183,32.166 +2020-03-16 10:30:00,115.46,165.19799999999998,51.183,32.166 +2020-03-16 10:45:00,117.05,164.111,51.183,32.166 +2020-03-16 11:00:00,113.1,158.343,50.065,32.166 +2020-03-16 11:15:00,113.17,158.403,50.065,32.166 +2020-03-16 11:30:00,113.75,160.048,50.065,32.166 +2020-03-16 11:45:00,114.16,160.379,50.065,32.166 +2020-03-16 12:00:00,111.33,156.921,48.141999999999996,32.166 +2020-03-16 12:15:00,113.06,157.437,48.141999999999996,32.166 +2020-03-16 12:30:00,110.37,156.293,48.141999999999996,32.166 +2020-03-16 12:45:00,110.4,157.15200000000002,48.141999999999996,32.166 +2020-03-16 13:00:00,108.44,157.352,47.887,32.166 +2020-03-16 13:15:00,107.98,155.593,47.887,32.166 +2020-03-16 13:30:00,107.97,152.868,47.887,32.166 +2020-03-16 13:45:00,108.56,152.096,47.887,32.166 +2020-03-16 14:00:00,111.41,153.593,48.571000000000005,32.166 +2020-03-16 14:15:00,108.91,152.257,48.571000000000005,32.166 +2020-03-16 14:30:00,108.43,151.621,48.571000000000005,32.166 +2020-03-16 14:45:00,109.18,152.847,48.571000000000005,32.166 +2020-03-16 15:00:00,110.53,154.037,49.937,32.166 +2020-03-16 15:15:00,110.99,152.216,49.937,32.166 +2020-03-16 15:30:00,110.57,151.31,49.937,32.166 +2020-03-16 15:45:00,112.88,150.865,49.937,32.166 +2020-03-16 16:00:00,113.73,154.678,52.963,32.166 +2020-03-16 16:15:00,114.16,156.461,52.963,32.166 +2020-03-16 16:30:00,116.67,156.502,52.963,32.166 +2020-03-16 16:45:00,119.49,155.469,52.963,32.166 +2020-03-16 17:00:00,120.61,156.90200000000002,61.163999999999994,32.166 +2020-03-16 17:15:00,121.52,159.283,61.163999999999994,32.166 +2020-03-16 17:30:00,124.46,161.386,61.163999999999994,32.166 +2020-03-16 17:45:00,126.0,162.251,61.163999999999994,32.166 +2020-03-16 18:00:00,128.25,166.34799999999998,63.788999999999994,32.166 +2020-03-16 18:15:00,128.87,167.03099999999998,63.788999999999994,32.166 +2020-03-16 18:30:00,132.59,165.72,63.788999999999994,32.166 +2020-03-16 18:45:00,135.2,169.049,63.788999999999994,32.166 +2020-03-16 19:00:00,135.47,167.59,63.913000000000004,32.166 +2020-03-16 19:15:00,135.93,165.708,63.913000000000004,32.166 +2020-03-16 19:30:00,136.42,165.69400000000002,63.913000000000004,32.166 +2020-03-16 19:45:00,139.91,163.987,63.913000000000004,32.166 +2020-03-16 20:00:00,131.55,157.97,65.44,32.166 +2020-03-16 20:15:00,123.08,154.694,65.44,32.166 +2020-03-16 20:30:00,115.5,151.886,65.44,32.166 +2020-03-16 20:45:00,115.34,150.49200000000002,65.44,32.166 +2020-03-16 21:00:00,111.28,145.696,59.117,32.166 +2020-03-16 21:15:00,109.77,142.936,59.117,32.166 +2020-03-16 21:30:00,108.18,142.46200000000002,59.117,32.166 +2020-03-16 21:45:00,108.66,141.463,59.117,32.166 +2020-03-16 22:00:00,100.71,133.118,52.301,32.166 +2020-03-16 22:15:00,101.8,129.96,52.301,32.166 +2020-03-16 22:30:00,98.84,115.694,52.301,32.166 +2020-03-16 22:45:00,97.68,108.209,52.301,32.166 +2020-03-16 23:00:00,98.26,101.245,44.373000000000005,32.166 +2020-03-16 23:15:00,99.14,100.038,44.373000000000005,32.166 +2020-03-16 23:30:00,95.57,100.943,44.373000000000005,32.166 +2020-03-16 23:45:00,90.74,101.705,44.373000000000005,32.166 +2020-03-17 00:00:00,83.29,96.788,44.647,32.166 +2020-03-17 00:15:00,85.18,96.06,44.647,32.166 +2020-03-17 00:30:00,88.73,94.814,44.647,32.166 +2020-03-17 00:45:00,90.01,93.965,44.647,32.166 +2020-03-17 01:00:00,86.46,95.25399999999999,41.433,32.166 +2020-03-17 01:15:00,85.81,95.272,41.433,32.166 +2020-03-17 01:30:00,81.39,94.90299999999999,41.433,32.166 +2020-03-17 01:45:00,87.26,94.779,41.433,32.166 +2020-03-17 02:00:00,87.84,96.57700000000001,39.909,32.166 +2020-03-17 02:15:00,88.12,96.916,39.909,32.166 +2020-03-17 02:30:00,80.08,98.25200000000001,39.909,32.166 +2020-03-17 02:45:00,81.87,99.441,39.909,32.166 +2020-03-17 03:00:00,88.39,102.213,39.14,32.166 +2020-03-17 03:15:00,89.75,104.1,39.14,32.166 +2020-03-17 03:30:00,82.45,105.178,39.14,32.166 +2020-03-17 03:45:00,84.57,105.939,39.14,32.166 +2020-03-17 04:00:00,86.69,118.898,40.015,32.166 +2020-03-17 04:15:00,91.6,131.80700000000002,40.015,32.166 +2020-03-17 04:30:00,94.85,132.954,40.015,32.166 +2020-03-17 04:45:00,92.46,134.70600000000002,40.015,32.166 +2020-03-17 05:00:00,92.95,168.956,44.93600000000001,32.166 +2020-03-17 05:15:00,94.92,200.56099999999998,44.93600000000001,32.166 +2020-03-17 05:30:00,98.34,193.25,44.93600000000001,32.166 +2020-03-17 05:45:00,99.36,184.088,44.93600000000001,32.166 +2020-03-17 06:00:00,109.4,182.72,57.271,32.166 +2020-03-17 06:15:00,112.51,188.58599999999998,57.271,32.166 +2020-03-17 06:30:00,114.99,188.97299999999998,57.271,32.166 +2020-03-17 06:45:00,116.99,190.85,57.271,32.166 +2020-03-17 07:00:00,118.61,193.696,68.352,32.166 +2020-03-17 07:15:00,121.29,195.703,68.352,32.166 +2020-03-17 07:30:00,121.0,195.18,68.352,32.166 +2020-03-17 07:45:00,121.45,192.78,68.352,32.166 +2020-03-17 08:00:00,121.87,191.438,60.717,32.166 +2020-03-17 08:15:00,119.41,189.315,60.717,32.166 +2020-03-17 08:30:00,117.8,184.7,60.717,32.166 +2020-03-17 08:45:00,117.14,180.785,60.717,32.166 +2020-03-17 09:00:00,114.19,174.417,54.603,32.166 +2020-03-17 09:15:00,117.59,171.476,54.603,32.166 +2020-03-17 09:30:00,123.71,171.49200000000002,54.603,32.166 +2020-03-17 09:45:00,123.51,169.47299999999998,54.603,32.166 +2020-03-17 10:00:00,123.65,166.34900000000002,52.308,32.166 +2020-03-17 10:15:00,126.87,164.386,52.308,32.166 +2020-03-17 10:30:00,129.22,161.95,52.308,32.166 +2020-03-17 10:45:00,132.76,161.387,52.308,32.166 +2020-03-17 11:00:00,132.25,156.829,51.838,32.166 +2020-03-17 11:15:00,132.0,156.743,51.838,32.166 +2020-03-17 11:30:00,132.65,157.129,51.838,32.166 +2020-03-17 11:45:00,134.64,157.938,51.838,32.166 +2020-03-17 12:00:00,132.83,153.315,50.375,32.166 +2020-03-17 12:15:00,132.99,153.584,50.375,32.166 +2020-03-17 12:30:00,132.12,153.22299999999998,50.375,32.166 +2020-03-17 12:45:00,133.93,154.017,50.375,32.166 +2020-03-17 13:00:00,132.01,153.819,50.735,32.166 +2020-03-17 13:15:00,130.45,152.21200000000002,50.735,32.166 +2020-03-17 13:30:00,124.25,150.447,50.735,32.166 +2020-03-17 13:45:00,128.13,149.58700000000002,50.735,32.166 +2020-03-17 14:00:00,127.73,151.502,50.946000000000005,32.166 +2020-03-17 14:15:00,128.74,150.222,50.946000000000005,32.166 +2020-03-17 14:30:00,128.92,150.161,50.946000000000005,32.166 +2020-03-17 14:45:00,130.63,151.126,50.946000000000005,32.166 +2020-03-17 15:00:00,132.16,151.93,53.18,32.166 +2020-03-17 15:15:00,132.92,150.592,53.18,32.166 +2020-03-17 15:30:00,132.15,149.77,53.18,32.166 +2020-03-17 15:45:00,132.5,149.059,53.18,32.166 +2020-03-17 16:00:00,131.86,153.01,54.928999999999995,32.166 +2020-03-17 16:15:00,131.47,155.187,54.928999999999995,32.166 +2020-03-17 16:30:00,129.12,155.662,54.928999999999995,32.166 +2020-03-17 16:45:00,130.42,155.007,54.928999999999995,32.166 +2020-03-17 17:00:00,131.53,156.974,60.913000000000004,32.166 +2020-03-17 17:15:00,135.08,159.489,60.913000000000004,32.166 +2020-03-17 17:30:00,143.36,162.031,60.913000000000004,32.166 +2020-03-17 17:45:00,144.28,162.69899999999998,60.913000000000004,32.166 +2020-03-17 18:00:00,137.97,166.455,62.214,32.166 +2020-03-17 18:15:00,138.35,167.197,62.214,32.166 +2020-03-17 18:30:00,140.04,165.55599999999998,62.214,32.166 +2020-03-17 18:45:00,139.14,169.495,62.214,32.166 +2020-03-17 19:00:00,138.8,167.745,62.38,32.166 +2020-03-17 19:15:00,134.96,165.702,62.38,32.166 +2020-03-17 19:30:00,135.63,165.108,62.38,32.166 +2020-03-17 19:45:00,132.59,163.565,62.38,32.166 +2020-03-17 20:00:00,123.95,157.756,65.018,32.166 +2020-03-17 20:15:00,121.08,153.566,65.018,32.166 +2020-03-17 20:30:00,114.15,151.616,65.018,32.166 +2020-03-17 20:45:00,114.92,149.891,65.018,32.166 +2020-03-17 21:00:00,114.69,144.774,56.416000000000004,32.166 +2020-03-17 21:15:00,109.67,142.322,56.416000000000004,32.166 +2020-03-17 21:30:00,107.76,141.31,56.416000000000004,32.166 +2020-03-17 21:45:00,105.9,140.57399999999998,56.416000000000004,32.166 +2020-03-17 22:00:00,108.3,133.65,52.846000000000004,32.166 +2020-03-17 22:15:00,108.41,130.213,52.846000000000004,32.166 +2020-03-17 22:30:00,101.96,116.061,52.846000000000004,32.166 +2020-03-17 22:45:00,96.25,108.795,52.846000000000004,32.166 +2020-03-17 23:00:00,91.66,101.624,44.435,32.166 +2020-03-17 23:15:00,90.02,100.15700000000001,44.435,32.166 +2020-03-17 23:30:00,86.99,100.775,44.435,32.166 +2020-03-17 23:45:00,89.71,101.256,44.435,32.166 +2020-03-18 00:00:00,83.73,96.42299999999999,42.527,32.166 +2020-03-18 00:15:00,89.73,95.705,42.527,32.166 +2020-03-18 00:30:00,86.8,94.446,42.527,32.166 +2020-03-18 00:45:00,85.84,93.59899999999999,42.527,32.166 +2020-03-18 01:00:00,80.74,94.855,38.655,32.166 +2020-03-18 01:15:00,80.02,94.855,38.655,32.166 +2020-03-18 01:30:00,85.25,94.46799999999999,38.655,32.166 +2020-03-18 01:45:00,87.28,94.34899999999999,38.655,32.166 +2020-03-18 02:00:00,86.8,96.135,36.912,32.166 +2020-03-18 02:15:00,84.18,96.462,36.912,32.166 +2020-03-18 02:30:00,79.64,97.82,36.912,32.166 +2020-03-18 02:45:00,86.07,99.01100000000001,36.912,32.166 +2020-03-18 03:00:00,88.69,101.79799999999999,36.98,32.166 +2020-03-18 03:15:00,89.95,103.663,36.98,32.166 +2020-03-18 03:30:00,85.36,104.735,36.98,32.166 +2020-03-18 03:45:00,81.81,105.51,36.98,32.166 +2020-03-18 04:00:00,86.79,118.46600000000001,38.052,32.166 +2020-03-18 04:15:00,90.82,131.358,38.052,32.166 +2020-03-18 04:30:00,92.75,132.518,38.052,32.166 +2020-03-18 04:45:00,88.52,134.257,38.052,32.166 +2020-03-18 05:00:00,90.89,168.46599999999998,42.455,32.166 +2020-03-18 05:15:00,92.19,200.058,42.455,32.166 +2020-03-18 05:30:00,96.66,192.72400000000002,42.455,32.166 +2020-03-18 05:45:00,100.78,183.581,42.455,32.166 +2020-03-18 06:00:00,108.49,182.225,57.986000000000004,32.166 +2020-03-18 06:15:00,111.7,188.088,57.986000000000004,32.166 +2020-03-18 06:30:00,114.37,188.43599999999998,57.986000000000004,32.166 +2020-03-18 06:45:00,118.53,190.293,57.986000000000004,32.166 +2020-03-18 07:00:00,124.03,193.15400000000002,71.868,32.166 +2020-03-18 07:15:00,124.83,195.13,71.868,32.166 +2020-03-18 07:30:00,127.19,194.567,71.868,32.166 +2020-03-18 07:45:00,130.33,192.137,71.868,32.166 +2020-03-18 08:00:00,133.17,190.771,62.225,32.166 +2020-03-18 08:15:00,131.53,188.65099999999998,62.225,32.166 +2020-03-18 08:30:00,133.54,183.987,62.225,32.166 +2020-03-18 08:45:00,132.96,180.095,62.225,32.166 +2020-03-18 09:00:00,129.67,173.738,58.802,32.166 +2020-03-18 09:15:00,131.06,170.799,58.802,32.166 +2020-03-18 09:30:00,130.32,170.835,58.802,32.166 +2020-03-18 09:45:00,125.59,168.834,58.802,32.166 +2020-03-18 10:00:00,123.25,165.722,54.122,32.166 +2020-03-18 10:15:00,118.73,163.805,54.122,32.166 +2020-03-18 10:30:00,115.43,161.391,54.122,32.166 +2020-03-18 10:45:00,117.34,160.849,54.122,32.166 +2020-03-18 11:00:00,117.69,156.282,54.368,32.166 +2020-03-18 11:15:00,118.38,156.218,54.368,32.166 +2020-03-18 11:30:00,118.73,156.61,54.368,32.166 +2020-03-18 11:45:00,118.1,157.438,54.368,32.166 +2020-03-18 12:00:00,117.22,152.83700000000002,52.74,32.166 +2020-03-18 12:15:00,117.72,153.116,52.74,32.166 +2020-03-18 12:30:00,117.55,152.716,52.74,32.166 +2020-03-18 12:45:00,115.41,153.509,52.74,32.166 +2020-03-18 13:00:00,116.23,153.35399999999998,52.544,32.166 +2020-03-18 13:15:00,118.56,151.731,52.544,32.166 +2020-03-18 13:30:00,118.92,149.958,52.544,32.166 +2020-03-18 13:45:00,118.2,149.1,52.544,32.166 +2020-03-18 14:00:00,119.88,151.083,53.602,32.166 +2020-03-18 14:15:00,117.8,149.78,53.602,32.166 +2020-03-18 14:30:00,116.98,149.68200000000002,53.602,32.166 +2020-03-18 14:45:00,119.05,150.655,53.602,32.166 +2020-03-18 15:00:00,115.92,151.475,55.59,32.166 +2020-03-18 15:15:00,111.49,150.108,55.59,32.166 +2020-03-18 15:30:00,111.91,149.237,55.59,32.166 +2020-03-18 15:45:00,121.29,148.507,55.59,32.166 +2020-03-18 16:00:00,119.54,152.477,57.586999999999996,32.166 +2020-03-18 16:15:00,120.09,154.63,57.586999999999996,32.166 +2020-03-18 16:30:00,114.99,155.105,57.586999999999996,32.166 +2020-03-18 16:45:00,117.72,154.4,57.586999999999996,32.166 +2020-03-18 17:00:00,122.47,156.395,62.111999999999995,32.166 +2020-03-18 17:15:00,129.68,158.906,62.111999999999995,32.166 +2020-03-18 17:30:00,131.77,161.46200000000002,62.111999999999995,32.166 +2020-03-18 17:45:00,132.41,162.132,62.111999999999995,32.166 +2020-03-18 18:00:00,131.64,165.895,64.605,32.166 +2020-03-18 18:15:00,126.8,166.695,64.605,32.166 +2020-03-18 18:30:00,135.48,165.045,64.605,32.166 +2020-03-18 18:45:00,144.49,169.00099999999998,64.605,32.166 +2020-03-18 19:00:00,144.79,167.22,65.55199999999999,32.166 +2020-03-18 19:15:00,137.82,165.19299999999998,65.55199999999999,32.166 +2020-03-18 19:30:00,133.32,164.62400000000002,65.55199999999999,32.166 +2020-03-18 19:45:00,131.22,163.12,65.55199999999999,32.166 +2020-03-18 20:00:00,129.19,157.282,66.778,32.166 +2020-03-18 20:15:00,125.46,153.107,66.778,32.166 +2020-03-18 20:30:00,120.34,151.188,66.778,32.166 +2020-03-18 20:45:00,117.63,149.47299999999998,66.778,32.166 +2020-03-18 21:00:00,114.44,144.349,56.103,32.166 +2020-03-18 21:15:00,114.71,141.899,56.103,32.166 +2020-03-18 21:30:00,109.71,140.885,56.103,32.166 +2020-03-18 21:45:00,108.39,140.174,56.103,32.166 +2020-03-18 22:00:00,107.26,133.243,51.371,32.166 +2020-03-18 22:15:00,105.38,129.833,51.371,32.166 +2020-03-18 22:30:00,98.14,115.63600000000001,51.371,32.166 +2020-03-18 22:45:00,94.88,108.369,51.371,32.166 +2020-03-18 23:00:00,88.42,101.18700000000001,42.798,32.166 +2020-03-18 23:15:00,89.09,99.745,42.798,32.166 +2020-03-18 23:30:00,91.31,100.363,42.798,32.166 +2020-03-18 23:45:00,92.36,100.868,42.798,32.166 +2020-03-19 00:00:00,87.88,96.053,39.069,32.166 +2020-03-19 00:15:00,85.89,95.34700000000001,39.069,32.166 +2020-03-19 00:30:00,87.68,94.073,39.069,32.166 +2020-03-19 00:45:00,87.98,93.228,39.069,32.166 +2020-03-19 01:00:00,84.61,94.45200000000001,37.043,32.166 +2020-03-19 01:15:00,79.55,94.434,37.043,32.166 +2020-03-19 01:30:00,77.18,94.027,37.043,32.166 +2020-03-19 01:45:00,78.42,93.915,37.043,32.166 +2020-03-19 02:00:00,79.65,95.69,34.625,32.166 +2020-03-19 02:15:00,86.0,96.006,34.625,32.166 +2020-03-19 02:30:00,83.94,97.384,34.625,32.166 +2020-03-19 02:45:00,86.89,98.575,34.625,32.166 +2020-03-19 03:00:00,82.57,101.37899999999999,33.812,32.166 +2020-03-19 03:15:00,86.09,103.221,33.812,32.166 +2020-03-19 03:30:00,88.78,104.287,33.812,32.166 +2020-03-19 03:45:00,86.84,105.07799999999999,33.812,32.166 +2020-03-19 04:00:00,86.04,118.03,35.236999999999995,32.166 +2020-03-19 04:15:00,87.46,130.905,35.236999999999995,32.166 +2020-03-19 04:30:00,90.13,132.077,35.236999999999995,32.166 +2020-03-19 04:45:00,89.22,133.804,35.236999999999995,32.166 +2020-03-19 05:00:00,87.18,167.97099999999998,40.375,32.166 +2020-03-19 05:15:00,92.65,199.551,40.375,32.166 +2020-03-19 05:30:00,91.79,192.196,40.375,32.166 +2020-03-19 05:45:00,99.25,183.07,40.375,32.166 +2020-03-19 06:00:00,101.84,181.726,52.316,32.166 +2020-03-19 06:15:00,108.97,187.58700000000002,52.316,32.166 +2020-03-19 06:30:00,112.26,187.893,52.316,32.166 +2020-03-19 06:45:00,114.99,189.731,52.316,32.166 +2020-03-19 07:00:00,119.6,192.607,64.115,32.166 +2020-03-19 07:15:00,119.74,194.554,64.115,32.166 +2020-03-19 07:30:00,122.99,193.949,64.115,32.166 +2020-03-19 07:45:00,125.11,191.489,64.115,32.166 +2020-03-19 08:00:00,132.62,190.1,55.033,32.166 +2020-03-19 08:15:00,133.14,187.981,55.033,32.166 +2020-03-19 08:30:00,132.06,183.27,55.033,32.166 +2020-03-19 08:45:00,130.67,179.4,55.033,32.166 +2020-03-19 09:00:00,131.44,173.053,49.411,32.166 +2020-03-19 09:15:00,128.19,170.11599999999999,49.411,32.166 +2020-03-19 09:30:00,130.89,170.173,49.411,32.166 +2020-03-19 09:45:00,133.55,168.19299999999998,49.411,32.166 +2020-03-19 10:00:00,135.03,165.09099999999998,45.82899999999999,32.166 +2020-03-19 10:15:00,135.81,163.22,45.82899999999999,32.166 +2020-03-19 10:30:00,135.42,160.829,45.82899999999999,32.166 +2020-03-19 10:45:00,133.48,160.30700000000002,45.82899999999999,32.166 +2020-03-19 11:00:00,134.38,155.732,44.333,32.166 +2020-03-19 11:15:00,136.87,155.691,44.333,32.166 +2020-03-19 11:30:00,136.53,156.089,44.333,32.166 +2020-03-19 11:45:00,135.15,156.935,44.333,32.166 +2020-03-19 12:00:00,134.45,152.355,42.95,32.166 +2020-03-19 12:15:00,132.99,152.64600000000002,42.95,32.166 +2020-03-19 12:30:00,130.93,152.20600000000002,42.95,32.166 +2020-03-19 12:45:00,131.7,152.998,42.95,32.166 +2020-03-19 13:00:00,131.55,152.886,42.489,32.166 +2020-03-19 13:15:00,136.58,151.246,42.489,32.166 +2020-03-19 13:30:00,137.49,149.465,42.489,32.166 +2020-03-19 13:45:00,130.19,148.61,42.489,32.166 +2020-03-19 14:00:00,126.15,150.662,43.448,32.166 +2020-03-19 14:15:00,126.13,149.335,43.448,32.166 +2020-03-19 14:30:00,128.46,149.201,43.448,32.166 +2020-03-19 14:45:00,126.37,150.18,43.448,32.166 +2020-03-19 15:00:00,127.54,151.016,45.994,32.166 +2020-03-19 15:15:00,133.12,149.622,45.994,32.166 +2020-03-19 15:30:00,132.92,148.701,45.994,32.166 +2020-03-19 15:45:00,133.74,147.954,45.994,32.166 +2020-03-19 16:00:00,129.51,151.94,48.167,32.166 +2020-03-19 16:15:00,133.23,154.07,48.167,32.166 +2020-03-19 16:30:00,134.2,154.545,48.167,32.166 +2020-03-19 16:45:00,133.59,153.78799999999998,48.167,32.166 +2020-03-19 17:00:00,130.78,155.813,52.637,32.166 +2020-03-19 17:15:00,129.72,158.317,52.637,32.166 +2020-03-19 17:30:00,130.87,160.888,52.637,32.166 +2020-03-19 17:45:00,140.13,161.56,52.637,32.166 +2020-03-19 18:00:00,142.78,165.331,55.739,32.166 +2020-03-19 18:15:00,139.96,166.188,55.739,32.166 +2020-03-19 18:30:00,137.06,164.53099999999998,55.739,32.166 +2020-03-19 18:45:00,135.97,168.502,55.739,32.166 +2020-03-19 19:00:00,140.2,166.69099999999997,56.36600000000001,32.166 +2020-03-19 19:15:00,139.3,164.68,56.36600000000001,32.166 +2020-03-19 19:30:00,137.47,164.135,56.36600000000001,32.166 +2020-03-19 19:45:00,134.57,162.673,56.36600000000001,32.166 +2020-03-19 20:00:00,124.92,156.805,56.338,32.166 +2020-03-19 20:15:00,122.58,152.644,56.338,32.166 +2020-03-19 20:30:00,124.08,150.756,56.338,32.166 +2020-03-19 20:45:00,121.71,149.05200000000002,56.338,32.166 +2020-03-19 21:00:00,111.74,143.921,49.894,32.166 +2020-03-19 21:15:00,110.78,141.474,49.894,32.166 +2020-03-19 21:30:00,112.46,140.45600000000002,49.894,32.166 +2020-03-19 21:45:00,110.73,139.77100000000002,49.894,32.166 +2020-03-19 22:00:00,106.07,132.833,46.687,32.166 +2020-03-19 22:15:00,102.14,129.44899999999998,46.687,32.166 +2020-03-19 22:30:00,100.56,115.20700000000001,46.687,32.166 +2020-03-19 22:45:00,100.02,107.939,46.687,32.166 +2020-03-19 23:00:00,93.88,100.74600000000001,39.211,32.166 +2020-03-19 23:15:00,92.98,99.32700000000001,39.211,32.166 +2020-03-19 23:30:00,90.87,99.948,39.211,32.166 +2020-03-19 23:45:00,92.97,100.475,39.211,32.166 +2020-03-20 00:00:00,81.31,94.274,36.616,32.166 +2020-03-20 00:15:00,83.1,93.804,36.616,32.166 +2020-03-20 00:30:00,80.74,92.495,36.616,32.166 +2020-03-20 00:45:00,87.41,91.87299999999999,36.616,32.166 +2020-03-20 01:00:00,85.22,92.698,33.799,32.166 +2020-03-20 01:15:00,85.31,93.18799999999999,33.799,32.166 +2020-03-20 01:30:00,79.72,92.814,33.799,32.166 +2020-03-20 01:45:00,79.16,92.711,33.799,32.166 +2020-03-20 02:00:00,75.56,94.837,32.968,32.166 +2020-03-20 02:15:00,78.31,95.037,32.968,32.166 +2020-03-20 02:30:00,84.24,97.10799999999999,32.968,32.166 +2020-03-20 02:45:00,84.88,98.116,32.968,32.166 +2020-03-20 03:00:00,84.31,100.412,33.533,32.166 +2020-03-20 03:15:00,80.11,102.57,33.533,32.166 +2020-03-20 03:30:00,86.07,103.539,33.533,32.166 +2020-03-20 03:45:00,86.71,104.882,33.533,32.166 +2020-03-20 04:00:00,89.04,118.052,36.102,32.166 +2020-03-20 04:15:00,86.87,130.207,36.102,32.166 +2020-03-20 04:30:00,82.36,131.881,36.102,32.166 +2020-03-20 04:45:00,84.69,132.501,36.102,32.166 +2020-03-20 05:00:00,89.4,165.488,42.423,32.166 +2020-03-20 05:15:00,90.87,198.532,42.423,32.166 +2020-03-20 05:30:00,93.45,192.05200000000002,42.423,32.166 +2020-03-20 05:45:00,99.81,182.74599999999998,42.423,32.166 +2020-03-20 06:00:00,105.17,181.84099999999998,55.38,32.166 +2020-03-20 06:15:00,110.26,186.604,55.38,32.166 +2020-03-20 06:30:00,112.63,186.18099999999998,55.38,32.166 +2020-03-20 06:45:00,116.56,189.22299999999998,55.38,32.166 +2020-03-20 07:00:00,123.53,191.669,65.929,32.166 +2020-03-20 07:15:00,124.74,194.675,65.929,32.166 +2020-03-20 07:30:00,128.22,193.28099999999998,65.929,32.166 +2020-03-20 07:45:00,131.67,190.02700000000002,65.929,32.166 +2020-03-20 08:00:00,135.19,187.975,57.336999999999996,32.166 +2020-03-20 08:15:00,135.75,185.748,57.336999999999996,32.166 +2020-03-20 08:30:00,136.9,181.74200000000002,57.336999999999996,32.166 +2020-03-20 08:45:00,138.65,176.588,57.336999999999996,32.166 +2020-03-20 09:00:00,138.65,169.93400000000003,54.226000000000006,32.166 +2020-03-20 09:15:00,141.78,168.017,54.226000000000006,32.166 +2020-03-20 09:30:00,141.41,167.548,54.226000000000006,32.166 +2020-03-20 09:45:00,140.33,165.61,54.226000000000006,32.166 +2020-03-20 10:00:00,137.44,161.526,51.298,32.166 +2020-03-20 10:15:00,138.85,160.215,51.298,32.166 +2020-03-20 10:30:00,139.49,157.942,51.298,32.166 +2020-03-20 10:45:00,140.16,157.05,51.298,32.166 +2020-03-20 11:00:00,135.6,152.503,50.839,32.166 +2020-03-20 11:15:00,134.52,151.446,50.839,32.166 +2020-03-20 11:30:00,136.04,153.204,50.839,32.166 +2020-03-20 11:45:00,132.53,153.84799999999998,50.839,32.166 +2020-03-20 12:00:00,128.08,150.317,47.976000000000006,32.166 +2020-03-20 12:15:00,131.48,148.69899999999998,47.976000000000006,32.166 +2020-03-20 12:30:00,131.48,148.384,47.976000000000006,32.166 +2020-03-20 12:45:00,128.91,149.39600000000002,47.976000000000006,32.166 +2020-03-20 13:00:00,125.64,150.276,46.299,32.166 +2020-03-20 13:15:00,127.45,149.352,46.299,32.166 +2020-03-20 13:30:00,121.83,147.784,46.299,32.166 +2020-03-20 13:45:00,121.45,146.958,46.299,32.166 +2020-03-20 14:00:00,122.13,147.91299999999998,44.971000000000004,32.166 +2020-03-20 14:15:00,122.95,146.52700000000002,44.971000000000004,32.166 +2020-03-20 14:30:00,119.93,147.18,44.971000000000004,32.166 +2020-03-20 14:45:00,118.5,148.24,44.971000000000004,32.166 +2020-03-20 15:00:00,118.31,148.666,47.48,32.166 +2020-03-20 15:15:00,118.3,146.813,47.48,32.166 +2020-03-20 15:30:00,117.43,144.417,47.48,32.166 +2020-03-20 15:45:00,118.9,143.97299999999998,47.48,32.166 +2020-03-20 16:00:00,119.03,146.799,50.648,32.166 +2020-03-20 16:15:00,117.4,149.28,50.648,32.166 +2020-03-20 16:30:00,117.62,149.784,50.648,32.166 +2020-03-20 16:45:00,116.65,148.667,50.648,32.166 +2020-03-20 17:00:00,118.31,151.38299999999998,56.251000000000005,32.166 +2020-03-20 17:15:00,119.69,153.495,56.251000000000005,32.166 +2020-03-20 17:30:00,121.15,155.864,56.251000000000005,32.166 +2020-03-20 17:45:00,124.53,156.298,56.251000000000005,32.166 +2020-03-20 18:00:00,130.12,160.666,58.982,32.166 +2020-03-20 18:15:00,129.06,160.994,58.982,32.166 +2020-03-20 18:30:00,130.46,159.622,58.982,32.166 +2020-03-20 18:45:00,132.02,163.724,58.982,32.166 +2020-03-20 19:00:00,134.39,162.879,57.293,32.166 +2020-03-20 19:15:00,130.14,162.16299999999998,57.293,32.166 +2020-03-20 19:30:00,128.96,161.32399999999998,57.293,32.166 +2020-03-20 19:45:00,127.91,159.238,57.293,32.166 +2020-03-20 20:00:00,121.85,153.34799999999998,59.433,32.166 +2020-03-20 20:15:00,124.01,149.438,59.433,32.166 +2020-03-20 20:30:00,121.62,147.405,59.433,32.166 +2020-03-20 20:45:00,115.58,145.928,59.433,32.166 +2020-03-20 21:00:00,107.59,141.58700000000002,52.153999999999996,32.166 +2020-03-20 21:15:00,107.1,139.983,52.153999999999996,32.166 +2020-03-20 21:30:00,102.66,138.957,52.153999999999996,32.166 +2020-03-20 21:45:00,102.64,138.795,52.153999999999996,32.166 +2020-03-20 22:00:00,95.47,132.6,47.125,32.166 +2020-03-20 22:15:00,92.65,129.063,47.125,32.166 +2020-03-20 22:30:00,94.55,121.314,47.125,32.166 +2020-03-20 22:45:00,96.74,117.149,47.125,32.166 +2020-03-20 23:00:00,91.54,110.07799999999999,41.236000000000004,32.166 +2020-03-20 23:15:00,84.93,106.649,41.236000000000004,32.166 +2020-03-20 23:30:00,83.48,105.56200000000001,41.236000000000004,32.166 +2020-03-20 23:45:00,79.79,105.538,41.236000000000004,32.166 +2020-03-21 00:00:00,76.15,92.06,36.484,31.988000000000003 +2020-03-21 00:15:00,76.52,88.061,36.484,31.988000000000003 +2020-03-21 00:30:00,75.95,87.62700000000001,36.484,31.988000000000003 +2020-03-21 00:45:00,78.77,87.333,36.484,31.988000000000003 +2020-03-21 01:00:00,78.62,88.72,32.391999999999996,31.988000000000003 +2020-03-21 01:15:00,79.74,88.615,32.391999999999996,31.988000000000003 +2020-03-21 01:30:00,75.68,87.559,32.391999999999996,31.988000000000003 +2020-03-21 01:45:00,75.56,87.695,32.391999999999996,31.988000000000003 +2020-03-21 02:00:00,77.03,90.04299999999999,30.194000000000003,31.988000000000003 +2020-03-21 02:15:00,75.94,89.686,30.194000000000003,31.988000000000003 +2020-03-21 02:30:00,75.96,90.661,30.194000000000003,31.988000000000003 +2020-03-21 02:45:00,70.86,92.00399999999999,30.194000000000003,31.988000000000003 +2020-03-21 03:00:00,76.24,94.37,29.677,31.988000000000003 +2020-03-21 03:15:00,77.68,95.339,29.677,31.988000000000003 +2020-03-21 03:30:00,77.67,95.18299999999999,29.677,31.988000000000003 +2020-03-21 03:45:00,73.57,97.11200000000001,29.677,31.988000000000003 +2020-03-21 04:00:00,69.82,106.507,29.616,31.988000000000003 +2020-03-21 04:15:00,70.66,116.397,29.616,31.988000000000003 +2020-03-21 04:30:00,72.22,115.867,29.616,31.988000000000003 +2020-03-21 04:45:00,72.34,116.185,29.616,31.988000000000003 +2020-03-21 05:00:00,73.28,134.24200000000002,29.625,31.988000000000003 +2020-03-21 05:15:00,70.66,149.116,29.625,31.988000000000003 +2020-03-21 05:30:00,72.31,143.395,29.625,31.988000000000003 +2020-03-21 05:45:00,71.97,139.756,29.625,31.988000000000003 +2020-03-21 06:00:00,72.01,156.734,30.551,31.988000000000003 +2020-03-21 06:15:00,74.11,176.644,30.551,31.988000000000003 +2020-03-21 06:30:00,70.85,171.11,30.551,31.988000000000003 +2020-03-21 06:45:00,75.14,166.157,30.551,31.988000000000003 +2020-03-21 07:00:00,77.83,164.997,34.865,31.988000000000003 +2020-03-21 07:15:00,78.22,166.58599999999998,34.865,31.988000000000003 +2020-03-21 07:30:00,79.47,167.74200000000002,34.865,31.988000000000003 +2020-03-21 07:45:00,80.88,167.858,34.865,31.988000000000003 +2020-03-21 08:00:00,82.4,168.946,41.456,31.988000000000003 +2020-03-21 08:15:00,82.02,169.545,41.456,31.988000000000003 +2020-03-21 08:30:00,82.13,166.77,41.456,31.988000000000003 +2020-03-21 08:45:00,83.0,164.435,41.456,31.988000000000003 +2020-03-21 09:00:00,84.22,160.035,43.001999999999995,31.988000000000003 +2020-03-21 09:15:00,84.04,158.866,43.001999999999995,31.988000000000003 +2020-03-21 09:30:00,83.23,159.287,43.001999999999995,31.988000000000003 +2020-03-21 09:45:00,82.37,157.369,43.001999999999995,31.988000000000003 +2020-03-21 10:00:00,81.8,153.608,42.047,31.988000000000003 +2020-03-21 10:15:00,80.91,152.577,42.047,31.988000000000003 +2020-03-21 10:30:00,79.58,150.36700000000002,42.047,31.988000000000003 +2020-03-21 10:45:00,80.21,150.458,42.047,31.988000000000003 +2020-03-21 11:00:00,81.27,145.994,39.894,31.988000000000003 +2020-03-21 11:15:00,82.37,144.628,39.894,31.988000000000003 +2020-03-21 11:30:00,82.93,145.55,39.894,31.988000000000003 +2020-03-21 11:45:00,86.04,145.608,39.894,31.988000000000003 +2020-03-21 12:00:00,81.5,141.393,38.122,31.988000000000003 +2020-03-21 12:15:00,78.76,140.543,38.122,31.988000000000003 +2020-03-21 12:30:00,77.88,140.417,38.122,31.988000000000003 +2020-03-21 12:45:00,78.92,140.98,38.122,31.988000000000003 +2020-03-21 13:00:00,73.16,141.30200000000002,34.645,31.988000000000003 +2020-03-21 13:15:00,75.26,138.506,34.645,31.988000000000003 +2020-03-21 13:30:00,72.49,136.60399999999998,34.645,31.988000000000003 +2020-03-21 13:45:00,72.8,135.825,34.645,31.988000000000003 +2020-03-21 14:00:00,71.82,137.874,33.739000000000004,31.988000000000003 +2020-03-21 14:15:00,68.31,135.69299999999998,33.739000000000004,31.988000000000003 +2020-03-21 14:30:00,69.19,134.681,33.739000000000004,31.988000000000003 +2020-03-21 14:45:00,72.21,136.053,33.739000000000004,31.988000000000003 +2020-03-21 15:00:00,73.63,137.141,35.908,31.988000000000003 +2020-03-21 15:15:00,74.28,136.116,35.908,31.988000000000003 +2020-03-21 15:30:00,72.5,135.011,35.908,31.988000000000003 +2020-03-21 15:45:00,76.03,134.32299999999998,35.908,31.988000000000003 +2020-03-21 16:00:00,78.29,136.875,39.249,31.988000000000003 +2020-03-21 16:15:00,79.04,139.828,39.249,31.988000000000003 +2020-03-21 16:30:00,80.66,140.35,39.249,31.988000000000003 +2020-03-21 16:45:00,82.94,139.906,39.249,31.988000000000003 +2020-03-21 17:00:00,86.08,141.953,46.045,31.988000000000003 +2020-03-21 17:15:00,88.1,144.812,46.045,31.988000000000003 +2020-03-21 17:30:00,90.2,147.079,46.045,31.988000000000003 +2020-03-21 17:45:00,92.79,147.321,46.045,31.988000000000003 +2020-03-21 18:00:00,97.5,151.714,48.238,31.988000000000003 +2020-03-21 18:15:00,97.87,154.032,48.238,31.988000000000003 +2020-03-21 18:30:00,100.71,154.118,48.238,31.988000000000003 +2020-03-21 18:45:00,101.17,154.534,48.238,31.988000000000003 +2020-03-21 19:00:00,107.14,153.971,46.785,31.988000000000003 +2020-03-21 19:15:00,105.31,152.583,46.785,31.988000000000003 +2020-03-21 19:30:00,103.63,152.597,46.785,31.988000000000003 +2020-03-21 19:45:00,103.35,150.908,46.785,31.988000000000003 +2020-03-21 20:00:00,97.14,147.029,39.830999999999996,31.988000000000003 +2020-03-21 20:15:00,94.26,144.661,39.830999999999996,31.988000000000003 +2020-03-21 20:30:00,91.73,142.124,39.830999999999996,31.988000000000003 +2020-03-21 20:45:00,90.18,140.87,39.830999999999996,31.988000000000003 +2020-03-21 21:00:00,84.4,137.915,34.063,31.988000000000003 +2020-03-21 21:15:00,85.72,136.566,34.063,31.988000000000003 +2020-03-21 21:30:00,84.05,136.578,34.063,31.988000000000003 +2020-03-21 21:45:00,83.62,135.95600000000002,34.063,31.988000000000003 +2020-03-21 22:00:00,79.7,130.829,34.455999999999996,31.988000000000003 +2020-03-21 22:15:00,78.84,129.39,34.455999999999996,31.988000000000003 +2020-03-21 22:30:00,73.84,126.485,34.455999999999996,31.988000000000003 +2020-03-21 22:45:00,75.89,123.93,34.455999999999996,31.988000000000003 +2020-03-21 23:00:00,70.82,118.62,27.840999999999998,31.988000000000003 +2020-03-21 23:15:00,70.94,114.022,27.840999999999998,31.988000000000003 +2020-03-21 23:30:00,69.79,112.245,27.840999999999998,31.988000000000003 +2020-03-21 23:45:00,68.39,110.39399999999999,27.840999999999998,31.988000000000003 +2020-03-22 00:00:00,60.08,92.443,20.007,31.988000000000003 +2020-03-22 00:15:00,58.06,87.84200000000001,20.007,31.988000000000003 +2020-03-22 00:30:00,55.51,87.055,20.007,31.988000000000003 +2020-03-22 00:45:00,56.45,87.271,20.007,31.988000000000003 +2020-03-22 01:00:00,54.53,88.59700000000001,17.378,31.988000000000003 +2020-03-22 01:15:00,52.88,89.23700000000001,17.378,31.988000000000003 +2020-03-22 01:30:00,52.07,88.521,17.378,31.988000000000003 +2020-03-22 01:45:00,53.98,88.29799999999999,17.378,31.988000000000003 +2020-03-22 02:00:00,48.89,90.07600000000001,16.145,31.988000000000003 +2020-03-22 02:15:00,53.69,89.28200000000001,16.145,31.988000000000003 +2020-03-22 02:30:00,51.28,90.99600000000001,16.145,31.988000000000003 +2020-03-22 02:45:00,53.95,92.603,16.145,31.988000000000003 +2020-03-22 03:00:00,50.72,95.43799999999999,15.427999999999999,31.988000000000003 +2020-03-22 03:15:00,54.24,96.069,15.427999999999999,31.988000000000003 +2020-03-22 03:30:00,51.64,96.738,15.427999999999999,31.988000000000003 +2020-03-22 03:45:00,51.01,98.361,15.427999999999999,31.988000000000003 +2020-03-22 04:00:00,50.48,107.544,16.663,31.988000000000003 +2020-03-22 04:15:00,51.91,116.501,16.663,31.988000000000003 +2020-03-22 04:30:00,51.98,116.48,16.663,31.988000000000003 +2020-03-22 04:45:00,52.83,116.838,16.663,31.988000000000003 +2020-03-22 05:00:00,51.89,132.247,17.271,31.988000000000003 +2020-03-22 05:15:00,52.74,145.017,17.271,31.988000000000003 +2020-03-22 05:30:00,52.54,139.015,17.271,31.988000000000003 +2020-03-22 05:45:00,54.38,135.485,17.271,31.988000000000003 +2020-03-22 06:00:00,55.46,151.58700000000002,17.612000000000002,31.988000000000003 +2020-03-22 06:15:00,53.48,170.449,17.612000000000002,31.988000000000003 +2020-03-22 06:30:00,55.9,163.799,17.612000000000002,31.988000000000003 +2020-03-22 06:45:00,53.71,157.689,17.612000000000002,31.988000000000003 +2020-03-22 07:00:00,55.53,158.501,20.88,31.988000000000003 +2020-03-22 07:15:00,59.67,158.875,20.88,31.988000000000003 +2020-03-22 07:30:00,62.15,159.44299999999998,20.88,31.988000000000003 +2020-03-22 07:45:00,61.32,158.92700000000002,20.88,31.988000000000003 +2020-03-22 08:00:00,67.05,161.621,25.861,31.988000000000003 +2020-03-22 08:15:00,67.97,162.512,25.861,31.988000000000003 +2020-03-22 08:30:00,69.68,161.264,25.861,31.988000000000003 +2020-03-22 08:45:00,69.61,160.52,25.861,31.988000000000003 +2020-03-22 09:00:00,74.12,155.761,27.921999999999997,31.988000000000003 +2020-03-22 09:15:00,75.03,154.89,27.921999999999997,31.988000000000003 +2020-03-22 09:30:00,74.98,155.334,27.921999999999997,31.988000000000003 +2020-03-22 09:45:00,73.91,153.639,27.921999999999997,31.988000000000003 +2020-03-22 10:00:00,76.48,152.053,29.048000000000002,31.988000000000003 +2020-03-22 10:15:00,73.74,151.536,29.048000000000002,31.988000000000003 +2020-03-22 10:30:00,75.72,149.9,29.048000000000002,31.988000000000003 +2020-03-22 10:45:00,74.12,148.77100000000002,29.048000000000002,31.988000000000003 +2020-03-22 11:00:00,72.15,144.928,32.02,31.988000000000003 +2020-03-22 11:15:00,77.67,143.569,32.02,31.988000000000003 +2020-03-22 11:30:00,77.66,143.93200000000002,32.02,31.988000000000003 +2020-03-22 11:45:00,82.07,144.57399999999998,32.02,31.988000000000003 +2020-03-22 12:00:00,72.99,140.191,28.55,31.988000000000003 +2020-03-22 12:15:00,75.01,140.744,28.55,31.988000000000003 +2020-03-22 12:30:00,71.52,139.441,28.55,31.988000000000003 +2020-03-22 12:45:00,69.58,139.036,28.55,31.988000000000003 +2020-03-22 13:00:00,68.21,138.718,25.601999999999997,31.988000000000003 +2020-03-22 13:15:00,67.52,138.237,25.601999999999997,31.988000000000003 +2020-03-22 13:30:00,66.81,135.855,25.601999999999997,31.988000000000003 +2020-03-22 13:45:00,67.38,134.836,25.601999999999997,31.988000000000003 +2020-03-22 14:00:00,60.59,137.475,23.916999999999998,31.988000000000003 +2020-03-22 14:15:00,59.39,136.376,23.916999999999998,31.988000000000003 +2020-03-22 14:30:00,58.18,136.016,23.916999999999998,31.988000000000003 +2020-03-22 14:45:00,60.55,136.768,23.916999999999998,31.988000000000003 +2020-03-22 15:00:00,59.16,136.64700000000002,24.064,31.988000000000003 +2020-03-22 15:15:00,60.01,136.006,24.064,31.988000000000003 +2020-03-22 15:30:00,56.92,135.28799999999998,24.064,31.988000000000003 +2020-03-22 15:45:00,58.07,135.231,24.064,31.988000000000003 +2020-03-22 16:00:00,61.44,138.836,28.189,31.988000000000003 +2020-03-22 16:15:00,62.16,141.086,28.189,31.988000000000003 +2020-03-22 16:30:00,62.09,142.095,28.189,31.988000000000003 +2020-03-22 16:45:00,63.69,141.726,28.189,31.988000000000003 +2020-03-22 17:00:00,63.44,143.861,37.576,31.988000000000003 +2020-03-22 17:15:00,65.92,146.877,37.576,31.988000000000003 +2020-03-22 17:30:00,69.07,149.62,37.576,31.988000000000003 +2020-03-22 17:45:00,69.42,151.817,37.576,31.988000000000003 +2020-03-22 18:00:00,77.2,155.935,42.669,31.988000000000003 +2020-03-22 18:15:00,75.08,159.263,42.669,31.988000000000003 +2020-03-22 18:30:00,76.8,157.589,42.669,31.988000000000003 +2020-03-22 18:45:00,76.23,159.547,42.669,31.988000000000003 +2020-03-22 19:00:00,79.58,159.303,43.538999999999994,31.988000000000003 +2020-03-22 19:15:00,77.87,158.088,43.538999999999994,31.988000000000003 +2020-03-22 19:30:00,78.53,157.924,43.538999999999994,31.988000000000003 +2020-03-22 19:45:00,80.41,157.30700000000002,43.538999999999994,31.988000000000003 +2020-03-22 20:00:00,79.37,153.393,37.330999999999996,31.988000000000003 +2020-03-22 20:15:00,79.52,151.787,37.330999999999996,31.988000000000003 +2020-03-22 20:30:00,78.47,150.509,37.330999999999996,31.988000000000003 +2020-03-22 20:45:00,77.25,147.811,37.330999999999996,31.988000000000003 +2020-03-22 21:00:00,74.47,142.727,33.856,31.988000000000003 +2020-03-22 21:15:00,73.54,140.791,33.856,31.988000000000003 +2020-03-22 21:30:00,73.03,140.843,33.856,31.988000000000003 +2020-03-22 21:45:00,72.54,140.459,33.856,31.988000000000003 +2020-03-22 22:00:00,69.45,134.984,34.711999999999996,31.988000000000003 +2020-03-22 22:15:00,69.2,132.468,34.711999999999996,31.988000000000003 +2020-03-22 22:30:00,64.59,126.84,34.711999999999996,31.988000000000003 +2020-03-22 22:45:00,67.2,123.23,34.711999999999996,31.988000000000003 +2020-03-22 23:00:00,62.87,115.586,29.698,31.988000000000003 +2020-03-22 23:15:00,62.97,112.869,29.698,31.988000000000003 +2020-03-22 23:30:00,58.12,111.583,29.698,31.988000000000003 +2020-03-22 23:45:00,60.08,110.514,29.698,31.988000000000003 +2020-03-23 00:00:00,82.34,95.79,29.983,32.166 +2020-03-23 00:15:00,83.55,93.75399999999999,29.983,32.166 +2020-03-23 00:30:00,79.21,92.929,29.983,32.166 +2020-03-23 00:45:00,78.16,92.605,29.983,32.166 +2020-03-23 01:00:00,79.25,94.01700000000001,29.122,32.166 +2020-03-23 01:15:00,80.78,94.23700000000001,29.122,32.166 +2020-03-23 01:30:00,76.93,93.65899999999999,29.122,32.166 +2020-03-23 01:45:00,77.19,93.501,29.122,32.166 +2020-03-23 02:00:00,77.95,95.383,28.676,32.166 +2020-03-23 02:15:00,78.39,95.369,28.676,32.166 +2020-03-23 02:30:00,81.25,97.412,28.676,32.166 +2020-03-23 02:45:00,76.31,98.49799999999999,28.676,32.166 +2020-03-23 03:00:00,80.56,102.464,26.552,32.166 +2020-03-23 03:15:00,79.77,104.568,26.552,32.166 +2020-03-23 03:30:00,79.74,105.24799999999999,26.552,32.166 +2020-03-23 03:45:00,77.39,106.31200000000001,26.552,32.166 +2020-03-23 04:00:00,81.0,119.811,27.44,32.166 +2020-03-23 04:15:00,84.58,132.873,27.44,32.166 +2020-03-23 04:30:00,82.05,134.373,27.44,32.166 +2020-03-23 04:45:00,82.35,134.965,27.44,32.166 +2020-03-23 05:00:00,81.8,164.62599999999998,36.825,32.166 +2020-03-23 05:15:00,86.07,196.108,36.825,32.166 +2020-03-23 05:30:00,94.52,189.77700000000002,36.825,32.166 +2020-03-23 05:45:00,100.38,180.954,36.825,32.166 +2020-03-23 06:00:00,108.8,180.22799999999998,56.589,32.166 +2020-03-23 06:15:00,114.59,184.75799999999998,56.589,32.166 +2020-03-23 06:30:00,111.33,185.50099999999998,56.589,32.166 +2020-03-23 06:45:00,111.22,187.83,56.589,32.166 +2020-03-23 07:00:00,120.07,190.862,67.49,32.166 +2020-03-23 07:15:00,121.71,192.895,67.49,32.166 +2020-03-23 07:30:00,125.53,192.547,67.49,32.166 +2020-03-23 07:45:00,126.83,190.105,67.49,32.166 +2020-03-23 08:00:00,132.73,188.553,60.028,32.166 +2020-03-23 08:15:00,130.98,187.391,60.028,32.166 +2020-03-23 08:30:00,131.58,182.579,60.028,32.166 +2020-03-23 08:45:00,133.7,179.28900000000002,60.028,32.166 +2020-03-23 09:00:00,132.55,173.55599999999998,55.018,32.166 +2020-03-23 09:15:00,133.07,169.37900000000002,55.018,32.166 +2020-03-23 09:30:00,135.0,168.774,55.018,32.166 +2020-03-23 09:45:00,134.08,166.667,55.018,32.166 +2020-03-23 10:00:00,129.98,164.472,51.183,32.166 +2020-03-23 10:15:00,130.53,163.696,51.183,32.166 +2020-03-23 10:30:00,128.68,161.246,51.183,32.166 +2020-03-23 10:45:00,130.19,160.298,51.183,32.166 +2020-03-23 11:00:00,126.38,154.475,50.065,32.166 +2020-03-23 11:15:00,128.38,154.691,50.065,32.166 +2020-03-23 11:30:00,129.3,156.38,50.065,32.166 +2020-03-23 11:45:00,130.58,156.843,50.065,32.166 +2020-03-23 12:00:00,126.27,153.531,48.141999999999996,32.166 +2020-03-23 12:15:00,128.79,154.124,48.141999999999996,32.166 +2020-03-23 12:30:00,127.53,152.702,48.141999999999996,32.166 +2020-03-23 12:45:00,127.44,153.553,48.141999999999996,32.166 +2020-03-23 13:00:00,125.66,154.064,47.887,32.166 +2020-03-23 13:15:00,135.19,152.185,47.887,32.166 +2020-03-23 13:30:00,135.03,149.401,47.887,32.166 +2020-03-23 13:45:00,132.18,148.64700000000002,47.887,32.166 +2020-03-23 14:00:00,128.57,150.628,48.571000000000005,32.166 +2020-03-23 14:15:00,127.42,149.127,48.571000000000005,32.166 +2020-03-23 14:30:00,130.25,148.237,48.571000000000005,32.166 +2020-03-23 14:45:00,133.63,149.51,48.571000000000005,32.166 +2020-03-23 15:00:00,134.03,150.812,49.937,32.166 +2020-03-23 15:15:00,132.5,148.795,49.937,32.166 +2020-03-23 15:30:00,125.71,147.537,49.937,32.166 +2020-03-23 15:45:00,123.67,146.966,49.937,32.166 +2020-03-23 16:00:00,123.22,150.901,52.963,32.166 +2020-03-23 16:15:00,127.85,152.52,52.963,32.166 +2020-03-23 16:30:00,129.07,152.556,52.963,32.166 +2020-03-23 16:45:00,128.76,151.165,52.963,32.166 +2020-03-23 17:00:00,126.16,152.803,61.163999999999994,32.166 +2020-03-23 17:15:00,121.94,155.144,61.163999999999994,32.166 +2020-03-23 17:30:00,125.11,157.346,61.163999999999994,32.166 +2020-03-23 17:45:00,128.06,158.218,61.163999999999994,32.166 +2020-03-23 18:00:00,127.94,162.369,63.788999999999994,32.166 +2020-03-23 18:15:00,126.59,163.451,63.788999999999994,32.166 +2020-03-23 18:30:00,126.68,162.086,63.788999999999994,32.166 +2020-03-23 18:45:00,129.76,165.52599999999998,63.788999999999994,32.166 +2020-03-23 19:00:00,131.55,163.859,63.913000000000004,32.166 +2020-03-23 19:15:00,124.43,162.089,63.913000000000004,32.166 +2020-03-23 19:30:00,127.1,162.246,63.913000000000004,32.166 +2020-03-23 19:45:00,128.86,160.82399999999998,63.913000000000004,32.166 +2020-03-23 20:00:00,128.96,154.60299999999998,65.44,32.166 +2020-03-23 20:15:00,120.05,151.42600000000002,65.44,32.166 +2020-03-23 20:30:00,119.49,148.838,65.44,32.166 +2020-03-23 20:45:00,118.33,147.517,65.44,32.166 +2020-03-23 21:00:00,111.59,142.67700000000002,59.117,32.166 +2020-03-23 21:15:00,108.56,139.935,59.117,32.166 +2020-03-23 21:30:00,108.54,139.444,59.117,32.166 +2020-03-23 21:45:00,110.95,138.616,59.117,32.166 +2020-03-23 22:00:00,104.38,130.222,52.301,32.166 +2020-03-23 22:15:00,99.93,127.251,52.301,32.166 +2020-03-23 22:30:00,99.37,112.665,52.301,32.166 +2020-03-23 22:45:00,98.94,105.17399999999999,52.301,32.166 +2020-03-23 23:00:00,93.28,98.132,44.373000000000005,32.166 +2020-03-23 23:15:00,87.35,97.09299999999999,44.373000000000005,32.166 +2020-03-23 23:30:00,90.84,98.01299999999999,44.373000000000005,32.166 +2020-03-23 23:45:00,92.8,98.931,44.373000000000005,32.166 +2020-03-24 00:00:00,87.21,94.149,44.647,32.166 +2020-03-24 00:15:00,81.24,93.507,44.647,32.166 +2020-03-24 00:30:00,77.75,92.161,44.647,32.166 +2020-03-24 00:45:00,80.38,91.33200000000001,44.647,32.166 +2020-03-24 01:00:00,79.49,92.387,41.433,32.166 +2020-03-24 01:15:00,81.5,92.28,41.433,32.166 +2020-03-24 01:30:00,81.05,91.77600000000001,41.433,32.166 +2020-03-24 01:45:00,82.59,91.697,41.433,32.166 +2020-03-24 02:00:00,74.4,93.40899999999999,39.909,32.166 +2020-03-24 02:15:00,83.06,93.669,39.909,32.166 +2020-03-24 02:30:00,82.28,95.147,39.909,32.166 +2020-03-24 02:45:00,84.99,96.35,39.909,32.166 +2020-03-24 03:00:00,80.16,99.23100000000001,39.14,32.166 +2020-03-24 03:15:00,82.68,100.959,39.14,32.166 +2020-03-24 03:30:00,86.24,101.995,39.14,32.166 +2020-03-24 03:45:00,85.57,102.86200000000001,39.14,32.166 +2020-03-24 04:00:00,85.36,115.8,40.015,32.166 +2020-03-24 04:15:00,84.21,128.582,40.015,32.166 +2020-03-24 04:30:00,88.34,129.81799999999998,40.015,32.166 +2020-03-24 04:45:00,83.04,131.484,40.015,32.166 +2020-03-24 05:00:00,83.64,165.446,44.93600000000001,32.166 +2020-03-24 05:15:00,88.14,196.958,44.93600000000001,32.166 +2020-03-24 05:30:00,90.73,189.49900000000002,44.93600000000001,32.166 +2020-03-24 05:45:00,92.93,180.458,44.93600000000001,32.166 +2020-03-24 06:00:00,104.65,179.174,57.271,32.166 +2020-03-24 06:15:00,109.33,185.017,57.271,32.166 +2020-03-24 06:30:00,113.7,185.11599999999999,57.271,32.166 +2020-03-24 06:45:00,113.97,186.847,57.271,32.166 +2020-03-24 07:00:00,123.2,189.797,68.352,32.166 +2020-03-24 07:15:00,124.58,191.597,68.352,32.166 +2020-03-24 07:30:00,126.5,190.78599999999997,68.352,32.166 +2020-03-24 07:45:00,129.13,188.18099999999998,68.352,32.166 +2020-03-24 08:00:00,133.3,186.676,60.717,32.166 +2020-03-24 08:15:00,134.37,184.57,60.717,32.166 +2020-03-24 08:30:00,134.29,179.618,60.717,32.166 +2020-03-24 08:45:00,134.4,175.868,60.717,32.166 +2020-03-24 09:00:00,132.5,169.576,54.603,32.166 +2020-03-24 09:15:00,131.97,166.65,54.603,32.166 +2020-03-24 09:30:00,130.44,166.808,54.603,32.166 +2020-03-24 09:45:00,126.35,164.928,54.603,32.166 +2020-03-24 10:00:00,123.85,161.881,52.308,32.166 +2020-03-24 10:15:00,123.58,160.246,52.308,32.166 +2020-03-24 10:30:00,121.34,157.974,52.308,32.166 +2020-03-24 10:45:00,117.88,157.553,52.308,32.166 +2020-03-24 11:00:00,119.32,152.941,51.838,32.166 +2020-03-24 11:15:00,119.66,153.012,51.838,32.166 +2020-03-24 11:30:00,122.6,153.441,51.838,32.166 +2020-03-24 11:45:00,119.94,154.38299999999998,51.838,32.166 +2020-03-24 12:00:00,116.79,149.907,50.375,32.166 +2020-03-24 12:15:00,116.39,150.251,50.375,32.166 +2020-03-24 12:30:00,117.13,149.612,50.375,32.166 +2020-03-24 12:45:00,113.27,150.39600000000002,50.375,32.166 +2020-03-24 13:00:00,113.92,150.511,50.735,32.166 +2020-03-24 13:15:00,118.49,148.784,50.735,32.166 +2020-03-24 13:30:00,117.98,146.963,50.735,32.166 +2020-03-24 13:45:00,116.71,146.122,50.735,32.166 +2020-03-24 14:00:00,115.13,148.52100000000002,50.946000000000005,32.166 +2020-03-24 14:15:00,113.45,147.077,50.946000000000005,32.166 +2020-03-24 14:30:00,110.95,146.75799999999998,50.946000000000005,32.166 +2020-03-24 14:45:00,110.16,147.769,50.946000000000005,32.166 +2020-03-24 15:00:00,114.6,148.683,53.18,32.166 +2020-03-24 15:15:00,116.6,147.15,53.18,32.166 +2020-03-24 15:30:00,109.45,145.975,53.18,32.166 +2020-03-24 15:45:00,117.18,145.137,53.18,32.166 +2020-03-24 16:00:00,117.12,149.214,54.928999999999995,32.166 +2020-03-24 16:15:00,118.32,151.22299999999998,54.928999999999995,32.166 +2020-03-24 16:30:00,119.29,151.694,54.928999999999995,32.166 +2020-03-24 16:45:00,117.54,150.678,54.928999999999995,32.166 +2020-03-24 17:00:00,119.96,152.85299999999998,60.913000000000004,32.166 +2020-03-24 17:15:00,118.56,155.32399999999998,60.913000000000004,32.166 +2020-03-24 17:30:00,119.39,157.963,60.913000000000004,32.166 +2020-03-24 17:45:00,119.91,158.639,60.913000000000004,32.166 +2020-03-24 18:00:00,123.8,162.444,62.214,32.166 +2020-03-24 18:15:00,123.93,163.588,62.214,32.166 +2020-03-24 18:30:00,123.06,161.892,62.214,32.166 +2020-03-24 18:45:00,120.31,165.94099999999997,62.214,32.166 +2020-03-24 19:00:00,128.27,163.985,62.38,32.166 +2020-03-24 19:15:00,128.22,162.053,62.38,32.166 +2020-03-24 19:30:00,127.86,161.632,62.38,32.166 +2020-03-24 19:45:00,126.09,160.375,62.38,32.166 +2020-03-24 20:00:00,128.41,154.361,65.018,32.166 +2020-03-24 20:15:00,124.95,150.27200000000002,65.018,32.166 +2020-03-24 20:30:00,121.53,148.543,65.018,32.166 +2020-03-24 20:45:00,118.91,146.891,65.018,32.166 +2020-03-24 21:00:00,112.76,141.732,56.416000000000004,32.166 +2020-03-24 21:15:00,116.6,139.299,56.416000000000004,32.166 +2020-03-24 21:30:00,112.66,138.269,56.416000000000004,32.166 +2020-03-24 21:45:00,107.6,137.704,56.416000000000004,32.166 +2020-03-24 22:00:00,104.55,130.731,52.846000000000004,32.166 +2020-03-24 22:15:00,103.78,127.48100000000001,52.846000000000004,32.166 +2020-03-24 22:30:00,102.03,113.005,52.846000000000004,32.166 +2020-03-24 22:45:00,97.36,105.73200000000001,52.846000000000004,32.166 +2020-03-24 23:00:00,93.61,98.484,44.435,32.166 +2020-03-24 23:15:00,97.37,97.18700000000001,44.435,32.166 +2020-03-24 23:30:00,91.61,97.818,44.435,32.166 +2020-03-24 23:45:00,86.1,98.45700000000001,44.435,32.166 +2020-03-25 00:00:00,86.32,93.757,42.527,32.166 +2020-03-25 00:15:00,85.72,93.12899999999999,42.527,32.166 +2020-03-25 00:30:00,83.35,91.76899999999999,42.527,32.166 +2020-03-25 00:45:00,80.67,90.944,42.527,32.166 +2020-03-25 01:00:00,76.45,91.96600000000001,38.655,32.166 +2020-03-25 01:15:00,74.51,91.84,38.655,32.166 +2020-03-25 01:30:00,78.1,91.316,38.655,32.166 +2020-03-25 01:45:00,84.0,91.244,38.655,32.166 +2020-03-25 02:00:00,80.41,92.943,36.912,32.166 +2020-03-25 02:15:00,80.95,93.19200000000001,36.912,32.166 +2020-03-25 02:30:00,75.56,94.69,36.912,32.166 +2020-03-25 02:45:00,79.65,95.89399999999999,36.912,32.166 +2020-03-25 03:00:00,81.67,98.792,36.98,32.166 +2020-03-25 03:15:00,81.92,100.49799999999999,36.98,32.166 +2020-03-25 03:30:00,82.53,101.52600000000001,36.98,32.166 +2020-03-25 03:45:00,81.28,102.40899999999999,36.98,32.166 +2020-03-25 04:00:00,76.87,115.345,38.052,32.166 +2020-03-25 04:15:00,82.91,128.107,38.052,32.166 +2020-03-25 04:30:00,87.12,129.356,38.052,32.166 +2020-03-25 04:45:00,89.8,131.01,38.052,32.166 +2020-03-25 05:00:00,84.78,164.93,42.455,32.166 +2020-03-25 05:15:00,86.31,196.429,42.455,32.166 +2020-03-25 05:30:00,91.91,188.94799999999998,42.455,32.166 +2020-03-25 05:45:00,94.23,179.926,42.455,32.166 +2020-03-25 06:00:00,103.12,178.65200000000002,57.986000000000004,32.166 +2020-03-25 06:15:00,110.12,184.49099999999999,57.986000000000004,32.166 +2020-03-25 06:30:00,113.23,184.548,57.986000000000004,32.166 +2020-03-25 06:45:00,114.61,186.25900000000001,57.986000000000004,32.166 +2020-03-25 07:00:00,121.68,189.222,71.868,32.166 +2020-03-25 07:15:00,122.82,190.99200000000002,71.868,32.166 +2020-03-25 07:30:00,123.06,190.141,71.868,32.166 +2020-03-25 07:45:00,125.56,187.507,71.868,32.166 +2020-03-25 08:00:00,131.42,185.979,62.225,32.166 +2020-03-25 08:15:00,131.94,183.877,62.225,32.166 +2020-03-25 08:30:00,129.5,178.877,62.225,32.166 +2020-03-25 08:45:00,130.38,175.15099999999998,62.225,32.166 +2020-03-25 09:00:00,128.7,168.872,58.802,32.166 +2020-03-25 09:15:00,130.92,165.947,58.802,32.166 +2020-03-25 09:30:00,130.3,166.12400000000002,58.802,32.166 +2020-03-25 09:45:00,129.49,164.265,58.802,32.166 +2020-03-25 10:00:00,129.92,161.23,54.122,32.166 +2020-03-25 10:15:00,129.34,159.642,54.122,32.166 +2020-03-25 10:30:00,131.48,157.39600000000002,54.122,32.166 +2020-03-25 10:45:00,131.23,156.995,54.122,32.166 +2020-03-25 11:00:00,133.57,152.376,54.368,32.166 +2020-03-25 11:15:00,133.01,152.469,54.368,32.166 +2020-03-25 11:30:00,135.91,152.905,54.368,32.166 +2020-03-25 11:45:00,134.24,153.866,54.368,32.166 +2020-03-25 12:00:00,134.65,149.411,52.74,32.166 +2020-03-25 12:15:00,134.3,149.766,52.74,32.166 +2020-03-25 12:30:00,131.7,149.085,52.74,32.166 +2020-03-25 12:45:00,131.3,149.868,52.74,32.166 +2020-03-25 13:00:00,128.24,150.028,52.544,32.166 +2020-03-25 13:15:00,135.9,148.285,52.544,32.166 +2020-03-25 13:30:00,134.63,146.457,52.544,32.166 +2020-03-25 13:45:00,130.07,145.619,52.544,32.166 +2020-03-25 14:00:00,125.5,148.08700000000002,53.602,32.166 +2020-03-25 14:15:00,124.21,146.62,53.602,32.166 +2020-03-25 14:30:00,121.48,146.262,53.602,32.166 +2020-03-25 14:45:00,124.82,147.278,53.602,32.166 +2020-03-25 15:00:00,128.87,148.209,55.59,32.166 +2020-03-25 15:15:00,132.49,146.649,55.59,32.166 +2020-03-25 15:30:00,130.48,145.423,55.59,32.166 +2020-03-25 15:45:00,125.19,144.566,55.59,32.166 +2020-03-25 16:00:00,124.83,148.662,57.586999999999996,32.166 +2020-03-25 16:15:00,120.76,150.645,57.586999999999996,32.166 +2020-03-25 16:30:00,126.07,151.116,57.586999999999996,32.166 +2020-03-25 16:45:00,129.45,150.047,57.586999999999996,32.166 +2020-03-25 17:00:00,129.43,152.254,62.111999999999995,32.166 +2020-03-25 17:15:00,125.36,154.717,62.111999999999995,32.166 +2020-03-25 17:30:00,128.18,157.368,62.111999999999995,32.166 +2020-03-25 17:45:00,127.2,158.043,62.111999999999995,32.166 +2020-03-25 18:00:00,131.15,161.856,64.605,32.166 +2020-03-25 18:15:00,124.35,163.05700000000002,64.605,32.166 +2020-03-25 18:30:00,128.29,161.352,64.605,32.166 +2020-03-25 18:45:00,127.92,165.417,64.605,32.166 +2020-03-25 19:00:00,132.44,163.43200000000002,65.55199999999999,32.166 +2020-03-25 19:15:00,123.83,161.515,65.55199999999999,32.166 +2020-03-25 19:30:00,129.2,161.12,65.55199999999999,32.166 +2020-03-25 19:45:00,131.36,159.905,65.55199999999999,32.166 +2020-03-25 20:00:00,129.62,153.862,66.778,32.166 +2020-03-25 20:15:00,119.8,149.78799999999998,66.778,32.166 +2020-03-25 20:30:00,115.17,148.091,66.778,32.166 +2020-03-25 20:45:00,115.5,146.44899999999998,66.778,32.166 +2020-03-25 21:00:00,109.54,141.285,56.103,32.166 +2020-03-25 21:15:00,115.91,138.856,56.103,32.166 +2020-03-25 21:30:00,110.45,137.822,56.103,32.166 +2020-03-25 21:45:00,111.02,137.282,56.103,32.166 +2020-03-25 22:00:00,101.35,130.30200000000002,51.371,32.166 +2020-03-25 22:15:00,99.56,127.07799999999999,51.371,32.166 +2020-03-25 22:30:00,100.15,112.553,51.371,32.166 +2020-03-25 22:45:00,100.47,105.279,51.371,32.166 +2020-03-25 23:00:00,91.64,98.023,42.798,32.166 +2020-03-25 23:15:00,90.67,96.74799999999999,42.798,32.166 +2020-03-25 23:30:00,91.31,97.381,42.798,32.166 +2020-03-25 23:45:00,89.57,98.04299999999999,42.798,32.166 +2020-03-26 00:00:00,81.94,93.36200000000001,39.069,32.166 +2020-03-26 00:15:00,81.2,92.749,39.069,32.166 +2020-03-26 00:30:00,84.64,91.374,39.069,32.166 +2020-03-26 00:45:00,84.28,90.554,39.069,32.166 +2020-03-26 01:00:00,81.91,91.542,37.043,32.166 +2020-03-26 01:15:00,73.61,91.398,37.043,32.166 +2020-03-26 01:30:00,80.41,90.853,37.043,32.166 +2020-03-26 01:45:00,81.37,90.79,37.043,32.166 +2020-03-26 02:00:00,77.06,92.475,34.625,32.166 +2020-03-26 02:15:00,76.31,92.712,34.625,32.166 +2020-03-26 02:30:00,71.77,94.23,34.625,32.166 +2020-03-26 02:45:00,75.66,95.43700000000001,34.625,32.166 +2020-03-26 03:00:00,77.66,98.35,33.812,32.166 +2020-03-26 03:15:00,81.48,100.03200000000001,33.812,32.166 +2020-03-26 03:30:00,74.62,101.055,33.812,32.166 +2020-03-26 03:45:00,78.13,101.95299999999999,33.812,32.166 +2020-03-26 04:00:00,75.81,114.887,35.236999999999995,32.166 +2020-03-26 04:15:00,75.77,127.63,35.236999999999995,32.166 +2020-03-26 04:30:00,74.86,128.892,35.236999999999995,32.166 +2020-03-26 04:45:00,78.41,130.533,35.236999999999995,32.166 +2020-03-26 05:00:00,84.12,164.412,40.375,32.166 +2020-03-26 05:15:00,86.05,195.898,40.375,32.166 +2020-03-26 05:30:00,87.24,188.39700000000002,40.375,32.166 +2020-03-26 05:45:00,92.96,179.391,40.375,32.166 +2020-03-26 06:00:00,98.47,178.128,52.316,32.166 +2020-03-26 06:15:00,106.48,183.963,52.316,32.166 +2020-03-26 06:30:00,108.98,183.97799999999998,52.316,32.166 +2020-03-26 06:45:00,110.31,185.66400000000002,52.316,32.166 +2020-03-26 07:00:00,119.63,188.642,64.115,32.166 +2020-03-26 07:15:00,121.23,190.38400000000001,64.115,32.166 +2020-03-26 07:30:00,122.47,189.49099999999999,64.115,32.166 +2020-03-26 07:45:00,125.54,186.829,64.115,32.166 +2020-03-26 08:00:00,126.35,185.27700000000002,55.033,32.166 +2020-03-26 08:15:00,127.67,183.18099999999998,55.033,32.166 +2020-03-26 08:30:00,128.36,178.132,55.033,32.166 +2020-03-26 08:45:00,129.96,174.43099999999998,55.033,32.166 +2020-03-26 09:00:00,128.86,168.16400000000002,49.411,32.166 +2020-03-26 09:15:00,126.99,165.24200000000002,49.411,32.166 +2020-03-26 09:30:00,128.41,165.438,49.411,32.166 +2020-03-26 09:45:00,128.5,163.6,49.411,32.166 +2020-03-26 10:00:00,125.96,160.575,45.82899999999999,32.166 +2020-03-26 10:15:00,126.19,159.036,45.82899999999999,32.166 +2020-03-26 10:30:00,122.45,156.815,45.82899999999999,32.166 +2020-03-26 10:45:00,122.6,156.434,45.82899999999999,32.166 +2020-03-26 11:00:00,124.89,151.809,44.333,32.166 +2020-03-26 11:15:00,127.98,151.92600000000002,44.333,32.166 +2020-03-26 11:30:00,126.17,152.36700000000002,44.333,32.166 +2020-03-26 11:45:00,127.4,153.346,44.333,32.166 +2020-03-26 12:00:00,122.97,148.91299999999998,42.95,32.166 +2020-03-26 12:15:00,124.07,149.278,42.95,32.166 +2020-03-26 12:30:00,129.47,148.55700000000002,42.95,32.166 +2020-03-26 12:45:00,132.19,149.338,42.95,32.166 +2020-03-26 13:00:00,126.6,149.54399999999998,42.489,32.166 +2020-03-26 13:15:00,121.2,147.784,42.489,32.166 +2020-03-26 13:30:00,122.77,145.94799999999998,42.489,32.166 +2020-03-26 13:45:00,122.03,145.113,42.489,32.166 +2020-03-26 14:00:00,118.12,147.65200000000002,43.448,32.166 +2020-03-26 14:15:00,120.48,146.16,43.448,32.166 +2020-03-26 14:30:00,124.87,145.765,43.448,32.166 +2020-03-26 14:45:00,124.56,146.787,43.448,32.166 +2020-03-26 15:00:00,122.41,147.733,45.994,32.166 +2020-03-26 15:15:00,117.85,146.145,45.994,32.166 +2020-03-26 15:30:00,118.99,144.86700000000002,45.994,32.166 +2020-03-26 15:45:00,120.98,143.993,45.994,32.166 +2020-03-26 16:00:00,116.33,148.107,48.167,32.166 +2020-03-26 16:15:00,113.12,150.065,48.167,32.166 +2020-03-26 16:30:00,110.23,150.535,48.167,32.166 +2020-03-26 16:45:00,103.8,149.41299999999998,48.167,32.166 +2020-03-26 17:00:00,103.8,151.651,52.637,32.166 +2020-03-26 17:15:00,109.33,154.106,52.637,32.166 +2020-03-26 17:30:00,115.38,156.77100000000002,52.637,32.166 +2020-03-26 17:45:00,118.72,157.446,52.637,32.166 +2020-03-26 18:00:00,121.33,161.264,55.739,32.166 +2020-03-26 18:15:00,118.59,162.52200000000002,55.739,32.166 +2020-03-26 18:30:00,113.07,160.809,55.739,32.166 +2020-03-26 18:45:00,113.68,164.889,55.739,32.166 +2020-03-26 19:00:00,113.24,162.876,56.36600000000001,32.166 +2020-03-26 19:15:00,115.41,160.976,56.36600000000001,32.166 +2020-03-26 19:30:00,121.08,160.605,56.36600000000001,32.166 +2020-03-26 19:45:00,117.92,159.431,56.36600000000001,32.166 +2020-03-26 20:00:00,115.8,153.36,56.338,32.166 +2020-03-26 20:15:00,112.12,149.299,56.338,32.166 +2020-03-26 20:30:00,109.36,147.637,56.338,32.166 +2020-03-26 20:45:00,108.08,146.005,56.338,32.166 +2020-03-26 21:00:00,100.24,140.836,49.894,32.166 +2020-03-26 21:15:00,98.69,138.41,49.894,32.166 +2020-03-26 21:30:00,92.64,137.374,49.894,32.166 +2020-03-26 21:45:00,91.85,136.857,49.894,32.166 +2020-03-26 22:00:00,85.8,129.87,46.687,32.166 +2020-03-26 22:15:00,85.45,126.67299999999999,46.687,32.166 +2020-03-26 22:30:00,78.83,112.09899999999999,46.687,32.166 +2020-03-26 22:45:00,80.03,104.823,46.687,32.166 +2020-03-26 23:00:00,73.44,97.556,39.211,32.166 +2020-03-26 23:15:00,74.45,96.307,39.211,32.166 +2020-03-26 23:30:00,71.79,96.94200000000001,39.211,32.166 +2020-03-26 23:45:00,69.13,97.62700000000001,39.211,32.166 +2020-03-27 00:00:00,81.29,91.55799999999999,36.616,32.166 +2020-03-27 00:15:00,83.4,91.184,36.616,32.166 +2020-03-27 00:30:00,84.17,89.77600000000001,36.616,32.166 +2020-03-27 00:45:00,83.96,89.18,36.616,32.166 +2020-03-27 01:00:00,77.52,89.76700000000001,33.799,32.166 +2020-03-27 01:15:00,76.7,90.131,33.799,32.166 +2020-03-27 01:30:00,76.43,89.62,33.799,32.166 +2020-03-27 01:45:00,77.15,89.56700000000001,33.799,32.166 +2020-03-27 02:00:00,76.99,91.602,32.968,32.166 +2020-03-27 02:15:00,77.28,91.721,32.968,32.166 +2020-03-27 02:30:00,77.43,93.932,32.968,32.166 +2020-03-27 02:45:00,79.33,94.956,32.968,32.166 +2020-03-27 03:00:00,79.5,97.36200000000001,33.533,32.166 +2020-03-27 03:15:00,77.66,99.35600000000001,33.533,32.166 +2020-03-27 03:30:00,81.27,100.28299999999999,33.533,32.166 +2020-03-27 03:45:00,79.74,101.734,33.533,32.166 +2020-03-27 04:00:00,79.68,114.887,36.102,32.166 +2020-03-27 04:15:00,79.42,126.90899999999999,36.102,32.166 +2020-03-27 04:30:00,77.92,128.674,36.102,32.166 +2020-03-27 04:45:00,80.87,129.208,36.102,32.166 +2020-03-27 05:00:00,85.69,161.907,42.423,32.166 +2020-03-27 05:15:00,87.55,194.855,42.423,32.166 +2020-03-27 05:30:00,89.94,188.229,42.423,32.166 +2020-03-27 05:45:00,94.07,179.046,42.423,32.166 +2020-03-27 06:00:00,101.92,178.21599999999998,55.38,32.166 +2020-03-27 06:15:00,105.46,182.955,55.38,32.166 +2020-03-27 06:30:00,108.23,182.237,55.38,32.166 +2020-03-27 06:45:00,111.45,185.12599999999998,55.38,32.166 +2020-03-27 07:00:00,118.3,187.672,65.929,32.166 +2020-03-27 07:15:00,118.8,190.475,65.929,32.166 +2020-03-27 07:30:00,122.1,188.794,65.929,32.166 +2020-03-27 07:45:00,124.3,185.33900000000003,65.929,32.166 +2020-03-27 08:00:00,127.28,183.12400000000002,57.336999999999996,32.166 +2020-03-27 08:15:00,127.22,180.92,57.336999999999996,32.166 +2020-03-27 08:30:00,127.94,176.578,57.336999999999996,32.166 +2020-03-27 08:45:00,128.1,171.59599999999998,57.336999999999996,32.166 +2020-03-27 09:00:00,127.49,165.024,54.226000000000006,32.166 +2020-03-27 09:15:00,128.67,163.12,54.226000000000006,32.166 +2020-03-27 09:30:00,129.09,162.789,54.226000000000006,32.166 +2020-03-27 09:45:00,128.83,160.995,54.226000000000006,32.166 +2020-03-27 10:00:00,127.42,156.991,51.298,32.166 +2020-03-27 10:15:00,127.88,156.01,51.298,32.166 +2020-03-27 10:30:00,126.44,153.909,51.298,32.166 +2020-03-27 10:45:00,127.39,153.159,51.298,32.166 +2020-03-27 11:00:00,126.52,148.564,50.839,32.166 +2020-03-27 11:15:00,127.61,147.666,50.839,32.166 +2020-03-27 11:30:00,125.8,149.467,50.839,32.166 +2020-03-27 11:45:00,125.03,150.244,50.839,32.166 +2020-03-27 12:00:00,122.0,146.861,47.976000000000006,32.166 +2020-03-27 12:15:00,122.03,145.314,47.976000000000006,32.166 +2020-03-27 12:30:00,121.02,144.716,47.976000000000006,32.166 +2020-03-27 12:45:00,120.19,145.718,47.976000000000006,32.166 +2020-03-27 13:00:00,119.24,146.917,46.299,32.166 +2020-03-27 13:15:00,120.7,145.874,46.299,32.166 +2020-03-27 13:30:00,117.46,144.253,46.299,32.166 +2020-03-27 13:45:00,119.26,143.44799999999998,46.299,32.166 +2020-03-27 14:00:00,117.14,144.891,44.971000000000004,32.166 +2020-03-27 14:15:00,113.78,143.339,44.971000000000004,32.166 +2020-03-27 14:30:00,110.02,143.72899999999998,44.971000000000004,32.166 +2020-03-27 14:45:00,113.2,144.83100000000002,44.971000000000004,32.166 +2020-03-27 15:00:00,114.23,145.365,47.48,32.166 +2020-03-27 15:15:00,112.17,143.32,47.48,32.166 +2020-03-27 15:30:00,106.47,140.567,47.48,32.166 +2020-03-27 15:45:00,107.87,139.994,47.48,32.166 +2020-03-27 16:00:00,110.03,142.95,50.648,32.166 +2020-03-27 16:15:00,110.03,145.256,50.648,32.166 +2020-03-27 16:30:00,112.39,145.755,50.648,32.166 +2020-03-27 16:45:00,112.32,144.27,50.648,32.166 +2020-03-27 17:00:00,115.94,147.203,56.251000000000005,32.166 +2020-03-27 17:15:00,116.05,149.262,56.251000000000005,32.166 +2020-03-27 17:30:00,117.7,151.722,56.251000000000005,32.166 +2020-03-27 17:45:00,118.13,152.159,56.251000000000005,32.166 +2020-03-27 18:00:00,121.7,156.57299999999998,58.982,32.166 +2020-03-27 18:15:00,122.86,157.303,58.982,32.166 +2020-03-27 18:30:00,122.89,155.875,58.982,32.166 +2020-03-27 18:45:00,124.43,160.083,58.982,32.166 +2020-03-27 19:00:00,125.7,159.037,57.293,32.166 +2020-03-27 19:15:00,121.2,158.434,57.293,32.166 +2020-03-27 19:30:00,121.4,157.769,57.293,32.166 +2020-03-27 19:45:00,119.95,155.972,57.293,32.166 +2020-03-27 20:00:00,116.48,149.879,59.433,32.166 +2020-03-27 20:15:00,114.79,146.07,59.433,32.166 +2020-03-27 20:30:00,113.96,144.265,59.433,32.166 +2020-03-27 20:45:00,113.11,142.858,59.433,32.166 +2020-03-27 21:00:00,108.02,138.48,52.153999999999996,32.166 +2020-03-27 21:15:00,105.58,136.90200000000002,52.153999999999996,32.166 +2020-03-27 21:30:00,99.98,135.85399999999998,52.153999999999996,32.166 +2020-03-27 21:45:00,100.41,135.86,52.153999999999996,32.166 +2020-03-27 22:00:00,93.64,129.616,47.125,32.166 +2020-03-27 22:15:00,92.04,126.265,47.125,32.166 +2020-03-27 22:30:00,91.62,118.181,47.125,32.166 +2020-03-27 22:45:00,93.54,114.007,47.125,32.166 +2020-03-27 23:00:00,88.63,106.86399999999999,41.236000000000004,32.166 +2020-03-27 23:15:00,85.58,103.60600000000001,41.236000000000004,32.166 +2020-03-27 23:30:00,83.35,102.531,41.236000000000004,32.166 +2020-03-27 23:45:00,81.51,102.666,41.236000000000004,32.166 +2020-03-28 00:00:00,57.71,89.321,36.484,31.988000000000003 +2020-03-28 00:15:00,54.17,85.42,36.484,31.988000000000003 +2020-03-28 00:30:00,56.34,84.88799999999999,36.484,31.988000000000003 +2020-03-28 00:45:00,54.63,84.62299999999999,36.484,31.988000000000003 +2020-03-28 01:00:00,49.06,85.77,32.391999999999996,31.988000000000003 +2020-03-28 01:15:00,52.94,85.538,32.391999999999996,31.988000000000003 +2020-03-28 01:30:00,49.74,84.344,32.391999999999996,31.988000000000003 +2020-03-28 01:45:00,52.99,84.53,32.391999999999996,31.988000000000003 +2020-03-28 02:00:00,51.54,86.786,30.194000000000003,31.988000000000003 +2020-03-28 02:15:00,52.29,86.351,30.194000000000003,31.988000000000003 +2020-03-28 02:30:00,52.34,87.463,30.194000000000003,31.988000000000003 +2020-03-28 02:45:00,50.12,88.823,30.194000000000003,31.988000000000003 +2020-03-28 03:00:00,50.63,91.3,29.677,31.988000000000003 +2020-03-28 03:15:00,47.78,92.103,29.677,31.988000000000003 +2020-03-28 03:30:00,48.43,91.905,29.677,31.988000000000003 +2020-03-28 03:45:00,51.9,93.94200000000001,29.677,31.988000000000003 +2020-03-28 04:00:00,49.36,103.32,29.616,31.988000000000003 +2020-03-28 04:15:00,52.2,113.07700000000001,29.616,31.988000000000003 +2020-03-28 04:30:00,53.04,112.63799999999999,29.616,31.988000000000003 +2020-03-28 04:45:00,51.37,112.87,29.616,31.988000000000003 +2020-03-28 05:00:00,55.2,130.638,29.625,31.988000000000003 +2020-03-28 05:15:00,53.94,145.415,29.625,31.988000000000003 +2020-03-28 05:30:00,57.62,139.55100000000002,29.625,31.988000000000003 +2020-03-28 05:45:00,59.67,136.032,29.625,31.988000000000003 +2020-03-28 06:00:00,60.42,153.085,30.551,31.988000000000003 +2020-03-28 06:15:00,63.03,172.97099999999998,30.551,31.988000000000003 +2020-03-28 06:30:00,60.51,167.139,30.551,31.988000000000003 +2020-03-28 06:45:00,61.22,162.032,30.551,31.988000000000003 +2020-03-28 07:00:00,66.83,160.97,34.865,31.988000000000003 +2020-03-28 07:15:00,68.4,162.357,34.865,31.988000000000003 +2020-03-28 07:30:00,67.37,163.225,34.865,31.988000000000003 +2020-03-28 07:45:00,72.4,163.143,34.865,31.988000000000003 +2020-03-28 08:00:00,72.28,164.06799999999998,41.456,31.988000000000003 +2020-03-28 08:15:00,77.29,164.692,41.456,31.988000000000003 +2020-03-28 08:30:00,80.4,161.583,41.456,31.988000000000003 +2020-03-28 08:45:00,80.83,159.421,41.456,31.988000000000003 +2020-03-28 09:00:00,86.02,155.105,43.001999999999995,31.988000000000003 +2020-03-28 09:15:00,86.55,153.95,43.001999999999995,31.988000000000003 +2020-03-28 09:30:00,87.98,154.50799999999998,43.001999999999995,31.988000000000003 +2020-03-28 09:45:00,89.07,152.734,43.001999999999995,31.988000000000003 +2020-03-28 10:00:00,88.02,149.053,42.047,31.988000000000003 +2020-03-28 10:15:00,91.44,148.35399999999998,42.047,31.988000000000003 +2020-03-28 10:30:00,89.49,146.31799999999998,42.047,31.988000000000003 +2020-03-28 10:45:00,95.01,146.55200000000002,42.047,31.988000000000003 +2020-03-28 11:00:00,94.88,142.039,39.894,31.988000000000003 +2020-03-28 11:15:00,97.49,140.834,39.894,31.988000000000003 +2020-03-28 11:30:00,97.87,141.798,39.894,31.988000000000003 +2020-03-28 11:45:00,97.43,141.989,39.894,31.988000000000003 +2020-03-28 12:00:00,92.61,137.923,38.122,31.988000000000003 +2020-03-28 12:15:00,94.95,137.143,38.122,31.988000000000003 +2020-03-28 12:30:00,90.34,136.732,38.122,31.988000000000003 +2020-03-28 12:45:00,86.38,137.285,38.122,31.988000000000003 +2020-03-28 13:00:00,82.75,137.92700000000002,34.645,31.988000000000003 +2020-03-28 13:15:00,84.34,135.013,34.645,31.988000000000003 +2020-03-28 13:30:00,84.37,133.059,34.645,31.988000000000003 +2020-03-28 13:45:00,84.04,132.303,34.645,31.988000000000003 +2020-03-28 14:00:00,79.67,134.838,33.739000000000004,31.988000000000003 +2020-03-28 14:15:00,81.26,132.49200000000002,33.739000000000004,31.988000000000003 +2020-03-28 14:30:00,79.9,131.214,33.739000000000004,31.988000000000003 +2020-03-28 14:45:00,79.54,132.628,33.739000000000004,31.988000000000003 +2020-03-28 15:00:00,78.41,133.822,35.908,31.988000000000003 +2020-03-28 15:15:00,80.8,132.606,35.908,31.988000000000003 +2020-03-28 15:30:00,80.27,131.143,35.908,31.988000000000003 +2020-03-28 15:45:00,77.77,130.329,35.908,31.988000000000003 +2020-03-28 16:00:00,80.98,133.009,39.249,31.988000000000003 +2020-03-28 16:15:00,80.76,135.78799999999998,39.249,31.988000000000003 +2020-03-28 16:30:00,81.37,136.305,39.249,31.988000000000003 +2020-03-28 16:45:00,79.09,135.488,39.249,31.988000000000003 +2020-03-28 17:00:00,83.53,137.756,46.045,31.988000000000003 +2020-03-28 17:15:00,82.0,140.559,46.045,31.988000000000003 +2020-03-28 17:30:00,86.27,142.915,46.045,31.988000000000003 +2020-03-28 17:45:00,87.5,143.157,46.045,31.988000000000003 +2020-03-28 18:00:00,89.89,147.595,48.238,31.988000000000003 +2020-03-28 18:15:00,89.13,150.316,48.238,31.988000000000003 +2020-03-28 18:30:00,88.57,150.346,48.238,31.988000000000003 +2020-03-28 18:45:00,85.87,150.866,48.238,31.988000000000003 +2020-03-28 19:00:00,91.17,150.105,46.785,31.988000000000003 +2020-03-28 19:15:00,86.77,148.829,46.785,31.988000000000003 +2020-03-28 19:30:00,89.79,149.017,46.785,31.988000000000003 +2020-03-28 19:45:00,91.35,147.619,46.785,31.988000000000003 +2020-03-28 20:00:00,86.33,143.537,39.830999999999996,31.988000000000003 +2020-03-28 20:15:00,86.08,141.27,39.830999999999996,31.988000000000003 +2020-03-28 20:30:00,84.34,138.96200000000002,39.830999999999996,31.988000000000003 +2020-03-28 20:45:00,82.32,137.77700000000002,39.830999999999996,31.988000000000003 +2020-03-28 21:00:00,78.48,134.78799999999998,34.063,31.988000000000003 +2020-03-28 21:15:00,78.09,133.465,34.063,31.988000000000003 +2020-03-28 21:30:00,73.54,133.45600000000002,34.063,31.988000000000003 +2020-03-28 21:45:00,76.09,133.001,34.063,31.988000000000003 +2020-03-28 22:00:00,73.18,127.82700000000001,34.455999999999996,31.988000000000003 +2020-03-28 22:15:00,71.44,126.572,34.455999999999996,31.988000000000003 +2020-03-28 22:30:00,69.55,123.32799999999999,34.455999999999996,31.988000000000003 +2020-03-28 22:45:00,69.05,120.762,34.455999999999996,31.988000000000003 +2020-03-28 23:00:00,66.49,115.384,27.840999999999998,31.988000000000003 +2020-03-28 23:15:00,65.31,110.95700000000001,27.840999999999998,31.988000000000003 +2020-03-28 23:30:00,60.24,109.191,27.840999999999998,31.988000000000003 +2020-03-28 23:45:00,61.54,107.5,27.840999999999998,31.988000000000003 +2020-03-29 00:00:00,63.42,89.681,20.007,31.988000000000003 +2020-03-29 00:15:00,63.84,85.18,20.007,31.988000000000003 +2020-03-29 00:30:00,63.41,84.29700000000001,20.007,31.988000000000003 +2020-03-29 00:45:00,63.81,84.542,20.007,31.988000000000003 +2020-03-29 01:00:00,61.41,85.62700000000001,17.378,31.988000000000003 +2020-03-29 01:15:00,61.91,86.14200000000001,17.378,31.988000000000003 +2020-03-29 01:30:00,61.2,85.287,17.378,31.988000000000003 +2020-03-29 01:45:00,61.64,85.115,17.378,31.988000000000003 +2020-03-29 03:00:00,60.55,92.348,15.427999999999999,31.988000000000003 +2020-03-29 03:15:00,61.96,92.81200000000001,15.427999999999999,31.988000000000003 +2020-03-29 03:30:00,61.93,93.43799999999999,15.427999999999999,31.988000000000003 +2020-03-29 03:45:00,61.8,95.17,15.427999999999999,31.988000000000003 +2020-03-29 04:00:00,62.52,104.337,16.663,31.988000000000003 +2020-03-29 04:15:00,62.17,113.15899999999999,16.663,31.988000000000003 +2020-03-29 04:30:00,61.27,113.23,16.663,31.988000000000003 +2020-03-29 04:45:00,61.62,113.501,16.663,31.988000000000003 +2020-03-29 05:00:00,59.26,128.622,17.271,31.988000000000003 +2020-03-29 05:15:00,61.48,141.29399999999998,17.271,31.988000000000003 +2020-03-29 05:30:00,61.34,135.15,17.271,31.988000000000003 +2020-03-29 05:45:00,61.02,131.74200000000002,17.271,31.988000000000003 +2020-03-29 06:00:00,61.85,147.918,17.612000000000002,31.988000000000003 +2020-03-29 06:15:00,63.29,166.752,17.612000000000002,31.988000000000003 +2020-03-29 06:30:00,64.04,159.804,17.612000000000002,31.988000000000003 +2020-03-29 06:45:00,65.71,153.536,17.612000000000002,31.988000000000003 +2020-03-29 07:00:00,65.1,154.444,20.88,31.988000000000003 +2020-03-29 07:15:00,67.09,154.61700000000002,20.88,31.988000000000003 +2020-03-29 07:30:00,68.29,154.899,20.88,31.988000000000003 +2020-03-29 07:45:00,66.11,154.187,20.88,31.988000000000003 +2020-03-29 08:00:00,70.13,156.718,25.861,31.988000000000003 +2020-03-29 08:15:00,69.2,157.636,25.861,31.988000000000003 +2020-03-29 08:30:00,68.25,156.053,25.861,31.988000000000003 +2020-03-29 08:45:00,67.57,155.486,25.861,31.988000000000003 +2020-03-29 09:00:00,66.99,150.813,27.921999999999997,31.988000000000003 +2020-03-29 09:15:00,65.9,149.954,27.921999999999997,31.988000000000003 +2020-03-29 09:30:00,65.28,150.535,27.921999999999997,31.988000000000003 +2020-03-29 09:45:00,65.68,148.985,27.921999999999997,31.988000000000003 +2020-03-29 10:00:00,66.04,147.47799999999998,29.048000000000002,31.988000000000003 +2020-03-29 10:15:00,67.95,147.296,29.048000000000002,31.988000000000003 +2020-03-29 10:30:00,68.96,145.834,29.048000000000002,31.988000000000003 +2020-03-29 10:45:00,69.67,144.84799999999998,29.048000000000002,31.988000000000003 +2020-03-29 11:00:00,66.38,140.961,32.02,31.988000000000003 +2020-03-29 11:15:00,66.11,139.761,32.02,31.988000000000003 +2020-03-29 11:30:00,60.65,140.167,32.02,31.988000000000003 +2020-03-29 11:45:00,61.22,140.942,32.02,31.988000000000003 +2020-03-29 12:00:00,57.94,136.707,28.55,31.988000000000003 +2020-03-29 12:15:00,57.98,137.329,28.55,31.988000000000003 +2020-03-29 12:30:00,57.03,135.741,28.55,31.988000000000003 +2020-03-29 12:45:00,58.14,135.325,28.55,31.988000000000003 +2020-03-29 13:00:00,56.4,135.33,25.601999999999997,31.988000000000003 +2020-03-29 13:15:00,53.99,134.73,25.601999999999997,31.988000000000003 +2020-03-29 13:30:00,54.04,132.297,25.601999999999997,31.988000000000003 +2020-03-29 13:45:00,55.14,131.30200000000002,25.601999999999997,31.988000000000003 +2020-03-29 14:00:00,58.23,134.429,23.916999999999998,31.988000000000003 +2020-03-29 14:15:00,57.08,133.165,23.916999999999998,31.988000000000003 +2020-03-29 14:30:00,54.64,132.536,23.916999999999998,31.988000000000003 +2020-03-29 14:45:00,59.63,133.328,23.916999999999998,31.988000000000003 +2020-03-29 15:00:00,60.63,133.313,24.064,31.988000000000003 +2020-03-29 15:15:00,61.57,132.48,24.064,31.988000000000003 +2020-03-29 15:30:00,63.18,131.404,24.064,31.988000000000003 +2020-03-29 15:45:00,66.09,131.221,24.064,31.988000000000003 +2020-03-29 16:00:00,70.77,134.955,28.189,31.988000000000003 +2020-03-29 16:15:00,72.49,137.029,28.189,31.988000000000003 +2020-03-29 16:30:00,74.01,138.034,28.189,31.988000000000003 +2020-03-29 16:45:00,75.04,137.291,28.189,31.988000000000003 +2020-03-29 17:00:00,82.83,139.64700000000002,37.576,31.988000000000003 +2020-03-29 17:15:00,84.34,142.606,37.576,31.988000000000003 +2020-03-29 17:30:00,85.73,145.435,37.576,31.988000000000003 +2020-03-29 17:45:00,88.1,147.631,37.576,31.988000000000003 +2020-03-29 18:00:00,92.01,151.79,42.669,31.988000000000003 +2020-03-29 18:15:00,90.43,155.52200000000002,42.669,31.988000000000003 +2020-03-29 18:30:00,93.35,153.791,42.669,31.988000000000003 +2020-03-29 18:45:00,100.38,155.852,42.669,31.988000000000003 +2020-03-29 19:00:00,104.31,155.41299999999998,43.538999999999994,31.988000000000003 +2020-03-29 19:15:00,103.88,154.311,43.538999999999994,31.988000000000003 +2020-03-29 19:30:00,94.49,154.321,43.538999999999994,31.988000000000003 +2020-03-29 19:45:00,95.0,153.996,43.538999999999994,31.988000000000003 +2020-03-29 20:00:00,89.36,149.881,37.330999999999996,31.988000000000003 +2020-03-29 20:15:00,94.08,148.375,37.330999999999996,31.988000000000003 +2020-03-29 20:30:00,95.79,147.328,37.330999999999996,31.988000000000003 +2020-03-29 20:45:00,98.77,144.69799999999998,37.330999999999996,31.988000000000003 +2020-03-29 21:00:00,92.39,139.58100000000002,33.856,31.988000000000003 +2020-03-29 21:15:00,90.85,137.675,33.856,31.988000000000003 +2020-03-29 21:30:00,88.72,137.703,33.856,31.988000000000003 +2020-03-29 21:45:00,86.64,137.485,33.856,31.988000000000003 +2020-03-29 22:00:00,86.5,131.963,34.711999999999996,31.988000000000003 +2020-03-29 22:15:00,89.79,129.631,34.711999999999996,31.988000000000003 +2020-03-29 22:30:00,88.27,123.661,34.711999999999996,31.988000000000003 +2020-03-29 22:45:00,85.25,120.038,34.711999999999996,31.988000000000003 +2020-03-29 23:00:00,76.97,112.32799999999999,29.698,31.988000000000003 +2020-03-29 23:15:00,78.47,109.78299999999999,29.698,31.988000000000003 +2020-03-29 23:30:00,76.39,108.507,29.698,31.988000000000003 +2020-03-29 23:45:00,76.76,107.598,29.698,31.988000000000003 +2020-03-30 00:00:00,74.91,93.007,29.983,32.166 +2020-03-30 00:15:00,74.62,91.073,29.983,32.166 +2020-03-30 00:30:00,74.93,90.152,29.983,32.166 +2020-03-30 00:45:00,74.48,89.861,29.983,32.166 +2020-03-30 01:00:00,72.07,91.03,29.122,32.166 +2020-03-30 01:15:00,73.81,91.124,29.122,32.166 +2020-03-30 01:30:00,74.52,90.40700000000001,29.122,32.166 +2020-03-30 01:45:00,73.97,90.3,29.122,32.166 +2020-03-30 02:00:00,73.49,92.089,28.676,32.166 +2020-03-30 02:15:00,75.66,91.99600000000001,28.676,32.166 +2020-03-30 02:30:00,77.39,94.175,28.676,32.166 +2020-03-30 02:45:00,76.44,95.27600000000001,28.676,32.166 +2020-03-30 03:00:00,75.13,99.355,26.552,32.166 +2020-03-30 03:15:00,76.32,101.29,26.552,32.166 +2020-03-30 03:30:00,77.54,101.928,26.552,32.166 +2020-03-30 03:45:00,79.94,103.101,26.552,32.166 +2020-03-30 04:00:00,82.14,116.584,27.44,32.166 +2020-03-30 04:15:00,84.58,129.511,27.44,32.166 +2020-03-30 04:30:00,89.34,131.10299999999998,27.44,32.166 +2020-03-30 04:45:00,93.84,131.608,27.44,32.166 +2020-03-30 05:00:00,101.97,160.982,36.825,32.166 +2020-03-30 05:15:00,106.72,192.364,36.825,32.166 +2020-03-30 05:30:00,107.82,185.893,36.825,32.166 +2020-03-30 05:45:00,108.72,177.19099999999997,36.825,32.166 +2020-03-30 06:00:00,116.23,176.537,56.589,32.166 +2020-03-30 06:15:00,116.59,181.03900000000002,56.589,32.166 +2020-03-30 06:30:00,119.93,181.482,56.589,32.166 +2020-03-30 06:45:00,121.38,183.65099999999998,56.589,32.166 +2020-03-30 07:00:00,124.55,186.778,67.49,32.166 +2020-03-30 07:15:00,124.25,188.61,67.49,32.166 +2020-03-30 07:30:00,123.4,187.977,67.49,32.166 +2020-03-30 07:45:00,123.32,185.34099999999998,67.49,32.166 +2020-03-30 08:00:00,122.13,183.62599999999998,60.028,32.166 +2020-03-30 08:15:00,121.81,182.493,60.028,32.166 +2020-03-30 08:30:00,123.07,177.347,60.028,32.166 +2020-03-30 08:45:00,122.24,174.237,60.028,32.166 +2020-03-30 09:00:00,119.81,168.592,55.018,32.166 +2020-03-30 09:15:00,120.83,164.426,55.018,32.166 +2020-03-30 09:30:00,120.02,163.955,55.018,32.166 +2020-03-30 09:45:00,120.56,161.996,55.018,32.166 +2020-03-30 10:00:00,117.61,159.881,51.183,32.166 +2020-03-30 10:15:00,118.77,159.44,51.183,32.166 +2020-03-30 10:30:00,118.87,157.166,51.183,32.166 +2020-03-30 10:45:00,120.03,156.362,51.183,32.166 +2020-03-30 11:00:00,116.72,150.494,50.065,32.166 +2020-03-30 11:15:00,118.45,150.871,50.065,32.166 +2020-03-30 11:30:00,116.15,152.602,50.065,32.166 +2020-03-30 11:45:00,116.99,153.19799999999998,50.065,32.166 +2020-03-30 12:00:00,115.58,150.036,48.141999999999996,32.166 +2020-03-30 12:15:00,118.73,150.696,48.141999999999996,32.166 +2020-03-30 12:30:00,119.08,148.989,48.141999999999996,32.166 +2020-03-30 12:45:00,116.73,149.827,48.141999999999996,32.166 +2020-03-30 13:00:00,116.77,150.662,47.887,32.166 +2020-03-30 13:15:00,116.21,148.665,47.887,32.166 +2020-03-30 13:30:00,116.04,145.832,47.887,32.166 +2020-03-30 13:45:00,119.23,145.102,47.887,32.166 +2020-03-30 14:00:00,119.84,147.571,48.571000000000005,32.166 +2020-03-30 14:15:00,118.19,145.906,48.571000000000005,32.166 +2020-03-30 14:30:00,115.09,144.745,48.571000000000005,32.166 +2020-03-30 14:45:00,114.72,146.056,48.571000000000005,32.166 +2020-03-30 15:00:00,113.97,147.463,49.937,32.166 +2020-03-30 15:15:00,115.44,145.256,49.937,32.166 +2020-03-30 15:30:00,117.05,143.638,49.937,32.166 +2020-03-30 15:45:00,117.4,142.941,49.937,32.166 +2020-03-30 16:00:00,119.4,147.00799999999998,52.963,32.166 +2020-03-30 16:15:00,117.12,148.44799999999998,52.963,32.166 +2020-03-30 16:30:00,120.03,148.47899999999998,52.963,32.166 +2020-03-30 16:45:00,120.1,146.713,52.963,32.166 +2020-03-30 17:00:00,122.72,148.575,61.163999999999994,32.166 +2020-03-30 17:15:00,121.92,150.855,61.163999999999994,32.166 +2020-03-30 17:30:00,123.43,153.141,61.163999999999994,32.166 +2020-03-30 17:45:00,123.83,154.01,61.163999999999994,32.166 +2020-03-30 18:00:00,125.34,158.202,63.788999999999994,32.166 +2020-03-30 18:15:00,122.68,159.688,63.788999999999994,32.166 +2020-03-30 18:30:00,124.68,158.264,63.788999999999994,32.166 +2020-03-30 18:45:00,125.28,161.806,63.788999999999994,32.166 +2020-03-30 19:00:00,122.28,159.947,63.913000000000004,32.166 +2020-03-30 19:15:00,118.3,158.28799999999998,63.913000000000004,32.166 +2020-03-30 19:30:00,114.81,158.621,63.913000000000004,32.166 +2020-03-30 19:45:00,112.8,157.49200000000002,63.913000000000004,32.166 +2020-03-30 20:00:00,106.59,151.07,65.44,32.166 +2020-03-30 20:15:00,106.52,147.994,65.44,32.166 +2020-03-30 20:30:00,106.51,145.638,65.44,32.166 +2020-03-30 20:45:00,105.14,144.384,65.44,32.166 +2020-03-30 21:00:00,100.49,139.514,59.117,32.166 +2020-03-30 21:15:00,99.35,136.80100000000002,59.117,32.166 +2020-03-30 21:30:00,95.33,136.287,59.117,32.166 +2020-03-30 21:45:00,94.73,135.624,59.117,32.166 +2020-03-30 22:00:00,91.38,127.18299999999999,52.301,32.166 +2020-03-30 22:15:00,91.17,124.395,52.301,32.166 +2020-03-30 22:30:00,89.5,109.465,52.301,32.166 +2020-03-30 22:45:00,88.98,101.96,52.301,32.166 +2020-03-30 23:00:00,66.74,94.854,44.373000000000005,32.166 +2020-03-30 23:15:00,64.17,93.986,44.373000000000005,32.166 +2020-03-30 23:30:00,64.34,94.916,44.373000000000005,32.166 +2020-03-30 23:45:00,63.43,95.994,44.373000000000005,32.166 +2020-03-31 00:00:00,62.68,91.345,44.647,32.166 +2020-03-31 00:15:00,60.68,90.807,44.647,32.166 +2020-03-31 00:30:00,59.93,89.366,44.647,32.166 +2020-03-31 00:45:00,61.17,88.571,44.647,32.166 +2020-03-31 01:00:00,59.63,89.383,41.433,32.166 +2020-03-31 01:15:00,60.51,89.15,41.433,32.166 +2020-03-31 01:30:00,59.6,88.506,41.433,32.166 +2020-03-31 01:45:00,65.34,88.48,41.433,32.166 +2020-03-31 02:00:00,67.35,90.09700000000001,39.909,32.166 +2020-03-31 02:15:00,70.17,90.27799999999999,39.909,32.166 +2020-03-31 02:30:00,68.14,91.891,39.909,32.166 +2020-03-31 02:45:00,65.63,93.11,39.909,32.166 +2020-03-31 03:00:00,72.05,96.104,39.14,32.166 +2020-03-31 03:15:00,71.12,97.663,39.14,32.166 +2020-03-31 03:30:00,68.78,98.656,39.14,32.166 +2020-03-31 03:45:00,71.02,99.632,39.14,32.166 +2020-03-31 04:00:00,72.64,112.556,40.015,32.166 +2020-03-31 04:15:00,77.31,125.20100000000001,40.015,32.166 +2020-03-31 04:30:00,78.77,126.529,40.015,32.166 +2020-03-31 04:45:00,82.3,128.109,40.015,32.166 +2020-03-31 05:00:00,94.54,161.782,44.93600000000001,32.166 +2020-03-31 05:15:00,98.42,193.196,44.93600000000001,32.166 +2020-03-31 05:30:00,103.0,185.597,44.93600000000001,32.166 +2020-03-31 05:45:00,102.84,176.67700000000002,44.93600000000001,32.166 +2020-03-31 06:00:00,112.12,175.46099999999998,57.271,32.166 +2020-03-31 06:15:00,114.11,181.27599999999998,57.271,32.166 +2020-03-31 06:30:00,120.03,181.074,57.271,32.166 +2020-03-31 06:45:00,120.49,182.643,57.271,32.166 +2020-03-31 07:00:00,123.04,185.68599999999998,68.352,32.166 +2020-03-31 07:15:00,121.38,187.287,68.352,32.166 +2020-03-31 07:30:00,118.56,186.19099999999997,68.352,32.166 +2020-03-31 07:45:00,119.78,183.394,68.352,32.166 +2020-03-31 08:00:00,118.34,181.725,60.717,32.166 +2020-03-31 08:15:00,116.42,179.653,60.717,32.166 +2020-03-31 08:30:00,119.31,174.368,60.717,32.166 +2020-03-31 08:45:00,115.45,170.799,60.717,32.166 +2020-03-31 09:00:00,110.19,164.595,54.603,32.166 +2020-03-31 09:15:00,107.11,161.68,54.603,32.166 +2020-03-31 09:30:00,108.81,161.972,54.603,32.166 +2020-03-31 09:45:00,111.86,160.24,54.603,32.166 +2020-03-31 10:00:00,107.21,157.274,52.308,32.166 +2020-03-31 10:15:00,106.49,155.975,52.308,32.166 +2020-03-31 10:30:00,102.82,153.881,52.308,32.166 +2020-03-31 10:45:00,103.11,153.60299999999998,52.308,32.166 +2020-03-31 11:00:00,103.17,148.94899999999998,51.838,32.166 +2020-03-31 11:15:00,105.15,149.18200000000002,51.838,32.166 +2020-03-31 11:30:00,107.81,149.653,51.838,32.166 +2020-03-31 11:45:00,107.37,150.726,51.838,32.166 +2020-03-31 12:00:00,103.49,146.401,50.375,32.166 +2020-03-31 12:15:00,105.66,146.811,50.375,32.166 +2020-03-31 12:30:00,104.92,145.886,50.375,32.166 +2020-03-31 12:45:00,104.34,146.658,50.375,32.166 +2020-03-31 13:00:00,95.18,147.097,50.735,32.166 +2020-03-31 13:15:00,98.54,145.253,50.735,32.166 +2020-03-31 13:30:00,96.68,143.38299999999998,50.735,32.166 +2020-03-31 13:45:00,93.25,142.56799999999998,50.735,32.166 +2020-03-31 14:00:00,100.54,145.45600000000002,50.946000000000005,32.166 +2020-03-31 14:15:00,105.54,143.846,50.946000000000005,32.166 +2020-03-31 14:30:00,101.06,143.255,50.946000000000005,32.166 +2020-03-31 14:45:00,100.21,144.303,50.946000000000005,32.166 +2020-03-31 15:00:00,103.43,145.321,53.18,32.166 +2020-03-31 15:15:00,106.7,143.6,53.18,32.166 +2020-03-31 15:30:00,103.14,142.063,53.18,32.166 +2020-03-31 15:45:00,104.16,141.099,53.18,32.166 +2020-03-31 16:00:00,108.84,145.308,54.928999999999995,32.166 +2020-03-31 16:15:00,109.82,147.137,54.928999999999995,32.166 +2020-03-31 16:30:00,114.81,147.60299999999998,54.928999999999995,32.166 +2020-03-31 16:45:00,110.58,146.209,54.928999999999995,32.166 +2020-03-31 17:00:00,116.98,148.611,60.913000000000004,32.166 +2020-03-31 17:15:00,117.4,151.02,60.913000000000004,32.166 +2020-03-31 17:30:00,117.54,153.74,60.913000000000004,32.166 +2020-03-31 17:45:00,112.39,154.411,60.913000000000004,32.166 +2020-03-31 18:00:00,119.14,158.256,62.214,32.166 +2020-03-31 18:15:00,118.51,159.803,62.214,32.166 +2020-03-31 18:30:00,117.89,158.049,62.214,32.166 +2020-03-31 18:45:00,116.94,162.19799999999998,62.214,32.166 +2020-03-31 19:00:00,116.89,160.05200000000002,62.38,32.166 +2020-03-31 19:15:00,117.46,158.23,62.38,32.166 +2020-03-31 19:30:00,116.74,157.986,62.38,32.166 +2020-03-31 19:45:00,114.19,157.02200000000002,62.38,32.166 +2020-03-31 20:00:00,107.41,150.808,65.018,32.166 +2020-03-31 20:15:00,106.35,146.821,65.018,32.166 +2020-03-31 20:30:00,107.79,145.326,65.018,32.166 +2020-03-31 20:45:00,101.84,143.74,65.018,32.166 +2020-03-31 21:00:00,91.56,138.55100000000002,56.416000000000004,32.166 +2020-03-31 21:15:00,99.16,136.15,56.416000000000004,32.166 +2020-03-31 21:30:00,94.28,135.096,56.416000000000004,32.166 +2020-03-31 21:45:00,95.13,134.695,56.416000000000004,32.166 +2020-03-31 22:00:00,83.39,127.675,52.846000000000004,32.166 +2020-03-31 22:15:00,86.65,124.60799999999999,52.846000000000004,32.166 +2020-03-31 22:30:00,87.84,109.78200000000001,52.846000000000004,32.166 +2020-03-31 22:45:00,84.34,102.49700000000001,52.846000000000004,32.166 +2020-03-31 23:00:00,75.86,95.185,44.435,32.166 +2020-03-31 23:15:00,71.86,94.06,44.435,32.166 +2020-03-31 23:30:00,74.23,94.70100000000001,44.435,32.166 +2020-03-31 23:45:00,72.45,95.499,44.435,32.166 +2020-04-01 00:00:00,69.69,79.456,39.061,30.736 +2020-04-01 00:15:00,75.94,79.935,39.061,30.736 +2020-04-01 00:30:00,78.31,78.125,39.061,30.736 +2020-04-01 00:45:00,79.1,76.366,39.061,30.736 +2020-04-01 01:00:00,70.26,77.627,35.795,30.736 +2020-04-01 01:15:00,71.06,76.926,35.795,30.736 +2020-04-01 01:30:00,68.96,75.87100000000001,35.795,30.736 +2020-04-01 01:45:00,67.64,75.392,35.795,30.736 +2020-04-01 02:00:00,67.11,77.184,33.316,30.736 +2020-04-01 02:15:00,71.63,76.73899999999999,33.316,30.736 +2020-04-01 02:30:00,68.87,79.04899999999999,33.316,30.736 +2020-04-01 02:45:00,69.72,79.654,33.316,30.736 +2020-04-01 03:00:00,70.41,83.104,32.803000000000004,30.736 +2020-04-01 03:15:00,70.75,85.094,32.803000000000004,30.736 +2020-04-01 03:30:00,72.02,84.946,32.803000000000004,30.736 +2020-04-01 03:45:00,74.47,85.49700000000001,32.803000000000004,30.736 +2020-04-01 04:00:00,78.97,98.23200000000001,34.235,30.736 +2020-04-01 04:15:00,81.76,111.244,34.235,30.736 +2020-04-01 04:30:00,84.98,111.566,34.235,30.736 +2020-04-01 04:45:00,89.2,113.62700000000001,34.235,30.736 +2020-04-01 05:00:00,96.35,149.659,38.65,30.736 +2020-04-01 05:15:00,99.7,183.483,38.65,30.736 +2020-04-01 05:30:00,102.53,173.278,38.65,30.736 +2020-04-01 05:45:00,104.9,162.628,38.65,30.736 +2020-04-01 06:00:00,111.67,163.61700000000002,54.951,30.736 +2020-04-01 06:15:00,109.26,169.40099999999998,54.951,30.736 +2020-04-01 06:30:00,111.94,167.69799999999998,54.951,30.736 +2020-04-01 06:45:00,115.16,168.525,54.951,30.736 +2020-04-01 07:00:00,117.0,171.296,67.328,30.736 +2020-04-01 07:15:00,115.4,172.46400000000003,67.328,30.736 +2020-04-01 07:30:00,113.66,171.299,67.328,30.736 +2020-04-01 07:45:00,114.86,168.247,67.328,30.736 +2020-04-01 08:00:00,114.85,167.385,60.23,30.736 +2020-04-01 08:15:00,112.81,165.22400000000002,60.23,30.736 +2020-04-01 08:30:00,115.93,160.542,60.23,30.736 +2020-04-01 08:45:00,116.76,157.317,60.23,30.736 +2020-04-01 09:00:00,116.8,151.364,56.845,30.736 +2020-04-01 09:15:00,118.8,148.974,56.845,30.736 +2020-04-01 09:30:00,116.67,150.576,56.845,30.736 +2020-04-01 09:45:00,112.16,149.53,56.845,30.736 +2020-04-01 10:00:00,107.33,145.305,53.832,30.736 +2020-04-01 10:15:00,106.59,144.53,53.832,30.736 +2020-04-01 10:30:00,110.06,142.187,53.832,30.736 +2020-04-01 10:45:00,112.49,141.908,53.832,30.736 +2020-04-01 11:00:00,111.37,136.292,53.225,30.736 +2020-04-01 11:15:00,112.05,136.708,53.225,30.736 +2020-04-01 11:30:00,108.51,137.773,53.225,30.736 +2020-04-01 11:45:00,105.82,139.032,53.225,30.736 +2020-04-01 12:00:00,99.92,134.388,50.676,30.736 +2020-04-01 12:15:00,105.63,134.911,50.676,30.736 +2020-04-01 12:30:00,106.93,134.461,50.676,30.736 +2020-04-01 12:45:00,103.6,135.236,50.676,30.736 +2020-04-01 13:00:00,103.07,135.707,50.646,30.736 +2020-04-01 13:15:00,102.9,134.39600000000002,50.646,30.736 +2020-04-01 13:30:00,102.03,132.364,50.646,30.736 +2020-04-01 13:45:00,103.27,131.08700000000002,50.646,30.736 +2020-04-01 14:00:00,105.31,132.503,50.786,30.736 +2020-04-01 14:15:00,102.66,131.495,50.786,30.736 +2020-04-01 14:30:00,103.31,131.606,50.786,30.736 +2020-04-01 14:45:00,96.63,132.4,50.786,30.736 +2020-04-01 15:00:00,93.87,132.792,51.535,30.736 +2020-04-01 15:15:00,93.7,131.388,51.535,30.736 +2020-04-01 15:30:00,99.59,130.69299999999998,51.535,30.736 +2020-04-01 15:45:00,101.6,130.118,51.535,30.736 +2020-04-01 16:00:00,98.99,131.167,53.157,30.736 +2020-04-01 16:15:00,101.34,132.373,53.157,30.736 +2020-04-01 16:30:00,101.19,132.377,53.157,30.736 +2020-04-01 16:45:00,103.38,131.024,53.157,30.736 +2020-04-01 17:00:00,106.1,130.7,57.793,30.736 +2020-04-01 17:15:00,106.86,133.321,57.793,30.736 +2020-04-01 17:30:00,107.36,135.382,57.793,30.736 +2020-04-01 17:45:00,109.06,136.537,57.793,30.736 +2020-04-01 18:00:00,110.87,139.363,59.872,30.736 +2020-04-01 18:15:00,109.26,140.57,59.872,30.736 +2020-04-01 18:30:00,110.29,139.069,59.872,30.736 +2020-04-01 18:45:00,111.93,144.532,59.872,30.736 +2020-04-01 19:00:00,113.65,143.02200000000002,60.17100000000001,30.736 +2020-04-01 19:15:00,109.64,141.8,60.17100000000001,30.736 +2020-04-01 19:30:00,110.52,141.476,60.17100000000001,30.736 +2020-04-01 19:45:00,105.99,141.282,60.17100000000001,30.736 +2020-04-01 20:00:00,102.05,136.425,65.015,30.736 +2020-04-01 20:15:00,107.29,133.08100000000002,65.015,30.736 +2020-04-01 20:30:00,106.45,132.442,65.015,30.736 +2020-04-01 20:45:00,102.76,131.428,65.015,30.736 +2020-04-01 21:00:00,92.13,125.118,57.805,30.736 +2020-04-01 21:15:00,93.99,123.633,57.805,30.736 +2020-04-01 21:30:00,88.08,123.904,57.805,30.736 +2020-04-01 21:45:00,86.39,122.70100000000001,57.805,30.736 +2020-04-01 22:00:00,81.08,116.471,52.115,30.736 +2020-04-01 22:15:00,86.83,113.48200000000001,52.115,30.736 +2020-04-01 22:30:00,86.9,100.95,52.115,30.736 +2020-04-01 22:45:00,86.76,93.775,52.115,30.736 +2020-04-01 23:00:00,76.32,84.93299999999999,42.871,30.736 +2020-04-01 23:15:00,73.01,84.014,42.871,30.736 +2020-04-01 23:30:00,73.23,83.148,42.871,30.736 +2020-04-01 23:45:00,75.85,83.984,42.871,30.736 +2020-04-02 00:00:00,71.82,79.054,39.203,30.736 +2020-04-02 00:15:00,78.53,79.546,39.203,30.736 +2020-04-02 00:30:00,80.03,77.726,39.203,30.736 +2020-04-02 00:45:00,79.26,75.968,39.203,30.736 +2020-04-02 01:00:00,71.4,77.212,37.118,30.736 +2020-04-02 01:15:00,75.84,76.488,37.118,30.736 +2020-04-02 01:30:00,76.57,75.41199999999999,37.118,30.736 +2020-04-02 01:45:00,78.33,74.938,37.118,30.736 +2020-04-02 02:00:00,73.97,76.719,35.647,30.736 +2020-04-02 02:15:00,75.9,76.257,35.647,30.736 +2020-04-02 02:30:00,78.09,78.58800000000001,35.647,30.736 +2020-04-02 02:45:00,78.5,79.19800000000001,35.647,30.736 +2020-04-02 03:00:00,76.84,82.665,34.585,30.736 +2020-04-02 03:15:00,79.44,84.63,34.585,30.736 +2020-04-02 03:30:00,81.12,84.477,34.585,30.736 +2020-04-02 03:45:00,81.18,85.04700000000001,34.585,30.736 +2020-04-02 04:00:00,79.89,97.76100000000001,36.184,30.736 +2020-04-02 04:15:00,81.67,110.743,36.184,30.736 +2020-04-02 04:30:00,83.77,111.07,36.184,30.736 +2020-04-02 04:45:00,87.27,113.12,36.184,30.736 +2020-04-02 05:00:00,96.26,149.07299999999998,41.019,30.736 +2020-04-02 05:15:00,98.79,182.831,41.019,30.736 +2020-04-02 05:30:00,101.22,172.627,41.019,30.736 +2020-04-02 05:45:00,102.52,162.015,41.019,30.736 +2020-04-02 06:00:00,111.5,163.025,53.963,30.736 +2020-04-02 06:15:00,109.85,168.795,53.963,30.736 +2020-04-02 06:30:00,113.43,167.063,53.963,30.736 +2020-04-02 06:45:00,114.03,167.875,53.963,30.736 +2020-04-02 07:00:00,119.23,170.65400000000002,66.512,30.736 +2020-04-02 07:15:00,120.65,171.798,66.512,30.736 +2020-04-02 07:30:00,116.77,170.592,66.512,30.736 +2020-04-02 07:45:00,112.67,167.525,66.512,30.736 +2020-04-02 08:00:00,115.24,166.643,58.86,30.736 +2020-04-02 08:15:00,117.46,164.503,58.86,30.736 +2020-04-02 08:30:00,115.91,159.782,58.86,30.736 +2020-04-02 08:45:00,115.21,156.585,58.86,30.736 +2020-04-02 09:00:00,115.87,150.637,52.156000000000006,30.736 +2020-04-02 09:15:00,114.66,148.252,52.156000000000006,30.736 +2020-04-02 09:30:00,119.53,149.874,52.156000000000006,30.736 +2020-04-02 09:45:00,123.85,148.858,52.156000000000006,30.736 +2020-04-02 10:00:00,118.77,144.639,49.034,30.736 +2020-04-02 10:15:00,126.97,143.914,49.034,30.736 +2020-04-02 10:30:00,125.46,141.596,49.034,30.736 +2020-04-02 10:45:00,123.88,141.338,49.034,30.736 +2020-04-02 11:00:00,122.49,135.713,46.53,30.736 +2020-04-02 11:15:00,116.42,136.153,46.53,30.736 +2020-04-02 11:30:00,110.72,137.222,46.53,30.736 +2020-04-02 11:45:00,120.11,138.501,46.53,30.736 +2020-04-02 12:00:00,118.87,133.88299999999998,43.318000000000005,30.736 +2020-04-02 12:15:00,120.48,134.416,43.318000000000005,30.736 +2020-04-02 12:30:00,117.06,133.922,43.318000000000005,30.736 +2020-04-02 12:45:00,111.35,134.69899999999998,43.318000000000005,30.736 +2020-04-02 13:00:00,111.13,135.213,41.608000000000004,30.736 +2020-04-02 13:15:00,108.37,133.891,41.608000000000004,30.736 +2020-04-02 13:30:00,110.04,131.856,41.608000000000004,30.736 +2020-04-02 13:45:00,110.04,130.58100000000002,41.608000000000004,30.736 +2020-04-02 14:00:00,105.79,132.067,41.786,30.736 +2020-04-02 14:15:00,97.68,131.036,41.786,30.736 +2020-04-02 14:30:00,108.28,131.105,41.786,30.736 +2020-04-02 14:45:00,118.85,131.90200000000002,41.786,30.736 +2020-04-02 15:00:00,124.11,132.321,44.181999999999995,30.736 +2020-04-02 15:15:00,123.02,130.893,44.181999999999995,30.736 +2020-04-02 15:30:00,118.52,130.14700000000002,44.181999999999995,30.736 +2020-04-02 15:45:00,111.31,129.553,44.181999999999995,30.736 +2020-04-02 16:00:00,113.66,130.637,45.956,30.736 +2020-04-02 16:15:00,119.09,131.816,45.956,30.736 +2020-04-02 16:30:00,118.53,131.822,45.956,30.736 +2020-04-02 16:45:00,121.92,130.407,45.956,30.736 +2020-04-02 17:00:00,124.5,130.131,50.702,30.736 +2020-04-02 17:15:00,120.31,132.734,50.702,30.736 +2020-04-02 17:30:00,120.7,134.798,50.702,30.736 +2020-04-02 17:45:00,121.11,135.939,50.702,30.736 +2020-04-02 18:00:00,123.23,138.77700000000002,53.595,30.736 +2020-04-02 18:15:00,120.26,140.024,53.595,30.736 +2020-04-02 18:30:00,115.9,138.511,53.595,30.736 +2020-04-02 18:45:00,114.56,143.986,53.595,30.736 +2020-04-02 19:00:00,121.11,142.455,54.207,30.736 +2020-04-02 19:15:00,119.24,141.243,54.207,30.736 +2020-04-02 19:30:00,117.39,140.938,54.207,30.736 +2020-04-02 19:45:00,107.85,140.776,54.207,30.736 +2020-04-02 20:00:00,108.15,135.892,56.948,30.736 +2020-04-02 20:15:00,111.42,132.55700000000002,56.948,30.736 +2020-04-02 20:30:00,106.44,131.955,56.948,30.736 +2020-04-02 20:45:00,101.23,130.964,56.948,30.736 +2020-04-02 21:00:00,100.35,124.652,52.157,30.736 +2020-04-02 21:15:00,101.03,123.176,52.157,30.736 +2020-04-02 21:30:00,107.53,123.44,52.157,30.736 +2020-04-02 21:45:00,103.46,122.266,52.157,30.736 +2020-04-02 22:00:00,96.94,116.041,47.483000000000004,30.736 +2020-04-02 22:15:00,99.13,113.08,47.483000000000004,30.736 +2020-04-02 22:30:00,96.8,100.515,47.483000000000004,30.736 +2020-04-02 22:45:00,92.58,93.336,47.483000000000004,30.736 +2020-04-02 23:00:00,83.94,84.47,41.978,30.736 +2020-04-02 23:15:00,83.81,83.583,41.978,30.736 +2020-04-02 23:30:00,88.75,82.71600000000001,41.978,30.736 +2020-04-02 23:45:00,87.76,83.568,41.978,30.736 +2020-04-03 00:00:00,76.87,76.975,39.301,30.736 +2020-04-03 00:15:00,73.29,77.727,39.301,30.736 +2020-04-03 00:30:00,70.69,75.961,39.301,30.736 +2020-04-03 00:45:00,72.11,74.514,39.301,30.736 +2020-04-03 01:00:00,75.99,75.328,37.976,30.736 +2020-04-03 01:15:00,77.44,74.82,37.976,30.736 +2020-04-03 01:30:00,73.59,73.983,37.976,30.736 +2020-04-03 01:45:00,79.01,73.439,37.976,30.736 +2020-04-03 02:00:00,73.89,75.78399999999999,37.041,30.736 +2020-04-03 02:15:00,73.49,75.205,37.041,30.736 +2020-04-03 02:30:00,79.15,78.352,37.041,30.736 +2020-04-03 02:45:00,75.85,78.607,37.041,30.736 +2020-04-03 03:00:00,74.11,81.914,37.575,30.736 +2020-04-03 03:15:00,79.18,83.74799999999999,37.575,30.736 +2020-04-03 03:30:00,76.15,83.445,37.575,30.736 +2020-04-03 03:45:00,85.07,84.735,37.575,30.736 +2020-04-03 04:00:00,91.35,97.646,39.058,30.736 +2020-04-03 04:15:00,92.78,109.551,39.058,30.736 +2020-04-03 04:30:00,92.59,110.568,39.058,30.736 +2020-04-03 04:45:00,93.75,111.53399999999999,39.058,30.736 +2020-04-03 05:00:00,102.17,146.35299999999998,43.256,30.736 +2020-04-03 05:15:00,102.67,181.516,43.256,30.736 +2020-04-03 05:30:00,104.0,172.08599999999998,43.256,30.736 +2020-04-03 05:45:00,106.32,161.215,43.256,30.736 +2020-04-03 06:00:00,114.66,162.671,56.093999999999994,30.736 +2020-04-03 06:15:00,113.85,167.59900000000002,56.093999999999994,30.736 +2020-04-03 06:30:00,117.45,165.268,56.093999999999994,30.736 +2020-04-03 06:45:00,116.33,167.011,56.093999999999994,30.736 +2020-04-03 07:00:00,117.74,169.637,66.92699999999999,30.736 +2020-04-03 07:15:00,116.74,171.90900000000002,66.92699999999999,30.736 +2020-04-03 07:30:00,116.53,169.487,66.92699999999999,30.736 +2020-04-03 07:45:00,115.69,165.718,66.92699999999999,30.736 +2020-04-03 08:00:00,114.23,164.50900000000001,60.332,30.736 +2020-04-03 08:15:00,113.28,162.47799999999998,60.332,30.736 +2020-04-03 08:30:00,113.5,158.321,60.332,30.736 +2020-04-03 08:45:00,111.4,154.023,60.332,30.736 +2020-04-03 09:00:00,109.66,147.19899999999998,56.085,30.736 +2020-04-03 09:15:00,109.0,146.171,56.085,30.736 +2020-04-03 09:30:00,107.02,147.174,56.085,30.736 +2020-04-03 09:45:00,107.16,146.306,56.085,30.736 +2020-04-03 10:00:00,105.85,141.186,52.91,30.736 +2020-04-03 10:15:00,107.46,140.898,52.91,30.736 +2020-04-03 10:30:00,105.47,138.81799999999998,52.91,30.736 +2020-04-03 10:45:00,107.79,138.22299999999998,52.91,30.736 +2020-04-03 11:00:00,102.99,132.667,52.278999999999996,30.736 +2020-04-03 11:15:00,101.82,131.98,52.278999999999996,30.736 +2020-04-03 11:30:00,101.04,134.144,52.278999999999996,30.736 +2020-04-03 11:45:00,100.88,135.03,52.278999999999996,30.736 +2020-04-03 12:00:00,96.79,131.445,49.023999999999994,30.736 +2020-04-03 12:15:00,100.16,130.16,49.023999999999994,30.736 +2020-04-03 12:30:00,94.47,129.792,49.023999999999994,30.736 +2020-04-03 12:45:00,100.82,130.594,49.023999999999994,30.736 +2020-04-03 13:00:00,98.58,132.121,46.82,30.736 +2020-04-03 13:15:00,96.92,131.47299999999998,46.82,30.736 +2020-04-03 13:30:00,95.45,129.8,46.82,30.736 +2020-04-03 13:45:00,97.9,128.616,46.82,30.736 +2020-04-03 14:00:00,100.06,128.971,45.756,30.736 +2020-04-03 14:15:00,94.08,127.977,45.756,30.736 +2020-04-03 14:30:00,89.77,129.05200000000002,45.756,30.736 +2020-04-03 14:45:00,89.15,129.773,45.756,30.736 +2020-04-03 15:00:00,99.64,129.828,47.56,30.736 +2020-04-03 15:15:00,97.56,127.941,47.56,30.736 +2020-04-03 15:30:00,100.39,125.76799999999999,47.56,30.736 +2020-04-03 15:45:00,96.0,125.601,47.56,30.736 +2020-04-03 16:00:00,102.98,125.535,49.581,30.736 +2020-04-03 16:15:00,107.36,127.12799999999999,49.581,30.736 +2020-04-03 16:30:00,107.41,127.117,49.581,30.736 +2020-04-03 16:45:00,105.69,125.18799999999999,49.581,30.736 +2020-04-03 17:00:00,111.53,125.978,53.918,30.736 +2020-04-03 17:15:00,114.78,128.192,53.918,30.736 +2020-04-03 17:30:00,110.72,130.119,53.918,30.736 +2020-04-03 17:45:00,114.51,130.999,53.918,30.736 +2020-04-03 18:00:00,121.12,134.387,54.266000000000005,30.736 +2020-04-03 18:15:00,115.78,134.931,54.266000000000005,30.736 +2020-04-03 18:30:00,115.51,133.621,54.266000000000005,30.736 +2020-04-03 18:45:00,117.31,139.316,54.266000000000005,30.736 +2020-04-03 19:00:00,117.9,138.845,54.092,30.736 +2020-04-03 19:15:00,113.83,138.86700000000002,54.092,30.736 +2020-04-03 19:30:00,113.99,138.334,54.092,30.736 +2020-04-03 19:45:00,112.93,137.374,54.092,30.736 +2020-04-03 20:00:00,109.53,132.425,59.038999999999994,30.736 +2020-04-03 20:15:00,98.4,129.518,59.038999999999994,30.736 +2020-04-03 20:30:00,94.66,128.66899999999998,59.038999999999994,30.736 +2020-04-03 20:45:00,99.46,127.654,59.038999999999994,30.736 +2020-04-03 21:00:00,89.12,122.366,53.346000000000004,30.736 +2020-04-03 21:15:00,94.16,122.066,53.346000000000004,30.736 +2020-04-03 21:30:00,91.73,122.272,53.346000000000004,30.736 +2020-04-03 21:45:00,92.13,121.59700000000001,53.346000000000004,30.736 +2020-04-03 22:00:00,84.0,115.95,47.938,30.736 +2020-04-03 22:15:00,85.47,112.79700000000001,47.938,30.736 +2020-04-03 22:30:00,85.97,106.915,47.938,30.736 +2020-04-03 22:45:00,86.43,102.51899999999999,47.938,30.736 +2020-04-03 23:00:00,79.39,94.219,40.266,30.736 +2020-04-03 23:15:00,73.78,91.25299999999999,40.266,30.736 +2020-04-03 23:30:00,75.79,88.464,40.266,30.736 +2020-04-03 23:45:00,76.89,88.825,40.266,30.736 +2020-04-04 00:00:00,74.66,75.469,39.184,30.618000000000002 +2020-04-04 00:15:00,73.9,73.183,39.184,30.618000000000002 +2020-04-04 00:30:00,65.93,71.98100000000001,39.184,30.618000000000002 +2020-04-04 00:45:00,68.89,70.57600000000001,39.184,30.618000000000002 +2020-04-04 01:00:00,71.51,71.94,34.692,30.618000000000002 +2020-04-04 01:15:00,76.86,71.125,34.692,30.618000000000002 +2020-04-04 01:30:00,71.53,69.49,34.692,30.618000000000002 +2020-04-04 01:45:00,68.36,69.517,34.692,30.618000000000002 +2020-04-04 02:00:00,66.58,71.745,32.919000000000004,30.618000000000002 +2020-04-04 02:15:00,62.82,70.472,32.919000000000004,30.618000000000002 +2020-04-04 02:30:00,63.39,72.505,32.919000000000004,30.618000000000002 +2020-04-04 02:45:00,64.96,73.27199999999999,32.919000000000004,30.618000000000002 +2020-04-04 03:00:00,65.71,76.22399999999999,32.024,30.618000000000002 +2020-04-04 03:15:00,63.91,76.88600000000001,32.024,30.618000000000002 +2020-04-04 03:30:00,63.74,75.78699999999999,32.024,30.618000000000002 +2020-04-04 03:45:00,64.35,78.009,32.024,30.618000000000002 +2020-04-04 04:00:00,65.42,87.32600000000001,31.958000000000002,30.618000000000002 +2020-04-04 04:15:00,68.5,97.13600000000001,31.958000000000002,30.618000000000002 +2020-04-04 04:30:00,69.35,95.876,31.958000000000002,30.618000000000002 +2020-04-04 04:45:00,69.44,96.686,31.958000000000002,30.618000000000002 +2020-04-04 05:00:00,72.92,117.115,32.75,30.618000000000002 +2020-04-04 05:15:00,68.32,134.414,32.75,30.618000000000002 +2020-04-04 05:30:00,70.28,126.084,32.75,30.618000000000002 +2020-04-04 05:45:00,69.35,121.125,32.75,30.618000000000002 +2020-04-04 06:00:00,75.32,140.066,34.461999999999996,30.618000000000002 +2020-04-04 06:15:00,76.84,159.474,34.461999999999996,30.618000000000002 +2020-04-04 06:30:00,77.92,152.194,34.461999999999996,30.618000000000002 +2020-04-04 06:45:00,79.19,146.56799999999998,34.461999999999996,30.618000000000002 +2020-04-04 07:00:00,82.21,145.606,37.736,30.618000000000002 +2020-04-04 07:15:00,81.98,146.351,37.736,30.618000000000002 +2020-04-04 07:30:00,81.71,146.433,37.736,30.618000000000002 +2020-04-04 07:45:00,82.74,145.686,37.736,30.618000000000002 +2020-04-04 08:00:00,81.59,147.055,42.34,30.618000000000002 +2020-04-04 08:15:00,81.68,147.328,42.34,30.618000000000002 +2020-04-04 08:30:00,81.19,144.188,42.34,30.618000000000002 +2020-04-04 08:45:00,81.27,142.493,42.34,30.618000000000002 +2020-04-04 09:00:00,78.35,138.297,43.571999999999996,30.618000000000002 +2020-04-04 09:15:00,76.95,138.02700000000002,43.571999999999996,30.618000000000002 +2020-04-04 09:30:00,77.5,139.909,43.571999999999996,30.618000000000002 +2020-04-04 09:45:00,76.01,138.953,43.571999999999996,30.618000000000002 +2020-04-04 10:00:00,74.59,134.138,40.514,30.618000000000002 +2020-04-04 10:15:00,75.0,134.186,40.514,30.618000000000002 +2020-04-04 10:30:00,75.58,132.084,40.514,30.618000000000002 +2020-04-04 10:45:00,74.33,132.252,40.514,30.618000000000002 +2020-04-04 11:00:00,72.45,126.696,36.388000000000005,30.618000000000002 +2020-04-04 11:15:00,71.39,125.946,36.388000000000005,30.618000000000002 +2020-04-04 11:30:00,68.52,127.45,36.388000000000005,30.618000000000002 +2020-04-04 11:45:00,68.28,127.98700000000001,36.388000000000005,30.618000000000002 +2020-04-04 12:00:00,64.47,123.79799999999999,35.217,30.618000000000002 +2020-04-04 12:15:00,63.5,123.37200000000001,35.217,30.618000000000002 +2020-04-04 12:30:00,60.6,123.133,35.217,30.618000000000002 +2020-04-04 12:45:00,61.67,123.70200000000001,35.217,30.618000000000002 +2020-04-04 13:00:00,59.52,124.51700000000001,32.001999999999995,30.618000000000002 +2020-04-04 13:15:00,58.68,122.109,32.001999999999995,30.618000000000002 +2020-04-04 13:30:00,59.1,120.18700000000001,32.001999999999995,30.618000000000002 +2020-04-04 13:45:00,60.24,118.756,32.001999999999995,30.618000000000002 +2020-04-04 14:00:00,58.61,120.09200000000001,31.304000000000002,30.618000000000002 +2020-04-04 14:15:00,58.6,118.131,31.304000000000002,30.618000000000002 +2020-04-04 14:30:00,59.62,117.635,31.304000000000002,30.618000000000002 +2020-04-04 14:45:00,60.05,118.73200000000001,31.304000000000002,30.618000000000002 +2020-04-04 15:00:00,61.39,119.492,34.731,30.618000000000002 +2020-04-04 15:15:00,61.93,118.48299999999999,34.731,30.618000000000002 +2020-04-04 15:30:00,62.76,117.48299999999999,34.731,30.618000000000002 +2020-04-04 15:45:00,65.09,116.90299999999999,34.731,30.618000000000002 +2020-04-04 16:00:00,68.45,117.296,38.769,30.618000000000002 +2020-04-04 16:15:00,69.5,119.079,38.769,30.618000000000002 +2020-04-04 16:30:00,75.03,119.14,38.769,30.618000000000002 +2020-04-04 16:45:00,75.19,117.76299999999999,38.769,30.618000000000002 +2020-04-04 17:00:00,79.59,117.855,44.928000000000004,30.618000000000002 +2020-04-04 17:15:00,80.67,120.13799999999999,44.928000000000004,30.618000000000002 +2020-04-04 17:30:00,82.2,121.93700000000001,44.928000000000004,30.618000000000002 +2020-04-04 17:45:00,83.92,122.765,44.928000000000004,30.618000000000002 +2020-04-04 18:00:00,85.73,126.61399999999999,47.786,30.618000000000002 +2020-04-04 18:15:00,83.59,129.254,47.786,30.618000000000002 +2020-04-04 18:30:00,83.84,129.518,47.786,30.618000000000002 +2020-04-04 18:45:00,85.08,131.211,47.786,30.618000000000002 +2020-04-04 19:00:00,87.47,130.589,47.463,30.618000000000002 +2020-04-04 19:15:00,90.07,129.77700000000002,47.463,30.618000000000002 +2020-04-04 19:30:00,88.24,130.158,47.463,30.618000000000002 +2020-04-04 19:45:00,84.51,130.01,47.463,30.618000000000002 +2020-04-04 20:00:00,81.76,126.954,43.735,30.618000000000002 +2020-04-04 20:15:00,80.12,125.14200000000001,43.735,30.618000000000002 +2020-04-04 20:30:00,76.99,123.64399999999999,43.735,30.618000000000002 +2020-04-04 20:45:00,76.56,123.34,43.735,30.618000000000002 +2020-04-04 21:00:00,70.25,118.79799999999999,40.346,30.618000000000002 +2020-04-04 21:15:00,69.31,118.615,40.346,30.618000000000002 +2020-04-04 21:30:00,66.75,119.72200000000001,40.346,30.618000000000002 +2020-04-04 21:45:00,66.4,118.525,40.346,30.618000000000002 +2020-04-04 22:00:00,62.9,113.759,39.323,30.618000000000002 +2020-04-04 22:15:00,62.01,112.402,39.323,30.618000000000002 +2020-04-04 22:30:00,59.88,110.391,39.323,30.618000000000002 +2020-04-04 22:45:00,58.88,107.417,39.323,30.618000000000002 +2020-04-04 23:00:00,54.7,100.368,33.716,30.618000000000002 +2020-04-04 23:15:00,54.75,96.571,33.716,30.618000000000002 +2020-04-04 23:30:00,52.99,93.90100000000001,33.716,30.618000000000002 +2020-04-04 23:45:00,53.7,92.859,33.716,30.618000000000002 +2020-04-05 00:00:00,49.93,76.092,28.703000000000003,30.618000000000002 +2020-04-05 00:15:00,50.91,72.97800000000001,28.703000000000003,30.618000000000002 +2020-04-05 00:30:00,49.61,71.447,28.703000000000003,30.618000000000002 +2020-04-05 00:45:00,49.78,70.42,28.703000000000003,30.618000000000002 +2020-04-05 01:00:00,48.79,71.824,26.171,30.618000000000002 +2020-04-05 01:15:00,49.86,71.562,26.171,30.618000000000002 +2020-04-05 01:30:00,49.35,70.15,26.171,30.618000000000002 +2020-04-05 01:45:00,49.74,69.775,26.171,30.618000000000002 +2020-04-05 02:00:00,49.25,71.568,25.326999999999998,30.618000000000002 +2020-04-05 02:15:00,49.88,70.153,25.326999999999998,30.618000000000002 +2020-04-05 02:30:00,49.37,72.839,25.326999999999998,30.618000000000002 +2020-04-05 02:45:00,49.41,73.73100000000001,25.326999999999998,30.618000000000002 +2020-04-05 03:00:00,48.8,77.26,24.311999999999998,30.618000000000002 +2020-04-05 03:15:00,49.81,77.726,24.311999999999998,30.618000000000002 +2020-04-05 03:30:00,50.35,77.057,24.311999999999998,30.618000000000002 +2020-04-05 03:45:00,51.13,78.79899999999999,24.311999999999998,30.618000000000002 +2020-04-05 04:00:00,51.43,87.915,25.33,30.618000000000002 +2020-04-05 04:15:00,52.55,96.831,25.33,30.618000000000002 +2020-04-05 04:30:00,54.44,96.38600000000001,25.33,30.618000000000002 +2020-04-05 04:45:00,58.04,97.081,25.33,30.618000000000002 +2020-04-05 05:00:00,57.28,115.42299999999999,25.309,30.618000000000002 +2020-04-05 05:15:00,58.24,130.764,25.309,30.618000000000002 +2020-04-05 05:30:00,55.96,122.105,25.309,30.618000000000002 +2020-04-05 05:45:00,57.4,117.171,25.309,30.618000000000002 +2020-04-05 06:00:00,58.75,134.681,25.945999999999998,30.618000000000002 +2020-04-05 06:15:00,58.9,153.49,25.945999999999998,30.618000000000002 +2020-04-05 06:30:00,59.93,145.111,25.945999999999998,30.618000000000002 +2020-04-05 06:45:00,63.35,138.262,25.945999999999998,30.618000000000002 +2020-04-05 07:00:00,64.84,138.942,27.87,30.618000000000002 +2020-04-05 07:15:00,67.39,138.22799999999998,27.87,30.618000000000002 +2020-04-05 07:30:00,67.1,138.191,27.87,30.618000000000002 +2020-04-05 07:45:00,64.74,136.928,27.87,30.618000000000002 +2020-04-05 08:00:00,65.25,139.78799999999998,32.114000000000004,30.618000000000002 +2020-04-05 08:15:00,65.1,140.642,32.114000000000004,30.618000000000002 +2020-04-05 08:30:00,64.21,139.002,32.114000000000004,30.618000000000002 +2020-04-05 08:45:00,63.33,138.611,32.114000000000004,30.618000000000002 +2020-04-05 09:00:00,64.31,134.07,34.222,30.618000000000002 +2020-04-05 09:15:00,61.83,133.924,34.222,30.618000000000002 +2020-04-05 09:30:00,60.69,135.941,34.222,30.618000000000002 +2020-04-05 09:45:00,60.76,135.444,34.222,30.618000000000002 +2020-04-05 10:00:00,61.17,132.583,34.544000000000004,30.618000000000002 +2020-04-05 10:15:00,63.73,133.114,34.544000000000004,30.618000000000002 +2020-04-05 10:30:00,64.51,131.56,34.544000000000004,30.618000000000002 +2020-04-05 10:45:00,63.78,130.931,34.544000000000004,30.618000000000002 +2020-04-05 11:00:00,60.97,125.81200000000001,36.368,30.618000000000002 +2020-04-05 11:15:00,58.9,124.97,36.368,30.618000000000002 +2020-04-05 11:30:00,56.67,126.111,36.368,30.618000000000002 +2020-04-05 11:45:00,56.1,127.214,36.368,30.618000000000002 +2020-04-05 12:00:00,52.5,123.1,32.433,30.618000000000002 +2020-04-05 12:15:00,54.28,123.76299999999999,32.433,30.618000000000002 +2020-04-05 12:30:00,51.11,122.53399999999999,32.433,30.618000000000002 +2020-04-05 12:45:00,51.46,122.119,32.433,30.618000000000002 +2020-04-05 13:00:00,50.91,122.304,28.971999999999998,30.618000000000002 +2020-04-05 13:15:00,50.33,121.807,28.971999999999998,30.618000000000002 +2020-04-05 13:30:00,49.91,119.228,28.971999999999998,30.618000000000002 +2020-04-05 13:45:00,50.1,117.82600000000001,28.971999999999998,30.618000000000002 +2020-04-05 14:00:00,50.18,119.947,25.531999999999996,30.618000000000002 +2020-04-05 14:15:00,50.22,119.027,25.531999999999996,30.618000000000002 +2020-04-05 14:30:00,50.84,118.82700000000001,25.531999999999996,30.618000000000002 +2020-04-05 14:45:00,51.63,119.15,25.531999999999996,30.618000000000002 +2020-04-05 15:00:00,52.57,118.89200000000001,25.766,30.618000000000002 +2020-04-05 15:15:00,53.17,118.04299999999999,25.766,30.618000000000002 +2020-04-05 15:30:00,54.25,117.337,25.766,30.618000000000002 +2020-04-05 15:45:00,57.67,117.369,25.766,30.618000000000002 +2020-04-05 16:00:00,62.85,118.23899999999999,29.232,30.618000000000002 +2020-04-05 16:15:00,63.15,119.47,29.232,30.618000000000002 +2020-04-05 16:30:00,67.45,120.189,29.232,30.618000000000002 +2020-04-05 16:45:00,71.68,118.87299999999999,29.232,30.618000000000002 +2020-04-05 17:00:00,75.8,119.089,37.431,30.618000000000002 +2020-04-05 17:15:00,78.13,121.87700000000001,37.431,30.618000000000002 +2020-04-05 17:30:00,80.23,124.274,37.431,30.618000000000002 +2020-04-05 17:45:00,80.84,126.838,37.431,30.618000000000002 +2020-04-05 18:00:00,83.42,130.58700000000002,41.251999999999995,30.618000000000002 +2020-04-05 18:15:00,82.93,133.947,41.251999999999995,30.618000000000002 +2020-04-05 18:30:00,85.4,132.678,41.251999999999995,30.618000000000002 +2020-04-05 18:45:00,86.36,135.68,41.251999999999995,30.618000000000002 +2020-04-05 19:00:00,89.34,135.89700000000002,41.784,30.618000000000002 +2020-04-05 19:15:00,96.35,134.91899999999998,41.784,30.618000000000002 +2020-04-05 19:30:00,97.94,135.082,41.784,30.618000000000002 +2020-04-05 19:45:00,90.74,135.688,41.784,30.618000000000002 +2020-04-05 20:00:00,83.74,132.64,40.804,30.618000000000002 +2020-04-05 20:15:00,84.54,131.41299999999998,40.804,30.618000000000002 +2020-04-05 20:30:00,88.61,131.183,40.804,30.618000000000002 +2020-04-05 20:45:00,93.47,129.24200000000002,40.804,30.618000000000002 +2020-04-05 21:00:00,90.23,122.90100000000001,38.379,30.618000000000002 +2020-04-05 21:15:00,85.48,122.161,38.379,30.618000000000002 +2020-04-05 21:30:00,81.64,123.109,38.379,30.618000000000002 +2020-04-05 21:45:00,78.03,122.22,38.379,30.618000000000002 +2020-04-05 22:00:00,72.89,117.758,37.87,30.618000000000002 +2020-04-05 22:15:00,76.43,115.053,37.87,30.618000000000002 +2020-04-05 22:30:00,78.18,110.678,37.87,30.618000000000002 +2020-04-05 22:45:00,78.53,106.478,37.87,30.618000000000002 +2020-04-05 23:00:00,72.6,97.42299999999999,33.332,30.618000000000002 +2020-04-05 23:15:00,69.86,95.53299999999999,33.332,30.618000000000002 +2020-04-05 23:30:00,67.11,93.111,33.332,30.618000000000002 +2020-04-05 23:45:00,71.83,92.75,33.332,30.618000000000002 +2020-04-06 00:00:00,72.06,79.122,34.698,30.736 +2020-04-06 00:15:00,73.34,78.301,34.698,30.736 +2020-04-06 00:30:00,69.38,76.625,34.698,30.736 +2020-04-06 00:45:00,66.41,75.048,34.698,30.736 +2020-04-06 01:00:00,65.32,76.639,32.889,30.736 +2020-04-06 01:15:00,71.76,76.038,32.889,30.736 +2020-04-06 01:30:00,72.41,74.84100000000001,32.889,30.736 +2020-04-06 01:45:00,73.18,74.486,32.889,30.736 +2020-04-06 02:00:00,68.6,76.492,32.06,30.736 +2020-04-06 02:15:00,70.89,75.381,32.06,30.736 +2020-04-06 02:30:00,74.64,78.384,32.06,30.736 +2020-04-06 02:45:00,75.36,78.82,32.06,30.736 +2020-04-06 03:00:00,73.31,83.4,30.515,30.736 +2020-04-06 03:15:00,75.92,85.24799999999999,30.515,30.736 +2020-04-06 03:30:00,80.31,84.79899999999999,30.515,30.736 +2020-04-06 03:45:00,82.56,85.961,30.515,30.736 +2020-04-06 04:00:00,85.18,99.426,31.436,30.736 +2020-04-06 04:15:00,84.27,112.485,31.436,30.736 +2020-04-06 04:30:00,87.01,113.088,31.436,30.736 +2020-04-06 04:45:00,88.33,114.081,31.436,30.736 +2020-04-06 05:00:00,97.35,145.80200000000002,38.997,30.736 +2020-04-06 05:15:00,102.88,179.06,38.997,30.736 +2020-04-06 05:30:00,104.6,169.676,38.997,30.736 +2020-04-06 05:45:00,103.3,159.631,38.997,30.736 +2020-04-06 06:00:00,111.48,160.849,54.97,30.736 +2020-04-06 06:15:00,112.43,165.453,54.97,30.736 +2020-04-06 06:30:00,112.7,164.183,54.97,30.736 +2020-04-06 06:45:00,114.94,165.671,54.97,30.736 +2020-04-06 07:00:00,116.47,168.513,66.032,30.736 +2020-04-06 07:15:00,114.52,169.805,66.032,30.736 +2020-04-06 07:30:00,112.82,168.767,66.032,30.736 +2020-04-06 07:45:00,111.27,166.037,66.032,30.736 +2020-04-06 08:00:00,109.22,165.03599999999997,59.941,30.736 +2020-04-06 08:15:00,108.67,163.886,59.941,30.736 +2020-04-06 08:30:00,107.94,159.04,59.941,30.736 +2020-04-06 08:45:00,106.68,156.61,59.941,30.736 +2020-04-06 09:00:00,105.31,151.07299999999998,54.016000000000005,30.736 +2020-04-06 09:15:00,105.6,147.696,54.016000000000005,30.736 +2020-04-06 09:30:00,104.71,148.596,54.016000000000005,30.736 +2020-04-06 09:45:00,103.31,147.168,54.016000000000005,30.736 +2020-04-06 10:00:00,101.9,144.064,50.63,30.736 +2020-04-06 10:15:00,104.54,144.34799999999998,50.63,30.736 +2020-04-06 10:30:00,103.43,141.99200000000002,50.63,30.736 +2020-04-06 10:45:00,105.54,141.142,50.63,30.736 +2020-04-06 11:00:00,100.25,134.44,49.951,30.736 +2020-04-06 11:15:00,103.21,135.025,49.951,30.736 +2020-04-06 11:30:00,98.48,137.47,49.951,30.736 +2020-04-06 11:45:00,100.55,138.54,49.951,30.736 +2020-04-06 12:00:00,103.01,135.093,46.913000000000004,30.736 +2020-04-06 12:15:00,108.32,135.816,46.913000000000004,30.736 +2020-04-06 12:30:00,107.08,134.238,46.913000000000004,30.736 +2020-04-06 12:45:00,101.4,134.923,46.913000000000004,30.736 +2020-04-06 13:00:00,102.39,136.09,47.093999999999994,30.736 +2020-04-06 13:15:00,101.73,134.179,47.093999999999994,30.736 +2020-04-06 13:30:00,102.31,131.29,47.093999999999994,30.736 +2020-04-06 13:45:00,109.82,130.314,47.093999999999994,30.736 +2020-04-06 14:00:00,111.7,131.673,46.678000000000004,30.736 +2020-04-06 14:15:00,107.6,130.526,46.678000000000004,30.736 +2020-04-06 14:30:00,101.94,129.817,46.678000000000004,30.736 +2020-04-06 14:45:00,103.94,131.03799999999998,46.678000000000004,30.736 +2020-04-06 15:00:00,103.09,131.95600000000002,47.715,30.736 +2020-04-06 15:15:00,104.75,129.77100000000002,47.715,30.736 +2020-04-06 15:30:00,107.02,128.752,47.715,30.736 +2020-04-06 15:45:00,106.7,128.225,47.715,30.736 +2020-04-06 16:00:00,111.87,129.474,49.81100000000001,30.736 +2020-04-06 16:15:00,109.46,130.173,49.81100000000001,30.736 +2020-04-06 16:30:00,112.43,129.903,49.81100000000001,30.736 +2020-04-06 16:45:00,110.12,127.697,49.81100000000001,30.736 +2020-04-06 17:00:00,116.7,127.12899999999999,55.591,30.736 +2020-04-06 17:15:00,115.27,129.43200000000002,55.591,30.736 +2020-04-06 17:30:00,116.49,131.267,55.591,30.736 +2020-04-06 17:45:00,114.97,132.599,55.591,30.736 +2020-04-06 18:00:00,118.43,136.04,56.523,30.736 +2020-04-06 18:15:00,116.2,137.036,56.523,30.736 +2020-04-06 18:30:00,115.3,135.815,56.523,30.736 +2020-04-06 18:45:00,112.64,140.894,56.523,30.736 +2020-04-06 19:00:00,111.96,139.827,56.044,30.736 +2020-04-06 19:15:00,117.29,138.756,56.044,30.736 +2020-04-06 19:30:00,115.88,139.08,56.044,30.736 +2020-04-06 19:45:00,110.57,138.85299999999998,56.044,30.736 +2020-04-06 20:00:00,102.39,133.55,61.715,30.736 +2020-04-06 20:15:00,99.43,131.42600000000002,61.715,30.736 +2020-04-06 20:30:00,100.45,130.275,61.715,30.736 +2020-04-06 20:45:00,98.33,129.54,61.715,30.736 +2020-04-06 21:00:00,101.37,123.23200000000001,56.24,30.736 +2020-04-06 21:15:00,100.59,121.96799999999999,56.24,30.736 +2020-04-06 21:30:00,94.39,122.585,56.24,30.736 +2020-04-06 21:45:00,88.76,121.28200000000001,56.24,30.736 +2020-04-06 22:00:00,84.27,113.863,50.437,30.736 +2020-04-06 22:15:00,87.71,111.35700000000001,50.437,30.736 +2020-04-06 22:30:00,87.15,98.445,50.437,30.736 +2020-04-06 22:45:00,85.4,91.066,50.437,30.736 +2020-04-06 23:00:00,73.0,82.488,42.756,30.736 +2020-04-06 23:15:00,73.24,81.514,42.756,30.736 +2020-04-06 23:30:00,69.53,80.881,42.756,30.736 +2020-04-06 23:45:00,72.17,81.986,42.756,30.736 +2020-04-07 00:00:00,69.6,77.01899999999999,39.857,30.736 +2020-04-07 00:15:00,70.65,77.579,39.857,30.736 +2020-04-07 00:30:00,69.86,75.704,39.857,30.736 +2020-04-07 00:45:00,70.53,73.96600000000001,39.857,30.736 +2020-04-07 01:00:00,69.2,75.119,37.233000000000004,30.736 +2020-04-07 01:15:00,73.23,74.282,37.233000000000004,30.736 +2020-04-07 01:30:00,72.34,73.097,37.233000000000004,30.736 +2020-04-07 01:45:00,73.13,72.648,37.233000000000004,30.736 +2020-04-07 02:00:00,72.15,74.37,35.856,30.736 +2020-04-07 02:15:00,72.53,73.82600000000001,35.856,30.736 +2020-04-07 02:30:00,68.0,76.265,35.856,30.736 +2020-04-07 02:45:00,74.06,76.898,35.856,30.736 +2020-04-07 03:00:00,72.61,80.45100000000001,34.766999999999996,30.736 +2020-04-07 03:15:00,72.94,82.287,34.766999999999996,30.736 +2020-04-07 03:30:00,74.44,82.10799999999999,34.766999999999996,30.736 +2020-04-07 03:45:00,76.22,82.777,34.766999999999996,30.736 +2020-04-07 04:00:00,80.49,95.38600000000001,35.468,30.736 +2020-04-07 04:15:00,82.48,108.21,35.468,30.736 +2020-04-07 04:30:00,85.73,108.561,35.468,30.736 +2020-04-07 04:45:00,90.58,110.554,35.468,30.736 +2020-04-07 05:00:00,100.18,146.112,40.399,30.736 +2020-04-07 05:15:00,102.56,179.535,40.399,30.736 +2020-04-07 05:30:00,104.29,169.34400000000002,40.399,30.736 +2020-04-07 05:45:00,106.96,158.921,40.399,30.736 +2020-04-07 06:00:00,110.4,160.033,54.105,30.736 +2020-04-07 06:15:00,110.63,165.732,54.105,30.736 +2020-04-07 06:30:00,112.93,163.855,54.105,30.736 +2020-04-07 06:45:00,116.12,164.59900000000002,54.105,30.736 +2020-04-07 07:00:00,116.23,167.41099999999997,63.083,30.736 +2020-04-07 07:15:00,115.06,168.44,63.083,30.736 +2020-04-07 07:30:00,115.2,167.024,63.083,30.736 +2020-04-07 07:45:00,113.5,163.887,63.083,30.736 +2020-04-07 08:00:00,110.51,162.91,57.254,30.736 +2020-04-07 08:15:00,109.08,160.877,57.254,30.736 +2020-04-07 08:30:00,109.23,155.965,57.254,30.736 +2020-04-07 08:45:00,109.56,152.909,57.254,30.736 +2020-04-07 09:00:00,107.69,146.993,51.395,30.736 +2020-04-07 09:15:00,108.09,144.628,51.395,30.736 +2020-04-07 09:30:00,104.87,146.349,51.395,30.736 +2020-04-07 09:45:00,106.35,145.477,51.395,30.736 +2020-04-07 10:00:00,103.01,141.299,48.201,30.736 +2020-04-07 10:15:00,102.97,140.826,48.201,30.736 +2020-04-07 10:30:00,100.59,138.631,48.201,30.736 +2020-04-07 10:45:00,102.15,138.48,48.201,30.736 +2020-04-07 11:00:00,99.8,132.81,46.133,30.736 +2020-04-07 11:15:00,99.29,133.372,46.133,30.736 +2020-04-07 11:30:00,97.42,134.46,46.133,30.736 +2020-04-07 11:45:00,99.27,135.839,46.133,30.736 +2020-04-07 12:00:00,102.69,131.346,44.243,30.736 +2020-04-07 12:15:00,103.58,131.928,44.243,30.736 +2020-04-07 12:30:00,101.04,131.215,44.243,30.736 +2020-04-07 12:45:00,96.93,131.998,44.243,30.736 +2020-04-07 13:00:00,95.94,132.735,45.042,30.736 +2020-04-07 13:15:00,97.03,131.35399999999998,45.042,30.736 +2020-04-07 13:30:00,100.96,129.305,45.042,30.736 +2020-04-07 13:45:00,103.24,128.042,45.042,30.736 +2020-04-07 14:00:00,101.28,129.875,44.062,30.736 +2020-04-07 14:15:00,102.93,128.732,44.062,30.736 +2020-04-07 14:30:00,106.26,128.583,44.062,30.736 +2020-04-07 14:45:00,104.54,129.403,44.062,30.736 +2020-04-07 15:00:00,100.32,129.96200000000002,46.461999999999996,30.736 +2020-04-07 15:15:00,100.71,128.40200000000002,46.461999999999996,30.736 +2020-04-07 15:30:00,104.76,127.404,46.461999999999996,30.736 +2020-04-07 15:45:00,106.51,126.71600000000001,46.461999999999996,30.736 +2020-04-07 16:00:00,105.14,127.979,48.802,30.736 +2020-04-07 16:15:00,108.47,129.028,48.802,30.736 +2020-04-07 16:30:00,110.54,129.042,48.802,30.736 +2020-04-07 16:45:00,111.8,127.31,48.802,30.736 +2020-04-07 17:00:00,111.67,127.27799999999999,55.672,30.736 +2020-04-07 17:15:00,118.31,129.785,55.672,30.736 +2020-04-07 17:30:00,117.23,131.864,55.672,30.736 +2020-04-07 17:45:00,116.51,132.929,55.672,30.736 +2020-04-07 18:00:00,113.28,135.825,57.006,30.736 +2020-04-07 18:15:00,116.51,137.267,57.006,30.736 +2020-04-07 18:30:00,115.8,135.695,57.006,30.736 +2020-04-07 18:45:00,116.19,141.224,57.006,30.736 +2020-04-07 19:00:00,111.29,139.591,57.148,30.736 +2020-04-07 19:15:00,116.99,138.437,57.148,30.736 +2020-04-07 19:30:00,116.1,138.225,57.148,30.736 +2020-04-07 19:45:00,113.98,138.225,57.148,30.736 +2020-04-07 20:00:00,104.78,133.197,61.895,30.736 +2020-04-07 20:15:00,109.17,129.917,61.895,30.736 +2020-04-07 20:30:00,105.18,129.49200000000002,61.895,30.736 +2020-04-07 20:45:00,104.44,128.619,61.895,30.736 +2020-04-07 21:00:00,94.22,122.29899999999999,54.78,30.736 +2020-04-07 21:15:00,98.11,120.87200000000001,54.78,30.736 +2020-04-07 21:30:00,95.36,121.095,54.78,30.736 +2020-04-07 21:45:00,95.09,120.073,54.78,30.736 +2020-04-07 22:00:00,82.41,113.87100000000001,50.76,30.736 +2020-04-07 22:15:00,81.69,111.046,50.76,30.736 +2020-04-07 22:30:00,86.54,98.31200000000001,50.76,30.736 +2020-04-07 22:45:00,86.26,91.10600000000001,50.76,30.736 +2020-04-07 23:00:00,79.29,82.12700000000001,44.162,30.736 +2020-04-07 23:15:00,77.16,81.404,44.162,30.736 +2020-04-07 23:30:00,74.84,80.531,44.162,30.736 +2020-04-07 23:45:00,80.07,81.457,44.162,30.736 +2020-04-08 00:00:00,77.66,76.607,39.061,30.736 +2020-04-08 00:15:00,75.81,77.182,39.061,30.736 +2020-04-08 00:30:00,72.88,75.296,39.061,30.736 +2020-04-08 00:45:00,69.77,73.563,39.061,30.736 +2020-04-08 01:00:00,73.18,74.697,35.795,30.736 +2020-04-08 01:15:00,76.79,73.837,35.795,30.736 +2020-04-08 01:30:00,75.5,72.631,35.795,30.736 +2020-04-08 01:45:00,70.46,72.187,35.795,30.736 +2020-04-08 02:00:00,71.43,73.89699999999999,33.316,30.736 +2020-04-08 02:15:00,70.31,73.337,33.316,30.736 +2020-04-08 02:30:00,70.6,75.797,33.316,30.736 +2020-04-08 02:45:00,78.12,76.435,33.316,30.736 +2020-04-08 03:00:00,79.43,80.005,32.803000000000004,30.736 +2020-04-08 03:15:00,79.12,81.814,32.803000000000004,30.736 +2020-04-08 03:30:00,75.54,81.631,32.803000000000004,30.736 +2020-04-08 03:45:00,77.28,82.321,32.803000000000004,30.736 +2020-04-08 04:00:00,80.55,94.90899999999999,34.235,30.736 +2020-04-08 04:15:00,81.61,107.698,34.235,30.736 +2020-04-08 04:30:00,85.38,108.055,34.235,30.736 +2020-04-08 04:45:00,90.05,110.037,34.235,30.736 +2020-04-08 05:00:00,97.97,145.516,38.65,30.736 +2020-04-08 05:15:00,99.52,178.87,38.65,30.736 +2020-04-08 05:30:00,102.97,168.683,38.65,30.736 +2020-04-08 05:45:00,107.04,158.297,38.65,30.736 +2020-04-08 06:00:00,111.56,159.431,54.951,30.736 +2020-04-08 06:15:00,113.89,165.113,54.951,30.736 +2020-04-08 06:30:00,116.9,163.208,54.951,30.736 +2020-04-08 06:45:00,118.83,163.938,54.951,30.736 +2020-04-08 07:00:00,121.7,166.757,67.328,30.736 +2020-04-08 07:15:00,121.52,167.764,67.328,30.736 +2020-04-08 07:30:00,120.53,166.30700000000002,67.328,30.736 +2020-04-08 07:45:00,119.16,163.157,67.328,30.736 +2020-04-08 08:00:00,115.39,162.161,60.23,30.736 +2020-04-08 08:15:00,117.51,160.15,60.23,30.736 +2020-04-08 08:30:00,118.44,155.19899999999998,60.23,30.736 +2020-04-08 08:45:00,119.61,152.173,60.23,30.736 +2020-04-08 09:00:00,120.78,146.264,56.845,30.736 +2020-04-08 09:15:00,122.55,143.903,56.845,30.736 +2020-04-08 09:30:00,122.95,145.643,56.845,30.736 +2020-04-08 09:45:00,122.4,144.80100000000002,56.845,30.736 +2020-04-08 10:00:00,122.17,140.63,53.832,30.736 +2020-04-08 10:15:00,121.08,140.207,53.832,30.736 +2020-04-08 10:30:00,124.05,138.03799999999998,53.832,30.736 +2020-04-08 10:45:00,121.53,137.908,53.832,30.736 +2020-04-08 11:00:00,113.64,132.23,53.225,30.736 +2020-04-08 11:15:00,107.84,132.815,53.225,30.736 +2020-04-08 11:30:00,106.89,133.906,53.225,30.736 +2020-04-08 11:45:00,109.26,135.306,53.225,30.736 +2020-04-08 12:00:00,115.23,130.839,50.676,30.736 +2020-04-08 12:15:00,114.32,131.429,50.676,30.736 +2020-04-08 12:30:00,105.49,130.672,50.676,30.736 +2020-04-08 12:45:00,103.19,131.458,50.676,30.736 +2020-04-08 13:00:00,101.47,132.237,50.646,30.736 +2020-04-08 13:15:00,107.86,130.846,50.646,30.736 +2020-04-08 13:30:00,106.14,128.796,50.646,30.736 +2020-04-08 13:45:00,114.59,127.53399999999999,50.646,30.736 +2020-04-08 14:00:00,122.83,129.437,50.786,30.736 +2020-04-08 14:15:00,123.75,128.27200000000002,50.786,30.736 +2020-04-08 14:30:00,119.06,128.078,50.786,30.736 +2020-04-08 14:45:00,114.36,128.901,50.786,30.736 +2020-04-08 15:00:00,108.95,129.488,51.535,30.736 +2020-04-08 15:15:00,110.42,127.902,51.535,30.736 +2020-04-08 15:30:00,107.51,126.854,51.535,30.736 +2020-04-08 15:45:00,110.73,126.148,51.535,30.736 +2020-04-08 16:00:00,116.16,127.445,53.157,30.736 +2020-04-08 16:15:00,113.87,128.469,53.157,30.736 +2020-04-08 16:30:00,113.4,128.485,53.157,30.736 +2020-04-08 16:45:00,118.97,126.69,53.157,30.736 +2020-04-08 17:00:00,120.55,126.706,57.793,30.736 +2020-04-08 17:15:00,115.72,129.194,57.793,30.736 +2020-04-08 17:30:00,117.9,131.276,57.793,30.736 +2020-04-08 17:45:00,119.34,132.32399999999998,57.793,30.736 +2020-04-08 18:00:00,118.83,135.232,59.872,30.736 +2020-04-08 18:15:00,114.99,136.711,59.872,30.736 +2020-04-08 18:30:00,117.03,135.128,59.872,30.736 +2020-04-08 18:45:00,117.74,140.667,59.872,30.736 +2020-04-08 19:00:00,117.3,139.016,60.17100000000001,30.736 +2020-04-08 19:15:00,117.96,137.872,60.17100000000001,30.736 +2020-04-08 19:30:00,117.33,137.67700000000002,60.17100000000001,30.736 +2020-04-08 19:45:00,114.3,137.71,60.17100000000001,30.736 +2020-04-08 20:00:00,102.99,132.656,65.015,30.736 +2020-04-08 20:15:00,103.02,129.386,65.015,30.736 +2020-04-08 20:30:00,107.09,128.996,65.015,30.736 +2020-04-08 20:45:00,106.0,128.14600000000002,65.015,30.736 +2020-04-08 21:00:00,97.85,121.82600000000001,57.805,30.736 +2020-04-08 21:15:00,90.97,120.40899999999999,57.805,30.736 +2020-04-08 21:30:00,89.79,120.62299999999999,57.805,30.736 +2020-04-08 21:45:00,86.79,119.63,57.805,30.736 +2020-04-08 22:00:00,81.91,113.434,52.115,30.736 +2020-04-08 22:15:00,83.05,110.63600000000001,52.115,30.736 +2020-04-08 22:30:00,86.93,97.867,52.115,30.736 +2020-04-08 22:45:00,87.46,90.656,52.115,30.736 +2020-04-08 23:00:00,83.24,81.655,42.871,30.736 +2020-04-08 23:15:00,77.77,80.96300000000001,42.871,30.736 +2020-04-08 23:30:00,78.21,80.09,42.871,30.736 +2020-04-08 23:45:00,81.71,81.031,42.871,30.736 +2020-04-09 00:00:00,75.02,76.195,39.203,30.736 +2020-04-09 00:15:00,76.58,76.783,39.203,30.736 +2020-04-09 00:30:00,70.26,74.887,39.203,30.736 +2020-04-09 00:45:00,71.93,73.15899999999999,39.203,30.736 +2020-04-09 01:00:00,73.86,74.275,37.118,30.736 +2020-04-09 01:15:00,78.67,73.393,37.118,30.736 +2020-04-09 01:30:00,78.31,72.165,37.118,30.736 +2020-04-09 01:45:00,73.67,71.726,37.118,30.736 +2020-04-09 02:00:00,71.77,73.42399999999999,35.647,30.736 +2020-04-09 02:15:00,74.57,72.848,35.647,30.736 +2020-04-09 02:30:00,78.65,75.328,35.647,30.736 +2020-04-09 02:45:00,79.41,75.971,35.647,30.736 +2020-04-09 03:00:00,73.94,79.559,34.585,30.736 +2020-04-09 03:15:00,75.69,81.342,34.585,30.736 +2020-04-09 03:30:00,76.28,81.153,34.585,30.736 +2020-04-09 03:45:00,77.96,81.862,34.585,30.736 +2020-04-09 04:00:00,84.48,94.429,36.184,30.736 +2020-04-09 04:15:00,90.28,107.18700000000001,36.184,30.736 +2020-04-09 04:30:00,90.56,107.54799999999999,36.184,30.736 +2020-04-09 04:45:00,91.01,109.51899999999999,36.184,30.736 +2020-04-09 05:00:00,97.64,144.918,41.019,30.736 +2020-04-09 05:15:00,101.38,178.203,41.019,30.736 +2020-04-09 05:30:00,106.13,168.021,41.019,30.736 +2020-04-09 05:45:00,108.39,157.672,41.019,30.736 +2020-04-09 06:00:00,111.79,158.826,53.963,30.736 +2020-04-09 06:15:00,114.39,164.495,53.963,30.736 +2020-04-09 06:30:00,116.98,162.559,53.963,30.736 +2020-04-09 06:45:00,121.2,163.276,53.963,30.736 +2020-04-09 07:00:00,121.1,166.09900000000002,66.512,30.736 +2020-04-09 07:15:00,120.6,167.085,66.512,30.736 +2020-04-09 07:30:00,119.91,165.58700000000002,66.512,30.736 +2020-04-09 07:45:00,118.39,162.42600000000002,66.512,30.736 +2020-04-09 08:00:00,117.51,161.411,58.86,30.736 +2020-04-09 08:15:00,117.76,159.423,58.86,30.736 +2020-04-09 08:30:00,121.48,154.435,58.86,30.736 +2020-04-09 08:45:00,121.83,151.436,58.86,30.736 +2020-04-09 09:00:00,120.69,145.533,52.156000000000006,30.736 +2020-04-09 09:15:00,115.59,143.17700000000002,52.156000000000006,30.736 +2020-04-09 09:30:00,110.08,144.937,52.156000000000006,30.736 +2020-04-09 09:45:00,113.84,144.123,52.156000000000006,30.736 +2020-04-09 10:00:00,105.34,139.96200000000002,49.034,30.736 +2020-04-09 10:15:00,105.65,139.588,49.034,30.736 +2020-04-09 10:30:00,104.77,137.445,49.034,30.736 +2020-04-09 10:45:00,105.43,137.336,49.034,30.736 +2020-04-09 11:00:00,100.95,131.65,46.53,30.736 +2020-04-09 11:15:00,102.14,132.259,46.53,30.736 +2020-04-09 11:30:00,101.09,133.35299999999998,46.53,30.736 +2020-04-09 11:45:00,101.76,134.774,46.53,30.736 +2020-04-09 12:00:00,100.18,130.33100000000002,43.318000000000005,30.736 +2020-04-09 12:15:00,105.05,130.931,43.318000000000005,30.736 +2020-04-09 12:30:00,101.1,130.13,43.318000000000005,30.736 +2020-04-09 12:45:00,98.59,130.916,43.318000000000005,30.736 +2020-04-09 13:00:00,98.99,131.74,41.608000000000004,30.736 +2020-04-09 13:15:00,100.83,130.338,41.608000000000004,30.736 +2020-04-09 13:30:00,100.23,128.286,41.608000000000004,30.736 +2020-04-09 13:45:00,97.6,127.027,41.608000000000004,30.736 +2020-04-09 14:00:00,107.53,128.997,41.786,30.736 +2020-04-09 14:15:00,114.19,127.811,41.786,30.736 +2020-04-09 14:30:00,114.7,127.574,41.786,30.736 +2020-04-09 14:45:00,112.0,128.401,41.786,30.736 +2020-04-09 15:00:00,111.51,129.015,44.181999999999995,30.736 +2020-04-09 15:15:00,112.57,127.40299999999999,44.181999999999995,30.736 +2020-04-09 15:30:00,107.02,126.306,44.181999999999995,30.736 +2020-04-09 15:45:00,110.02,125.58,44.181999999999995,30.736 +2020-04-09 16:00:00,117.62,126.913,45.956,30.736 +2020-04-09 16:15:00,115.32,127.911,45.956,30.736 +2020-04-09 16:30:00,113.91,127.928,45.956,30.736 +2020-04-09 16:45:00,115.9,126.07,45.956,30.736 +2020-04-09 17:00:00,120.99,126.135,50.702,30.736 +2020-04-09 17:15:00,120.94,128.60299999999998,50.702,30.736 +2020-04-09 17:30:00,122.61,130.686,50.702,30.736 +2020-04-09 17:45:00,117.98,131.719,50.702,30.736 +2020-04-09 18:00:00,116.45,134.638,53.595,30.736 +2020-04-09 18:15:00,119.67,136.156,53.595,30.736 +2020-04-09 18:30:00,119.28,134.56,53.595,30.736 +2020-04-09 18:45:00,118.54,140.11,53.595,30.736 +2020-04-09 19:00:00,113.55,138.439,54.207,30.736 +2020-04-09 19:15:00,111.16,137.30700000000002,54.207,30.736 +2020-04-09 19:30:00,116.65,137.13,54.207,30.736 +2020-04-09 19:45:00,115.97,137.195,54.207,30.736 +2020-04-09 20:00:00,108.98,132.112,56.948,30.736 +2020-04-09 20:15:00,107.57,128.85299999999998,56.948,30.736 +2020-04-09 20:30:00,101.4,128.499,56.948,30.736 +2020-04-09 20:45:00,106.29,127.67299999999999,56.948,30.736 +2020-04-09 21:00:00,102.08,121.351,52.157,30.736 +2020-04-09 21:15:00,96.68,119.946,52.157,30.736 +2020-04-09 21:30:00,90.04,120.15100000000001,52.157,30.736 +2020-04-09 21:45:00,89.87,119.18700000000001,52.157,30.736 +2020-04-09 22:00:00,90.47,112.99700000000001,47.483000000000004,30.736 +2020-04-09 22:15:00,88.93,110.225,47.483000000000004,30.736 +2020-04-09 22:30:00,83.44,97.421,47.483000000000004,30.736 +2020-04-09 22:45:00,80.92,90.204,47.483000000000004,30.736 +2020-04-09 23:00:00,64.51,81.181,41.978,30.736 +2020-04-09 23:15:00,64.07,80.52199999999999,41.978,30.736 +2020-04-09 23:30:00,61.31,79.64699999999999,41.978,30.736 +2020-04-09 23:45:00,61.8,80.60300000000001,41.978,30.736 +2020-04-10 00:00:00,59.64,74.03399999999999,30.72,30.618000000000002 +2020-04-10 00:15:00,60.38,70.993,30.72,30.618000000000002 +2020-04-10 00:30:00,56.32,69.40899999999999,30.72,30.618000000000002 +2020-04-10 00:45:00,59.16,68.405,30.72,30.618000000000002 +2020-04-10 01:00:00,57.38,69.718,26.553,30.618000000000002 +2020-04-10 01:15:00,57.59,69.342,26.553,30.618000000000002 +2020-04-10 01:30:00,57.08,67.82300000000001,26.553,30.618000000000002 +2020-04-10 01:45:00,58.03,67.472,26.553,30.618000000000002 +2020-04-10 02:00:00,53.88,69.205,22.712,30.618000000000002 +2020-04-10 02:15:00,57.6,67.71,22.712,30.618000000000002 +2020-04-10 02:30:00,54.83,70.5,22.712,30.618000000000002 +2020-04-10 02:45:00,57.8,71.417,22.712,30.618000000000002 +2020-04-10 03:00:00,58.43,75.032,20.511999999999997,30.618000000000002 +2020-04-10 03:15:00,56.11,75.366,20.511999999999997,30.618000000000002 +2020-04-10 03:30:00,59.21,74.673,20.511999999999997,30.618000000000002 +2020-04-10 03:45:00,59.68,76.515,20.511999999999997,30.618000000000002 +2020-04-10 04:00:00,61.34,85.525,19.98,30.618000000000002 +2020-04-10 04:15:00,58.56,94.279,19.98,30.618000000000002 +2020-04-10 04:30:00,61.08,93.85799999999999,19.98,30.618000000000002 +2020-04-10 04:45:00,61.82,94.49600000000001,19.98,30.618000000000002 +2020-04-10 05:00:00,61.78,112.44,22.715,30.618000000000002 +2020-04-10 05:15:00,63.19,127.439,22.715,30.618000000000002 +2020-04-10 05:30:00,63.03,118.79899999999999,22.715,30.618000000000002 +2020-04-10 05:45:00,62.57,114.056,22.715,30.618000000000002 +2020-04-10 06:00:00,63.5,131.666,22.576999999999998,30.618000000000002 +2020-04-10 06:15:00,64.36,150.401,22.576999999999998,30.618000000000002 +2020-04-10 06:30:00,66.27,141.877,22.576999999999998,30.618000000000002 +2020-04-10 06:45:00,66.56,134.958,22.576999999999998,30.618000000000002 +2020-04-10 07:00:00,72.0,135.668,23.541999999999998,30.618000000000002 +2020-04-10 07:15:00,72.94,134.844,23.541999999999998,30.618000000000002 +2020-04-10 07:30:00,72.56,134.6,23.541999999999998,30.618000000000002 +2020-04-10 07:45:00,72.68,133.276,23.541999999999998,30.618000000000002 +2020-04-10 08:00:00,73.27,136.041,23.895,30.618000000000002 +2020-04-10 08:15:00,73.33,137.007,23.895,30.618000000000002 +2020-04-10 08:30:00,72.0,135.178,23.895,30.618000000000002 +2020-04-10 08:45:00,71.59,134.93200000000002,23.895,30.618000000000002 +2020-04-10 09:00:00,63.96,130.424,24.239,30.618000000000002 +2020-04-10 09:15:00,70.6,130.297,24.239,30.618000000000002 +2020-04-10 09:30:00,70.79,132.411,24.239,30.618000000000002 +2020-04-10 09:45:00,73.79,132.059,24.239,30.618000000000002 +2020-04-10 10:00:00,71.11,129.239,21.985,30.618000000000002 +2020-04-10 10:15:00,71.88,130.02,21.985,30.618000000000002 +2020-04-10 10:30:00,72.52,128.593,21.985,30.618000000000002 +2020-04-10 10:45:00,68.48,128.07,21.985,30.618000000000002 +2020-04-10 11:00:00,65.66,122.911,22.093000000000004,30.618000000000002 +2020-04-10 11:15:00,62.4,122.189,22.093000000000004,30.618000000000002 +2020-04-10 11:30:00,60.95,123.34700000000001,22.093000000000004,30.618000000000002 +2020-04-10 11:45:00,61.95,124.551,22.093000000000004,30.618000000000002 +2020-04-10 12:00:00,55.24,120.56299999999999,19.041,30.618000000000002 +2020-04-10 12:15:00,54.22,121.271,19.041,30.618000000000002 +2020-04-10 12:30:00,54.57,119.821,19.041,30.618000000000002 +2020-04-10 12:45:00,58.91,119.413,19.041,30.618000000000002 +2020-04-10 13:00:00,55.07,119.819,12.672,30.618000000000002 +2020-04-10 13:15:00,56.02,119.266,12.672,30.618000000000002 +2020-04-10 13:30:00,52.03,116.676,12.672,30.618000000000002 +2020-04-10 13:45:00,51.25,115.288,12.672,30.618000000000002 +2020-04-10 14:00:00,51.01,117.75200000000001,10.321,30.618000000000002 +2020-04-10 14:15:00,54.91,116.723,10.321,30.618000000000002 +2020-04-10 14:30:00,54.25,116.304,10.321,30.618000000000002 +2020-04-10 14:45:00,50.76,116.64299999999999,10.321,30.618000000000002 +2020-04-10 15:00:00,52.9,116.525,13.478,30.618000000000002 +2020-04-10 15:15:00,55.88,115.546,13.478,30.618000000000002 +2020-04-10 15:30:00,52.88,114.59100000000001,13.478,30.618000000000002 +2020-04-10 15:45:00,57.97,114.529,13.478,30.618000000000002 +2020-04-10 16:00:00,62.6,115.57799999999999,17.623,30.618000000000002 +2020-04-10 16:15:00,64.61,116.676,17.623,30.618000000000002 +2020-04-10 16:30:00,67.2,117.40700000000001,17.623,30.618000000000002 +2020-04-10 16:45:00,69.59,115.771,17.623,30.618000000000002 +2020-04-10 17:00:00,76.4,116.23299999999999,22.64,30.618000000000002 +2020-04-10 17:15:00,75.58,118.92200000000001,22.64,30.618000000000002 +2020-04-10 17:30:00,77.36,121.331,22.64,30.618000000000002 +2020-04-10 17:45:00,75.89,123.815,22.64,30.618000000000002 +2020-04-10 18:00:00,81.02,127.62,29.147,30.618000000000002 +2020-04-10 18:15:00,79.22,131.172,29.147,30.618000000000002 +2020-04-10 18:30:00,80.59,129.843,29.147,30.618000000000002 +2020-04-10 18:45:00,80.5,132.89600000000002,29.147,30.618000000000002 +2020-04-10 19:00:00,84.47,133.018,34.491,30.618000000000002 +2020-04-10 19:15:00,83.92,132.095,34.491,30.618000000000002 +2020-04-10 19:30:00,81.85,132.35,34.491,30.618000000000002 +2020-04-10 19:45:00,81.31,133.11700000000002,34.491,30.618000000000002 +2020-04-10 20:00:00,76.54,129.928,41.368,30.618000000000002 +2020-04-10 20:15:00,76.86,128.756,41.368,30.618000000000002 +2020-04-10 20:30:00,73.31,128.704,41.368,30.618000000000002 +2020-04-10 20:45:00,75.27,126.87899999999999,41.368,30.618000000000002 +2020-04-10 21:00:00,70.24,120.53399999999999,37.605,30.618000000000002 +2020-04-10 21:15:00,71.86,119.846,37.605,30.618000000000002 +2020-04-10 21:30:00,65.09,120.75200000000001,37.605,30.618000000000002 +2020-04-10 21:45:00,68.77,120.009,37.605,30.618000000000002 +2020-04-10 22:00:00,65.32,115.573,36.472,30.618000000000002 +2020-04-10 22:15:00,64.47,113.001,36.472,30.618000000000002 +2020-04-10 22:30:00,62.29,108.45200000000001,36.472,30.618000000000002 +2020-04-10 22:45:00,61.86,104.22399999999999,36.472,30.618000000000002 +2020-04-10 23:00:00,78.85,95.059,31.816,30.618000000000002 +2020-04-10 23:15:00,77.86,93.334,31.816,30.618000000000002 +2020-04-10 23:30:00,74.26,90.906,31.816,30.618000000000002 +2020-04-10 23:45:00,72.92,90.618,31.816,30.618000000000002 +2020-04-11 00:00:00,73.14,72.59,39.184,30.618000000000002 +2020-04-11 00:15:00,74.22,70.405,39.184,30.618000000000002 +2020-04-11 00:30:00,72.87,69.128,39.184,30.618000000000002 +2020-04-11 00:45:00,67.49,67.756,39.184,30.618000000000002 +2020-04-11 01:00:00,70.97,68.992,34.692,30.618000000000002 +2020-04-11 01:15:00,73.16,68.01899999999999,34.692,30.618000000000002 +2020-04-11 01:30:00,71.68,66.232,34.692,30.618000000000002 +2020-04-11 01:45:00,67.54,66.294,34.692,30.618000000000002 +2020-04-11 02:00:00,71.64,68.439,32.919000000000004,30.618000000000002 +2020-04-11 02:15:00,73.62,67.054,32.919000000000004,30.618000000000002 +2020-04-11 02:30:00,69.77,69.232,32.919000000000004,30.618000000000002 +2020-04-11 02:45:00,69.74,70.032,32.919000000000004,30.618000000000002 +2020-04-11 03:00:00,65.86,73.10600000000001,32.024,30.618000000000002 +2020-04-11 03:15:00,64.59,73.584,32.024,30.618000000000002 +2020-04-11 03:30:00,65.11,72.45,32.024,30.618000000000002 +2020-04-11 03:45:00,64.5,74.812,32.024,30.618000000000002 +2020-04-11 04:00:00,65.68,83.98200000000001,31.958000000000002,30.618000000000002 +2020-04-11 04:15:00,66.71,93.564,31.958000000000002,30.618000000000002 +2020-04-11 04:30:00,65.87,92.339,31.958000000000002,30.618000000000002 +2020-04-11 04:45:00,66.99,93.068,31.958000000000002,30.618000000000002 +2020-04-11 05:00:00,67.85,112.939,32.75,30.618000000000002 +2020-04-11 05:15:00,67.86,129.761,32.75,30.618000000000002 +2020-04-11 05:30:00,69.08,121.459,32.75,30.618000000000002 +2020-04-11 05:45:00,71.14,116.765,32.75,30.618000000000002 +2020-04-11 06:00:00,72.48,135.846,34.461999999999996,30.618000000000002 +2020-04-11 06:15:00,75.1,155.151,34.461999999999996,30.618000000000002 +2020-04-11 06:30:00,77.55,147.668,34.461999999999996,30.618000000000002 +2020-04-11 06:45:00,80.71,141.945,34.461999999999996,30.618000000000002 +2020-04-11 07:00:00,82.56,141.024,37.736,30.618000000000002 +2020-04-11 07:15:00,83.76,141.615,37.736,30.618000000000002 +2020-04-11 07:30:00,85.57,141.409,37.736,30.618000000000002 +2020-04-11 07:45:00,88.05,140.57399999999998,37.736,30.618000000000002 +2020-04-11 08:00:00,89.53,141.81,42.34,30.618000000000002 +2020-04-11 08:15:00,90.05,142.239,42.34,30.618000000000002 +2020-04-11 08:30:00,88.35,138.835,42.34,30.618000000000002 +2020-04-11 08:45:00,88.99,137.342,42.34,30.618000000000002 +2020-04-11 09:00:00,80.42,133.192,43.571999999999996,30.618000000000002 +2020-04-11 09:15:00,83.26,132.951,43.571999999999996,30.618000000000002 +2020-04-11 09:30:00,81.39,134.967,43.571999999999996,30.618000000000002 +2020-04-11 09:45:00,76.24,134.215,43.571999999999996,30.618000000000002 +2020-04-11 10:00:00,74.6,129.458,40.514,30.618000000000002 +2020-04-11 10:15:00,75.54,129.858,40.514,30.618000000000002 +2020-04-11 10:30:00,76.68,127.932,40.514,30.618000000000002 +2020-04-11 10:45:00,76.58,128.249,40.514,30.618000000000002 +2020-04-11 11:00:00,73.64,122.635,36.388000000000005,30.618000000000002 +2020-04-11 11:15:00,73.67,122.054,36.388000000000005,30.618000000000002 +2020-04-11 11:30:00,76.9,123.58200000000001,36.388000000000005,30.618000000000002 +2020-04-11 11:45:00,74.29,124.258,36.388000000000005,30.618000000000002 +2020-04-11 12:00:00,69.92,120.24700000000001,35.217,30.618000000000002 +2020-04-11 12:15:00,66.27,119.885,35.217,30.618000000000002 +2020-04-11 12:30:00,67.34,119.337,35.217,30.618000000000002 +2020-04-11 12:45:00,67.73,119.915,35.217,30.618000000000002 +2020-04-11 13:00:00,63.46,121.039,32.001999999999995,30.618000000000002 +2020-04-11 13:15:00,65.05,118.553,32.001999999999995,30.618000000000002 +2020-04-11 13:30:00,61.84,116.615,32.001999999999995,30.618000000000002 +2020-04-11 13:45:00,62.81,115.204,32.001999999999995,30.618000000000002 +2020-04-11 14:00:00,62.8,117.021,31.304000000000002,30.618000000000002 +2020-04-11 14:15:00,63.27,114.906,31.304000000000002,30.618000000000002 +2020-04-11 14:30:00,63.28,114.103,31.304000000000002,30.618000000000002 +2020-04-11 14:45:00,63.54,115.226,31.304000000000002,30.618000000000002 +2020-04-11 15:00:00,63.73,116.178,34.731,30.618000000000002 +2020-04-11 15:15:00,64.31,114.98899999999999,34.731,30.618000000000002 +2020-04-11 15:30:00,66.0,113.639,34.731,30.618000000000002 +2020-04-11 15:45:00,68.73,112.928,34.731,30.618000000000002 +2020-04-11 16:00:00,71.32,113.571,38.769,30.618000000000002 +2020-04-11 16:15:00,71.76,115.17,38.769,30.618000000000002 +2020-04-11 16:30:00,74.69,115.245,38.769,30.618000000000002 +2020-04-11 16:45:00,79.72,113.42200000000001,38.769,30.618000000000002 +2020-04-11 17:00:00,82.56,113.86,44.928000000000004,30.618000000000002 +2020-04-11 17:15:00,80.55,116.00200000000001,44.928000000000004,30.618000000000002 +2020-04-11 17:30:00,82.25,117.81700000000001,44.928000000000004,30.618000000000002 +2020-04-11 17:45:00,84.04,118.53399999999999,44.928000000000004,30.618000000000002 +2020-04-11 18:00:00,86.25,122.461,47.786,30.618000000000002 +2020-04-11 18:15:00,84.53,125.369,47.786,30.618000000000002 +2020-04-11 18:30:00,84.88,125.551,47.786,30.618000000000002 +2020-04-11 18:45:00,85.38,127.315,47.786,30.618000000000002 +2020-04-11 19:00:00,86.2,126.56,47.463,30.618000000000002 +2020-04-11 19:15:00,85.71,125.825,47.463,30.618000000000002 +2020-04-11 19:30:00,86.11,126.334,47.463,30.618000000000002 +2020-04-11 19:45:00,84.4,126.412,47.463,30.618000000000002 +2020-04-11 20:00:00,81.75,123.16,43.735,30.618000000000002 +2020-04-11 20:15:00,80.33,121.42200000000001,43.735,30.618000000000002 +2020-04-11 20:30:00,77.95,120.17399999999999,43.735,30.618000000000002 +2020-04-11 20:45:00,76.55,120.03200000000001,43.735,30.618000000000002 +2020-04-11 21:00:00,71.3,115.48700000000001,40.346,30.618000000000002 +2020-04-11 21:15:00,70.85,115.375,40.346,30.618000000000002 +2020-04-11 21:30:00,68.16,116.42200000000001,40.346,30.618000000000002 +2020-04-11 21:45:00,67.66,115.43,40.346,30.618000000000002 +2020-04-11 22:00:00,63.94,110.7,39.323,30.618000000000002 +2020-04-11 22:15:00,64.14,109.53200000000001,39.323,30.618000000000002 +2020-04-11 22:30:00,61.85,107.27600000000001,39.323,30.618000000000002 +2020-04-11 22:45:00,61.25,104.26299999999999,39.323,30.618000000000002 +2020-04-11 23:00:00,60.08,97.06,33.716,30.618000000000002 +2020-04-11 23:15:00,58.06,93.493,33.716,30.618000000000002 +2020-04-11 23:30:00,55.51,90.814,33.716,30.618000000000002 +2020-04-11 23:45:00,56.45,89.876,33.716,30.618000000000002 +2020-04-12 00:00:00,54.53,73.203,30.72,30.618000000000002 +2020-04-12 00:15:00,52.88,70.192,30.72,30.618000000000002 +2020-04-12 00:30:00,52.07,68.59,30.72,30.618000000000002 +2020-04-12 00:45:00,53.98,67.596,30.72,30.618000000000002 +2020-04-12 01:00:00,48.89,68.872,26.553,30.618000000000002 +2020-04-12 01:15:00,53.69,68.45100000000001,26.553,30.618000000000002 +2020-04-12 01:30:00,51.28,66.889,26.553,30.618000000000002 +2020-04-12 01:45:00,53.95,66.54899999999999,26.553,30.618000000000002 +2020-04-12 02:00:00,50.72,68.258,22.712,30.618000000000002 +2020-04-12 02:15:00,54.24,66.73,22.712,30.618000000000002 +2020-04-12 02:30:00,51.64,69.561,22.712,30.618000000000002 +2020-04-12 02:45:00,51.01,70.486,22.712,30.618000000000002 +2020-04-12 03:00:00,50.48,74.13600000000001,20.511999999999997,30.618000000000002 +2020-04-12 03:15:00,51.91,74.418,20.511999999999997,30.618000000000002 +2020-04-12 03:30:00,51.98,73.71600000000001,20.511999999999997,30.618000000000002 +2020-04-12 03:45:00,52.83,75.598,20.511999999999997,30.618000000000002 +2020-04-12 04:00:00,51.89,84.565,19.98,30.618000000000002 +2020-04-12 04:15:00,52.74,93.25200000000001,19.98,30.618000000000002 +2020-04-12 04:30:00,52.54,92.84100000000001,19.98,30.618000000000002 +2020-04-12 04:45:00,54.38,93.456,19.98,30.618000000000002 +2020-04-12 05:00:00,55.46,111.241,22.715,30.618000000000002 +2020-04-12 05:15:00,53.48,126.101,22.715,30.618000000000002 +2020-04-12 05:30:00,55.9,117.47200000000001,22.715,30.618000000000002 +2020-04-12 05:45:00,53.71,112.805,22.715,30.618000000000002 +2020-04-12 06:00:00,55.53,130.452,22.576999999999998,30.618000000000002 +2020-04-12 06:15:00,59.67,149.158,22.576999999999998,30.618000000000002 +2020-04-12 06:30:00,62.15,140.576,22.576999999999998,30.618000000000002 +2020-04-12 06:45:00,61.32,133.628,22.576999999999998,30.618000000000002 +2020-04-12 07:00:00,67.05,134.34799999999998,23.541999999999998,30.618000000000002 +2020-04-12 07:15:00,67.97,133.483,23.541999999999998,30.618000000000002 +2020-04-12 07:30:00,69.68,133.157,23.541999999999998,30.618000000000002 +2020-04-12 07:45:00,69.61,131.813,23.541999999999998,30.618000000000002 +2020-04-12 08:00:00,74.12,134.54,27.568,30.618000000000002 +2020-04-12 08:15:00,75.03,135.55200000000002,27.568,30.618000000000002 +2020-04-12 08:30:00,74.98,133.65,27.568,30.618000000000002 +2020-04-12 08:45:00,73.91,133.46200000000002,27.568,30.618000000000002 +2020-04-12 09:00:00,76.48,128.969,27.965,30.618000000000002 +2020-04-12 09:15:00,73.74,128.851,27.965,30.618000000000002 +2020-04-12 09:30:00,75.72,130.999,27.965,30.618000000000002 +2020-04-12 09:45:00,74.12,130.707,27.965,30.618000000000002 +2020-04-12 10:00:00,72.15,127.902,25.365,30.618000000000002 +2020-04-12 10:15:00,77.67,128.786,25.365,30.618000000000002 +2020-04-12 10:30:00,77.66,127.40899999999999,25.365,30.618000000000002 +2020-04-12 10:45:00,82.07,126.928,25.365,30.618000000000002 +2020-04-12 11:00:00,72.99,121.75399999999999,25.489,30.618000000000002 +2020-04-12 11:15:00,75.01,121.08,25.489,30.618000000000002 +2020-04-12 11:30:00,71.52,122.244,25.489,30.618000000000002 +2020-04-12 11:45:00,69.58,123.48700000000001,25.489,30.618000000000002 +2020-04-12 12:00:00,68.21,119.55,21.968000000000004,30.618000000000002 +2020-04-12 12:15:00,67.52,120.275,21.968000000000004,30.618000000000002 +2020-04-12 12:30:00,66.81,118.738,21.968000000000004,30.618000000000002 +2020-04-12 12:45:00,67.38,118.333,21.968000000000004,30.618000000000002 +2020-04-12 13:00:00,60.59,118.82600000000001,14.62,30.618000000000002 +2020-04-12 13:15:00,59.39,118.251,14.62,30.618000000000002 +2020-04-12 13:30:00,58.18,115.65799999999999,14.62,30.618000000000002 +2020-04-12 13:45:00,60.55,114.27600000000001,14.62,30.618000000000002 +2020-04-12 14:00:00,59.16,116.87700000000001,11.908,30.618000000000002 +2020-04-12 14:15:00,60.01,115.804,11.908,30.618000000000002 +2020-04-12 14:30:00,56.92,115.295,11.908,30.618000000000002 +2020-04-12 14:45:00,58.07,115.64200000000001,11.908,30.618000000000002 +2020-04-12 15:00:00,61.44,115.57700000000001,15.55,30.618000000000002 +2020-04-12 15:15:00,62.16,114.54899999999999,15.55,30.618000000000002 +2020-04-12 15:30:00,62.09,113.494,15.55,30.618000000000002 +2020-04-12 15:45:00,63.69,113.395,15.55,30.618000000000002 +2020-04-12 16:00:00,63.44,114.516,20.332,30.618000000000002 +2020-04-12 16:15:00,65.92,115.56,20.332,30.618000000000002 +2020-04-12 16:30:00,69.07,116.295,20.332,30.618000000000002 +2020-04-12 16:45:00,69.42,114.53200000000001,20.332,30.618000000000002 +2020-04-12 17:00:00,77.2,115.094,26.121,30.618000000000002 +2020-04-12 17:15:00,75.08,117.742,26.121,30.618000000000002 +2020-04-12 17:30:00,76.8,120.152,26.121,30.618000000000002 +2020-04-12 17:45:00,76.23,122.604,26.121,30.618000000000002 +2020-04-12 18:00:00,79.58,126.429,33.626999999999995,30.618000000000002 +2020-04-12 18:15:00,77.87,130.05700000000002,33.626999999999995,30.618000000000002 +2020-04-12 18:30:00,78.53,128.704,33.626999999999995,30.618000000000002 +2020-04-12 18:45:00,80.41,131.775,33.626999999999995,30.618000000000002 +2020-04-12 19:00:00,79.37,131.862,39.793,30.618000000000002 +2020-04-12 19:15:00,79.52,130.96200000000002,39.793,30.618000000000002 +2020-04-12 19:30:00,78.47,131.251,39.793,30.618000000000002 +2020-04-12 19:45:00,77.25,132.084,39.793,30.618000000000002 +2020-04-12 20:00:00,74.47,128.839,41.368,30.618000000000002 +2020-04-12 20:15:00,73.54,127.68799999999999,41.368,30.618000000000002 +2020-04-12 20:30:00,73.03,127.709,41.368,30.618000000000002 +2020-04-12 20:45:00,72.54,125.928,41.368,30.618000000000002 +2020-04-12 21:00:00,69.45,119.583,37.605,30.618000000000002 +2020-04-12 21:15:00,69.2,118.919,37.605,30.618000000000002 +2020-04-12 21:30:00,64.59,119.805,37.605,30.618000000000002 +2020-04-12 21:45:00,67.2,119.119,37.605,30.618000000000002 +2020-04-12 22:00:00,62.87,114.694,36.472,30.618000000000002 +2020-04-12 22:15:00,62.97,112.175,36.472,30.618000000000002 +2020-04-12 22:30:00,58.12,107.554,36.472,30.618000000000002 +2020-04-12 22:45:00,60.08,103.31299999999999,36.472,30.618000000000002 +2020-04-12 23:00:00,57.22,94.10700000000001,31.816,30.618000000000002 +2020-04-12 23:15:00,57.26,92.449,31.816,30.618000000000002 +2020-04-12 23:30:00,53.07,90.01700000000001,31.816,30.618000000000002 +2020-04-12 23:45:00,55.28,89.759,31.816,30.618000000000002 +2020-04-13 00:00:00,49.89,72.78699999999999,30.72,30.618000000000002 +2020-04-13 00:15:00,53.6,69.791,30.72,30.618000000000002 +2020-04-13 00:30:00,50.52,68.178,30.72,30.618000000000002 +2020-04-13 00:45:00,53.11,67.191,30.72,30.618000000000002 +2020-04-13 01:00:00,47.65,68.449,26.553,30.618000000000002 +2020-04-13 01:15:00,51.91,68.006,26.553,30.618000000000002 +2020-04-13 01:30:00,48.7,66.422,26.553,30.618000000000002 +2020-04-13 01:45:00,51.4,66.087,26.553,30.618000000000002 +2020-04-13 02:00:00,49.72,67.783,22.712,30.618000000000002 +2020-04-13 02:15:00,48.31,66.24,22.712,30.618000000000002 +2020-04-13 02:30:00,50.79,69.09100000000001,22.712,30.618000000000002 +2020-04-13 02:45:00,48.57,70.021,22.712,30.618000000000002 +2020-04-13 03:00:00,51.31,73.689,20.511999999999997,30.618000000000002 +2020-04-13 03:15:00,52.15,73.943,20.511999999999997,30.618000000000002 +2020-04-13 03:30:00,51.38,73.236,20.511999999999997,30.618000000000002 +2020-04-13 03:45:00,54.02,75.139,20.511999999999997,30.618000000000002 +2020-04-13 04:00:00,54.39,84.085,19.98,30.618000000000002 +2020-04-13 04:15:00,55.93,92.73899999999999,19.98,30.618000000000002 +2020-04-13 04:30:00,56.7,92.33200000000001,19.98,30.618000000000002 +2020-04-13 04:45:00,57.44,92.93700000000001,19.98,30.618000000000002 +2020-04-13 05:00:00,58.82,110.641,22.715,30.618000000000002 +2020-04-13 05:15:00,59.57,125.43,22.715,30.618000000000002 +2020-04-13 05:30:00,57.7,116.80799999999999,22.715,30.618000000000002 +2020-04-13 05:45:00,55.37,112.177,22.715,30.618000000000002 +2020-04-13 06:00:00,58.84,129.845,22.576999999999998,30.618000000000002 +2020-04-13 06:15:00,57.13,148.534,22.576999999999998,30.618000000000002 +2020-04-13 06:30:00,60.7,139.924,22.576999999999998,30.618000000000002 +2020-04-13 06:45:00,62.85,132.963,22.576999999999998,30.618000000000002 +2020-04-13 07:00:00,68.7,133.687,23.541999999999998,30.618000000000002 +2020-04-13 07:15:00,68.91,132.80200000000002,23.541999999999998,30.618000000000002 +2020-04-13 07:30:00,66.28,132.436,23.541999999999998,30.618000000000002 +2020-04-13 07:45:00,71.72,131.08,23.541999999999998,30.618000000000002 +2020-04-13 08:00:00,70.13,133.79,23.895,30.618000000000002 +2020-04-13 08:15:00,72.83,134.825,23.895,30.618000000000002 +2020-04-13 08:30:00,69.75,132.886,23.895,30.618000000000002 +2020-04-13 08:45:00,71.31,132.72899999999998,23.895,30.618000000000002 +2020-04-13 09:00:00,67.16,128.24200000000002,24.239,30.618000000000002 +2020-04-13 09:15:00,66.12,128.127,24.239,30.618000000000002 +2020-04-13 09:30:00,64.1,130.295,24.239,30.618000000000002 +2020-04-13 09:45:00,66.42,130.032,24.239,30.618000000000002 +2020-04-13 10:00:00,67.4,127.23700000000001,21.985,30.618000000000002 +2020-04-13 10:15:00,66.6,128.17,21.985,30.618000000000002 +2020-04-13 10:30:00,70.58,126.81700000000001,21.985,30.618000000000002 +2020-04-13 10:45:00,69.07,126.359,21.985,30.618000000000002 +2020-04-13 11:00:00,63.2,121.177,22.093000000000004,30.618000000000002 +2020-04-13 11:15:00,62.5,120.52799999999999,22.093000000000004,30.618000000000002 +2020-04-13 11:30:00,63.4,121.693,22.093000000000004,30.618000000000002 +2020-04-13 11:45:00,71.72,122.95700000000001,22.093000000000004,30.618000000000002 +2020-04-13 12:00:00,63.81,119.045,19.041,30.618000000000002 +2020-04-13 12:15:00,54.06,119.77799999999999,19.041,30.618000000000002 +2020-04-13 12:30:00,54.23,118.197,19.041,30.618000000000002 +2020-04-13 12:45:00,56.47,117.792,19.041,30.618000000000002 +2020-04-13 13:00:00,56.38,118.329,12.672,30.618000000000002 +2020-04-13 13:15:00,51.32,117.744,12.672,30.618000000000002 +2020-04-13 13:30:00,50.09,115.15,12.672,30.618000000000002 +2020-04-13 13:45:00,52.76,113.772,12.672,30.618000000000002 +2020-04-13 14:00:00,50.0,116.441,10.321,30.618000000000002 +2020-04-13 14:15:00,47.98,115.345,10.321,30.618000000000002 +2020-04-13 14:30:00,46.88,114.791,10.321,30.618000000000002 +2020-04-13 14:45:00,51.58,115.14200000000001,10.321,30.618000000000002 +2020-04-13 15:00:00,52.85,115.103,13.478,30.618000000000002 +2020-04-13 15:15:00,53.15,114.051,13.478,30.618000000000002 +2020-04-13 15:30:00,51.27,112.945,13.478,30.618000000000002 +2020-04-13 15:45:00,52.98,112.829,13.478,30.618000000000002 +2020-04-13 16:00:00,57.21,113.985,17.623,30.618000000000002 +2020-04-13 16:15:00,61.89,115.00399999999999,17.623,30.618000000000002 +2020-04-13 16:30:00,63.48,115.741,17.623,30.618000000000002 +2020-04-13 16:45:00,65.1,113.913,17.623,30.618000000000002 +2020-04-13 17:00:00,72.1,114.525,22.64,30.618000000000002 +2020-04-13 17:15:00,73.56,117.152,22.64,30.618000000000002 +2020-04-13 17:30:00,76.1,119.56299999999999,22.64,30.618000000000002 +2020-04-13 17:45:00,77.85,121.99799999999999,22.64,30.618000000000002 +2020-04-13 18:00:00,78.34,125.833,29.147,30.618000000000002 +2020-04-13 18:15:00,78.86,129.499,29.147,30.618000000000002 +2020-04-13 18:30:00,81.38,128.134,29.147,30.618000000000002 +2020-04-13 18:45:00,82.16,131.215,29.147,30.618000000000002 +2020-04-13 19:00:00,86.48,131.284,34.491,30.618000000000002 +2020-04-13 19:15:00,87.39,130.394,34.491,30.618000000000002 +2020-04-13 19:30:00,85.34,130.702,34.491,30.618000000000002 +2020-04-13 19:45:00,84.46,131.567,34.491,30.618000000000002 +2020-04-13 20:00:00,81.57,128.29399999999998,41.368,30.618000000000002 +2020-04-13 20:15:00,80.87,127.15299999999999,41.368,30.618000000000002 +2020-04-13 20:30:00,77.82,127.21,41.368,30.618000000000002 +2020-04-13 20:45:00,80.82,125.45200000000001,41.368,30.618000000000002 +2020-04-13 21:00:00,80.42,119.10700000000001,37.605,30.618000000000002 +2020-04-13 21:15:00,79.52,118.454,37.605,30.618000000000002 +2020-04-13 21:30:00,74.22,119.331,37.605,30.618000000000002 +2020-04-13 21:45:00,76.26,118.67399999999999,37.605,30.618000000000002 +2020-04-13 22:00:00,68.73,114.255,36.472,30.618000000000002 +2020-04-13 22:15:00,70.32,111.76100000000001,36.472,30.618000000000002 +2020-04-13 22:30:00,68.58,107.104,36.472,30.618000000000002 +2020-04-13 22:45:00,70.36,102.85700000000001,36.472,30.618000000000002 +2020-04-13 23:00:00,81.66,93.63,31.816,30.618000000000002 +2020-04-13 23:15:00,81.85,92.005,31.816,30.618000000000002 +2020-04-13 23:30:00,75.45,89.572,31.816,30.618000000000002 +2020-04-13 23:45:00,76.28,89.32799999999999,31.816,30.618000000000002 +2020-04-14 00:00:00,77.57,74.116,39.857,30.736 +2020-04-14 00:15:00,78.7,74.781,39.857,30.736 +2020-04-14 00:30:00,77.84,72.835,39.857,30.736 +2020-04-14 00:45:00,74.5,71.13600000000001,39.857,30.736 +2020-04-14 01:00:00,78.14,72.16,37.233000000000004,30.736 +2020-04-14 01:15:00,79.78,71.166,37.233000000000004,30.736 +2020-04-14 01:30:00,79.36,69.831,37.233000000000004,30.736 +2020-04-14 01:45:00,76.71,69.417,37.233000000000004,30.736 +2020-04-14 02:00:00,79.63,71.053,35.856,30.736 +2020-04-14 02:15:00,80.53,70.399,35.856,30.736 +2020-04-14 02:30:00,77.64,72.979,35.856,30.736 +2020-04-14 02:45:00,77.36,73.646,35.856,30.736 +2020-04-14 03:00:00,80.42,77.32,34.766999999999996,30.736 +2020-04-14 03:15:00,77.51,78.97,34.766999999999996,30.736 +2020-04-14 03:30:00,78.75,78.759,34.766999999999996,30.736 +2020-04-14 03:45:00,85.63,79.569,34.766999999999996,30.736 +2020-04-14 04:00:00,90.63,92.03,35.468,30.736 +2020-04-14 04:15:00,89.42,104.62,35.468,30.736 +2020-04-14 04:30:00,89.05,105.006,35.468,30.736 +2020-04-14 04:45:00,91.92,106.92,35.468,30.736 +2020-04-14 05:00:00,100.81,141.918,40.399,30.736 +2020-04-14 05:15:00,102.84,174.856,40.399,30.736 +2020-04-14 05:30:00,105.47,164.701,40.399,30.736 +2020-04-14 05:45:00,107.4,154.543,40.399,30.736 +2020-04-14 06:00:00,112.59,155.791,54.105,30.736 +2020-04-14 06:15:00,113.37,161.384,54.105,30.736 +2020-04-14 06:30:00,116.15,159.305,54.105,30.736 +2020-04-14 06:45:00,117.75,159.95,54.105,30.736 +2020-04-14 07:00:00,122.9,162.8,63.083,30.736 +2020-04-14 07:15:00,119.48,163.681,63.083,30.736 +2020-04-14 07:30:00,122.96,161.981,63.083,30.736 +2020-04-14 07:45:00,119.45,158.767,63.083,30.736 +2020-04-14 08:00:00,120.93,157.66,57.254,30.736 +2020-04-14 08:15:00,123.65,155.789,57.254,30.736 +2020-04-14 08:30:00,125.42,150.616,57.254,30.736 +2020-04-14 08:45:00,126.12,147.766,57.254,30.736 +2020-04-14 09:00:00,121.6,141.899,51.395,30.736 +2020-04-14 09:15:00,123.45,139.561,51.395,30.736 +2020-04-14 09:30:00,123.95,141.412,51.395,30.736 +2020-04-14 09:45:00,124.96,140.746,51.395,30.736 +2020-04-14 10:00:00,122.16,136.626,48.201,30.736 +2020-04-14 10:15:00,123.54,136.503,48.201,30.736 +2020-04-14 10:30:00,121.52,134.487,48.201,30.736 +2020-04-14 10:45:00,120.31,134.483,48.201,30.736 +2020-04-14 11:00:00,116.44,128.762,46.133,30.736 +2020-04-14 11:15:00,111.12,129.491,46.133,30.736 +2020-04-14 11:30:00,108.43,130.6,46.133,30.736 +2020-04-14 11:45:00,109.42,132.118,46.133,30.736 +2020-04-14 12:00:00,104.93,127.803,44.243,30.736 +2020-04-14 12:15:00,109.73,128.44299999999998,44.243,30.736 +2020-04-14 12:30:00,108.63,127.42299999999999,44.243,30.736 +2020-04-14 12:45:00,105.87,128.214,44.243,30.736 +2020-04-14 13:00:00,99.12,129.259,45.042,30.736 +2020-04-14 13:15:00,98.79,127.802,45.042,30.736 +2020-04-14 13:30:00,104.88,125.742,45.042,30.736 +2020-04-14 13:45:00,105.06,124.499,45.042,30.736 +2020-04-14 14:00:00,104.5,126.81,44.062,30.736 +2020-04-14 14:15:00,101.57,125.516,44.062,30.736 +2020-04-14 14:30:00,96.19,125.055,44.062,30.736 +2020-04-14 14:45:00,96.11,125.898,44.062,30.736 +2020-04-14 15:00:00,107.14,126.646,46.461999999999996,30.736 +2020-04-14 15:15:00,106.37,124.911,46.461999999999996,30.736 +2020-04-14 15:30:00,104.16,123.564,46.461999999999996,30.736 +2020-04-14 15:45:00,104.55,122.74700000000001,46.461999999999996,30.736 +2020-04-14 16:00:00,107.58,124.26,48.802,30.736 +2020-04-14 16:15:00,113.1,125.12299999999999,48.802,30.736 +2020-04-14 16:30:00,111.12,125.152,48.802,30.736 +2020-04-14 16:45:00,110.6,122.97399999999999,48.802,30.736 +2020-04-14 17:00:00,112.54,123.29,55.672,30.736 +2020-04-14 17:15:00,115.47,125.654,55.672,30.736 +2020-04-14 17:30:00,119.06,127.742,55.672,30.736 +2020-04-14 17:45:00,115.7,128.691,55.672,30.736 +2020-04-14 18:00:00,112.52,131.662,57.006,30.736 +2020-04-14 18:15:00,117.42,133.36700000000002,57.006,30.736 +2020-04-14 18:30:00,119.58,131.71200000000002,57.006,30.736 +2020-04-14 18:45:00,117.04,137.30700000000002,57.006,30.736 +2020-04-14 19:00:00,110.56,135.55100000000002,57.148,30.736 +2020-04-14 19:15:00,107.17,134.471,57.148,30.736 +2020-04-14 19:30:00,115.88,134.385,57.148,30.736 +2020-04-14 19:45:00,113.92,134.611,57.148,30.736 +2020-04-14 20:00:00,110.45,129.387,61.895,30.736 +2020-04-14 20:15:00,109.71,126.18299999999999,61.895,30.736 +2020-04-14 20:30:00,100.85,126.009,61.895,30.736 +2020-04-14 20:45:00,108.91,125.295,61.895,30.736 +2020-04-14 21:00:00,103.46,118.976,54.78,30.736 +2020-04-14 21:15:00,102.44,117.626,54.78,30.736 +2020-04-14 21:30:00,93.5,117.785,54.78,30.736 +2020-04-14 21:45:00,87.39,116.962,54.78,30.736 +2020-04-14 22:00:00,87.83,110.8,50.76,30.736 +2020-04-14 22:15:00,90.13,108.15799999999999,50.76,30.736 +2020-04-14 22:30:00,87.92,95.17399999999999,50.76,30.736 +2020-04-14 22:45:00,84.46,87.926,50.76,30.736 +2020-04-14 23:00:00,74.36,78.79899999999999,44.162,30.736 +2020-04-14 23:15:00,76.0,78.307,44.162,30.736 +2020-04-14 23:30:00,74.94,77.425,44.162,30.736 +2020-04-14 23:45:00,73.87,78.453,44.162,30.736 +2020-04-15 00:00:00,70.64,73.699,39.061,30.736 +2020-04-15 00:15:00,72.45,74.37899999999999,39.061,30.736 +2020-04-15 00:30:00,71.26,72.425,39.061,30.736 +2020-04-15 00:45:00,73.44,70.73100000000001,39.061,30.736 +2020-04-15 01:00:00,71.82,71.737,35.795,30.736 +2020-04-15 01:15:00,71.14,70.721,35.795,30.736 +2020-04-15 01:30:00,71.08,69.36399999999999,35.795,30.736 +2020-04-15 01:45:00,72.01,68.956,35.795,30.736 +2020-04-15 02:00:00,70.74,70.58,33.316,30.736 +2020-04-15 02:15:00,70.1,69.90899999999999,33.316,30.736 +2020-04-15 02:30:00,72.27,72.509,33.316,30.736 +2020-04-15 02:45:00,78.4,73.181,33.316,30.736 +2020-04-15 03:00:00,80.61,76.872,32.803000000000004,30.736 +2020-04-15 03:15:00,83.75,78.49600000000001,32.803000000000004,30.736 +2020-04-15 03:30:00,82.05,78.28,32.803000000000004,30.736 +2020-04-15 03:45:00,86.07,79.111,32.803000000000004,30.736 +2020-04-15 04:00:00,88.31,91.54899999999999,34.235,30.736 +2020-04-15 04:15:00,89.49,104.10700000000001,34.235,30.736 +2020-04-15 04:30:00,87.96,104.49600000000001,34.235,30.736 +2020-04-15 04:45:00,93.45,106.40100000000001,34.235,30.736 +2020-04-15 05:00:00,98.18,141.31799999999998,38.65,30.736 +2020-04-15 05:15:00,101.94,174.18400000000003,38.65,30.736 +2020-04-15 05:30:00,105.86,164.03599999999997,38.65,30.736 +2020-04-15 05:45:00,109.69,153.917,38.65,30.736 +2020-04-15 06:00:00,114.65,155.183,54.951,30.736 +2020-04-15 06:15:00,114.16,160.76,54.951,30.736 +2020-04-15 06:30:00,116.39,158.65200000000002,54.951,30.736 +2020-04-15 06:45:00,116.53,159.284,54.951,30.736 +2020-04-15 07:00:00,117.39,162.137,67.328,30.736 +2020-04-15 07:15:00,117.4,162.999,67.328,30.736 +2020-04-15 07:30:00,115.55,161.259,67.328,30.736 +2020-04-15 07:45:00,112.28,158.037,67.328,30.736 +2020-04-15 08:00:00,111.52,156.912,60.23,30.736 +2020-04-15 08:15:00,111.85,155.064,60.23,30.736 +2020-04-15 08:30:00,112.04,149.856,60.23,30.736 +2020-04-15 08:45:00,110.65,147.036,60.23,30.736 +2020-04-15 09:00:00,106.92,141.17600000000002,56.845,30.736 +2020-04-15 09:15:00,107.34,138.842,56.845,30.736 +2020-04-15 09:30:00,106.27,140.71,56.845,30.736 +2020-04-15 09:45:00,106.1,140.07299999999998,56.845,30.736 +2020-04-15 10:00:00,105.75,135.961,53.832,30.736 +2020-04-15 10:15:00,106.27,135.888,53.832,30.736 +2020-04-15 10:30:00,106.67,133.898,53.832,30.736 +2020-04-15 10:45:00,105.02,133.914,53.832,30.736 +2020-04-15 11:00:00,103.38,128.187,53.225,30.736 +2020-04-15 11:15:00,103.27,128.941,53.225,30.736 +2020-04-15 11:30:00,104.28,130.05100000000002,53.225,30.736 +2020-04-15 11:45:00,102.15,131.589,53.225,30.736 +2020-04-15 12:00:00,100.29,127.3,50.676,30.736 +2020-04-15 12:15:00,98.13,127.947,50.676,30.736 +2020-04-15 12:30:00,104.5,126.884,50.676,30.736 +2020-04-15 12:45:00,105.18,127.676,50.676,30.736 +2020-04-15 13:00:00,101.11,128.764,50.646,30.736 +2020-04-15 13:15:00,100.65,127.29700000000001,50.646,30.736 +2020-04-15 13:30:00,102.83,125.236,50.646,30.736 +2020-04-15 13:45:00,103.75,123.99700000000001,50.646,30.736 +2020-04-15 14:00:00,104.03,126.375,50.786,30.736 +2020-04-15 14:15:00,97.55,125.059,50.786,30.736 +2020-04-15 14:30:00,97.21,124.553,50.786,30.736 +2020-04-15 14:45:00,102.82,125.4,50.786,30.736 +2020-04-15 15:00:00,103.26,126.17299999999999,51.535,30.736 +2020-04-15 15:15:00,100.4,124.414,51.535,30.736 +2020-04-15 15:30:00,99.53,123.01799999999999,51.535,30.736 +2020-04-15 15:45:00,104.12,122.182,51.535,30.736 +2020-04-15 16:00:00,104.01,123.73200000000001,53.157,30.736 +2020-04-15 16:15:00,108.96,124.56700000000001,53.157,30.736 +2020-04-15 16:30:00,107.57,124.6,53.157,30.736 +2020-04-15 16:45:00,115.32,122.35799999999999,53.157,30.736 +2020-04-15 17:00:00,116.1,122.72399999999999,57.793,30.736 +2020-04-15 17:15:00,113.72,125.06700000000001,57.793,30.736 +2020-04-15 17:30:00,111.49,127.154,57.793,30.736 +2020-04-15 17:45:00,115.74,128.088,57.793,30.736 +2020-04-15 18:00:00,119.64,131.067,59.872,30.736 +2020-04-15 18:15:00,116.25,132.809,59.872,30.736 +2020-04-15 18:30:00,111.49,131.142,59.872,30.736 +2020-04-15 18:45:00,111.02,136.745,59.872,30.736 +2020-04-15 19:00:00,115.27,134.97299999999998,60.17100000000001,30.736 +2020-04-15 19:15:00,112.47,133.905,60.17100000000001,30.736 +2020-04-15 19:30:00,112.99,133.835,60.17100000000001,30.736 +2020-04-15 19:45:00,112.24,134.093,60.17100000000001,30.736 +2020-04-15 20:00:00,110.1,128.842,65.015,30.736 +2020-04-15 20:15:00,111.51,125.648,65.015,30.736 +2020-04-15 20:30:00,106.08,125.51100000000001,65.015,30.736 +2020-04-15 20:45:00,109.31,124.819,65.015,30.736 +2020-04-15 21:00:00,104.34,118.501,57.805,30.736 +2020-04-15 21:15:00,101.95,117.163,57.805,30.736 +2020-04-15 21:30:00,93.9,117.31200000000001,57.805,30.736 +2020-04-15 21:45:00,86.0,116.51700000000001,57.805,30.736 +2020-04-15 22:00:00,78.84,110.359,52.115,30.736 +2020-04-15 22:15:00,87.14,107.744,52.115,30.736 +2020-04-15 22:30:00,85.74,94.72200000000001,52.115,30.736 +2020-04-15 22:45:00,86.73,87.46799999999999,52.115,30.736 +2020-04-15 23:00:00,77.18,78.321,42.871,30.736 +2020-04-15 23:15:00,76.65,77.863,42.871,30.736 +2020-04-15 23:30:00,76.51,76.979,42.871,30.736 +2020-04-15 23:45:00,79.0,78.02199999999999,42.871,30.736 +2020-04-16 00:00:00,76.59,66.062,39.203,30.736 +2020-04-16 00:15:00,76.53,66.53699999999999,39.203,30.736 +2020-04-16 00:30:00,74.45,64.943,39.203,30.736 +2020-04-16 00:45:00,72.38,63.372,39.203,30.736 +2020-04-16 01:00:00,73.63,63.707,37.118,30.736 +2020-04-16 01:15:00,77.98,63.019,37.118,30.736 +2020-04-16 01:30:00,75.57,61.729,37.118,30.736 +2020-04-16 01:45:00,78.14,61.175,37.118,30.736 +2020-04-16 02:00:00,69.37,62.033,35.647,30.736 +2020-04-16 02:15:00,73.86,61.263000000000005,35.647,30.736 +2020-04-16 02:30:00,69.08,63.718999999999994,35.647,30.736 +2020-04-16 02:45:00,73.63,64.268,35.647,30.736 +2020-04-16 03:00:00,78.41,67.297,34.585,30.736 +2020-04-16 03:15:00,81.88,68.52,34.585,30.736 +2020-04-16 03:30:00,81.3,67.967,34.585,30.736 +2020-04-16 03:45:00,77.39,68.23100000000001,34.585,30.736 +2020-04-16 04:00:00,81.22,80.163,36.184,30.736 +2020-04-16 04:15:00,82.62,92.54,36.184,30.736 +2020-04-16 04:30:00,86.64,92.275,36.184,30.736 +2020-04-16 04:45:00,90.81,94.12799999999999,36.184,30.736 +2020-04-16 05:00:00,97.5,127.575,41.019,30.736 +2020-04-16 05:15:00,101.04,159.359,41.019,30.736 +2020-04-16 05:30:00,104.32,148.222,41.019,30.736 +2020-04-16 05:45:00,104.7,137.95,41.019,30.736 +2020-04-16 06:00:00,111.42,139.596,53.963,30.736 +2020-04-16 06:15:00,111.14,144.635,53.963,30.736 +2020-04-16 06:30:00,112.46,141.951,53.963,30.736 +2020-04-16 06:45:00,111.75,142.252,53.963,30.736 +2020-04-16 07:00:00,113.92,144.352,66.512,30.736 +2020-04-16 07:15:00,113.61,144.842,66.512,30.736 +2020-04-16 07:30:00,113.32,143.05100000000002,66.512,30.736 +2020-04-16 07:45:00,111.01,139.931,66.512,30.736 +2020-04-16 08:00:00,109.42,137.489,58.86,30.736 +2020-04-16 08:15:00,108.68,135.996,58.86,30.736 +2020-04-16 08:30:00,109.43,131.921,58.86,30.736 +2020-04-16 08:45:00,108.55,130.05200000000002,58.86,30.736 +2020-04-16 09:00:00,107.62,123.76799999999999,52.156000000000006,30.736 +2020-04-16 09:15:00,105.64,121.553,52.156000000000006,30.736 +2020-04-16 09:30:00,105.44,123.573,52.156000000000006,30.736 +2020-04-16 09:45:00,105.36,123.007,52.156000000000006,30.736 +2020-04-16 10:00:00,104.88,118.589,49.034,30.736 +2020-04-16 10:15:00,105.52,118.499,49.034,30.736 +2020-04-16 10:30:00,104.66,116.969,49.034,30.736 +2020-04-16 10:45:00,103.7,117.022,49.034,30.736 +2020-04-16 11:00:00,101.79,111.735,46.53,30.736 +2020-04-16 11:15:00,100.62,112.494,46.53,30.736 +2020-04-16 11:30:00,102.26,113.68,46.53,30.736 +2020-04-16 11:45:00,100.98,114.271,46.53,30.736 +2020-04-16 12:00:00,101.74,109.26899999999999,43.318000000000005,30.736 +2020-04-16 12:15:00,100.04,110.089,43.318000000000005,30.736 +2020-04-16 12:30:00,100.71,109.241,43.318000000000005,30.736 +2020-04-16 12:45:00,98.75,109.965,43.318000000000005,30.736 +2020-04-16 13:00:00,96.55,110.53399999999999,41.608000000000004,30.736 +2020-04-16 13:15:00,101.33,109.822,41.608000000000004,30.736 +2020-04-16 13:30:00,105.43,107.68799999999999,41.608000000000004,30.736 +2020-04-16 13:45:00,99.87,106.266,41.608000000000004,30.736 +2020-04-16 14:00:00,104.65,107.289,41.786,30.736 +2020-04-16 14:15:00,103.74,106.805,41.786,30.736 +2020-04-16 14:30:00,99.2,106.686,41.786,30.736 +2020-04-16 14:45:00,96.98,107.146,41.786,30.736 +2020-04-16 15:00:00,97.32,108.044,44.181999999999995,30.736 +2020-04-16 15:15:00,97.34,106.54299999999999,44.181999999999995,30.736 +2020-04-16 15:30:00,96.99,106.125,44.181999999999995,30.736 +2020-04-16 15:45:00,105.87,105.76,44.181999999999995,30.736 +2020-04-16 16:00:00,108.15,105.584,45.956,30.736 +2020-04-16 16:15:00,109.03,105.436,45.956,30.736 +2020-04-16 16:30:00,102.46,105.414,45.956,30.736 +2020-04-16 16:45:00,107.51,103.3,45.956,30.736 +2020-04-16 17:00:00,105.77,102.85,50.702,30.736 +2020-04-16 17:15:00,109.81,105.25399999999999,50.702,30.736 +2020-04-16 17:30:00,116.22,106.64299999999999,50.702,30.736 +2020-04-16 17:45:00,115.39,108.116,50.702,30.736 +2020-04-16 18:00:00,117.78,108.929,53.595,30.736 +2020-04-16 18:15:00,112.22,110.46799999999999,53.595,30.736 +2020-04-16 18:30:00,111.55,109.307,53.595,30.736 +2020-04-16 18:45:00,116.31,114.927,53.595,30.736 +2020-04-16 19:00:00,115.59,113.14,54.207,30.736 +2020-04-16 19:15:00,112.76,112.333,54.207,30.736 +2020-04-16 19:30:00,111.41,112.33,54.207,30.736 +2020-04-16 19:45:00,109.87,112.74,54.207,30.736 +2020-04-16 20:00:00,110.54,110.89399999999999,56.948,30.736 +2020-04-16 20:15:00,108.79,108.249,56.948,30.736 +2020-04-16 20:30:00,106.12,107.178,56.948,30.736 +2020-04-16 20:45:00,100.7,106.319,56.948,30.736 +2020-04-16 21:00:00,100.63,102.01,52.157,30.736 +2020-04-16 21:15:00,99.93,101.508,52.157,30.736 +2020-04-16 21:30:00,96.78,101.87899999999999,52.157,30.736 +2020-04-16 21:45:00,91.01,101.338,52.157,30.736 +2020-04-16 22:00:00,88.24,96.85600000000001,47.483000000000004,30.736 +2020-04-16 22:15:00,90.64,94.991,47.483000000000004,30.736 +2020-04-16 22:30:00,87.26,83.77600000000001,47.483000000000004,30.736 +2020-04-16 22:45:00,83.96,78.098,47.483000000000004,30.736 +2020-04-16 23:00:00,79.47,70.275,41.978,30.736 +2020-04-16 23:15:00,82.32,68.947,41.978,30.736 +2020-04-16 23:30:00,82.51,68.09100000000001,41.978,30.736 +2020-04-16 23:45:00,79.47,68.702,41.978,30.736 +2020-04-17 00:00:00,75.69,63.902,39.301,30.736 +2020-04-17 00:15:00,80.52,64.638,39.301,30.736 +2020-04-17 00:30:00,79.11,63.152,39.301,30.736 +2020-04-17 00:45:00,74.97,61.931000000000004,39.301,30.736 +2020-04-17 01:00:00,76.44,61.839,37.976,30.736 +2020-04-17 01:15:00,78.63,61.181999999999995,37.976,30.736 +2020-04-17 01:30:00,78.54,60.246,37.976,30.736 +2020-04-17 01:45:00,75.92,59.578,37.976,30.736 +2020-04-17 02:00:00,76.67,61.106,37.041,30.736 +2020-04-17 02:15:00,78.96,60.225,37.041,30.736 +2020-04-17 02:30:00,73.71,63.532,37.041,30.736 +2020-04-17 02:45:00,72.47,63.641000000000005,37.041,30.736 +2020-04-17 03:00:00,72.74,66.71300000000001,37.575,30.736 +2020-04-17 03:15:00,77.67,67.558,37.575,30.736 +2020-04-17 03:30:00,81.93,66.829,37.575,30.736 +2020-04-17 03:45:00,82.98,67.883,37.575,30.736 +2020-04-17 04:00:00,85.63,79.98899999999999,39.058,30.736 +2020-04-17 04:15:00,81.63,91.12700000000001,39.058,30.736 +2020-04-17 04:30:00,87.46,91.634,39.058,30.736 +2020-04-17 04:45:00,88.26,92.454,39.058,30.736 +2020-04-17 05:00:00,94.18,124.83,43.256,30.736 +2020-04-17 05:15:00,97.98,157.922,43.256,30.736 +2020-04-17 05:30:00,101.19,147.47299999999998,43.256,30.736 +2020-04-17 05:45:00,104.3,136.907,43.256,30.736 +2020-04-17 06:00:00,109.57,138.972,56.093999999999994,30.736 +2020-04-17 06:15:00,110.1,143.366,56.093999999999994,30.736 +2020-04-17 06:30:00,111.6,140.194,56.093999999999994,30.736 +2020-04-17 06:45:00,112.04,141.218,56.093999999999994,30.736 +2020-04-17 07:00:00,113.63,143.342,66.92699999999999,30.736 +2020-04-17 07:15:00,113.48,144.96,66.92699999999999,30.736 +2020-04-17 07:30:00,114.3,141.72799999999998,66.92699999999999,30.736 +2020-04-17 07:45:00,111.17,138.004,66.92699999999999,30.736 +2020-04-17 08:00:00,109.06,135.482,60.332,30.736 +2020-04-17 08:15:00,108.75,134.251,60.332,30.736 +2020-04-17 08:30:00,108.96,130.61,60.332,30.736 +2020-04-17 08:45:00,111.11,127.825,60.332,30.736 +2020-04-17 09:00:00,107.04,120.291,56.085,30.736 +2020-04-17 09:15:00,107.43,119.624,56.085,30.736 +2020-04-17 09:30:00,106.4,120.985,56.085,30.736 +2020-04-17 09:45:00,106.21,120.639,56.085,30.736 +2020-04-17 10:00:00,104.57,115.45100000000001,52.91,30.736 +2020-04-17 10:15:00,105.96,115.67200000000001,52.91,30.736 +2020-04-17 10:30:00,107.27,114.46600000000001,52.91,30.736 +2020-04-17 10:45:00,103.6,114.225,52.91,30.736 +2020-04-17 11:00:00,104.04,109.054,52.278999999999996,30.736 +2020-04-17 11:15:00,101.25,108.65100000000001,52.278999999999996,30.736 +2020-04-17 11:30:00,100.24,110.637,52.278999999999996,30.736 +2020-04-17 11:45:00,100.36,110.69200000000001,52.278999999999996,30.736 +2020-04-17 12:00:00,98.31,106.641,49.023999999999994,30.736 +2020-04-17 12:15:00,98.3,105.836,49.023999999999994,30.736 +2020-04-17 12:30:00,98.9,105.109,49.023999999999994,30.736 +2020-04-17 12:45:00,96.92,105.68,49.023999999999994,30.736 +2020-04-17 13:00:00,95.35,107.199,46.82,30.736 +2020-04-17 13:15:00,94.15,107.081,46.82,30.736 +2020-04-17 13:30:00,95.45,105.429,46.82,30.736 +2020-04-17 13:45:00,95.7,104.15100000000001,46.82,30.736 +2020-04-17 14:00:00,96.68,104.079,45.756,30.736 +2020-04-17 14:15:00,94.93,103.727,45.756,30.736 +2020-04-17 14:30:00,95.22,104.76,45.756,30.736 +2020-04-17 14:45:00,96.43,104.99799999999999,45.756,30.736 +2020-04-17 15:00:00,98.56,105.598,47.56,30.736 +2020-04-17 15:15:00,96.36,103.66799999999999,47.56,30.736 +2020-04-17 15:30:00,98.86,101.962,47.56,30.736 +2020-04-17 15:45:00,99.49,102.11399999999999,47.56,30.736 +2020-04-17 16:00:00,100.98,100.829,49.581,30.736 +2020-04-17 16:15:00,102.21,101.12700000000001,49.581,30.736 +2020-04-17 16:30:00,103.54,101.053,49.581,30.736 +2020-04-17 16:45:00,105.31,98.324,49.581,30.736 +2020-04-17 17:00:00,109.87,99.141,53.918,30.736 +2020-04-17 17:15:00,105.24,101.18,53.918,30.736 +2020-04-17 17:30:00,108.79,102.488,53.918,30.736 +2020-04-17 17:45:00,108.09,103.699,53.918,30.736 +2020-04-17 18:00:00,109.81,104.992,54.266000000000005,30.736 +2020-04-17 18:15:00,106.31,105.728,54.266000000000005,30.736 +2020-04-17 18:30:00,105.75,104.70200000000001,54.266000000000005,30.736 +2020-04-17 18:45:00,105.56,110.596,54.266000000000005,30.736 +2020-04-17 19:00:00,104.01,109.889,54.092,30.736 +2020-04-17 19:15:00,101.85,110.20700000000001,54.092,30.736 +2020-04-17 19:30:00,103.45,110.035,54.092,30.736 +2020-04-17 19:45:00,104.61,109.553,54.092,30.736 +2020-04-17 20:00:00,101.81,107.604,59.038999999999994,30.736 +2020-04-17 20:15:00,104.72,105.49,59.038999999999994,30.736 +2020-04-17 20:30:00,104.95,104.11200000000001,59.038999999999994,30.736 +2020-04-17 20:45:00,104.9,103.052,59.038999999999994,30.736 +2020-04-17 21:00:00,92.54,99.882,53.346000000000004,30.736 +2020-04-17 21:15:00,89.07,100.73,53.346000000000004,30.736 +2020-04-17 21:30:00,86.36,101.012,53.346000000000004,30.736 +2020-04-17 21:45:00,91.79,100.928,53.346000000000004,30.736 +2020-04-17 22:00:00,87.88,96.88600000000001,47.938,30.736 +2020-04-17 22:15:00,87.19,94.811,47.938,30.736 +2020-04-17 22:30:00,79.25,90.098,47.938,30.736 +2020-04-17 22:45:00,78.07,86.851,47.938,30.736 +2020-04-17 23:00:00,78.74,79.878,40.266,30.736 +2020-04-17 23:15:00,79.25,76.51899999999999,40.266,30.736 +2020-04-17 23:30:00,79.44,73.683,40.266,30.736 +2020-04-17 23:45:00,72.93,73.869,40.266,30.736 +2020-04-18 00:00:00,72.24,62.902,39.184,30.618000000000002 +2020-04-18 00:15:00,74.92,61.023999999999994,39.184,30.618000000000002 +2020-04-18 00:30:00,73.65,59.887,39.184,30.618000000000002 +2020-04-18 00:45:00,71.07,58.537,39.184,30.618000000000002 +2020-04-18 01:00:00,64.33,58.955,34.692,30.618000000000002 +2020-04-18 01:15:00,65.67,58.178000000000004,34.692,30.618000000000002 +2020-04-18 01:30:00,64.17,56.406000000000006,34.692,30.618000000000002 +2020-04-18 01:45:00,68.34,56.482,34.692,30.618000000000002 +2020-04-18 02:00:00,71.5,57.685,32.919000000000004,30.618000000000002 +2020-04-18 02:15:00,72.43,56.059,32.919000000000004,30.618000000000002 +2020-04-18 02:30:00,69.43,58.281000000000006,32.919000000000004,30.618000000000002 +2020-04-18 02:45:00,69.21,58.986000000000004,32.919000000000004,30.618000000000002 +2020-04-18 03:00:00,72.51,61.458999999999996,32.024,30.618000000000002 +2020-04-18 03:15:00,68.08,61.188,32.024,30.618000000000002 +2020-04-18 03:30:00,66.16,59.88399999999999,32.024,30.618000000000002 +2020-04-18 03:45:00,64.76,62.033,32.024,30.618000000000002 +2020-04-18 04:00:00,66.57,70.758,31.958000000000002,30.618000000000002 +2020-04-18 04:15:00,67.33,79.971,31.958000000000002,30.618000000000002 +2020-04-18 04:30:00,66.23,78.243,31.958000000000002,30.618000000000002 +2020-04-18 04:45:00,64.3,78.995,31.958000000000002,30.618000000000002 +2020-04-18 05:00:00,64.93,97.959,32.75,30.618000000000002 +2020-04-18 05:15:00,66.66,114.07,32.75,30.618000000000002 +2020-04-18 05:30:00,67.03,104.89,32.75,30.618000000000002 +2020-04-18 05:45:00,70.54,100.15,32.75,30.618000000000002 +2020-04-18 06:00:00,71.71,118.912,34.461999999999996,30.618000000000002 +2020-04-18 06:15:00,73.46,136.754,34.461999999999996,30.618000000000002 +2020-04-18 06:30:00,75.72,128.946,34.461999999999996,30.618000000000002 +2020-04-18 06:45:00,76.77,123.31,34.461999999999996,30.618000000000002 +2020-04-18 07:00:00,78.94,122.14299999999999,37.736,30.618000000000002 +2020-04-18 07:15:00,78.58,122.23200000000001,37.736,30.618000000000002 +2020-04-18 07:30:00,79.45,121.365,37.736,30.618000000000002 +2020-04-18 07:45:00,80.73,120.3,37.736,30.618000000000002 +2020-04-18 08:00:00,80.41,120.022,42.34,30.618000000000002 +2020-04-18 08:15:00,80.62,120.61,42.34,30.618000000000002 +2020-04-18 08:30:00,79.58,117.786,42.34,30.618000000000002 +2020-04-18 08:45:00,79.67,117.322,42.34,30.618000000000002 +2020-04-18 09:00:00,77.81,112.633,43.571999999999996,30.618000000000002 +2020-04-18 09:15:00,76.73,112.691,43.571999999999996,30.618000000000002 +2020-04-18 09:30:00,75.68,114.87700000000001,43.571999999999996,30.618000000000002 +2020-04-18 09:45:00,75.15,114.365,43.571999999999996,30.618000000000002 +2020-04-18 10:00:00,74.84,109.54700000000001,40.514,30.618000000000002 +2020-04-18 10:15:00,76.04,110.12799999999999,40.514,30.618000000000002 +2020-04-18 10:30:00,76.37,108.82600000000001,40.514,30.618000000000002 +2020-04-18 10:45:00,75.02,109.126,40.514,30.618000000000002 +2020-04-18 11:00:00,73.78,103.92299999999999,36.388000000000005,30.618000000000002 +2020-04-18 11:15:00,72.94,103.676,36.388000000000005,30.618000000000002 +2020-04-18 11:30:00,70.92,105.191,36.388000000000005,30.618000000000002 +2020-04-18 11:45:00,69.83,105.12100000000001,36.388000000000005,30.618000000000002 +2020-04-18 12:00:00,67.19,100.64200000000001,35.217,30.618000000000002 +2020-04-18 12:15:00,67.12,100.725,35.217,30.618000000000002 +2020-04-18 12:30:00,65.58,100.066,35.217,30.618000000000002 +2020-04-18 12:45:00,65.28,100.61399999999999,35.217,30.618000000000002 +2020-04-18 13:00:00,61.25,101.42399999999999,32.001999999999995,30.618000000000002 +2020-04-18 13:15:00,62.76,99.764,32.001999999999995,30.618000000000002 +2020-04-18 13:30:00,62.74,97.959,32.001999999999995,30.618000000000002 +2020-04-18 13:45:00,62.56,96.169,32.001999999999995,30.618000000000002 +2020-04-18 14:00:00,63.31,96.9,31.304000000000002,30.618000000000002 +2020-04-18 14:15:00,63.14,95.477,31.304000000000002,30.618000000000002 +2020-04-18 14:30:00,63.14,95.135,31.304000000000002,30.618000000000002 +2020-04-18 14:45:00,64.8,95.787,31.304000000000002,30.618000000000002 +2020-04-18 15:00:00,65.13,97.071,34.731,30.618000000000002 +2020-04-18 15:15:00,65.05,96.014,34.731,30.618000000000002 +2020-04-18 15:30:00,66.87,95.29700000000001,34.731,30.618000000000002 +2020-04-18 15:45:00,69.39,94.9,34.731,30.618000000000002 +2020-04-18 16:00:00,71.14,94.525,38.769,30.618000000000002 +2020-04-18 16:15:00,72.55,94.76299999999999,38.769,30.618000000000002 +2020-04-18 16:30:00,74.77,94.803,38.769,30.618000000000002 +2020-04-18 16:45:00,76.84,92.49600000000001,38.769,30.618000000000002 +2020-04-18 17:00:00,79.92,92.44,44.928000000000004,30.618000000000002 +2020-04-18 17:15:00,79.83,94.03399999999999,44.928000000000004,30.618000000000002 +2020-04-18 17:30:00,81.4,95.20200000000001,44.928000000000004,30.618000000000002 +2020-04-18 17:45:00,81.53,96.46799999999999,44.928000000000004,30.618000000000002 +2020-04-18 18:00:00,85.84,98.512,47.786,30.618000000000002 +2020-04-18 18:15:00,83.87,101.316,47.786,30.618000000000002 +2020-04-18 18:30:00,81.87,101.869,47.786,30.618000000000002 +2020-04-18 18:45:00,81.6,103.727,47.786,30.618000000000002 +2020-04-18 19:00:00,82.11,102.561,47.463,30.618000000000002 +2020-04-18 19:15:00,80.38,101.963,47.463,30.618000000000002 +2020-04-18 19:30:00,82.61,102.70299999999999,47.463,30.618000000000002 +2020-04-18 19:45:00,81.66,103.302,47.463,30.618000000000002 +2020-04-18 20:00:00,78.44,103.012,43.735,30.618000000000002 +2020-04-18 20:15:00,77.86,101.625,43.735,30.618000000000002 +2020-04-18 20:30:00,76.06,99.522,43.735,30.618000000000002 +2020-04-18 20:45:00,75.04,99.48700000000001,43.735,30.618000000000002 +2020-04-18 21:00:00,69.71,96.571,40.346,30.618000000000002 +2020-04-18 21:15:00,69.85,97.436,40.346,30.618000000000002 +2020-04-18 21:30:00,66.1,98.477,40.346,30.618000000000002 +2020-04-18 21:45:00,66.61,97.851,40.346,30.618000000000002 +2020-04-18 22:00:00,63.91,94.52,39.323,30.618000000000002 +2020-04-18 22:15:00,63.83,93.944,39.323,30.618000000000002 +2020-04-18 22:30:00,61.62,92.242,39.323,30.618000000000002 +2020-04-18 22:45:00,61.98,90.219,39.323,30.618000000000002 +2020-04-18 23:00:00,56.73,84.055,33.716,30.618000000000002 +2020-04-18 23:15:00,56.39,80.14699999999999,33.716,30.618000000000002 +2020-04-18 23:30:00,56.57,78.0,33.716,30.618000000000002 +2020-04-18 23:45:00,57.7,77.148,33.716,30.618000000000002 +2020-04-19 00:00:00,47.91,63.659,28.703000000000003,30.618000000000002 +2020-04-19 00:15:00,49.58,60.85,28.703000000000003,30.618000000000002 +2020-04-19 00:30:00,48.9,59.416000000000004,28.703000000000003,30.618000000000002 +2020-04-19 00:45:00,51.89,58.347,28.703000000000003,30.618000000000002 +2020-04-19 01:00:00,48.88,58.867,26.171,30.618000000000002 +2020-04-19 01:15:00,49.69,58.504,26.171,30.618000000000002 +2020-04-19 01:30:00,47.4,56.879,26.171,30.618000000000002 +2020-04-19 01:45:00,50.6,56.542,26.171,30.618000000000002 +2020-04-19 02:00:00,48.49,57.409,25.326999999999998,30.618000000000002 +2020-04-19 02:15:00,49.54,55.821000000000005,25.326999999999998,30.618000000000002 +2020-04-19 02:30:00,49.42,58.62,25.326999999999998,30.618000000000002 +2020-04-19 02:45:00,49.28,59.363,25.326999999999998,30.618000000000002 +2020-04-19 03:00:00,49.27,62.45399999999999,24.311999999999998,30.618000000000002 +2020-04-19 03:15:00,50.12,62.077,24.311999999999998,30.618000000000002 +2020-04-19 03:30:00,50.56,60.961999999999996,24.311999999999998,30.618000000000002 +2020-04-19 03:45:00,51.01,62.55,24.311999999999998,30.618000000000002 +2020-04-19 04:00:00,52.33,71.09100000000001,25.33,30.618000000000002 +2020-04-19 04:15:00,53.42,79.464,25.33,30.618000000000002 +2020-04-19 04:30:00,54.02,78.689,25.33,30.618000000000002 +2020-04-19 04:45:00,52.87,79.245,25.33,30.618000000000002 +2020-04-19 05:00:00,53.79,96.527,25.309,30.618000000000002 +2020-04-19 05:15:00,55.01,110.838,25.309,30.618000000000002 +2020-04-19 05:30:00,54.77,101.319,25.309,30.618000000000002 +2020-04-19 05:45:00,56.87,96.553,25.309,30.618000000000002 +2020-04-19 06:00:00,58.42,113.615,25.945999999999998,30.618000000000002 +2020-04-19 06:15:00,58.63,131.168,25.945999999999998,30.618000000000002 +2020-04-19 06:30:00,59.65,122.322,25.945999999999998,30.618000000000002 +2020-04-19 06:45:00,61.3,115.475,25.945999999999998,30.618000000000002 +2020-04-19 07:00:00,62.91,115.682,27.87,30.618000000000002 +2020-04-19 07:15:00,63.9,114.219,27.87,30.618000000000002 +2020-04-19 07:30:00,64.4,113.54899999999999,27.87,30.618000000000002 +2020-04-19 07:45:00,63.96,112.075,27.87,30.618000000000002 +2020-04-19 08:00:00,62.93,113.177,32.114000000000004,30.618000000000002 +2020-04-19 08:15:00,62.87,114.525,32.114000000000004,30.618000000000002 +2020-04-19 08:30:00,62.0,113.11399999999999,32.114000000000004,30.618000000000002 +2020-04-19 08:45:00,61.46,113.685,32.114000000000004,30.618000000000002 +2020-04-19 09:00:00,60.12,108.691,34.222,30.618000000000002 +2020-04-19 09:15:00,60.81,108.734,34.222,30.618000000000002 +2020-04-19 09:30:00,59.84,111.131,34.222,30.618000000000002 +2020-04-19 09:45:00,60.52,111.23299999999999,34.222,30.618000000000002 +2020-04-19 10:00:00,60.88,108.11,34.544000000000004,30.618000000000002 +2020-04-19 10:15:00,63.47,109.119,34.544000000000004,30.618000000000002 +2020-04-19 10:30:00,64.28,108.31299999999999,34.544000000000004,30.618000000000002 +2020-04-19 10:45:00,64.13,108.21,34.544000000000004,30.618000000000002 +2020-04-19 11:00:00,60.96,103.27799999999999,36.368,30.618000000000002 +2020-04-19 11:15:00,60.33,102.855,36.368,30.618000000000002 +2020-04-19 11:30:00,57.6,104.197,36.368,30.618000000000002 +2020-04-19 11:45:00,56.64,104.645,36.368,30.618000000000002 +2020-04-19 12:00:00,54.74,100.47,32.433,30.618000000000002 +2020-04-19 12:15:00,53.47,101.291,32.433,30.618000000000002 +2020-04-19 12:30:00,53.55,99.87299999999999,32.433,30.618000000000002 +2020-04-19 12:45:00,53.72,99.48299999999999,32.433,30.618000000000002 +2020-04-19 13:00:00,52.34,99.73100000000001,28.971999999999998,30.618000000000002 +2020-04-19 13:15:00,52.36,99.473,28.971999999999998,30.618000000000002 +2020-04-19 13:30:00,52.22,96.885,28.971999999999998,30.618000000000002 +2020-04-19 13:45:00,53.3,95.381,28.971999999999998,30.618000000000002 +2020-04-19 14:00:00,52.13,97.029,25.531999999999996,30.618000000000002 +2020-04-19 14:15:00,52.55,96.542,25.531999999999996,30.618000000000002 +2020-04-19 14:30:00,52.58,96.13799999999999,25.531999999999996,30.618000000000002 +2020-04-19 14:45:00,53.01,95.916,25.531999999999996,30.618000000000002 +2020-04-19 15:00:00,55.7,96.435,25.766,30.618000000000002 +2020-04-19 15:15:00,59.0,95.31299999999999,25.766,30.618000000000002 +2020-04-19 15:30:00,60.62,94.788,25.766,30.618000000000002 +2020-04-19 15:45:00,65.31,94.945,25.766,30.618000000000002 +2020-04-19 16:00:00,66.6,94.571,29.232,30.618000000000002 +2020-04-19 16:15:00,66.2,94.41799999999999,29.232,30.618000000000002 +2020-04-19 16:30:00,70.78,95.229,29.232,30.618000000000002 +2020-04-19 16:45:00,71.98,92.97,29.232,30.618000000000002 +2020-04-19 17:00:00,74.6,93.13,37.431,30.618000000000002 +2020-04-19 17:15:00,75.63,95.47,37.431,30.618000000000002 +2020-04-19 17:30:00,77.0,97.29899999999999,37.431,30.618000000000002 +2020-04-19 17:45:00,78.99,100.04899999999999,37.431,30.618000000000002 +2020-04-19 18:00:00,82.25,102.135,41.251999999999995,30.618000000000002 +2020-04-19 18:15:00,79.87,105.406,41.251999999999995,30.618000000000002 +2020-04-19 18:30:00,79.78,104.67200000000001,41.251999999999995,30.618000000000002 +2020-04-19 18:45:00,79.99,107.59899999999999,41.251999999999995,30.618000000000002 +2020-04-19 19:00:00,81.9,107.616,41.784,30.618000000000002 +2020-04-19 19:15:00,82.8,106.60600000000001,41.784,30.618000000000002 +2020-04-19 19:30:00,87.07,107.10799999999999,41.784,30.618000000000002 +2020-04-19 19:45:00,85.32,108.184,41.784,30.618000000000002 +2020-04-19 20:00:00,87.59,107.975,40.804,30.618000000000002 +2020-04-19 20:15:00,90.05,107.021,40.804,30.618000000000002 +2020-04-19 20:30:00,90.49,106.132,40.804,30.618000000000002 +2020-04-19 20:45:00,89.62,104.4,40.804,30.618000000000002 +2020-04-19 21:00:00,81.38,100.014,38.379,30.618000000000002 +2020-04-19 21:15:00,84.66,100.369,38.379,30.618000000000002 +2020-04-19 21:30:00,77.57,101.12200000000001,38.379,30.618000000000002 +2020-04-19 21:45:00,78.05,100.834,38.379,30.618000000000002 +2020-04-19 22:00:00,77.86,98.241,37.87,30.618000000000002 +2020-04-19 22:15:00,82.24,96.18799999999999,37.87,30.618000000000002 +2020-04-19 22:30:00,80.13,92.486,37.87,30.618000000000002 +2020-04-19 22:45:00,76.14,89.17200000000001,37.87,30.618000000000002 +2020-04-19 23:00:00,67.84,81.33,33.332,30.618000000000002 +2020-04-19 23:15:00,72.28,79.26100000000001,33.332,30.618000000000002 +2020-04-19 23:30:00,75.58,77.17699999999999,33.332,30.618000000000002 +2020-04-19 23:45:00,73.37,76.899,33.332,30.618000000000002 +2020-04-20 00:00:00,69.74,66.392,34.698,30.736 +2020-04-20 00:15:00,72.46,65.615,34.698,30.736 +2020-04-20 00:30:00,70.41,63.977,34.698,30.736 +2020-04-20 00:45:00,69.96,62.376000000000005,34.698,30.736 +2020-04-20 01:00:00,66.93,63.148,32.889,30.736 +2020-04-20 01:15:00,72.98,62.507,32.889,30.736 +2020-04-20 01:30:00,73.29,61.136,32.889,30.736 +2020-04-20 01:45:00,71.28,60.791000000000004,32.889,30.736 +2020-04-20 02:00:00,68.66,61.93899999999999,32.06,30.736 +2020-04-20 02:15:00,73.7,60.363,32.06,30.736 +2020-04-20 02:30:00,73.14,63.458999999999996,32.06,30.736 +2020-04-20 02:45:00,73.33,63.801,32.06,30.736 +2020-04-20 03:00:00,67.47,67.866,30.515,30.736 +2020-04-20 03:15:00,68.31,68.77,30.515,30.736 +2020-04-20 03:30:00,68.12,67.986,30.515,30.736 +2020-04-20 03:45:00,69.11,69.008,30.515,30.736 +2020-04-20 04:00:00,74.41,81.767,31.436,30.736 +2020-04-20 04:15:00,77.05,94.147,31.436,30.736 +2020-04-20 04:30:00,80.21,94.115,31.436,30.736 +2020-04-20 04:45:00,83.85,94.995,31.436,30.736 +2020-04-20 05:00:00,92.02,124.523,38.997,30.736 +2020-04-20 05:15:00,94.95,155.561,38.997,30.736 +2020-04-20 05:30:00,97.01,145.115,38.997,30.736 +2020-04-20 05:45:00,100.5,135.55100000000002,38.997,30.736 +2020-04-20 06:00:00,105.42,137.136,54.97,30.736 +2020-04-20 06:15:00,107.37,141.161,54.97,30.736 +2020-04-20 06:30:00,110.05,138.94799999999998,54.97,30.736 +2020-04-20 06:45:00,111.02,140.03,54.97,30.736 +2020-04-20 07:00:00,115.16,142.121,66.032,30.736 +2020-04-20 07:15:00,112.2,142.808,66.032,30.736 +2020-04-20 07:30:00,115.55,141.128,66.032,30.736 +2020-04-20 07:45:00,114.85,138.559,66.032,30.736 +2020-04-20 08:00:00,113.82,136.049,59.941,30.736 +2020-04-20 08:15:00,117.57,135.525,59.941,30.736 +2020-04-20 08:30:00,120.95,131.31799999999998,59.941,30.736 +2020-04-20 08:45:00,120.25,130.308,59.941,30.736 +2020-04-20 09:00:00,121.46,124.26299999999999,54.016000000000005,30.736 +2020-04-20 09:15:00,120.31,121.29700000000001,54.016000000000005,30.736 +2020-04-20 09:30:00,119.35,122.581,54.016000000000005,30.736 +2020-04-20 09:45:00,116.25,121.405,54.016000000000005,30.736 +2020-04-20 10:00:00,114.59,118.214,50.63,30.736 +2020-04-20 10:15:00,118.18,118.99600000000001,50.63,30.736 +2020-04-20 10:30:00,111.25,117.441,50.63,30.736 +2020-04-20 10:45:00,112.04,116.792,50.63,30.736 +2020-04-20 11:00:00,118.8,110.635,49.951,30.736 +2020-04-20 11:15:00,119.3,111.428,49.951,30.736 +2020-04-20 11:30:00,123.96,113.979,49.951,30.736 +2020-04-20 11:45:00,126.42,114.51899999999999,49.951,30.736 +2020-04-20 12:00:00,124.57,110.541,46.913000000000004,30.736 +2020-04-20 12:15:00,122.52,111.43799999999999,46.913000000000004,30.736 +2020-04-20 12:30:00,115.69,109.486,46.913000000000004,30.736 +2020-04-20 12:45:00,118.2,109.985,46.913000000000004,30.736 +2020-04-20 13:00:00,113.18,111.163,47.093999999999994,30.736 +2020-04-20 13:15:00,112.18,109.56700000000001,47.093999999999994,30.736 +2020-04-20 13:30:00,112.03,106.774,47.093999999999994,30.736 +2020-04-20 13:45:00,106.19,105.821,47.093999999999994,30.736 +2020-04-20 14:00:00,99.72,106.662,46.678000000000004,30.736 +2020-04-20 14:15:00,107.85,106.13,46.678000000000004,30.736 +2020-04-20 14:30:00,105.99,105.26899999999999,46.678000000000004,30.736 +2020-04-20 14:45:00,95.87,106.25399999999999,46.678000000000004,30.736 +2020-04-20 15:00:00,100.13,107.649,47.715,30.736 +2020-04-20 15:15:00,102.74,105.315,47.715,30.736 +2020-04-20 15:30:00,106.75,104.714,47.715,30.736 +2020-04-20 15:45:00,109.06,104.306,47.715,30.736 +2020-04-20 16:00:00,111.0,104.484,49.81100000000001,30.736 +2020-04-20 16:15:00,110.49,103.917,49.81100000000001,30.736 +2020-04-20 16:30:00,109.82,103.781,49.81100000000001,30.736 +2020-04-20 16:45:00,113.31,100.8,49.81100000000001,30.736 +2020-04-20 17:00:00,114.39,100.116,55.591,30.736 +2020-04-20 17:15:00,113.67,102.141,55.591,30.736 +2020-04-20 17:30:00,113.09,103.419,55.591,30.736 +2020-04-20 17:45:00,114.97,105.07,55.591,30.736 +2020-04-20 18:00:00,116.55,106.62,56.523,30.736 +2020-04-20 18:15:00,113.53,107.551,56.523,30.736 +2020-04-20 18:30:00,111.92,106.67,56.523,30.736 +2020-04-20 18:45:00,111.5,112.01299999999999,56.523,30.736 +2020-04-20 19:00:00,109.89,110.913,56.044,30.736 +2020-04-20 19:15:00,115.06,110.16,56.044,30.736 +2020-04-20 19:30:00,113.63,110.69200000000001,56.044,30.736 +2020-04-20 19:45:00,112.78,110.954,56.044,30.736 +2020-04-20 20:00:00,101.75,108.655,61.715,30.736 +2020-04-20 20:15:00,100.7,107.319,61.715,30.736 +2020-04-20 20:30:00,100.68,105.82600000000001,61.715,30.736 +2020-04-20 20:45:00,100.06,105.12200000000001,61.715,30.736 +2020-04-20 21:00:00,99.18,100.616,56.24,30.736 +2020-04-20 21:15:00,98.71,100.664,56.24,30.736 +2020-04-20 21:30:00,94.69,101.24700000000001,56.24,30.736 +2020-04-20 21:45:00,91.68,100.58200000000001,56.24,30.736 +2020-04-20 22:00:00,88.84,95.15299999999999,50.437,30.736 +2020-04-20 22:15:00,88.96,93.74600000000001,50.437,30.736 +2020-04-20 22:30:00,84.92,82.199,50.437,30.736 +2020-04-20 22:45:00,85.41,76.347,50.437,30.736 +2020-04-20 23:00:00,81.6,68.904,42.756,30.736 +2020-04-20 23:15:00,81.0,67.168,42.756,30.736 +2020-04-20 23:30:00,80.1,66.495,42.756,30.736 +2020-04-20 23:45:00,81.46,67.245,42.756,30.736 +2020-04-21 00:00:00,77.51,64.109,39.857,30.736 +2020-04-21 00:15:00,75.11,64.649,39.857,30.736 +2020-04-21 00:30:00,73.12,63.016999999999996,39.857,30.736 +2020-04-21 00:45:00,78.89,61.471000000000004,39.857,30.736 +2020-04-21 01:00:00,78.67,61.772,37.233000000000004,30.736 +2020-04-21 01:15:00,77.8,60.961000000000006,37.233000000000004,30.736 +2020-04-21 01:30:00,72.87,59.566,37.233000000000004,30.736 +2020-04-21 01:45:00,74.1,59.026,37.233000000000004,30.736 +2020-04-21 02:00:00,78.95,59.831,35.856,30.736 +2020-04-21 02:15:00,78.47,58.976000000000006,35.856,30.736 +2020-04-21 02:30:00,72.53,61.526,35.856,30.736 +2020-04-21 02:45:00,71.94,62.106,35.856,30.736 +2020-04-21 03:00:00,74.86,65.212,34.766999999999996,30.736 +2020-04-21 03:15:00,80.59,66.313,34.766999999999996,30.736 +2020-04-21 03:30:00,83.48,65.743,34.766999999999996,30.736 +2020-04-21 03:45:00,83.58,66.119,34.766999999999996,30.736 +2020-04-21 04:00:00,79.94,77.88,35.468,30.736 +2020-04-21 04:15:00,82.73,90.056,35.468,30.736 +2020-04-21 04:30:00,86.26,89.789,35.468,30.736 +2020-04-21 04:45:00,87.7,91.59100000000001,35.468,30.736 +2020-04-21 05:00:00,95.23,124.53200000000001,40.399,30.736 +2020-04-21 05:15:00,98.1,155.803,40.399,30.736 +2020-04-21 05:30:00,100.01,144.778,40.399,30.736 +2020-04-21 05:45:00,102.09,134.755,40.399,30.736 +2020-04-21 06:00:00,107.66,136.52100000000002,54.105,30.736 +2020-04-21 06:15:00,108.52,141.458,54.105,30.736 +2020-04-21 06:30:00,110.71,138.69,54.105,30.736 +2020-04-21 06:45:00,111.29,138.968,54.105,30.736 +2020-04-21 07:00:00,110.64,141.067,63.083,30.736 +2020-04-21 07:15:00,112.61,141.486,63.083,30.736 +2020-04-21 07:30:00,117.23,139.503,63.083,30.736 +2020-04-21 07:45:00,111.61,136.388,63.083,30.736 +2020-04-21 08:00:00,109.22,133.88299999999998,57.254,30.736 +2020-04-21 08:15:00,106.0,132.562,57.254,30.736 +2020-04-21 08:30:00,108.93,128.343,57.254,30.736 +2020-04-21 08:45:00,111.02,126.616,57.254,30.736 +2020-04-21 09:00:00,108.14,120.348,51.395,30.736 +2020-04-21 09:15:00,111.76,118.16,51.395,30.736 +2020-04-21 09:30:00,112.64,120.26700000000001,51.395,30.736 +2020-04-21 09:45:00,108.02,119.867,51.395,30.736 +2020-04-21 10:00:00,106.08,115.491,48.201,30.736 +2020-04-21 10:15:00,104.55,115.639,48.201,30.736 +2020-04-21 10:30:00,104.69,114.223,48.201,30.736 +2020-04-21 10:45:00,104.67,114.375,48.201,30.736 +2020-04-21 11:00:00,103.29,109.04799999999999,46.133,30.736 +2020-04-21 11:15:00,102.35,109.921,46.133,30.736 +2020-04-21 11:30:00,98.0,111.10700000000001,46.133,30.736 +2020-04-21 11:45:00,100.05,111.792,46.133,30.736 +2020-04-21 12:00:00,95.93,106.93700000000001,44.243,30.736 +2020-04-21 12:15:00,104.04,107.79700000000001,44.243,30.736 +2020-04-21 12:30:00,105.24,106.734,44.243,30.736 +2020-04-21 12:45:00,107.04,107.476,44.243,30.736 +2020-04-21 13:00:00,100.69,108.23700000000001,45.042,30.736 +2020-04-21 13:15:00,99.99,107.499,45.042,30.736 +2020-04-21 13:30:00,104.95,105.37799999999999,45.042,30.736 +2020-04-21 13:45:00,111.42,103.964,45.042,30.736 +2020-04-21 14:00:00,114.25,105.295,44.062,30.736 +2020-04-21 14:15:00,112.57,104.719,44.062,30.736 +2020-04-21 14:30:00,109.07,104.374,44.062,30.736 +2020-04-21 14:45:00,106.59,104.85,44.062,30.736 +2020-04-21 15:00:00,113.59,105.935,46.461999999999996,30.736 +2020-04-21 15:15:00,116.36,104.32,46.461999999999996,30.736 +2020-04-21 15:30:00,115.99,103.68299999999999,46.461999999999996,30.736 +2020-04-21 15:45:00,113.69,103.225,46.461999999999996,30.736 +2020-04-21 16:00:00,109.01,103.26299999999999,48.802,30.736 +2020-04-21 16:15:00,111.19,102.992,48.802,30.736 +2020-04-21 16:30:00,117.84,102.99799999999999,48.802,30.736 +2020-04-21 16:45:00,116.61,100.555,48.802,30.736 +2020-04-21 17:00:00,119.91,100.361,55.672,30.736 +2020-04-21 17:15:00,117.41,102.63799999999999,55.672,30.736 +2020-04-21 17:30:00,119.66,104.00299999999999,55.672,30.736 +2020-04-21 17:45:00,120.44,105.34700000000001,55.672,30.736 +2020-04-21 18:00:00,118.14,106.23299999999999,57.006,30.736 +2020-04-21 18:15:00,114.94,107.87,57.006,30.736 +2020-04-21 18:30:00,117.32,106.64200000000001,57.006,30.736 +2020-04-21 18:45:00,117.13,112.29299999999999,57.006,30.736 +2020-04-21 19:00:00,110.96,110.45100000000001,57.148,30.736 +2020-04-21 19:15:00,108.42,109.675,57.148,30.736 +2020-04-21 19:30:00,113.5,109.726,57.148,30.736 +2020-04-21 19:45:00,116.0,110.249,57.148,30.736 +2020-04-21 20:00:00,107.84,108.26299999999999,61.895,30.736 +2020-04-21 20:15:00,108.53,105.655,61.895,30.736 +2020-04-21 20:30:00,108.46,104.756,61.895,30.736 +2020-04-21 20:45:00,106.57,104.056,61.895,30.736 +2020-04-21 21:00:00,95.96,99.756,54.78,30.736 +2020-04-21 21:15:00,98.48,99.322,54.78,30.736 +2020-04-21 21:30:00,95.81,99.62700000000001,54.78,30.736 +2020-04-21 21:45:00,93.29,99.24,54.78,30.736 +2020-04-21 22:00:00,84.54,94.82799999999999,50.76,30.736 +2020-04-21 22:15:00,88.53,93.089,50.76,30.736 +2020-04-21 22:30:00,86.77,81.759,50.76,30.736 +2020-04-21 22:45:00,86.04,76.04,50.76,30.736 +2020-04-21 23:00:00,75.63,68.074,44.162,30.736 +2020-04-21 23:15:00,72.93,66.936,44.162,30.736 +2020-04-21 23:30:00,77.07,66.072,44.162,30.736 +2020-04-21 23:45:00,80.79,66.721,44.162,30.736 +2020-04-22 00:00:00,77.42,63.72,39.061,30.736 +2020-04-22 00:15:00,75.61,64.27199999999999,39.061,30.736 +2020-04-22 00:30:00,73.58,62.63399999999999,39.061,30.736 +2020-04-22 00:45:00,72.45,61.093,39.061,30.736 +2020-04-22 01:00:00,69.55,61.388000000000005,35.795,30.736 +2020-04-22 01:15:00,76.2,60.552,35.795,30.736 +2020-04-22 01:30:00,70.64,59.136,35.795,30.736 +2020-04-22 01:45:00,71.82,58.599,35.795,30.736 +2020-04-22 02:00:00,71.59,59.394,33.316,30.736 +2020-04-22 02:15:00,78.92,58.523,33.316,30.736 +2020-04-22 02:30:00,78.64,61.091,33.316,30.736 +2020-04-22 02:45:00,78.89,61.676,33.316,30.736 +2020-04-22 03:00:00,80.34,64.79899999999999,32.803000000000004,30.736 +2020-04-22 03:15:00,75.97,65.874,32.803000000000004,30.736 +2020-04-22 03:30:00,72.33,65.30199999999999,32.803000000000004,30.736 +2020-04-22 03:45:00,75.91,65.7,32.803000000000004,30.736 +2020-04-22 04:00:00,78.96,77.42699999999999,34.235,30.736 +2020-04-22 04:15:00,78.93,89.56200000000001,34.235,30.736 +2020-04-22 04:30:00,83.97,89.294,34.235,30.736 +2020-04-22 04:45:00,84.72,91.086,34.235,30.736 +2020-04-22 05:00:00,92.29,123.926,38.65,30.736 +2020-04-22 05:15:00,96.41,155.092,38.65,30.736 +2020-04-22 05:30:00,96.57,144.094,38.65,30.736 +2020-04-22 05:45:00,103.85,134.12,38.65,30.736 +2020-04-22 06:00:00,107.64,135.908,54.951,30.736 +2020-04-22 06:15:00,108.62,140.82299999999998,54.951,30.736 +2020-04-22 06:30:00,111.0,138.041,54.951,30.736 +2020-04-22 06:45:00,110.88,138.314,54.951,30.736 +2020-04-22 07:00:00,112.83,140.412,67.328,30.736 +2020-04-22 07:15:00,112.0,140.819,67.328,30.736 +2020-04-22 07:30:00,110.25,138.798,67.328,30.736 +2020-04-22 07:45:00,108.56,135.684,67.328,30.736 +2020-04-22 08:00:00,105.32,133.16899999999998,60.23,30.736 +2020-04-22 08:15:00,105.49,131.882,60.23,30.736 +2020-04-22 08:30:00,110.55,127.635,60.23,30.736 +2020-04-22 08:45:00,113.42,125.93700000000001,60.23,30.736 +2020-04-22 09:00:00,107.8,119.671,56.845,30.736 +2020-04-22 09:15:00,107.23,117.49,56.845,30.736 +2020-04-22 09:30:00,106.86,119.613,56.845,30.736 +2020-04-22 09:45:00,108.88,119.24600000000001,56.845,30.736 +2020-04-22 10:00:00,117.52,114.87799999999999,53.832,30.736 +2020-04-22 10:15:00,121.69,115.073,53.832,30.736 +2020-04-22 10:30:00,118.62,113.68,53.832,30.736 +2020-04-22 10:45:00,118.16,113.852,53.832,30.736 +2020-04-22 11:00:00,108.04,108.51700000000001,53.225,30.736 +2020-04-22 11:15:00,106.35,109.412,53.225,30.736 +2020-04-22 11:30:00,113.65,110.598,53.225,30.736 +2020-04-22 11:45:00,119.82,111.302,53.225,30.736 +2020-04-22 12:00:00,111.54,106.476,50.676,30.736 +2020-04-22 12:15:00,112.94,107.34299999999999,50.676,30.736 +2020-04-22 12:30:00,106.75,106.238,50.676,30.736 +2020-04-22 12:45:00,107.24,106.98299999999999,50.676,30.736 +2020-04-22 13:00:00,106.99,107.78200000000001,50.646,30.736 +2020-04-22 13:15:00,103.94,107.039,50.646,30.736 +2020-04-22 13:30:00,111.47,104.92,50.646,30.736 +2020-04-22 13:45:00,121.34,103.508,50.646,30.736 +2020-04-22 14:00:00,117.59,104.902,50.786,30.736 +2020-04-22 14:15:00,115.99,104.306,50.786,30.736 +2020-04-22 14:30:00,118.6,103.917,50.786,30.736 +2020-04-22 14:45:00,123.1,104.39399999999999,50.786,30.736 +2020-04-22 15:00:00,122.69,105.51799999999999,51.535,30.736 +2020-04-22 15:15:00,113.87,103.881,51.535,30.736 +2020-04-22 15:30:00,107.0,103.198,51.535,30.736 +2020-04-22 15:45:00,105.01,102.72399999999999,51.535,30.736 +2020-04-22 16:00:00,115.45,102.803,53.157,30.736 +2020-04-22 16:15:00,119.01,102.509,53.157,30.736 +2020-04-22 16:30:00,121.41,102.52,53.157,30.736 +2020-04-22 16:45:00,118.86,100.01100000000001,53.157,30.736 +2020-04-22 17:00:00,126.78,99.868,57.793,30.736 +2020-04-22 17:15:00,124.76,102.12,57.793,30.736 +2020-04-22 17:30:00,124.73,103.48,57.793,30.736 +2020-04-22 17:45:00,117.96,104.79799999999999,57.793,30.736 +2020-04-22 18:00:00,119.79,105.698,59.872,30.736 +2020-04-22 18:15:00,119.56,107.354,59.872,30.736 +2020-04-22 18:30:00,118.02,106.11200000000001,59.872,30.736 +2020-04-22 18:45:00,114.89,111.771,59.872,30.736 +2020-04-22 19:00:00,117.2,109.917,60.17100000000001,30.736 +2020-04-22 19:15:00,115.46,109.146,60.17100000000001,30.736 +2020-04-22 19:30:00,111.46,109.208,60.17100000000001,30.736 +2020-04-22 19:45:00,114.24,109.75299999999999,60.17100000000001,30.736 +2020-04-22 20:00:00,110.05,107.742,65.015,30.736 +2020-04-22 20:15:00,112.02,105.141,65.015,30.736 +2020-04-22 20:30:00,103.59,104.27600000000001,65.015,30.736 +2020-04-22 20:45:00,101.23,103.60600000000001,65.015,30.736 +2020-04-22 21:00:00,101.65,99.30799999999999,57.805,30.736 +2020-04-22 21:15:00,100.48,98.88799999999999,57.805,30.736 +2020-04-22 21:30:00,91.45,99.18,57.805,30.736 +2020-04-22 21:45:00,90.16,98.823,57.805,30.736 +2020-04-22 22:00:00,83.23,94.425,52.115,30.736 +2020-04-22 22:15:00,86.17,92.711,52.115,30.736 +2020-04-22 22:30:00,88.07,81.357,52.115,30.736 +2020-04-22 22:45:00,85.72,75.62899999999999,52.115,30.736 +2020-04-22 23:00:00,77.3,67.635,42.871,30.736 +2020-04-22 23:15:00,76.06,66.535,42.871,30.736 +2020-04-22 23:30:00,81.31,65.67,42.871,30.736 +2020-04-22 23:45:00,81.61,66.325,42.871,30.736 +2020-04-23 00:00:00,77.46,63.331,39.203,30.736 +2020-04-23 00:15:00,73.8,63.897,39.203,30.736 +2020-04-23 00:30:00,72.13,62.251000000000005,39.203,30.736 +2020-04-23 00:45:00,78.28,60.717,39.203,30.736 +2020-04-23 01:00:00,77.24,61.004,37.118,30.736 +2020-04-23 01:15:00,76.9,60.145,37.118,30.736 +2020-04-23 01:30:00,73.05,58.708,37.118,30.736 +2020-04-23 01:45:00,74.2,58.173,37.118,30.736 +2020-04-23 02:00:00,77.66,58.958,35.647,30.736 +2020-04-23 02:15:00,74.79,58.07,35.647,30.736 +2020-04-23 02:30:00,73.24,60.656000000000006,35.647,30.736 +2020-04-23 02:45:00,74.37,61.248000000000005,35.647,30.736 +2020-04-23 03:00:00,74.42,64.38600000000001,34.585,30.736 +2020-04-23 03:15:00,83.44,65.436,34.585,30.736 +2020-04-23 03:30:00,82.78,64.861,34.585,30.736 +2020-04-23 03:45:00,85.04,65.282,34.585,30.736 +2020-04-23 04:00:00,81.36,76.975,36.184,30.736 +2020-04-23 04:15:00,83.4,89.069,36.184,30.736 +2020-04-23 04:30:00,86.65,88.79899999999999,36.184,30.736 +2020-04-23 04:45:00,90.42,90.58200000000001,36.184,30.736 +2020-04-23 05:00:00,96.73,123.321,41.019,30.736 +2020-04-23 05:15:00,99.72,154.384,41.019,30.736 +2020-04-23 05:30:00,104.3,143.409,41.019,30.736 +2020-04-23 05:45:00,108.09,133.485,41.019,30.736 +2020-04-23 06:00:00,115.69,135.297,53.963,30.736 +2020-04-23 06:15:00,112.99,140.192,53.963,30.736 +2020-04-23 06:30:00,117.65,137.392,53.963,30.736 +2020-04-23 06:45:00,114.98,137.661,53.963,30.736 +2020-04-23 07:00:00,122.88,139.759,66.512,30.736 +2020-04-23 07:15:00,119.72,140.15200000000002,66.512,30.736 +2020-04-23 07:30:00,116.23,138.096,66.512,30.736 +2020-04-23 07:45:00,110.91,134.984,66.512,30.736 +2020-04-23 08:00:00,109.73,132.45600000000002,58.86,30.736 +2020-04-23 08:15:00,111.4,131.204,58.86,30.736 +2020-04-23 08:30:00,115.66,126.929,58.86,30.736 +2020-04-23 08:45:00,116.9,125.26,58.86,30.736 +2020-04-23 09:00:00,117.14,118.99799999999999,52.156000000000006,30.736 +2020-04-23 09:15:00,117.82,116.822,52.156000000000006,30.736 +2020-04-23 09:30:00,125.16,118.961,52.156000000000006,30.736 +2020-04-23 09:45:00,126.64,118.62700000000001,52.156000000000006,30.736 +2020-04-23 10:00:00,115.39,114.26899999999999,49.034,30.736 +2020-04-23 10:15:00,107.22,114.51,49.034,30.736 +2020-04-23 10:30:00,110.97,113.139,49.034,30.736 +2020-04-23 10:45:00,105.98,113.331,49.034,30.736 +2020-04-23 11:00:00,110.48,107.988,46.53,30.736 +2020-04-23 11:15:00,109.12,108.90700000000001,46.53,30.736 +2020-04-23 11:30:00,104.52,110.09200000000001,46.53,30.736 +2020-04-23 11:45:00,118.1,110.81299999999999,46.53,30.736 +2020-04-23 12:00:00,100.86,106.01799999999999,43.318000000000005,30.736 +2020-04-23 12:15:00,107.42,106.89200000000001,43.318000000000005,30.736 +2020-04-23 12:30:00,119.08,105.743,43.318000000000005,30.736 +2020-04-23 12:45:00,113.59,106.492,43.318000000000005,30.736 +2020-04-23 13:00:00,105.64,107.32799999999999,41.608000000000004,30.736 +2020-04-23 13:15:00,109.66,106.58,41.608000000000004,30.736 +2020-04-23 13:30:00,107.3,104.464,41.608000000000004,30.736 +2020-04-23 13:45:00,109.96,103.055,41.608000000000004,30.736 +2020-04-23 14:00:00,108.12,104.509,41.786,30.736 +2020-04-23 14:15:00,98.99,103.896,41.786,30.736 +2020-04-23 14:30:00,100.33,103.461,41.786,30.736 +2020-04-23 14:45:00,105.57,103.94200000000001,41.786,30.736 +2020-04-23 15:00:00,105.07,105.101,44.181999999999995,30.736 +2020-04-23 15:15:00,107.35,103.44200000000001,44.181999999999995,30.736 +2020-04-23 15:30:00,99.98,102.71700000000001,44.181999999999995,30.736 +2020-04-23 15:45:00,100.25,102.22399999999999,44.181999999999995,30.736 +2020-04-23 16:00:00,98.86,102.346,45.956,30.736 +2020-04-23 16:15:00,107.63,102.027,45.956,30.736 +2020-04-23 16:30:00,113.08,102.045,45.956,30.736 +2020-04-23 16:45:00,113.6,99.471,45.956,30.736 +2020-04-23 17:00:00,112.41,99.37799999999999,50.702,30.736 +2020-04-23 17:15:00,112.21,101.604,50.702,30.736 +2020-04-23 17:30:00,118.08,102.958,50.702,30.736 +2020-04-23 17:45:00,120.54,104.251,50.702,30.736 +2020-04-23 18:00:00,119.87,105.165,53.595,30.736 +2020-04-23 18:15:00,109.3,106.839,53.595,30.736 +2020-04-23 18:30:00,109.67,105.585,53.595,30.736 +2020-04-23 18:45:00,108.25,111.24799999999999,53.595,30.736 +2020-04-23 19:00:00,107.8,109.385,54.207,30.736 +2020-04-23 19:15:00,105.82,108.62,54.207,30.736 +2020-04-23 19:30:00,107.51,108.69200000000001,54.207,30.736 +2020-04-23 19:45:00,112.01,109.26,54.207,30.736 +2020-04-23 20:00:00,110.19,107.22,56.948,30.736 +2020-04-23 20:15:00,109.28,104.626,56.948,30.736 +2020-04-23 20:30:00,106.75,103.795,56.948,30.736 +2020-04-23 20:45:00,105.41,103.15899999999999,56.948,30.736 +2020-04-23 21:00:00,103.61,98.861,52.157,30.736 +2020-04-23 21:15:00,98.19,98.456,52.157,30.736 +2020-04-23 21:30:00,87.51,98.734,52.157,30.736 +2020-04-23 21:45:00,93.02,98.40700000000001,52.157,30.736 +2020-04-23 22:00:00,90.12,94.023,47.483000000000004,30.736 +2020-04-23 22:15:00,89.7,92.33200000000001,47.483000000000004,30.736 +2020-04-23 22:30:00,83.45,80.955,47.483000000000004,30.736 +2020-04-23 22:45:00,79.56,75.218,47.483000000000004,30.736 +2020-04-23 23:00:00,78.7,67.197,41.978,30.736 +2020-04-23 23:15:00,82.28,66.135,41.978,30.736 +2020-04-23 23:30:00,81.32,65.26899999999999,41.978,30.736 +2020-04-23 23:45:00,80.34,65.931,41.978,30.736 +2020-04-24 00:00:00,72.2,61.173,39.301,30.736 +2020-04-24 00:15:00,76.53,62.001999999999995,39.301,30.736 +2020-04-24 00:30:00,78.65,60.465,39.301,30.736 +2020-04-24 00:45:00,78.84,59.282,39.301,30.736 +2020-04-24 01:00:00,72.93,59.141999999999996,37.976,30.736 +2020-04-24 01:15:00,76.27,58.315,37.976,30.736 +2020-04-24 01:30:00,79.2,57.232,37.976,30.736 +2020-04-24 01:45:00,79.29,56.583999999999996,37.976,30.736 +2020-04-24 02:00:00,75.93,58.038999999999994,37.041,30.736 +2020-04-24 02:15:00,76.43,57.04,37.041,30.736 +2020-04-24 02:30:00,79.9,60.477,37.041,30.736 +2020-04-24 02:45:00,79.92,60.628,37.041,30.736 +2020-04-24 03:00:00,78.47,63.809,37.575,30.736 +2020-04-24 03:15:00,78.54,64.48100000000001,37.575,30.736 +2020-04-24 03:30:00,83.02,63.73,37.575,30.736 +2020-04-24 03:45:00,86.25,64.94,37.575,30.736 +2020-04-24 04:00:00,84.35,76.809,39.058,30.736 +2020-04-24 04:15:00,84.36,87.662,39.058,30.736 +2020-04-24 04:30:00,84.53,88.164,39.058,30.736 +2020-04-24 04:45:00,87.97,88.916,39.058,30.736 +2020-04-24 05:00:00,94.74,120.58200000000001,43.256,30.736 +2020-04-24 05:15:00,98.06,152.952,43.256,30.736 +2020-04-24 05:30:00,101.99,142.67,43.256,30.736 +2020-04-24 05:45:00,104.32,132.45,43.256,30.736 +2020-04-24 06:00:00,110.14,134.679,56.093999999999994,30.736 +2020-04-24 06:15:00,110.92,138.92700000000002,56.093999999999994,30.736 +2020-04-24 06:30:00,115.01,135.642,56.093999999999994,30.736 +2020-04-24 06:45:00,113.33,136.634,56.093999999999994,30.736 +2020-04-24 07:00:00,114.79,138.756,66.92699999999999,30.736 +2020-04-24 07:15:00,114.94,140.278,66.92699999999999,30.736 +2020-04-24 07:30:00,111.87,136.784,66.92699999999999,30.736 +2020-04-24 07:45:00,113.65,133.072,66.92699999999999,30.736 +2020-04-24 08:00:00,111.88,130.465,60.332,30.736 +2020-04-24 08:15:00,111.97,129.475,60.332,30.736 +2020-04-24 08:30:00,116.04,125.63600000000001,60.332,30.736 +2020-04-24 08:45:00,116.96,123.051,60.332,30.736 +2020-04-24 09:00:00,113.47,115.54,56.085,30.736 +2020-04-24 09:15:00,109.61,114.911,56.085,30.736 +2020-04-24 09:30:00,104.73,116.389,56.085,30.736 +2020-04-24 09:45:00,106.39,116.274,56.085,30.736 +2020-04-24 10:00:00,104.8,111.147,52.91,30.736 +2020-04-24 10:15:00,110.96,111.698,52.91,30.736 +2020-04-24 10:30:00,108.8,110.649,52.91,30.736 +2020-04-24 10:45:00,112.42,110.54799999999999,52.91,30.736 +2020-04-24 11:00:00,105.01,105.321,52.278999999999996,30.736 +2020-04-24 11:15:00,106.04,105.079,52.278999999999996,30.736 +2020-04-24 11:30:00,106.23,107.06299999999999,52.278999999999996,30.736 +2020-04-24 11:45:00,105.8,107.24700000000001,52.278999999999996,30.736 +2020-04-24 12:00:00,101.15,103.402,49.023999999999994,30.736 +2020-04-24 12:15:00,100.58,102.65100000000001,49.023999999999994,30.736 +2020-04-24 12:30:00,96.07,101.62299999999999,49.023999999999994,30.736 +2020-04-24 12:45:00,90.58,102.21799999999999,49.023999999999994,30.736 +2020-04-24 13:00:00,91.3,104.00399999999999,46.82,30.736 +2020-04-24 13:15:00,91.72,103.84899999999999,46.82,30.736 +2020-04-24 13:30:00,89.96,102.21700000000001,46.82,30.736 +2020-04-24 13:45:00,90.43,100.95299999999999,46.82,30.736 +2020-04-24 14:00:00,91.03,101.309,45.756,30.736 +2020-04-24 14:15:00,90.38,100.82799999999999,45.756,30.736 +2020-04-24 14:30:00,90.13,101.54700000000001,45.756,30.736 +2020-04-24 14:45:00,87.82,101.804,45.756,30.736 +2020-04-24 15:00:00,88.57,102.664,47.56,30.736 +2020-04-24 15:15:00,89.54,100.579,47.56,30.736 +2020-04-24 15:30:00,90.6,98.565,47.56,30.736 +2020-04-24 15:45:00,93.53,98.59200000000001,47.56,30.736 +2020-04-24 16:00:00,96.08,97.604,49.581,30.736 +2020-04-24 16:15:00,98.58,97.73100000000001,49.581,30.736 +2020-04-24 16:30:00,103.04,97.697,49.581,30.736 +2020-04-24 16:45:00,102.74,94.509,49.581,30.736 +2020-04-24 17:00:00,106.18,95.684,53.918,30.736 +2020-04-24 17:15:00,105.16,97.54299999999999,53.918,30.736 +2020-04-24 17:30:00,105.91,98.814,53.918,30.736 +2020-04-24 17:45:00,107.32,99.845,53.918,30.736 +2020-04-24 18:00:00,107.44,101.23700000000001,54.266000000000005,30.736 +2020-04-24 18:15:00,104.49,102.10700000000001,54.266000000000005,30.736 +2020-04-24 18:30:00,104.57,100.98899999999999,54.266000000000005,30.736 +2020-04-24 18:45:00,103.28,106.92399999999999,54.266000000000005,30.736 +2020-04-24 19:00:00,105.31,106.14399999999999,54.092,30.736 +2020-04-24 19:15:00,99.77,106.50299999999999,54.092,30.736 +2020-04-24 19:30:00,104.65,106.406,54.092,30.736 +2020-04-24 19:45:00,102.74,106.08,54.092,30.736 +2020-04-24 20:00:00,99.02,103.93700000000001,59.038999999999994,30.736 +2020-04-24 20:15:00,105.31,101.875,59.038999999999994,30.736 +2020-04-24 20:30:00,106.75,100.73700000000001,59.038999999999994,30.736 +2020-04-24 20:45:00,103.82,99.896,59.038999999999994,30.736 +2020-04-24 21:00:00,92.72,96.742,53.346000000000004,30.736 +2020-04-24 21:15:00,89.77,97.68700000000001,53.346000000000004,30.736 +2020-04-24 21:30:00,92.29,97.876,53.346000000000004,30.736 +2020-04-24 21:45:00,91.53,98.0,53.346000000000004,30.736 +2020-04-24 22:00:00,86.77,94.05799999999999,47.938,30.736 +2020-04-24 22:15:00,82.96,92.156,47.938,30.736 +2020-04-24 22:30:00,85.15,87.279,47.938,30.736 +2020-04-24 22:45:00,83.5,83.97200000000001,47.938,30.736 +2020-04-24 23:00:00,78.39,76.804,40.266,30.736 +2020-04-24 23:15:00,76.03,73.711,40.266,30.736 +2020-04-24 23:30:00,79.22,70.86399999999999,40.266,30.736 +2020-04-24 23:45:00,77.46,71.1,40.266,30.736 +2020-04-25 00:00:00,72.92,60.177,39.184,30.618000000000002 +2020-04-25 00:15:00,67.06,58.391000000000005,39.184,30.618000000000002 +2020-04-25 00:30:00,65.85,57.206,39.184,30.618000000000002 +2020-04-25 00:45:00,69.65,55.895,39.184,30.618000000000002 +2020-04-25 01:00:00,71.38,56.265,34.692,30.618000000000002 +2020-04-25 01:15:00,72.76,55.318000000000005,34.692,30.618000000000002 +2020-04-25 01:30:00,69.42,53.401,34.692,30.618000000000002 +2020-04-25 01:45:00,71.88,53.498000000000005,34.692,30.618000000000002 +2020-04-25 02:00:00,71.15,54.626000000000005,32.919000000000004,30.618000000000002 +2020-04-25 02:15:00,71.09,52.883,32.919000000000004,30.618000000000002 +2020-04-25 02:30:00,65.69,55.233999999999995,32.919000000000004,30.618000000000002 +2020-04-25 02:45:00,61.4,55.98,32.919000000000004,30.618000000000002 +2020-04-25 03:00:00,64.48,58.56100000000001,32.024,30.618000000000002 +2020-04-25 03:15:00,63.49,58.118,32.024,30.618000000000002 +2020-04-25 03:30:00,63.7,56.793,32.024,30.618000000000002 +2020-04-25 03:45:00,64.2,59.101000000000006,32.024,30.618000000000002 +2020-04-25 04:00:00,64.64,67.586,31.958000000000002,30.618000000000002 +2020-04-25 04:15:00,64.45,76.515,31.958000000000002,30.618000000000002 +2020-04-25 04:30:00,62.58,74.78,31.958000000000002,30.618000000000002 +2020-04-25 04:45:00,61.79,75.464,31.958000000000002,30.618000000000002 +2020-04-25 05:00:00,62.67,93.719,32.75,30.618000000000002 +2020-04-25 05:15:00,63.83,109.10600000000001,32.75,30.618000000000002 +2020-04-25 05:30:00,66.59,100.096,32.75,30.618000000000002 +2020-04-25 05:45:00,66.17,95.70100000000001,32.75,30.618000000000002 +2020-04-25 06:00:00,69.06,114.625,34.461999999999996,30.618000000000002 +2020-04-25 06:15:00,71.02,132.321,34.461999999999996,30.618000000000002 +2020-04-25 06:30:00,71.93,124.402,34.461999999999996,30.618000000000002 +2020-04-25 06:45:00,73.64,118.735,34.461999999999996,30.618000000000002 +2020-04-25 07:00:00,76.52,117.56200000000001,37.736,30.618000000000002 +2020-04-25 07:15:00,76.86,117.56,37.736,30.618000000000002 +2020-04-25 07:30:00,77.45,116.432,37.736,30.618000000000002 +2020-04-25 07:45:00,75.16,115.383,37.736,30.618000000000002 +2020-04-25 08:00:00,75.87,115.021,42.34,30.618000000000002 +2020-04-25 08:15:00,75.01,115.853,42.34,30.618000000000002 +2020-04-25 08:30:00,76.19,112.833,42.34,30.618000000000002 +2020-04-25 08:45:00,75.0,112.568,42.34,30.618000000000002 +2020-04-25 09:00:00,77.22,107.902,43.571999999999996,30.618000000000002 +2020-04-25 09:15:00,74.1,107.99700000000001,43.571999999999996,30.618000000000002 +2020-04-25 09:30:00,74.58,110.29799999999999,43.571999999999996,30.618000000000002 +2020-04-25 09:45:00,72.53,110.01700000000001,43.571999999999996,30.618000000000002 +2020-04-25 10:00:00,70.38,105.26,40.514,30.618000000000002 +2020-04-25 10:15:00,71.83,106.17,40.514,30.618000000000002 +2020-04-25 10:30:00,72.86,105.02600000000001,40.514,30.618000000000002 +2020-04-25 10:45:00,76.31,105.464,40.514,30.618000000000002 +2020-04-25 11:00:00,75.64,100.208,36.388000000000005,30.618000000000002 +2020-04-25 11:15:00,72.22,100.119,36.388000000000005,30.618000000000002 +2020-04-25 11:30:00,70.44,101.631,36.388000000000005,30.618000000000002 +2020-04-25 11:45:00,66.44,101.69,36.388000000000005,30.618000000000002 +2020-04-25 12:00:00,62.4,97.416,35.217,30.618000000000002 +2020-04-25 12:15:00,63.37,97.552,35.217,30.618000000000002 +2020-04-25 12:30:00,62.6,96.59299999999999,35.217,30.618000000000002 +2020-04-25 12:45:00,62.97,97.165,35.217,30.618000000000002 +2020-04-25 13:00:00,66.68,98.23899999999999,32.001999999999995,30.618000000000002 +2020-04-25 13:15:00,66.46,96.544,32.001999999999995,30.618000000000002 +2020-04-25 13:30:00,68.38,94.76,32.001999999999995,30.618000000000002 +2020-04-25 13:45:00,63.86,92.984,32.001999999999995,30.618000000000002 +2020-04-25 14:00:00,65.79,94.14200000000001,31.304000000000002,30.618000000000002 +2020-04-25 14:15:00,65.45,92.59,31.304000000000002,30.618000000000002 +2020-04-25 14:30:00,67.98,91.934,31.304000000000002,30.618000000000002 +2020-04-25 14:45:00,70.14,92.603,31.304000000000002,30.618000000000002 +2020-04-25 15:00:00,73.95,94.147,34.731,30.618000000000002 +2020-04-25 15:15:00,68.45,92.935,34.731,30.618000000000002 +2020-04-25 15:30:00,69.17,91.913,34.731,30.618000000000002 +2020-04-25 15:45:00,71.96,91.391,34.731,30.618000000000002 +2020-04-25 16:00:00,74.03,91.31200000000001,38.769,30.618000000000002 +2020-04-25 16:15:00,73.54,91.38,38.769,30.618000000000002 +2020-04-25 16:30:00,77.94,91.461,38.769,30.618000000000002 +2020-04-25 16:45:00,77.97,88.696,38.769,30.618000000000002 +2020-04-25 17:00:00,79.95,88.99700000000001,44.928000000000004,30.618000000000002 +2020-04-25 17:15:00,79.81,90.412,44.928000000000004,30.618000000000002 +2020-04-25 17:30:00,81.88,91.541,44.928000000000004,30.618000000000002 +2020-04-25 17:45:00,79.84,92.62799999999999,44.928000000000004,30.618000000000002 +2020-04-25 18:00:00,81.95,94.76899999999999,47.786,30.618000000000002 +2020-04-25 18:15:00,80.8,97.704,47.786,30.618000000000002 +2020-04-25 18:30:00,80.62,98.166,47.786,30.618000000000002 +2020-04-25 18:45:00,81.58,100.06200000000001,47.786,30.618000000000002 +2020-04-25 19:00:00,82.25,98.82600000000001,47.463,30.618000000000002 +2020-04-25 19:15:00,81.6,98.26799999999999,47.463,30.618000000000002 +2020-04-25 19:30:00,78.79,99.083,47.463,30.618000000000002 +2020-04-25 19:45:00,81.11,99.837,47.463,30.618000000000002 +2020-04-25 20:00:00,79.47,99.35600000000001,43.735,30.618000000000002 +2020-04-25 20:15:00,80.74,98.01899999999999,43.735,30.618000000000002 +2020-04-25 20:30:00,77.33,96.155,43.735,30.618000000000002 +2020-04-25 20:45:00,77.88,96.339,43.735,30.618000000000002 +2020-04-25 21:00:00,76.72,93.439,40.346,30.618000000000002 +2020-04-25 21:15:00,73.54,94.40100000000001,40.346,30.618000000000002 +2020-04-25 21:30:00,70.17,95.348,40.346,30.618000000000002 +2020-04-25 21:45:00,69.94,94.93,40.346,30.618000000000002 +2020-04-25 22:00:00,66.61,91.697,39.323,30.618000000000002 +2020-04-25 22:15:00,65.99,91.294,39.323,30.618000000000002 +2020-04-25 22:30:00,63.67,89.425,39.323,30.618000000000002 +2020-04-25 22:45:00,62.7,87.34100000000001,39.323,30.618000000000002 +2020-04-25 23:00:00,58.03,80.985,33.716,30.618000000000002 +2020-04-25 23:15:00,58.89,77.345,33.716,30.618000000000002 +2020-04-25 23:30:00,57.34,75.185,33.716,30.618000000000002 +2020-04-25 23:45:00,57.63,74.384,33.716,30.618000000000002 +2020-04-26 00:00:00,53.94,60.93899999999999,28.703000000000003,30.618000000000002 +2020-04-26 00:15:00,54.77,58.223,28.703000000000003,30.618000000000002 +2020-04-26 00:30:00,54.11,56.742,28.703000000000003,30.618000000000002 +2020-04-26 00:45:00,54.48,55.713,28.703000000000003,30.618000000000002 +2020-04-26 01:00:00,53.07,56.185,26.171,30.618000000000002 +2020-04-26 01:15:00,53.77,55.653,26.171,30.618000000000002 +2020-04-26 01:30:00,53.29,53.88399999999999,26.171,30.618000000000002 +2020-04-26 01:45:00,53.5,53.567,26.171,30.618000000000002 +2020-04-26 02:00:00,52.56,54.358999999999995,25.326999999999998,30.618000000000002 +2020-04-26 02:15:00,53.08,52.657,25.326999999999998,30.618000000000002 +2020-04-26 02:30:00,53.0,55.581,25.326999999999998,30.618000000000002 +2020-04-26 02:45:00,53.59,56.367,25.326999999999998,30.618000000000002 +2020-04-26 03:00:00,53.57,59.565,24.311999999999998,30.618000000000002 +2020-04-26 03:15:00,54.44,59.016000000000005,24.311999999999998,30.618000000000002 +2020-04-26 03:30:00,54.58,57.88,24.311999999999998,30.618000000000002 +2020-04-26 03:45:00,54.59,59.626999999999995,24.311999999999998,30.618000000000002 +2020-04-26 04:00:00,55.25,67.928,25.33,30.618000000000002 +2020-04-26 04:15:00,56.11,76.015,25.33,30.618000000000002 +2020-04-26 04:30:00,54.4,75.235,25.33,30.618000000000002 +2020-04-26 04:45:00,54.42,75.722,25.33,30.618000000000002 +2020-04-26 05:00:00,53.23,92.29700000000001,25.309,30.618000000000002 +2020-04-26 05:15:00,54.13,105.882,25.309,30.618000000000002 +2020-04-26 05:30:00,53.73,96.536,25.309,30.618000000000002 +2020-04-26 05:45:00,55.13,92.115,25.309,30.618000000000002 +2020-04-26 06:00:00,56.83,109.336,25.945999999999998,30.618000000000002 +2020-04-26 06:15:00,59.16,126.744,25.945999999999998,30.618000000000002 +2020-04-26 06:30:00,59.67,117.788,25.945999999999998,30.618000000000002 +2020-04-26 06:45:00,60.03,110.90899999999999,25.945999999999998,30.618000000000002 +2020-04-26 07:00:00,62.06,111.11,27.87,30.618000000000002 +2020-04-26 07:15:00,63.4,109.559,27.87,30.618000000000002 +2020-04-26 07:30:00,62.84,108.62899999999999,27.87,30.618000000000002 +2020-04-26 07:45:00,61.39,107.17399999999999,27.87,30.618000000000002 +2020-04-26 08:00:00,61.91,108.194,32.114000000000004,30.618000000000002 +2020-04-26 08:15:00,60.92,109.787,32.114000000000004,30.618000000000002 +2020-04-26 08:30:00,59.14,108.181,32.114000000000004,30.618000000000002 +2020-04-26 08:45:00,58.58,108.95200000000001,32.114000000000004,30.618000000000002 +2020-04-26 09:00:00,57.18,103.98299999999999,34.222,30.618000000000002 +2020-04-26 09:15:00,58.03,104.06200000000001,34.222,30.618000000000002 +2020-04-26 09:30:00,58.37,106.571,34.222,30.618000000000002 +2020-04-26 09:45:00,60.08,106.904,34.222,30.618000000000002 +2020-04-26 10:00:00,59.48,103.84200000000001,34.544000000000004,30.618000000000002 +2020-04-26 10:15:00,60.83,105.178,34.544000000000004,30.618000000000002 +2020-04-26 10:30:00,61.23,104.53,34.544000000000004,30.618000000000002 +2020-04-26 10:45:00,61.1,104.56299999999999,34.544000000000004,30.618000000000002 +2020-04-26 11:00:00,62.69,99.581,36.368,30.618000000000002 +2020-04-26 11:15:00,58.41,99.315,36.368,30.618000000000002 +2020-04-26 11:30:00,56.55,100.654,36.368,30.618000000000002 +2020-04-26 11:45:00,57.98,101.229,36.368,30.618000000000002 +2020-04-26 12:00:00,52.84,97.259,32.433,30.618000000000002 +2020-04-26 12:15:00,51.83,98.132,32.433,30.618000000000002 +2020-04-26 12:30:00,49.53,96.414,32.433,30.618000000000002 +2020-04-26 12:45:00,50.89,96.04799999999999,32.433,30.618000000000002 +2020-04-26 13:00:00,45.96,96.559,28.971999999999998,30.618000000000002 +2020-04-26 13:15:00,46.59,96.266,28.971999999999998,30.618000000000002 +2020-04-26 13:30:00,46.54,93.7,28.971999999999998,30.618000000000002 +2020-04-26 13:45:00,49.1,92.21,28.971999999999998,30.618000000000002 +2020-04-26 14:00:00,49.36,94.28200000000001,25.531999999999996,30.618000000000002 +2020-04-26 14:15:00,47.82,93.669,25.531999999999996,30.618000000000002 +2020-04-26 14:30:00,46.91,92.95,25.531999999999996,30.618000000000002 +2020-04-26 14:45:00,50.24,92.745,25.531999999999996,30.618000000000002 +2020-04-26 15:00:00,48.5,93.521,25.766,30.618000000000002 +2020-04-26 15:15:00,49.38,92.24700000000001,25.766,30.618000000000002 +2020-04-26 15:30:00,52.0,91.41799999999999,25.766,30.618000000000002 +2020-04-26 15:45:00,56.72,91.45100000000001,25.766,30.618000000000002 +2020-04-26 16:00:00,58.03,91.37200000000001,29.232,30.618000000000002 +2020-04-26 16:15:00,58.12,91.04899999999999,29.232,30.618000000000002 +2020-04-26 16:30:00,61.98,91.90100000000001,29.232,30.618000000000002 +2020-04-26 16:45:00,66.32,89.185,29.232,30.618000000000002 +2020-04-26 17:00:00,69.92,89.704,37.431,30.618000000000002 +2020-04-26 17:15:00,72.25,91.865,37.431,30.618000000000002 +2020-04-26 17:30:00,71.68,93.652,37.431,30.618000000000002 +2020-04-26 17:45:00,72.65,96.22200000000001,37.431,30.618000000000002 +2020-04-26 18:00:00,74.1,98.405,41.251999999999995,30.618000000000002 +2020-04-26 18:15:00,73.58,101.804,41.251999999999995,30.618000000000002 +2020-04-26 18:30:00,79.83,100.978,41.251999999999995,30.618000000000002 +2020-04-26 18:45:00,81.73,103.944,41.251999999999995,30.618000000000002 +2020-04-26 19:00:00,82.6,103.89399999999999,41.784,30.618000000000002 +2020-04-26 19:15:00,74.77,102.92299999999999,41.784,30.618000000000002 +2020-04-26 19:30:00,78.18,103.49700000000001,41.784,30.618000000000002 +2020-04-26 19:45:00,82.92,104.728,41.784,30.618000000000002 +2020-04-26 20:00:00,80.12,104.32799999999999,40.804,30.618000000000002 +2020-04-26 20:15:00,79.67,103.42299999999999,40.804,30.618000000000002 +2020-04-26 20:30:00,83.74,102.774,40.804,30.618000000000002 +2020-04-26 20:45:00,87.56,101.26,40.804,30.618000000000002 +2020-04-26 21:00:00,86.73,96.89200000000001,38.379,30.618000000000002 +2020-04-26 21:15:00,77.37,97.345,38.379,30.618000000000002 +2020-04-26 21:30:00,78.96,98.001,38.379,30.618000000000002 +2020-04-26 21:45:00,72.49,97.919,38.379,30.618000000000002 +2020-04-26 22:00:00,71.02,95.426,37.87,30.618000000000002 +2020-04-26 22:15:00,74.6,93.542,37.87,30.618000000000002 +2020-04-26 22:30:00,74.46,89.67299999999999,37.87,30.618000000000002 +2020-04-26 22:45:00,77.49,86.29799999999999,37.87,30.618000000000002 +2020-04-26 23:00:00,67.34,78.266,33.332,30.618000000000002 +2020-04-26 23:15:00,67.41,76.46300000000001,33.332,30.618000000000002 +2020-04-26 23:30:00,65.25,74.368,33.332,30.618000000000002 +2020-04-26 23:45:00,71.84,74.14,33.332,30.618000000000002 +2020-04-27 00:00:00,65.34,63.676,34.698,30.736 +2020-04-27 00:15:00,68.11,62.993,34.698,30.736 +2020-04-27 00:30:00,62.17,61.31,34.698,30.736 +2020-04-27 00:45:00,65.5,59.75,34.698,30.736 +2020-04-27 01:00:00,67.94,60.476000000000006,32.889,30.736 +2020-04-27 01:15:00,68.09,59.667,32.889,30.736 +2020-04-27 01:30:00,64.87,58.151,32.889,30.736 +2020-04-27 01:45:00,62.39,57.826,32.889,30.736 +2020-04-27 02:00:00,60.51,58.898999999999994,32.06,30.736 +2020-04-27 02:15:00,61.52,57.211000000000006,32.06,30.736 +2020-04-27 02:30:00,63.11,60.428999999999995,32.06,30.736 +2020-04-27 02:45:00,65.0,60.816,32.06,30.736 +2020-04-27 03:00:00,70.94,64.986,30.515,30.736 +2020-04-27 03:15:00,71.15,65.718,30.515,30.736 +2020-04-27 03:30:00,66.39,64.915,30.515,30.736 +2020-04-27 03:45:00,68.04,66.095,30.515,30.736 +2020-04-27 04:00:00,69.68,78.61399999999999,31.436,30.736 +2020-04-27 04:15:00,70.3,90.708,31.436,30.736 +2020-04-27 04:30:00,70.44,90.67,31.436,30.736 +2020-04-27 04:45:00,74.91,91.48200000000001,31.436,30.736 +2020-04-27 05:00:00,81.04,120.303,38.997,30.736 +2020-04-27 05:15:00,85.59,150.614,38.997,30.736 +2020-04-27 05:30:00,88.32,140.344,38.997,30.736 +2020-04-27 05:45:00,87.62,131.126,38.997,30.736 +2020-04-27 06:00:00,93.16,132.86700000000002,54.97,30.736 +2020-04-27 06:15:00,90.44,136.747,54.97,30.736 +2020-04-27 06:30:00,94.92,134.424,54.97,30.736 +2020-04-27 06:45:00,93.91,135.476,54.97,30.736 +2020-04-27 07:00:00,97.72,137.559,66.032,30.736 +2020-04-27 07:15:00,94.82,138.159,66.032,30.736 +2020-04-27 07:30:00,94.52,136.225,66.032,30.736 +2020-04-27 07:45:00,94.9,133.67700000000002,66.032,30.736 +2020-04-27 08:00:00,92.49,131.08700000000002,59.941,30.736 +2020-04-27 08:15:00,91.85,130.80700000000002,59.941,30.736 +2020-04-27 08:30:00,93.31,126.40799999999999,59.941,30.736 +2020-04-27 08:45:00,92.94,125.59700000000001,59.941,30.736 +2020-04-27 09:00:00,92.84,119.57600000000001,54.016000000000005,30.736 +2020-04-27 09:15:00,91.53,116.648,54.016000000000005,30.736 +2020-04-27 09:30:00,89.3,118.041,54.016000000000005,30.736 +2020-04-27 09:45:00,89.57,117.095,54.016000000000005,30.736 +2020-04-27 10:00:00,89.14,113.96600000000001,50.63,30.736 +2020-04-27 10:15:00,98.83,115.074,50.63,30.736 +2020-04-27 10:30:00,93.52,113.675,50.63,30.736 +2020-04-27 10:45:00,89.12,113.164,50.63,30.736 +2020-04-27 11:00:00,89.55,106.956,49.951,30.736 +2020-04-27 11:15:00,91.6,107.90700000000001,49.951,30.736 +2020-04-27 11:30:00,86.24,110.45299999999999,49.951,30.736 +2020-04-27 11:45:00,87.15,111.12,49.951,30.736 +2020-04-27 12:00:00,88.45,107.34700000000001,46.913000000000004,30.736 +2020-04-27 12:15:00,99.89,108.29299999999999,46.913000000000004,30.736 +2020-04-27 12:30:00,92.92,106.042,46.913000000000004,30.736 +2020-04-27 12:45:00,89.09,106.566,46.913000000000004,30.736 +2020-04-27 13:00:00,96.03,108.00299999999999,47.093999999999994,30.736 +2020-04-27 13:15:00,84.25,106.37299999999999,47.093999999999994,30.736 +2020-04-27 13:30:00,77.31,103.604,47.093999999999994,30.736 +2020-04-27 13:45:00,88.26,102.666,47.093999999999994,30.736 +2020-04-27 14:00:00,85.89,103.928,46.678000000000004,30.736 +2020-04-27 14:15:00,80.66,103.271,46.678000000000004,30.736 +2020-04-27 14:30:00,80.46,102.094,46.678000000000004,30.736 +2020-04-27 14:45:00,81.74,103.096,46.678000000000004,30.736 +2020-04-27 15:00:00,82.97,104.74700000000001,47.715,30.736 +2020-04-27 15:15:00,81.62,102.262,47.715,30.736 +2020-04-27 15:30:00,82.55,101.359,47.715,30.736 +2020-04-27 15:45:00,85.7,100.82799999999999,47.715,30.736 +2020-04-27 16:00:00,85.53,101.301,49.81100000000001,30.736 +2020-04-27 16:15:00,85.86,100.56299999999999,49.81100000000001,30.736 +2020-04-27 16:30:00,88.12,100.47,49.81100000000001,30.736 +2020-04-27 16:45:00,89.52,97.03399999999999,49.81100000000001,30.736 +2020-04-27 17:00:00,93.73,96.706,55.591,30.736 +2020-04-27 17:15:00,92.69,98.552,55.591,30.736 +2020-04-27 17:30:00,95.38,99.787,55.591,30.736 +2020-04-27 17:45:00,94.15,101.258,55.591,30.736 +2020-04-27 18:00:00,95.38,102.904,56.523,30.736 +2020-04-27 18:15:00,92.93,103.962,56.523,30.736 +2020-04-27 18:30:00,92.0,102.98899999999999,56.523,30.736 +2020-04-27 18:45:00,90.2,108.369,56.523,30.736 +2020-04-27 19:00:00,90.92,107.20299999999999,56.044,30.736 +2020-04-27 19:15:00,86.2,106.48899999999999,56.044,30.736 +2020-04-27 19:30:00,85.27,107.094,56.044,30.736 +2020-04-27 19:45:00,86.87,107.507,56.044,30.736 +2020-04-27 20:00:00,84.36,105.01899999999999,61.715,30.736 +2020-04-27 20:15:00,83.51,103.73299999999999,61.715,30.736 +2020-04-27 20:30:00,80.49,102.479,61.715,30.736 +2020-04-27 20:45:00,78.45,101.991,61.715,30.736 +2020-04-27 21:00:00,72.63,97.50399999999999,56.24,30.736 +2020-04-27 21:15:00,73.27,97.652,56.24,30.736 +2020-04-27 21:30:00,68.74,98.13600000000001,56.24,30.736 +2020-04-27 21:45:00,67.7,97.67399999999999,56.24,30.736 +2020-04-27 22:00:00,63.52,92.344,50.437,30.736 +2020-04-27 22:15:00,63.05,91.10600000000001,50.437,30.736 +2020-04-27 22:30:00,60.88,79.39,50.437,30.736 +2020-04-27 22:45:00,60.19,73.476,50.437,30.736 +2020-04-27 23:00:00,81.41,65.845,42.756,30.736 +2020-04-27 23:15:00,80.65,64.377,42.756,30.736 +2020-04-27 23:30:00,78.41,63.692,42.756,30.736 +2020-04-27 23:45:00,78.52,64.492,42.756,30.736 +2020-04-28 00:00:00,77.46,61.4,39.857,30.736 +2020-04-28 00:15:00,76.93,62.035,39.857,30.736 +2020-04-28 00:30:00,76.12,60.358000000000004,39.857,30.736 +2020-04-28 00:45:00,76.92,58.856,39.857,30.736 +2020-04-28 01:00:00,78.02,59.108999999999995,37.233000000000004,30.736 +2020-04-28 01:15:00,78.26,58.13,37.233000000000004,30.736 +2020-04-28 01:30:00,75.49,56.593,37.233000000000004,30.736 +2020-04-28 01:45:00,75.85,56.073,37.233000000000004,30.736 +2020-04-28 02:00:00,78.67,56.803000000000004,35.856,30.736 +2020-04-28 02:15:00,78.88,55.836999999999996,35.856,30.736 +2020-04-28 02:30:00,73.42,58.508,35.856,30.736 +2020-04-28 02:45:00,73.56,59.13,35.856,30.736 +2020-04-28 03:00:00,74.82,62.342,34.766999999999996,30.736 +2020-04-28 03:15:00,76.05,63.271,34.766999999999996,30.736 +2020-04-28 03:30:00,79.51,62.683,34.766999999999996,30.736 +2020-04-28 03:45:00,83.12,63.217,34.766999999999996,30.736 +2020-04-28 04:00:00,84.91,74.73899999999999,35.468,30.736 +2020-04-28 04:15:00,84.94,86.62700000000001,35.468,30.736 +2020-04-28 04:30:00,87.14,86.353,35.468,30.736 +2020-04-28 04:45:00,88.32,88.088,35.468,30.736 +2020-04-28 05:00:00,95.45,120.323,40.399,30.736 +2020-04-28 05:15:00,98.01,150.866,40.399,30.736 +2020-04-28 05:30:00,100.42,140.02200000000002,40.399,30.736 +2020-04-28 05:45:00,102.69,130.343,40.399,30.736 +2020-04-28 06:00:00,107.5,132.263,54.105,30.736 +2020-04-28 06:15:00,108.18,137.054,54.105,30.736 +2020-04-28 06:30:00,110.19,134.178,54.105,30.736 +2020-04-28 06:45:00,110.95,134.42600000000002,54.105,30.736 +2020-04-28 07:00:00,112.62,136.516,63.083,30.736 +2020-04-28 07:15:00,112.53,136.851,63.083,30.736 +2020-04-28 07:30:00,113.87,134.61700000000002,63.083,30.736 +2020-04-28 07:45:00,112.32,131.526,63.083,30.736 +2020-04-28 08:00:00,110.21,128.942,57.254,30.736 +2020-04-28 08:15:00,108.73,127.866,57.254,30.736 +2020-04-28 08:30:00,109.99,123.456,57.254,30.736 +2020-04-28 08:45:00,111.24,121.929,57.254,30.736 +2020-04-28 09:00:00,110.59,115.686,51.395,30.736 +2020-04-28 09:15:00,110.8,113.535,51.395,30.736 +2020-04-28 09:30:00,110.96,115.749,51.395,30.736 +2020-04-28 09:45:00,110.3,115.579,51.395,30.736 +2020-04-28 10:00:00,108.23,111.265,48.201,30.736 +2020-04-28 10:15:00,110.0,111.735,48.201,30.736 +2020-04-28 10:30:00,108.55,110.477,48.201,30.736 +2020-04-28 10:45:00,108.8,110.765,48.201,30.736 +2020-04-28 11:00:00,106.28,105.389,46.133,30.736 +2020-04-28 11:15:00,105.09,106.42,46.133,30.736 +2020-04-28 11:30:00,103.75,107.59899999999999,46.133,30.736 +2020-04-28 11:45:00,106.98,108.41,46.133,30.736 +2020-04-28 12:00:00,106.37,103.759,44.243,30.736 +2020-04-28 12:15:00,109.77,104.667,44.243,30.736 +2020-04-28 12:30:00,108.48,103.306,44.243,30.736 +2020-04-28 12:45:00,108.19,104.073,44.243,30.736 +2020-04-28 13:00:00,104.13,105.09,45.042,30.736 +2020-04-28 13:15:00,103.69,104.32,45.042,30.736 +2020-04-28 13:30:00,104.39,102.223,45.042,30.736 +2020-04-28 13:45:00,106.18,100.82600000000001,45.042,30.736 +2020-04-28 14:00:00,111.13,102.575,44.062,30.736 +2020-04-28 14:15:00,109.93,101.874,44.062,30.736 +2020-04-28 14:30:00,107.35,101.215,44.062,30.736 +2020-04-28 14:45:00,104.4,101.706,44.062,30.736 +2020-04-28 15:00:00,109.65,103.045,46.461999999999996,30.736 +2020-04-28 15:15:00,109.95,101.281,46.461999999999996,30.736 +2020-04-28 15:30:00,109.44,100.34299999999999,46.461999999999996,30.736 +2020-04-28 15:45:00,108.73,99.764,46.461999999999996,30.736 +2020-04-28 16:00:00,108.15,100.094,48.802,30.736 +2020-04-28 16:15:00,109.08,99.656,48.802,30.736 +2020-04-28 16:30:00,111.59,99.704,48.802,30.736 +2020-04-28 16:45:00,112.11,96.806,48.802,30.736 +2020-04-28 17:00:00,114.23,96.969,55.672,30.736 +2020-04-28 17:15:00,114.12,99.066,55.672,30.736 +2020-04-28 17:30:00,116.32,100.38799999999999,55.672,30.736 +2020-04-28 17:45:00,115.06,101.552,55.672,30.736 +2020-04-28 18:00:00,116.52,102.531,57.006,30.736 +2020-04-28 18:15:00,116.37,104.29299999999999,57.006,30.736 +2020-04-28 18:30:00,116.22,102.975,57.006,30.736 +2020-04-28 18:45:00,113.05,108.661,57.006,30.736 +2020-04-28 19:00:00,109.57,106.756,57.148,30.736 +2020-04-28 19:15:00,107.41,106.01799999999999,57.148,30.736 +2020-04-28 19:30:00,111.56,106.139,57.148,30.736 +2020-04-28 19:45:00,110.69,106.81299999999999,57.148,30.736 +2020-04-28 20:00:00,106.84,104.641,61.895,30.736 +2020-04-28 20:15:00,104.83,102.081,61.895,30.736 +2020-04-28 20:30:00,102.09,101.42,61.895,30.736 +2020-04-28 20:45:00,105.29,100.936,61.895,30.736 +2020-04-28 21:00:00,99.4,96.655,54.78,30.736 +2020-04-28 21:15:00,99.25,96.321,54.78,30.736 +2020-04-28 21:30:00,93.24,96.527,54.78,30.736 +2020-04-28 21:45:00,89.26,96.34100000000001,54.78,30.736 +2020-04-28 22:00:00,87.28,92.029,50.76,30.736 +2020-04-28 22:15:00,89.13,90.45700000000001,50.76,30.736 +2020-04-28 22:30:00,86.27,78.956,50.76,30.736 +2020-04-28 22:45:00,83.49,73.17399999999999,50.76,30.736 +2020-04-28 23:00:00,71.4,65.023,44.162,30.736 +2020-04-28 23:15:00,70.29,64.152,44.162,30.736 +2020-04-28 23:30:00,75.32,63.277,44.162,30.736 +2020-04-28 23:45:00,78.15,63.973,44.162,30.736 +2020-04-29 00:00:00,73.93,61.016999999999996,39.061,30.736 +2020-04-29 00:15:00,68.61,61.667,39.061,30.736 +2020-04-29 00:30:00,69.83,59.983999999999995,39.061,30.736 +2020-04-29 00:45:00,74.41,58.489,39.061,30.736 +2020-04-29 01:00:00,73.44,58.735,35.795,30.736 +2020-04-29 01:15:00,71.44,57.733000000000004,35.795,30.736 +2020-04-29 01:30:00,73.21,56.176,35.795,30.736 +2020-04-29 01:45:00,74.77,55.658,35.795,30.736 +2020-04-29 02:00:00,72.45,56.379,33.316,30.736 +2020-04-29 02:15:00,65.54,55.397,33.316,30.736 +2020-04-29 02:30:00,66.56,58.082,33.316,30.736 +2020-04-29 02:45:00,70.87,58.713,33.316,30.736 +2020-04-29 03:00:00,70.07,61.93899999999999,32.803000000000004,30.736 +2020-04-29 03:15:00,76.58,62.843,32.803000000000004,30.736 +2020-04-29 03:30:00,78.46,62.253,32.803000000000004,30.736 +2020-04-29 03:45:00,77.53,62.809,32.803000000000004,30.736 +2020-04-29 04:00:00,79.53,74.297,34.235,30.736 +2020-04-29 04:15:00,80.34,86.14399999999999,34.235,30.736 +2020-04-29 04:30:00,82.42,85.869,34.235,30.736 +2020-04-29 04:45:00,86.48,87.594,34.235,30.736 +2020-04-29 05:00:00,93.77,119.73,38.65,30.736 +2020-04-29 05:15:00,97.73,150.167,38.65,30.736 +2020-04-29 05:30:00,101.06,139.352,38.65,30.736 +2020-04-29 05:45:00,102.22,129.722,38.65,30.736 +2020-04-29 06:00:00,110.99,131.662,54.951,30.736 +2020-04-29 06:15:00,110.65,136.43200000000002,54.951,30.736 +2020-04-29 06:30:00,113.7,133.542,54.951,30.736 +2020-04-29 06:45:00,116.43,133.786,54.951,30.736 +2020-04-29 07:00:00,121.5,135.873,67.328,30.736 +2020-04-29 07:15:00,121.34,136.19899999999998,67.328,30.736 +2020-04-29 07:30:00,121.78,133.929,67.328,30.736 +2020-04-29 07:45:00,122.25,130.845,67.328,30.736 +2020-04-29 08:00:00,122.93,128.249,60.23,30.736 +2020-04-29 08:15:00,125.82,127.21,60.23,30.736 +2020-04-29 08:30:00,127.65,122.774,60.23,30.736 +2020-04-29 08:45:00,128.25,121.274,60.23,30.736 +2020-04-29 09:00:00,129.04,115.035,56.845,30.736 +2020-04-29 09:15:00,129.51,112.889,56.845,30.736 +2020-04-29 09:30:00,126.37,115.118,56.845,30.736 +2020-04-29 09:45:00,124.86,114.979,56.845,30.736 +2020-04-29 10:00:00,121.63,110.675,53.832,30.736 +2020-04-29 10:15:00,121.03,111.19,53.832,30.736 +2020-04-29 10:30:00,122.36,109.954,53.832,30.736 +2020-04-29 10:45:00,116.08,110.26100000000001,53.832,30.736 +2020-04-29 11:00:00,109.95,104.87799999999999,53.225,30.736 +2020-04-29 11:15:00,114.24,105.931,53.225,30.736 +2020-04-29 11:30:00,109.62,107.11,53.225,30.736 +2020-04-29 11:45:00,101.59,107.93799999999999,53.225,30.736 +2020-04-29 12:00:00,99.09,103.316,50.676,30.736 +2020-04-29 12:15:00,97.93,104.23,50.676,30.736 +2020-04-29 12:30:00,97.46,102.82700000000001,50.676,30.736 +2020-04-29 12:45:00,105.95,103.596,50.676,30.736 +2020-04-29 13:00:00,105.96,104.65100000000001,50.646,30.736 +2020-04-29 13:15:00,108.82,103.876,50.646,30.736 +2020-04-29 13:30:00,106.36,101.78200000000001,50.646,30.736 +2020-04-29 13:45:00,107.35,100.38799999999999,50.646,30.736 +2020-04-29 14:00:00,108.22,102.194,50.786,30.736 +2020-04-29 14:15:00,105.88,101.476,50.786,30.736 +2020-04-29 14:30:00,100.87,100.773,50.786,30.736 +2020-04-29 14:45:00,97.81,101.265,50.786,30.736 +2020-04-29 15:00:00,99.63,102.64,51.535,30.736 +2020-04-29 15:15:00,95.3,100.855,51.535,30.736 +2020-04-29 15:30:00,104.15,99.876,51.535,30.736 +2020-04-29 15:45:00,108.0,99.28,51.535,30.736 +2020-04-29 16:00:00,107.58,99.65299999999999,53.157,30.736 +2020-04-29 16:15:00,105.42,99.19,53.157,30.736 +2020-04-29 16:30:00,106.46,99.243,53.157,30.736 +2020-04-29 16:45:00,108.12,96.28399999999999,53.157,30.736 +2020-04-29 17:00:00,109.38,96.49600000000001,57.793,30.736 +2020-04-29 17:15:00,106.94,98.56700000000001,57.793,30.736 +2020-04-29 17:30:00,107.97,99.882,57.793,30.736 +2020-04-29 17:45:00,107.1,101.01899999999999,57.793,30.736 +2020-04-29 18:00:00,108.18,102.01299999999999,59.872,30.736 +2020-04-29 18:15:00,106.95,103.79,59.872,30.736 +2020-04-29 18:30:00,114.64,102.459,59.872,30.736 +2020-04-29 18:45:00,114.76,108.149,59.872,30.736 +2020-04-29 19:00:00,112.55,106.23700000000001,60.17100000000001,30.736 +2020-04-29 19:15:00,104.09,105.50299999999999,60.17100000000001,30.736 +2020-04-29 19:30:00,105.95,105.635,60.17100000000001,30.736 +2020-04-29 19:45:00,108.76,106.331,60.17100000000001,30.736 +2020-04-29 20:00:00,102.11,104.131,65.015,30.736 +2020-04-29 20:15:00,102.79,101.57799999999999,65.015,30.736 +2020-04-29 20:30:00,101.29,100.95100000000001,65.015,30.736 +2020-04-29 20:45:00,104.92,100.49700000000001,65.015,30.736 +2020-04-29 21:00:00,99.96,96.219,57.805,30.736 +2020-04-29 21:15:00,99.95,95.9,57.805,30.736 +2020-04-29 21:30:00,91.66,96.09100000000001,57.805,30.736 +2020-04-29 21:45:00,92.16,95.932,57.805,30.736 +2020-04-29 22:00:00,89.14,91.635,52.115,30.736 +2020-04-29 22:15:00,89.74,90.085,52.115,30.736 +2020-04-29 22:30:00,84.06,78.56,52.115,30.736 +2020-04-29 22:45:00,79.26,72.768,52.115,30.736 +2020-04-29 23:00:00,72.0,64.59100000000001,42.871,30.736 +2020-04-29 23:15:00,77.84,63.75899999999999,42.871,30.736 +2020-04-29 23:30:00,81.54,62.883,42.871,30.736 +2020-04-29 23:45:00,81.39,63.585,42.871,30.736 +2020-04-30 00:00:00,75.37,60.636,39.203,30.736 +2020-04-30 00:15:00,69.88,61.299,39.203,30.736 +2020-04-30 00:30:00,76.11,59.611999999999995,39.203,30.736 +2020-04-30 00:45:00,78.46,58.123000000000005,39.203,30.736 +2020-04-30 01:00:00,76.07,58.363,37.118,30.736 +2020-04-30 01:15:00,74.99,57.338,37.118,30.736 +2020-04-30 01:30:00,69.98,55.761,37.118,30.736 +2020-04-30 01:45:00,72.88,55.246,37.118,30.736 +2020-04-30 02:00:00,77.43,55.956,35.647,30.736 +2020-04-30 02:15:00,77.87,54.958999999999996,35.647,30.736 +2020-04-30 02:30:00,70.93,57.66,35.647,30.736 +2020-04-30 02:45:00,72.1,58.297,35.647,30.736 +2020-04-30 03:00:00,71.36,61.538000000000004,34.585,30.736 +2020-04-30 03:15:00,72.29,62.419,34.585,30.736 +2020-04-30 03:30:00,75.56,61.825,34.585,30.736 +2020-04-30 03:45:00,75.33,62.405,34.585,30.736 +2020-04-30 04:00:00,79.58,73.857,36.184,30.736 +2020-04-30 04:15:00,81.32,85.664,36.184,30.736 +2020-04-30 04:30:00,83.16,85.387,36.184,30.736 +2020-04-30 04:45:00,86.22,87.103,36.184,30.736 +2020-04-30 05:00:00,94.0,119.139,41.019,30.736 +2020-04-30 05:15:00,94.93,149.472,41.019,30.736 +2020-04-30 05:30:00,98.47,138.685,41.019,30.736 +2020-04-30 05:45:00,103.17,129.10299999999998,41.019,30.736 +2020-04-30 06:00:00,110.4,131.064,53.963,30.736 +2020-04-30 06:15:00,111.54,135.813,53.963,30.736 +2020-04-30 06:30:00,114.87,132.909,53.963,30.736 +2020-04-30 06:45:00,116.61,133.149,53.963,30.736 +2020-04-30 07:00:00,121.28,135.233,66.512,30.736 +2020-04-30 07:15:00,121.03,135.549,66.512,30.736 +2020-04-30 07:30:00,122.18,133.245,66.512,30.736 +2020-04-30 07:45:00,121.34,130.166,66.512,30.736 +2020-04-30 08:00:00,120.47,127.561,58.86,30.736 +2020-04-30 08:15:00,121.39,126.557,58.86,30.736 +2020-04-30 08:30:00,122.76,122.095,58.86,30.736 +2020-04-30 08:45:00,121.08,120.624,58.86,30.736 +2020-04-30 09:00:00,121.88,114.389,52.156000000000006,30.736 +2020-04-30 09:15:00,123.85,112.24799999999999,52.156000000000006,30.736 +2020-04-30 09:30:00,124.67,114.49,52.156000000000006,30.736 +2020-04-30 09:45:00,124.74,114.385,52.156000000000006,30.736 +2020-04-30 10:00:00,125.1,110.089,49.034,30.736 +2020-04-30 10:15:00,125.31,110.649,49.034,30.736 +2020-04-30 10:30:00,126.21,109.434,49.034,30.736 +2020-04-30 10:45:00,124.71,109.76,49.034,30.736 +2020-04-30 11:00:00,121.6,104.37200000000001,46.53,30.736 +2020-04-30 11:15:00,123.88,105.447,46.53,30.736 +2020-04-30 11:30:00,120.2,106.624,46.53,30.736 +2020-04-30 11:45:00,119.8,107.46799999999999,46.53,30.736 +2020-04-30 12:00:00,110.03,102.876,43.318000000000005,30.736 +2020-04-30 12:15:00,106.44,103.795,43.318000000000005,30.736 +2020-04-30 12:30:00,100.78,102.351,43.318000000000005,30.736 +2020-04-30 12:45:00,99.22,103.12299999999999,43.318000000000005,30.736 +2020-04-30 13:00:00,106.85,104.212,41.608000000000004,30.736 +2020-04-30 13:15:00,112.1,103.434,41.608000000000004,30.736 +2020-04-30 13:30:00,101.38,101.345,41.608000000000004,30.736 +2020-04-30 13:45:00,100.96,99.954,41.608000000000004,30.736 +2020-04-30 14:00:00,101.38,101.816,41.786,30.736 +2020-04-30 14:15:00,104.57,101.083,41.786,30.736 +2020-04-30 14:30:00,102.6,100.335,41.786,30.736 +2020-04-30 14:45:00,105.7,100.82799999999999,41.786,30.736 +2020-04-30 15:00:00,101.95,102.238,44.181999999999995,30.736 +2020-04-30 15:15:00,104.28,100.432,44.181999999999995,30.736 +2020-04-30 15:30:00,101.87,99.412,44.181999999999995,30.736 +2020-04-30 15:45:00,103.04,98.79899999999999,44.181999999999995,30.736 +2020-04-30 16:00:00,109.28,99.213,45.956,30.736 +2020-04-30 16:15:00,109.2,98.727,45.956,30.736 +2020-04-30 16:30:00,110.26,98.787,45.956,30.736 +2020-04-30 16:45:00,111.89,95.76299999999999,45.956,30.736 +2020-04-30 17:00:00,113.27,96.027,50.702,30.736 +2020-04-30 17:15:00,113.08,98.072,50.702,30.736 +2020-04-30 17:30:00,112.36,99.37899999999999,50.702,30.736 +2020-04-30 17:45:00,111.24,100.491,50.702,30.736 +2020-04-30 18:00:00,111.37,101.49600000000001,53.595,30.736 +2020-04-30 18:15:00,110.86,103.29,53.595,30.736 +2020-04-30 18:30:00,118.75,101.945,53.595,30.736 +2020-04-30 18:45:00,118.09,107.64,53.595,30.736 +2020-04-30 19:00:00,113.08,105.721,54.207,30.736 +2020-04-30 19:15:00,103.48,104.993,54.207,30.736 +2020-04-30 19:30:00,104.39,105.134,54.207,30.736 +2020-04-30 19:45:00,104.48,105.85,54.207,30.736 +2020-04-30 20:00:00,102.44,103.625,56.948,30.736 +2020-04-30 20:15:00,99.4,101.079,56.948,30.736 +2020-04-30 20:30:00,98.53,100.484,56.948,30.736 +2020-04-30 20:45:00,100.91,100.059,56.948,30.736 +2020-04-30 21:00:00,92.46,95.787,52.157,30.736 +2020-04-30 21:15:00,95.11,95.48200000000001,52.157,30.736 +2020-04-30 21:30:00,94.9,95.65799999999999,52.157,30.736 +2020-04-30 21:45:00,93.18,95.525,52.157,30.736 +2020-04-30 22:00:00,90.2,91.242,47.483000000000004,30.736 +2020-04-30 22:15:00,83.38,89.715,47.483000000000004,30.736 +2020-04-30 22:30:00,79.85,78.164,47.483000000000004,30.736 +2020-04-30 22:45:00,83.09,72.363,47.483000000000004,30.736 +2020-04-30 23:00:00,58.59,64.16199999999999,41.978,30.736 +2020-04-30 23:15:00,56.65,63.367,41.978,30.736 +2020-04-30 23:30:00,56.38,62.49100000000001,41.978,30.736 +2020-04-30 23:45:00,55.11,63.199,41.978,30.736 +2020-05-01 00:00:00,50.49,50.163000000000004,18.527,29.662 +2020-05-01 00:15:00,53.64,47.663999999999994,18.527,29.662 +2020-05-01 00:30:00,52.6,46.467,18.527,29.662 +2020-05-01 00:45:00,52.95,45.35,18.527,29.662 +2020-05-01 01:00:00,48.0,45.506,16.348,29.662 +2020-05-01 01:15:00,51.98,45.135,16.348,29.662 +2020-05-01 01:30:00,48.94,43.453,16.348,29.662 +2020-05-01 01:45:00,52.32,43.106,16.348,29.662 +2020-05-01 02:00:00,51.36,43.566,12.581,29.662 +2020-05-01 02:15:00,51.11,41.968,12.581,29.662 +2020-05-01 02:30:00,48.05,44.556999999999995,12.581,29.662 +2020-05-01 02:45:00,47.72,45.224,12.581,29.662 +2020-05-01 03:00:00,48.95,48.13399999999999,10.712,29.662 +2020-05-01 03:15:00,50.28,46.619,10.712,29.662 +2020-05-01 03:30:00,50.66,45.36600000000001,10.712,29.662 +2020-05-01 03:45:00,51.81,46.526,10.712,29.662 +2020-05-01 04:00:00,53.03,54.876000000000005,9.084,29.662 +2020-05-01 04:15:00,54.27,62.676,9.084,29.662 +2020-05-01 04:30:00,51.73,61.516999999999996,9.084,29.662 +2020-05-01 04:45:00,51.01,61.666000000000004,9.084,29.662 +2020-05-01 05:00:00,49.69,76.642,9.388,29.662 +2020-05-01 05:15:00,52.41,87.50299999999999,9.388,29.662 +2020-05-01 05:30:00,52.41,77.738,9.388,29.662 +2020-05-01 05:45:00,50.98,74.282,9.388,29.662 +2020-05-01 06:00:00,55.06,89.3,11.109000000000002,29.662 +2020-05-01 06:15:00,53.68,104.529,11.109000000000002,29.662 +2020-05-01 06:30:00,58.38,96.056,11.109000000000002,29.662 +2020-05-01 06:45:00,60.23,89.587,11.109000000000002,29.662 +2020-05-01 07:00:00,61.8,89.125,13.77,29.662 +2020-05-01 07:15:00,63.86,86.988,13.77,29.662 +2020-05-01 07:30:00,65.25,85.859,13.77,29.662 +2020-05-01 07:45:00,66.17,84.554,13.77,29.662 +2020-05-01 08:00:00,67.11,83.42200000000001,12.868,29.662 +2020-05-01 08:15:00,66.63,85.50299999999999,12.868,29.662 +2020-05-01 08:30:00,67.33,84.87100000000001,12.868,29.662 +2020-05-01 08:45:00,66.74,86.586,12.868,29.662 +2020-05-01 09:00:00,67.45,81.148,12.804,29.662 +2020-05-01 09:15:00,68.21,81.42699999999999,12.804,29.662 +2020-05-01 09:30:00,67.2,84.065,12.804,29.662 +2020-05-01 09:45:00,68.2,84.78399999999999,12.804,29.662 +2020-05-01 10:00:00,69.04,81.47,11.029000000000002,29.662 +2020-05-01 10:15:00,69.41,82.72200000000001,11.029000000000002,29.662 +2020-05-01 10:30:00,70.12,82.583,11.029000000000002,29.662 +2020-05-01 10:45:00,66.61,82.947,11.029000000000002,29.662 +2020-05-01 11:00:00,57.19,79.26,11.681,29.662 +2020-05-01 11:15:00,57.35,79.181,11.681,29.662 +2020-05-01 11:30:00,54.68,80.666,11.681,29.662 +2020-05-01 11:45:00,54.35,80.57,11.681,29.662 +2020-05-01 12:00:00,52.36,78.047,8.915,29.662 +2020-05-01 12:15:00,56.13,78.738,8.915,29.662 +2020-05-01 12:30:00,51.09,77.492,8.915,29.662 +2020-05-01 12:45:00,50.81,77.333,8.915,29.662 +2020-05-01 13:00:00,49.83,78.273,5.4639999999999995,29.662 +2020-05-01 13:15:00,45.08,78.259,5.4639999999999995,29.662 +2020-05-01 13:30:00,46.19,75.832,5.4639999999999995,29.662 +2020-05-01 13:45:00,47.46,74.252,5.4639999999999995,29.662 +2020-05-01 14:00:00,49.01,76.367,3.2939999999999996,29.662 +2020-05-01 14:15:00,48.35,75.575,3.2939999999999996,29.662 +2020-05-01 14:30:00,45.56,75.003,3.2939999999999996,29.662 +2020-05-01 14:45:00,48.43,74.546,3.2939999999999996,29.662 +2020-05-01 15:00:00,49.06,75.21,4.689,29.662 +2020-05-01 15:15:00,49.67,73.595,4.689,29.662 +2020-05-01 15:30:00,51.56,72.592,4.689,29.662 +2020-05-01 15:45:00,53.04,71.998,4.689,29.662 +2020-05-01 16:00:00,55.11,72.08,7.732,29.662 +2020-05-01 16:15:00,59.46,71.52,7.732,29.662 +2020-05-01 16:30:00,62.16,72.118,7.732,29.662 +2020-05-01 16:45:00,62.3,69.263,7.732,29.662 +2020-05-01 17:00:00,67.83,70.095,17.558,29.662 +2020-05-01 17:15:00,72.29,71.555,17.558,29.662 +2020-05-01 17:30:00,72.68,72.794,17.558,29.662 +2020-05-01 17:45:00,73.94,74.653,17.558,29.662 +2020-05-01 18:00:00,75.38,75.821,24.763,29.662 +2020-05-01 18:15:00,74.58,78.516,24.763,29.662 +2020-05-01 18:30:00,75.18,77.906,24.763,29.662 +2020-05-01 18:45:00,75.67,80.471,24.763,29.662 +2020-05-01 19:00:00,79.11,79.535,29.633000000000003,29.662 +2020-05-01 19:15:00,84.51,78.283,29.633000000000003,29.662 +2020-05-01 19:30:00,85.92,78.82,29.633000000000003,29.662 +2020-05-01 19:45:00,82.48,79.994,29.633000000000003,29.662 +2020-05-01 20:00:00,83.91,80.059,38.826,29.662 +2020-05-01 20:15:00,81.16,79.312,38.826,29.662 +2020-05-01 20:30:00,79.77,77.578,38.826,29.662 +2020-05-01 20:45:00,88.48,76.283,38.826,29.662 +2020-05-01 21:00:00,88.98,74.173,37.751,29.662 +2020-05-01 21:15:00,88.97,75.696,37.751,29.662 +2020-05-01 21:30:00,78.96,76.152,37.751,29.662 +2020-05-01 21:45:00,81.17,76.479,37.751,29.662 +2020-05-01 22:00:00,73.75,76.163,39.799,29.662 +2020-05-01 22:15:00,80.27,74.783,39.799,29.662 +2020-05-01 22:30:00,81.01,72.372,39.799,29.662 +2020-05-01 22:45:00,80.12,70.10600000000001,39.799,29.662 +2020-05-01 23:00:00,80.75,64.072,33.686,29.662 +2020-05-01 23:15:00,77.21,61.755,33.686,29.662 +2020-05-01 23:30:00,72.86,60.244,33.686,29.662 +2020-05-01 23:45:00,74.22,59.806999999999995,33.686,29.662 +2020-05-02 00:00:00,67.4,48.595,42.833999999999996,29.662 +2020-05-02 00:15:00,71.63,47.126000000000005,42.833999999999996,29.662 +2020-05-02 00:30:00,75.66,46.176,42.833999999999996,29.662 +2020-05-02 00:45:00,76.11,44.898999999999994,42.833999999999996,29.662 +2020-05-02 01:00:00,72.31,44.903999999999996,37.859,29.662 +2020-05-02 01:15:00,70.22,44.23,37.859,29.662 +2020-05-02 01:30:00,73.08,42.45,37.859,29.662 +2020-05-02 01:45:00,73.7,42.513999999999996,37.859,29.662 +2020-05-02 02:00:00,66.85,43.174,35.327,29.662 +2020-05-02 02:15:00,66.49,41.31100000000001,35.327,29.662 +2020-05-02 02:30:00,68.26,43.461000000000006,35.327,29.662 +2020-05-02 02:45:00,66.14,44.196999999999996,35.327,29.662 +2020-05-02 03:00:00,65.71,46.493,34.908,29.662 +2020-05-02 03:15:00,66.15,44.941,34.908,29.662 +2020-05-02 03:30:00,67.61,43.762,34.908,29.662 +2020-05-02 03:45:00,68.7,45.599,34.908,29.662 +2020-05-02 04:00:00,69.79,54.015,34.84,29.662 +2020-05-02 04:15:00,67.03,62.473,34.84,29.662 +2020-05-02 04:30:00,66.17,60.231,34.84,29.662 +2020-05-02 04:45:00,67.69,60.641999999999996,34.84,29.662 +2020-05-02 05:00:00,69.51,76.563,34.222,29.662 +2020-05-02 05:15:00,70.18,88.72,34.222,29.662 +2020-05-02 05:30:00,69.35,79.377,34.222,29.662 +2020-05-02 05:45:00,71.72,76.12100000000001,34.222,29.662 +2020-05-02 06:00:00,73.67,93.12,35.515,29.662 +2020-05-02 06:15:00,73.73,108.242,35.515,29.662 +2020-05-02 06:30:00,75.39,100.70299999999999,35.515,29.662 +2020-05-02 06:45:00,76.42,95.399,35.515,29.662 +2020-05-02 07:00:00,79.28,93.88799999999999,39.687,29.662 +2020-05-02 07:15:00,77.96,93.337,39.687,29.662 +2020-05-02 07:30:00,78.31,91.61399999999999,39.687,29.662 +2020-05-02 07:45:00,79.86,90.62100000000001,39.687,29.662 +2020-05-02 08:00:00,80.34,88.26799999999999,44.9,29.662 +2020-05-02 08:15:00,78.9,89.515,44.9,29.662 +2020-05-02 08:30:00,78.61,87.58,44.9,29.662 +2020-05-02 08:45:00,79.87,88.637,44.9,29.662 +2020-05-02 09:00:00,76.21,83.447,45.724,29.662 +2020-05-02 09:15:00,75.76,83.902,45.724,29.662 +2020-05-02 09:30:00,75.2,86.28399999999999,45.724,29.662 +2020-05-02 09:45:00,75.61,86.314,45.724,29.662 +2020-05-02 10:00:00,75.42,81.66199999999999,43.123999999999995,29.662 +2020-05-02 10:15:00,76.95,82.645,43.123999999999995,29.662 +2020-05-02 10:30:00,77.79,82.12200000000001,43.123999999999995,29.662 +2020-05-02 10:45:00,76.44,82.48899999999999,43.123999999999995,29.662 +2020-05-02 11:00:00,72.26,78.703,40.255,29.662 +2020-05-02 11:15:00,71.56,78.921,40.255,29.662 +2020-05-02 11:30:00,69.64,80.362,40.255,29.662 +2020-05-02 11:45:00,69.91,79.851,40.255,29.662 +2020-05-02 12:00:00,65.59,76.855,38.582,29.662 +2020-05-02 12:15:00,66.22,77.22399999999999,38.582,29.662 +2020-05-02 12:30:00,64.72,76.383,38.582,29.662 +2020-05-02 12:45:00,64.95,77.09,38.582,29.662 +2020-05-02 13:00:00,62.78,78.554,36.043,29.662 +2020-05-02 13:15:00,62.25,77.737,36.043,29.662 +2020-05-02 13:30:00,62.59,76.217,36.043,29.662 +2020-05-02 13:45:00,62.56,74.087,36.043,29.662 +2020-05-02 14:00:00,61.85,75.279,35.216,29.662 +2020-05-02 14:15:00,62.08,73.669,35.216,29.662 +2020-05-02 14:30:00,62.24,73.452,35.216,29.662 +2020-05-02 14:45:00,62.4,73.947,35.216,29.662 +2020-05-02 15:00:00,62.97,75.17399999999999,36.759,29.662 +2020-05-02 15:15:00,63.02,73.821,36.759,29.662 +2020-05-02 15:30:00,63.12,72.676,36.759,29.662 +2020-05-02 15:45:00,64.76,71.574,36.759,29.662 +2020-05-02 16:00:00,68.0,72.26,40.086,29.662 +2020-05-02 16:15:00,68.86,71.86399999999999,40.086,29.662 +2020-05-02 16:30:00,71.22,71.62,40.086,29.662 +2020-05-02 16:45:00,74.4,68.60300000000001,40.086,29.662 +2020-05-02 17:00:00,79.85,69.22399999999999,44.876999999999995,29.662 +2020-05-02 17:15:00,79.81,69.649,44.876999999999995,29.662 +2020-05-02 17:30:00,80.79,70.16199999999999,44.876999999999995,29.662 +2020-05-02 17:45:00,81.12,70.793,44.876999999999995,29.662 +2020-05-02 18:00:00,83.13,71.803,47.056000000000004,29.662 +2020-05-02 18:15:00,81.4,74.316,47.056000000000004,29.662 +2020-05-02 18:30:00,81.48,74.656,47.056000000000004,29.662 +2020-05-02 18:45:00,82.35,76.45,47.056000000000004,29.662 +2020-05-02 19:00:00,81.48,74.001,45.57,29.662 +2020-05-02 19:15:00,79.11,73.41,45.57,29.662 +2020-05-02 19:30:00,78.9,74.202,45.57,29.662 +2020-05-02 19:45:00,79.86,75.236,45.57,29.662 +2020-05-02 20:00:00,81.06,75.092,41.685,29.662 +2020-05-02 20:15:00,80.73,74.097,41.685,29.662 +2020-05-02 20:30:00,80.18,71.314,41.685,29.662 +2020-05-02 20:45:00,80.23,71.797,41.685,29.662 +2020-05-02 21:00:00,75.92,70.756,39.576,29.662 +2020-05-02 21:15:00,76.58,72.755,39.576,29.662 +2020-05-02 21:30:00,73.98,73.593,39.576,29.662 +2020-05-02 21:45:00,73.06,73.62,39.576,29.662 +2020-05-02 22:00:00,69.29,72.173,39.068000000000005,29.662 +2020-05-02 22:15:00,69.76,72.399,39.068000000000005,29.662 +2020-05-02 22:30:00,67.03,71.508,39.068000000000005,29.662 +2020-05-02 22:45:00,66.09,70.529,39.068000000000005,29.662 +2020-05-02 23:00:00,64.59,65.696,32.06,29.662 +2020-05-02 23:15:00,62.92,61.768,32.06,29.662 +2020-05-02 23:30:00,62.13,60.387,32.06,29.662 +2020-05-02 23:45:00,61.12,59.508,32.06,29.662 +2020-05-03 00:00:00,56.25,49.497,28.825,29.662 +2020-05-03 00:15:00,59.42,47.018,28.825,29.662 +2020-05-03 00:30:00,55.48,45.813,28.825,29.662 +2020-05-03 00:45:00,57.9,44.705,28.825,29.662 +2020-05-03 01:00:00,55.98,44.872,25.995,29.662 +2020-05-03 01:15:00,56.5,44.451,25.995,29.662 +2020-05-03 01:30:00,53.94,42.732,25.995,29.662 +2020-05-03 01:45:00,55.87,42.38399999999999,25.995,29.662 +2020-05-03 02:00:00,55.81,42.83,24.394000000000002,29.662 +2020-05-03 02:15:00,56.25,41.198,24.394000000000002,29.662 +2020-05-03 02:30:00,55.72,43.818000000000005,24.394000000000002,29.662 +2020-05-03 02:45:00,55.33,44.498999999999995,24.394000000000002,29.662 +2020-05-03 03:00:00,51.94,47.433,22.916999999999998,29.662 +2020-05-03 03:15:00,55.79,45.878,22.916999999999998,29.662 +2020-05-03 03:30:00,56.34,44.623999999999995,22.916999999999998,29.662 +2020-05-03 03:45:00,56.91,45.833,22.916999999999998,29.662 +2020-05-03 04:00:00,57.32,54.089,23.576999999999998,29.662 +2020-05-03 04:15:00,55.6,61.795,23.576999999999998,29.662 +2020-05-03 04:30:00,55.41,60.622,23.576999999999998,29.662 +2020-05-03 04:45:00,55.04,60.756,23.576999999999998,29.662 +2020-05-03 05:00:00,56.02,75.493,22.730999999999998,29.662 +2020-05-03 05:15:00,56.44,86.085,22.730999999999998,29.662 +2020-05-03 05:30:00,56.1,76.407,22.730999999999998,29.662 +2020-05-03 05:45:00,56.79,73.071,22.730999999999998,29.662 +2020-05-03 06:00:00,57.69,88.141,22.34,29.662 +2020-05-03 06:15:00,57.57,103.32,22.34,29.662 +2020-05-03 06:30:00,58.58,94.846,22.34,29.662 +2020-05-03 06:45:00,59.95,88.39299999999999,22.34,29.662 +2020-05-03 07:00:00,61.08,87.919,24.691999999999997,29.662 +2020-05-03 07:15:00,61.55,85.772,24.691999999999997,29.662 +2020-05-03 07:30:00,61.04,84.58,24.691999999999997,29.662 +2020-05-03 07:45:00,61.04,83.305,24.691999999999997,29.662 +2020-05-03 08:00:00,61.08,82.164,29.340999999999998,29.662 +2020-05-03 08:15:00,62.23,84.333,29.340999999999998,29.662 +2020-05-03 08:30:00,61.79,83.662,29.340999999999998,29.662 +2020-05-03 08:45:00,60.97,85.425,29.340999999999998,29.662 +2020-05-03 09:00:00,59.6,79.984,30.788,29.662 +2020-05-03 09:15:00,59.67,80.277,30.788,29.662 +2020-05-03 09:30:00,59.03,82.943,30.788,29.662 +2020-05-03 09:45:00,59.85,83.73,30.788,29.662 +2020-05-03 10:00:00,60.2,80.434,30.158,29.662 +2020-05-03 10:15:00,61.9,81.768,30.158,29.662 +2020-05-03 10:30:00,63.99,81.663,30.158,29.662 +2020-05-03 10:45:00,64.7,82.06200000000001,30.158,29.662 +2020-05-03 11:00:00,62.24,78.36,32.056,29.662 +2020-05-03 11:15:00,59.36,78.319,32.056,29.662 +2020-05-03 11:30:00,54.19,79.796,32.056,29.662 +2020-05-03 11:45:00,55.34,79.73100000000001,32.056,29.662 +2020-05-03 12:00:00,52.93,77.274,28.671999999999997,29.662 +2020-05-03 12:15:00,52.8,77.979,28.671999999999997,29.662 +2020-05-03 12:30:00,49.69,76.653,28.671999999999997,29.662 +2020-05-03 12:45:00,47.47,76.506,28.671999999999997,29.662 +2020-05-03 13:00:00,45.23,77.5,23.171,29.662 +2020-05-03 13:15:00,43.21,77.488,23.171,29.662 +2020-05-03 13:30:00,45.06,75.075,23.171,29.662 +2020-05-03 13:45:00,46.0,73.49600000000001,23.171,29.662 +2020-05-03 14:00:00,46.8,75.712,19.11,29.662 +2020-05-03 14:15:00,46.46,74.891,19.11,29.662 +2020-05-03 14:30:00,45.78,74.235,19.11,29.662 +2020-05-03 14:45:00,46.36,73.783,19.11,29.662 +2020-05-03 15:00:00,48.14,74.539,19.689,29.662 +2020-05-03 15:15:00,48.74,72.88600000000001,19.689,29.662 +2020-05-03 15:30:00,49.3,71.814,19.689,29.662 +2020-05-03 15:45:00,51.69,71.186,19.689,29.662 +2020-05-03 16:00:00,55.94,71.361,22.875,29.662 +2020-05-03 16:15:00,57.49,70.76100000000001,22.875,29.662 +2020-05-03 16:30:00,59.74,71.37899999999999,22.875,29.662 +2020-05-03 16:45:00,64.35,68.4,22.875,29.662 +2020-05-03 17:00:00,68.62,69.329,33.884,29.662 +2020-05-03 17:15:00,69.55,70.735,33.884,29.662 +2020-05-03 17:30:00,71.62,71.954,33.884,29.662 +2020-05-03 17:45:00,72.87,73.748,33.884,29.662 +2020-05-03 18:00:00,76.29,74.952,38.453,29.662 +2020-05-03 18:15:00,74.34,77.643,38.453,29.662 +2020-05-03 18:30:00,74.52,77.007,38.453,29.662 +2020-05-03 18:45:00,74.5,79.578,38.453,29.662 +2020-05-03 19:00:00,80.75,78.635,39.221,29.662 +2020-05-03 19:15:00,82.66,77.384,39.221,29.662 +2020-05-03 19:30:00,83.29,77.925,39.221,29.662 +2020-05-03 19:45:00,75.88,79.12100000000001,39.221,29.662 +2020-05-03 20:00:00,79.39,79.132,37.871,29.662 +2020-05-03 20:15:00,79.33,78.392,37.871,29.662 +2020-05-03 20:30:00,79.68,76.717,37.871,29.662 +2020-05-03 20:45:00,81.36,75.5,37.871,29.662 +2020-05-03 21:00:00,87.04,73.398,36.465,29.662 +2020-05-03 21:15:00,90.9,74.95,36.465,29.662 +2020-05-03 21:30:00,84.7,75.37,36.465,29.662 +2020-05-03 21:45:00,81.23,75.756,36.465,29.662 +2020-05-03 22:00:00,78.55,75.484,36.092,29.662 +2020-05-03 22:15:00,82.23,74.148,36.092,29.662 +2020-05-03 22:30:00,81.02,71.717,36.092,29.662 +2020-05-03 22:45:00,76.17,69.429,36.092,29.662 +2020-05-03 23:00:00,72.51,63.32899999999999,31.013,29.662 +2020-05-03 23:15:00,74.86,61.097,31.013,29.662 +2020-05-03 23:30:00,75.21,59.583999999999996,31.013,29.662 +2020-05-03 23:45:00,74.48,59.146,31.013,29.662 +2020-05-04 00:00:00,69.6,51.836999999999996,31.174,29.775 +2020-05-04 00:15:00,72.1,51.037,31.174,29.775 +2020-05-04 00:30:00,73.55,49.571999999999996,31.174,29.775 +2020-05-04 00:45:00,72.62,47.968999999999994,31.174,29.775 +2020-05-04 01:00:00,67.74,48.449,29.663,29.775 +2020-05-04 01:15:00,66.22,47.828,29.663,29.775 +2020-05-04 01:30:00,70.56,46.394,29.663,29.775 +2020-05-04 01:45:00,73.57,46.01,29.663,29.775 +2020-05-04 02:00:00,73.5,46.79600000000001,28.793000000000003,29.775 +2020-05-04 02:15:00,69.89,44.87,28.793000000000003,29.775 +2020-05-04 02:30:00,68.85,47.751000000000005,28.793000000000003,29.775 +2020-05-04 02:45:00,73.7,48.104,28.793000000000003,29.775 +2020-05-04 03:00:00,73.78,51.895,27.728,29.775 +2020-05-04 03:15:00,70.78,51.468,27.728,29.775 +2020-05-04 03:30:00,74.92,50.652,27.728,29.775 +2020-05-04 03:45:00,71.42,51.328,27.728,29.775 +2020-05-04 04:00:00,75.17,63.512,29.266,29.775 +2020-05-04 04:15:00,76.56,74.935,29.266,29.775 +2020-05-04 04:30:00,80.37,74.154,29.266,29.775 +2020-05-04 04:45:00,84.87,74.626,29.266,29.775 +2020-05-04 05:00:00,91.28,99.954,37.889,29.775 +2020-05-04 05:15:00,95.44,125.398,37.889,29.775 +2020-05-04 05:30:00,98.76,114.611,37.889,29.775 +2020-05-04 05:45:00,100.67,106.984,37.889,29.775 +2020-05-04 06:00:00,103.98,108.009,55.485,29.775 +2020-05-04 06:15:00,105.43,110.87700000000001,55.485,29.775 +2020-05-04 06:30:00,106.87,108.288,55.485,29.775 +2020-05-04 06:45:00,108.09,109.04,55.485,29.775 +2020-05-04 07:00:00,108.57,110.086,65.765,29.775 +2020-05-04 07:15:00,107.31,110.166,65.765,29.775 +2020-05-04 07:30:00,106.72,107.994,65.765,29.775 +2020-05-04 07:45:00,105.4,106.05799999999999,65.765,29.775 +2020-05-04 08:00:00,104.99,101.705,56.745,29.775 +2020-05-04 08:15:00,103.93,102.205,56.745,29.775 +2020-05-04 08:30:00,104.33,99.277,56.745,29.775 +2020-05-04 08:45:00,103.33,100.006,56.745,29.775 +2020-05-04 09:00:00,102.16,93.492,53.321999999999996,29.775 +2020-05-04 09:15:00,101.67,91.12200000000001,53.321999999999996,29.775 +2020-05-04 09:30:00,101.56,92.71799999999999,53.321999999999996,29.775 +2020-05-04 09:45:00,101.91,91.898,53.321999999999996,29.775 +2020-05-04 10:00:00,101.39,88.725,51.309,29.775 +2020-05-04 10:15:00,101.65,89.86200000000001,51.309,29.775 +2020-05-04 10:30:00,101.66,89.089,51.309,29.775 +2020-05-04 10:45:00,101.33,88.617,51.309,29.775 +2020-05-04 11:00:00,101.22,84.113,50.415,29.775 +2020-05-04 11:15:00,98.86,85.01899999999999,50.415,29.775 +2020-05-04 11:30:00,98.41,87.557,50.415,29.775 +2020-05-04 11:45:00,97.54,87.715,50.415,29.775 +2020-05-04 12:00:00,97.03,84.945,48.273,29.775 +2020-05-04 12:15:00,95.22,85.738,48.273,29.775 +2020-05-04 12:30:00,95.04,83.7,48.273,29.775 +2020-05-04 12:45:00,94.65,84.18700000000001,48.273,29.775 +2020-05-04 13:00:00,93.91,86.016,48.452,29.775 +2020-05-04 13:15:00,94.72,84.79299999999999,48.452,29.775 +2020-05-04 13:30:00,93.9,82.296,48.452,29.775 +2020-05-04 13:45:00,94.22,81.383,48.452,29.775 +2020-05-04 14:00:00,96.6,82.76899999999999,48.35,29.775 +2020-05-04 14:15:00,94.21,82.103,48.35,29.775 +2020-05-04 14:30:00,94.03,81.062,48.35,29.775 +2020-05-04 14:45:00,93.5,82.105,48.35,29.775 +2020-05-04 15:00:00,92.87,83.38600000000001,48.838,29.775 +2020-05-04 15:15:00,93.09,80.695,48.838,29.775 +2020-05-04 15:30:00,94.12,79.807,48.838,29.775 +2020-05-04 15:45:00,96.9,78.626,48.838,29.775 +2020-05-04 16:00:00,97.74,79.515,50.873000000000005,29.775 +2020-05-04 16:15:00,97.79,78.64699999999999,50.873000000000005,29.775 +2020-05-04 16:30:00,102.74,78.398,50.873000000000005,29.775 +2020-05-04 16:45:00,102.67,74.907,50.873000000000005,29.775 +2020-05-04 17:00:00,105.47,74.95100000000001,56.637,29.775 +2020-05-04 17:15:00,105.44,76.24,56.637,29.775 +2020-05-04 17:30:00,106.23,76.945,56.637,29.775 +2020-05-04 17:45:00,107.54,77.82600000000001,56.637,29.775 +2020-05-04 18:00:00,108.37,78.27,56.35,29.775 +2020-05-04 18:15:00,107.47,78.734,56.35,29.775 +2020-05-04 18:30:00,113.16,77.749,56.35,29.775 +2020-05-04 18:45:00,114.56,83.012,56.35,29.775 +2020-05-04 19:00:00,111.01,81.166,56.023,29.775 +2020-05-04 19:15:00,98.89,80.538,56.023,29.775 +2020-05-04 19:30:00,101.05,80.969,56.023,29.775 +2020-05-04 19:45:00,98.85,81.399,56.023,29.775 +2020-05-04 20:00:00,97.88,79.572,62.372,29.775 +2020-05-04 20:15:00,103.11,79.016,62.372,29.775 +2020-05-04 20:30:00,108.62,77.098,62.372,29.775 +2020-05-04 20:45:00,107.25,76.679,62.372,29.775 +2020-05-04 21:00:00,93.21,74.293,57.516999999999996,29.775 +2020-05-04 21:15:00,90.29,75.783,57.516999999999996,29.775 +2020-05-04 21:30:00,87.29,76.21,57.516999999999996,29.775 +2020-05-04 21:45:00,93.2,76.271,57.516999999999996,29.775 +2020-05-04 22:00:00,88.85,73.393,51.823,29.775 +2020-05-04 22:15:00,88.88,73.15899999999999,51.823,29.775 +2020-05-04 22:30:00,83.03,63.908,51.823,29.775 +2020-05-04 22:45:00,81.21,59.86,51.823,29.775 +2020-05-04 23:00:00,80.18,54.06100000000001,43.832,29.775 +2020-05-04 23:15:00,81.63,51.527,43.832,29.775 +2020-05-04 23:30:00,78.05,50.97,43.832,29.775 +2020-05-04 23:45:00,75.22,51.051,43.832,29.775 +2020-05-05 00:00:00,76.0,49.474,42.371,29.775 +2020-05-05 00:15:00,79.2,49.869,42.371,29.775 +2020-05-05 00:30:00,77.68,48.63,42.371,29.775 +2020-05-05 00:45:00,75.02,47.31100000000001,42.371,29.775 +2020-05-05 01:00:00,77.48,47.3,39.597,29.775 +2020-05-05 01:15:00,78.55,46.585,39.597,29.775 +2020-05-05 01:30:00,78.09,45.091,39.597,29.775 +2020-05-05 01:45:00,74.26,44.409,39.597,29.775 +2020-05-05 02:00:00,77.89,44.806999999999995,38.298,29.775 +2020-05-05 02:15:00,78.97,43.738,38.298,29.775 +2020-05-05 02:30:00,73.18,46.115,38.298,29.775 +2020-05-05 02:45:00,74.65,46.74,38.298,29.775 +2020-05-05 03:00:00,72.05,49.676,37.884,29.775 +2020-05-05 03:15:00,72.97,49.666000000000004,37.884,29.775 +2020-05-05 03:30:00,81.18,48.996,37.884,29.775 +2020-05-05 03:45:00,86.82,48.886,37.884,29.775 +2020-05-05 04:00:00,86.35,59.956,39.442,29.775 +2020-05-05 04:15:00,80.15,71.218,39.442,29.775 +2020-05-05 04:30:00,82.91,70.225,39.442,29.775 +2020-05-05 04:45:00,86.29,71.503,39.442,29.775 +2020-05-05 05:00:00,93.14,99.67200000000001,43.608000000000004,29.775 +2020-05-05 05:15:00,96.1,125.415,43.608000000000004,29.775 +2020-05-05 05:30:00,99.08,114.31700000000001,43.608000000000004,29.775 +2020-05-05 05:45:00,102.21,106.15799999999999,43.608000000000004,29.775 +2020-05-05 06:00:00,105.73,107.665,54.99100000000001,29.775 +2020-05-05 06:15:00,105.97,111.215,54.99100000000001,29.775 +2020-05-05 06:30:00,106.51,108.149,54.99100000000001,29.775 +2020-05-05 06:45:00,106.79,108.066,54.99100000000001,29.775 +2020-05-05 07:00:00,108.05,109.16,66.217,29.775 +2020-05-05 07:15:00,107.67,108.979,66.217,29.775 +2020-05-05 07:30:00,106.25,106.595,66.217,29.775 +2020-05-05 07:45:00,104.55,103.98200000000001,66.217,29.775 +2020-05-05 08:00:00,102.47,99.61399999999999,60.151,29.775 +2020-05-05 08:15:00,101.53,99.43299999999999,60.151,29.775 +2020-05-05 08:30:00,101.82,96.552,60.151,29.775 +2020-05-05 08:45:00,102.85,96.48899999999999,60.151,29.775 +2020-05-05 09:00:00,103.32,89.926,53.873000000000005,29.775 +2020-05-05 09:15:00,101.69,88.057,53.873000000000005,29.775 +2020-05-05 09:30:00,99.95,90.448,53.873000000000005,29.775 +2020-05-05 09:45:00,102.43,90.609,53.873000000000005,29.775 +2020-05-05 10:00:00,101.34,86.17399999999999,51.417,29.775 +2020-05-05 10:15:00,102.24,86.82799999999999,51.417,29.775 +2020-05-05 10:30:00,102.25,86.163,51.417,29.775 +2020-05-05 10:45:00,102.01,86.572,51.417,29.775 +2020-05-05 11:00:00,98.68,82.649,50.43600000000001,29.775 +2020-05-05 11:15:00,99.53,83.743,50.43600000000001,29.775 +2020-05-05 11:30:00,98.28,84.958,50.43600000000001,29.775 +2020-05-05 11:45:00,97.89,85.07700000000001,50.43600000000001,29.775 +2020-05-05 12:00:00,97.44,81.642,47.468,29.775 +2020-05-05 12:15:00,100.02,82.514,47.468,29.775 +2020-05-05 12:30:00,104.48,81.36,47.468,29.775 +2020-05-05 12:45:00,103.25,82.234,47.468,29.775 +2020-05-05 13:00:00,98.15,83.67200000000001,48.453,29.775 +2020-05-05 13:15:00,97.89,83.62799999999999,48.453,29.775 +2020-05-05 13:30:00,98.63,81.594,48.453,29.775 +2020-05-05 13:45:00,96.77,80.05199999999999,48.453,29.775 +2020-05-05 14:00:00,101.19,81.928,48.435,29.775 +2020-05-05 14:15:00,102.3,81.167,48.435,29.775 +2020-05-05 14:30:00,106.02,80.582,48.435,29.775 +2020-05-05 14:45:00,96.9,81.017,48.435,29.775 +2020-05-05 15:00:00,96.46,82.051,49.966,29.775 +2020-05-05 15:15:00,96.66,80.153,49.966,29.775 +2020-05-05 15:30:00,101.42,79.169,49.966,29.775 +2020-05-05 15:45:00,102.58,78.059,49.966,29.775 +2020-05-05 16:00:00,102.74,78.64699999999999,51.184,29.775 +2020-05-05 16:15:00,101.99,78.009,51.184,29.775 +2020-05-05 16:30:00,104.08,77.745,51.184,29.775 +2020-05-05 16:45:00,110.91,74.844,51.184,29.775 +2020-05-05 17:00:00,111.11,75.308,56.138999999999996,29.775 +2020-05-05 17:15:00,110.09,76.896,56.138999999999996,29.775 +2020-05-05 17:30:00,109.87,77.51899999999999,56.138999999999996,29.775 +2020-05-05 17:45:00,109.83,78.067,56.138999999999996,29.775 +2020-05-05 18:00:00,116.18,77.743,57.038000000000004,29.775 +2020-05-05 18:15:00,114.49,79.166,57.038000000000004,29.775 +2020-05-05 18:30:00,112.35,77.851,57.038000000000004,29.775 +2020-05-05 18:45:00,106.65,83.26299999999999,57.038000000000004,29.775 +2020-05-05 19:00:00,104.1,80.51100000000001,56.492,29.775 +2020-05-05 19:15:00,101.34,79.92399999999999,56.492,29.775 +2020-05-05 19:30:00,108.09,79.952,56.492,29.775 +2020-05-05 19:45:00,108.89,80.669,56.492,29.775 +2020-05-05 20:00:00,106.25,79.186,62.534,29.775 +2020-05-05 20:15:00,101.8,77.277,62.534,29.775 +2020-05-05 20:30:00,98.86,75.78699999999999,62.534,29.775 +2020-05-05 20:45:00,99.69,75.52600000000001,62.534,29.775 +2020-05-05 21:00:00,99.28,73.567,55.506,29.775 +2020-05-05 21:15:00,98.12,74.248,55.506,29.775 +2020-05-05 21:30:00,92.99,74.533,55.506,29.775 +2020-05-05 21:45:00,88.48,74.859,55.506,29.775 +2020-05-05 22:00:00,87.66,72.74600000000001,51.472,29.775 +2020-05-05 22:15:00,89.2,72.176,51.472,29.775 +2020-05-05 22:30:00,85.37,63.177,51.472,29.775 +2020-05-05 22:45:00,84.36,59.214,51.472,29.775 +2020-05-05 23:00:00,81.88,52.781000000000006,44.593,29.775 +2020-05-05 23:15:00,80.29,51.196000000000005,44.593,29.775 +2020-05-05 23:30:00,81.46,50.50899999999999,44.593,29.775 +2020-05-05 23:45:00,77.25,50.575,44.593,29.775 +2020-05-06 00:00:00,71.85,49.147,41.978,29.775 +2020-05-06 00:15:00,78.55,49.553000000000004,41.978,29.775 +2020-05-06 00:30:00,77.81,48.31,41.978,29.775 +2020-05-06 00:45:00,77.29,46.997,41.978,29.775 +2020-05-06 01:00:00,73.94,46.992,38.59,29.775 +2020-05-06 01:15:00,78.49,46.251999999999995,38.59,29.775 +2020-05-06 01:30:00,78.56,44.74100000000001,38.59,29.775 +2020-05-06 01:45:00,76.62,44.058,38.59,29.775 +2020-05-06 02:00:00,73.33,44.446999999999996,36.23,29.775 +2020-05-06 02:15:00,78.33,43.364,36.23,29.775 +2020-05-06 02:30:00,77.82,45.753,36.23,29.775 +2020-05-06 02:45:00,72.36,46.386,36.23,29.775 +2020-05-06 03:00:00,70.81,49.335,35.867,29.775 +2020-05-06 03:15:00,72.19,49.305,35.867,29.775 +2020-05-06 03:30:00,73.52,48.63399999999999,35.867,29.775 +2020-05-06 03:45:00,75.1,48.548,35.867,29.775 +2020-05-06 04:00:00,78.39,59.571999999999996,36.75,29.775 +2020-05-06 04:15:00,78.83,70.78699999999999,36.75,29.775 +2020-05-06 04:30:00,81.3,69.78699999999999,36.75,29.775 +2020-05-06 04:45:00,84.96,71.058,36.75,29.775 +2020-05-06 05:00:00,93.15,99.10799999999999,40.461,29.775 +2020-05-06 05:15:00,95.53,124.71799999999999,40.461,29.775 +2020-05-06 05:30:00,97.79,113.664,40.461,29.775 +2020-05-06 05:45:00,101.14,105.565,40.461,29.775 +2020-05-06 06:00:00,106.4,107.095,55.481,29.775 +2020-05-06 06:15:00,106.6,110.62100000000001,55.481,29.775 +2020-05-06 06:30:00,108.89,107.556,55.481,29.775 +2020-05-06 06:45:00,109.94,107.48100000000001,55.481,29.775 +2020-05-06 07:00:00,114.39,108.568,68.45,29.775 +2020-05-06 07:15:00,107.66,108.384,68.45,29.775 +2020-05-06 07:30:00,105.69,105.969,68.45,29.775 +2020-05-06 07:45:00,106.74,103.374,68.45,29.775 +2020-05-06 08:00:00,103.47,99.00200000000001,60.885,29.775 +2020-05-06 08:15:00,101.98,98.86399999999999,60.885,29.775 +2020-05-06 08:30:00,104.01,95.965,60.885,29.775 +2020-05-06 08:45:00,102.48,95.925,60.885,29.775 +2020-05-06 09:00:00,101.52,89.361,56.887,29.775 +2020-05-06 09:15:00,101.65,87.49799999999999,56.887,29.775 +2020-05-06 09:30:00,101.44,89.904,56.887,29.775 +2020-05-06 09:45:00,104.08,90.09700000000001,56.887,29.775 +2020-05-06 10:00:00,109.12,85.67200000000001,54.401,29.775 +2020-05-06 10:15:00,110.04,86.36399999999999,54.401,29.775 +2020-05-06 10:30:00,109.63,85.71600000000001,54.401,29.775 +2020-05-06 10:45:00,105.76,86.14200000000001,54.401,29.775 +2020-05-06 11:00:00,105.77,82.212,53.678000000000004,29.775 +2020-05-06 11:15:00,105.27,83.325,53.678000000000004,29.775 +2020-05-06 11:30:00,108.22,84.535,53.678000000000004,29.775 +2020-05-06 11:45:00,101.7,84.671,53.678000000000004,29.775 +2020-05-06 12:00:00,98.23,81.267,51.68,29.775 +2020-05-06 12:15:00,98.72,82.146,51.68,29.775 +2020-05-06 12:30:00,99.41,80.953,51.68,29.775 +2020-05-06 12:45:00,104.66,81.832,51.68,29.775 +2020-05-06 13:00:00,100.13,83.296,51.263000000000005,29.775 +2020-05-06 13:15:00,102.95,83.25200000000001,51.263000000000005,29.775 +2020-05-06 13:30:00,97.84,81.226,51.263000000000005,29.775 +2020-05-06 13:45:00,96.57,79.685,51.263000000000005,29.775 +2020-05-06 14:00:00,96.82,81.609,51.107,29.775 +2020-05-06 14:15:00,95.84,80.835,51.107,29.775 +2020-05-06 14:30:00,96.81,80.208,51.107,29.775 +2020-05-06 14:45:00,93.98,80.645,51.107,29.775 +2020-05-06 15:00:00,94.2,81.723,51.498000000000005,29.775 +2020-05-06 15:15:00,93.38,79.808,51.498000000000005,29.775 +2020-05-06 15:30:00,95.59,78.79,51.498000000000005,29.775 +2020-05-06 15:45:00,98.49,77.665,51.498000000000005,29.775 +2020-05-06 16:00:00,101.69,78.296,53.376999999999995,29.775 +2020-05-06 16:15:00,101.79,77.641,53.376999999999995,29.775 +2020-05-06 16:30:00,102.02,77.387,53.376999999999995,29.775 +2020-05-06 16:45:00,100.4,74.425,53.376999999999995,29.775 +2020-05-06 17:00:00,102.39,74.937,56.965,29.775 +2020-05-06 17:15:00,101.21,76.498,56.965,29.775 +2020-05-06 17:30:00,100.01,77.111,56.965,29.775 +2020-05-06 17:45:00,102.1,77.626,56.965,29.775 +2020-05-06 18:00:00,104.0,77.32,58.231,29.775 +2020-05-06 18:15:00,104.34,78.742,58.231,29.775 +2020-05-06 18:30:00,105.4,77.414,58.231,29.775 +2020-05-06 18:45:00,103.72,82.82700000000001,58.231,29.775 +2020-05-06 19:00:00,102.23,80.072,58.865,29.775 +2020-05-06 19:15:00,97.93,79.486,58.865,29.775 +2020-05-06 19:30:00,95.7,79.515,58.865,29.775 +2020-05-06 19:45:00,96.66,80.242,58.865,29.775 +2020-05-06 20:00:00,95.25,78.734,65.605,29.775 +2020-05-06 20:15:00,98.04,76.829,65.605,29.775 +2020-05-06 20:30:00,91.55,75.367,65.605,29.775 +2020-05-06 20:45:00,91.56,75.143,65.605,29.775 +2020-05-06 21:00:00,82.99,73.189,58.083999999999996,29.775 +2020-05-06 21:15:00,81.12,73.88600000000001,58.083999999999996,29.775 +2020-05-06 21:30:00,78.05,74.15100000000001,58.083999999999996,29.775 +2020-05-06 21:45:00,75.34,74.505,58.083999999999996,29.775 +2020-05-06 22:00:00,70.55,72.414,53.243,29.775 +2020-05-06 22:15:00,70.4,71.865,53.243,29.775 +2020-05-06 22:30:00,68.32,62.855,53.243,29.775 +2020-05-06 22:45:00,66.71,58.88,53.243,29.775 +2020-05-06 23:00:00,76.44,52.417,44.283,29.775 +2020-05-06 23:15:00,77.93,50.872,44.283,29.775 +2020-05-06 23:30:00,80.4,50.187,44.283,29.775 +2020-05-06 23:45:00,79.81,50.251999999999995,44.283,29.775 +2020-05-07 00:00:00,73.78,48.823,40.219,29.775 +2020-05-07 00:15:00,72.71,49.239,40.219,29.775 +2020-05-07 00:30:00,73.87,47.994,40.219,29.775 +2020-05-07 00:45:00,77.01,46.685,40.219,29.775 +2020-05-07 01:00:00,74.22,46.685,37.959,29.775 +2020-05-07 01:15:00,73.3,45.92100000000001,37.959,29.775 +2020-05-07 01:30:00,72.97,44.391999999999996,37.959,29.775 +2020-05-07 01:45:00,74.22,43.708999999999996,37.959,29.775 +2020-05-07 02:00:00,75.92,44.092,36.113,29.775 +2020-05-07 02:15:00,74.19,42.993,36.113,29.775 +2020-05-07 02:30:00,72.07,45.396,36.113,29.775 +2020-05-07 02:45:00,74.15,46.036,36.113,29.775 +2020-05-07 03:00:00,74.4,48.995,35.546,29.775 +2020-05-07 03:15:00,77.16,48.946000000000005,35.546,29.775 +2020-05-07 03:30:00,76.61,48.275,35.546,29.775 +2020-05-07 03:45:00,78.26,48.214,35.546,29.775 +2020-05-07 04:00:00,79.9,59.19,37.169000000000004,29.775 +2020-05-07 04:15:00,80.7,70.359,37.169000000000004,29.775 +2020-05-07 04:30:00,83.75,69.352,37.169000000000004,29.775 +2020-05-07 04:45:00,87.24,70.615,37.169000000000004,29.775 +2020-05-07 05:00:00,95.0,98.54899999999999,41.233000000000004,29.775 +2020-05-07 05:15:00,97.6,124.024,41.233000000000004,29.775 +2020-05-07 05:30:00,100.93,113.01700000000001,41.233000000000004,29.775 +2020-05-07 05:45:00,104.36,104.975,41.233000000000004,29.775 +2020-05-07 06:00:00,111.07,106.531,52.57,29.775 +2020-05-07 06:15:00,111.79,110.03200000000001,52.57,29.775 +2020-05-07 06:30:00,115.27,106.96700000000001,52.57,29.775 +2020-05-07 06:45:00,116.41,106.9,52.57,29.775 +2020-05-07 07:00:00,121.35,107.98,64.53,29.775 +2020-05-07 07:15:00,120.69,107.794,64.53,29.775 +2020-05-07 07:30:00,120.64,105.34899999999999,64.53,29.775 +2020-05-07 07:45:00,119.58,102.771,64.53,29.775 +2020-05-07 08:00:00,118.88,98.395,55.911,29.775 +2020-05-07 08:15:00,119.04,98.3,55.911,29.775 +2020-05-07 08:30:00,120.65,95.383,55.911,29.775 +2020-05-07 08:45:00,121.31,95.367,55.911,29.775 +2020-05-07 09:00:00,120.75,88.802,50.949,29.775 +2020-05-07 09:15:00,121.94,86.946,50.949,29.775 +2020-05-07 09:30:00,124.2,89.363,50.949,29.775 +2020-05-07 09:45:00,125.86,89.59,50.949,29.775 +2020-05-07 10:00:00,123.35,85.17299999999999,48.136,29.775 +2020-05-07 10:15:00,121.67,85.905,48.136,29.775 +2020-05-07 10:30:00,122.7,85.274,48.136,29.775 +2020-05-07 10:45:00,121.37,85.71700000000001,48.136,29.775 +2020-05-07 11:00:00,120.01,81.78,46.643,29.775 +2020-05-07 11:15:00,119.7,82.912,46.643,29.775 +2020-05-07 11:30:00,116.44,84.117,46.643,29.775 +2020-05-07 11:45:00,120.55,84.266,46.643,29.775 +2020-05-07 12:00:00,114.26,80.895,44.098,29.775 +2020-05-07 12:15:00,115.33,81.78,44.098,29.775 +2020-05-07 12:30:00,115.25,80.548,44.098,29.775 +2020-05-07 12:45:00,111.95,81.433,44.098,29.775 +2020-05-07 13:00:00,111.66,82.92299999999999,43.717,29.775 +2020-05-07 13:15:00,115.79,82.87899999999999,43.717,29.775 +2020-05-07 13:30:00,112.84,80.861,43.717,29.775 +2020-05-07 13:45:00,113.94,79.32,43.717,29.775 +2020-05-07 14:00:00,112.73,81.293,44.218999999999994,29.775 +2020-05-07 14:15:00,110.34,80.506,44.218999999999994,29.775 +2020-05-07 14:30:00,109.63,79.837,44.218999999999994,29.775 +2020-05-07 14:45:00,110.46,80.277,44.218999999999994,29.775 +2020-05-07 15:00:00,109.7,81.399,46.159,29.775 +2020-05-07 15:15:00,111.05,79.46600000000001,46.159,29.775 +2020-05-07 15:30:00,108.64,78.415,46.159,29.775 +2020-05-07 15:45:00,109.14,77.273,46.159,29.775 +2020-05-07 16:00:00,109.78,77.95100000000001,47.115,29.775 +2020-05-07 16:15:00,111.93,77.275,47.115,29.775 +2020-05-07 16:30:00,114.03,77.032,47.115,29.775 +2020-05-07 16:45:00,114.16,74.009,47.115,29.775 +2020-05-07 17:00:00,114.59,74.568,50.827,29.775 +2020-05-07 17:15:00,113.02,76.104,50.827,29.775 +2020-05-07 17:30:00,114.7,76.706,50.827,29.775 +2020-05-07 17:45:00,114.23,77.19,50.827,29.775 +2020-05-07 18:00:00,114.07,76.90100000000001,52.586000000000006,29.775 +2020-05-07 18:15:00,110.01,78.32,52.586000000000006,29.775 +2020-05-07 18:30:00,114.3,76.979,52.586000000000006,29.775 +2020-05-07 18:45:00,113.58,82.39299999999999,52.586000000000006,29.775 +2020-05-07 19:00:00,112.14,79.639,51.886,29.775 +2020-05-07 19:15:00,106.35,79.051,51.886,29.775 +2020-05-07 19:30:00,105.41,79.083,51.886,29.775 +2020-05-07 19:45:00,106.24,79.819,51.886,29.775 +2020-05-07 20:00:00,105.1,78.285,56.162,29.775 +2020-05-07 20:15:00,103.7,76.383,56.162,29.775 +2020-05-07 20:30:00,103.6,74.949,56.162,29.775 +2020-05-07 20:45:00,103.67,74.764,56.162,29.775 +2020-05-07 21:00:00,98.44,72.814,53.023,29.775 +2020-05-07 21:15:00,98.45,73.525,53.023,29.775 +2020-05-07 21:30:00,93.13,73.773,53.023,29.775 +2020-05-07 21:45:00,94.44,74.152,53.023,29.775 +2020-05-07 22:00:00,88.82,72.084,49.303999999999995,29.775 +2020-05-07 22:15:00,86.68,71.556,49.303999999999995,29.775 +2020-05-07 22:30:00,83.38,62.534,49.303999999999995,29.775 +2020-05-07 22:45:00,84.4,58.549,49.303999999999995,29.775 +2020-05-07 23:00:00,58.37,52.053999999999995,43.409,29.775 +2020-05-07 23:15:00,57.74,50.552,43.409,29.775 +2020-05-07 23:30:00,55.82,49.86600000000001,43.409,29.775 +2020-05-07 23:45:00,54.77,49.93,43.409,29.775 +2020-05-08 00:00:00,53.24,46.693000000000005,39.884,29.775 +2020-05-08 00:15:00,54.19,47.361999999999995,39.884,29.775 +2020-05-08 00:30:00,54.32,46.278999999999996,39.884,29.775 +2020-05-08 00:45:00,55.58,45.352,39.884,29.775 +2020-05-08 01:00:00,52.76,44.941,37.658,29.775 +2020-05-08 01:15:00,53.95,44.012,37.658,29.775 +2020-05-08 01:30:00,54.34,42.949,37.658,29.775 +2020-05-08 01:45:00,53.53,42.105,37.658,29.775 +2020-05-08 02:00:00,52.96,43.25,36.707,29.775 +2020-05-08 02:15:00,54.39,42.055,36.707,29.775 +2020-05-08 02:30:00,54.97,45.31399999999999,36.707,29.775 +2020-05-08 02:45:00,54.2,45.437,36.707,29.775 +2020-05-08 03:00:00,55.31,48.655,37.025,29.775 +2020-05-08 03:15:00,56.15,47.978,37.025,29.775 +2020-05-08 03:30:00,57.39,47.113,37.025,29.775 +2020-05-08 03:45:00,59.16,47.886,37.025,29.775 +2020-05-08 04:00:00,61.87,59.01,38.349000000000004,29.775 +2020-05-08 04:15:00,63.45,68.811,38.349000000000004,29.775 +2020-05-08 04:30:00,66.08,68.631,38.349000000000004,29.775 +2020-05-08 04:45:00,68.24,68.958,38.349000000000004,29.775 +2020-05-08 05:00:00,72.24,95.93,41.565,29.775 +2020-05-08 05:15:00,73.47,122.565,41.565,29.775 +2020-05-08 05:30:00,76.45,112.135,41.565,29.775 +2020-05-08 05:45:00,79.96,103.76799999999999,41.565,29.775 +2020-05-08 06:00:00,84.65,105.696,53.861000000000004,29.775 +2020-05-08 06:15:00,86.18,108.787,53.861000000000004,29.775 +2020-05-08 06:30:00,88.73,105.376,53.861000000000004,29.775 +2020-05-08 06:45:00,89.53,105.786,53.861000000000004,29.775 +2020-05-08 07:00:00,93.58,107.083,63.497,29.775 +2020-05-08 07:15:00,92.57,107.986,63.497,29.775 +2020-05-08 07:30:00,92.28,103.915,63.497,29.775 +2020-05-08 07:45:00,92.57,100.859,63.497,29.775 +2020-05-08 08:00:00,92.12,96.67299999999999,55.43899999999999,29.775 +2020-05-08 08:15:00,93.47,96.991,55.43899999999999,29.775 +2020-05-08 08:30:00,95.97,94.354,55.43899999999999,29.775 +2020-05-08 08:45:00,98.27,93.648,55.43899999999999,29.775 +2020-05-08 09:00:00,97.16,85.48,52.132,29.775 +2020-05-08 09:15:00,97.44,85.31700000000001,52.132,29.775 +2020-05-08 09:30:00,96.52,87.052,52.132,29.775 +2020-05-08 09:45:00,93.66,87.565,52.132,29.775 +2020-05-08 10:00:00,90.57,82.54700000000001,49.881,29.775 +2020-05-08 10:15:00,90.43,83.43799999999999,49.881,29.775 +2020-05-08 10:30:00,92.89,83.205,49.881,29.775 +2020-05-08 10:45:00,92.64,83.412,49.881,29.775 +2020-05-08 11:00:00,87.14,79.635,49.396,29.775 +2020-05-08 11:15:00,84.45,79.61,49.396,29.775 +2020-05-08 11:30:00,82.3,81.271,49.396,29.775 +2020-05-08 11:45:00,84.56,80.74600000000001,49.396,29.775 +2020-05-08 12:00:00,85.34,78.204,46.7,29.775 +2020-05-08 12:15:00,87.59,77.72800000000001,46.7,29.775 +2020-05-08 12:30:00,82.79,76.611,46.7,29.775 +2020-05-08 12:45:00,84.03,77.154,46.7,29.775 +2020-05-08 13:00:00,86.69,79.491,44.05,29.775 +2020-05-08 13:15:00,90.34,79.928,44.05,29.775 +2020-05-08 13:30:00,92.17,78.503,44.05,29.775 +2020-05-08 13:45:00,91.91,77.16,44.05,29.775 +2020-05-08 14:00:00,91.0,78.111,42.805,29.775 +2020-05-08 14:15:00,87.13,77.55199999999999,42.805,29.775 +2020-05-08 14:30:00,85.29,78.155,42.805,29.775 +2020-05-08 14:45:00,85.31,78.226,42.805,29.775 +2020-05-08 15:00:00,81.82,79.128,44.36600000000001,29.775 +2020-05-08 15:15:00,80.11,76.816,44.36600000000001,29.775 +2020-05-08 15:30:00,79.41,74.672,44.36600000000001,29.775 +2020-05-08 15:45:00,79.63,74.131,44.36600000000001,29.775 +2020-05-08 16:00:00,80.31,73.779,46.928999999999995,29.775 +2020-05-08 16:15:00,83.58,73.572,46.928999999999995,29.775 +2020-05-08 16:30:00,85.81,73.242,46.928999999999995,29.775 +2020-05-08 16:45:00,88.21,69.52600000000001,46.928999999999995,29.775 +2020-05-08 17:00:00,90.9,71.516,51.468,29.775 +2020-05-08 17:15:00,90.29,72.729,51.468,29.775 +2020-05-08 17:30:00,91.83,73.315,51.468,29.775 +2020-05-08 17:45:00,93.13,73.54899999999999,51.468,29.775 +2020-05-08 18:00:00,95.38,73.646,52.58,29.775 +2020-05-08 18:15:00,93.36,74.184,52.58,29.775 +2020-05-08 18:30:00,94.59,72.903,52.58,29.775 +2020-05-08 18:45:00,91.5,78.64,52.58,29.775 +2020-05-08 19:00:00,88.14,76.949,52.183,29.775 +2020-05-08 19:15:00,85.24,77.32600000000001,52.183,29.775 +2020-05-08 19:30:00,84.68,77.257,52.183,29.775 +2020-05-08 19:45:00,85.55,77.033,52.183,29.775 +2020-05-08 20:00:00,86.18,75.357,58.497,29.775 +2020-05-08 20:15:00,86.8,74.078,58.497,29.775 +2020-05-08 20:30:00,85.42,72.279,58.497,29.775 +2020-05-08 20:45:00,84.39,71.708,58.497,29.775 +2020-05-08 21:00:00,79.14,70.98,54.731,29.775 +2020-05-08 21:15:00,78.08,73.17699999999999,54.731,29.775 +2020-05-08 21:30:00,75.44,73.305,54.731,29.775 +2020-05-08 21:45:00,73.8,74.07300000000001,54.731,29.775 +2020-05-08 22:00:00,69.96,72.279,51.386,29.775 +2020-05-08 22:15:00,70.53,71.528,51.386,29.775 +2020-05-08 22:30:00,67.66,68.579,51.386,29.775 +2020-05-08 22:45:00,66.61,66.559,51.386,29.775 +2020-05-08 23:00:00,62.1,61.192,44.463,29.775 +2020-05-08 23:15:00,61.96,57.783,44.463,29.775 +2020-05-08 23:30:00,61.24,55.128,44.463,29.775 +2020-05-08 23:45:00,61.9,54.85,44.463,29.775 +2020-05-09 00:00:00,59.19,46.31399999999999,42.833999999999996,29.662 +2020-05-09 00:15:00,59.48,44.915,42.833999999999996,29.662 +2020-05-09 00:30:00,58.27,43.941,42.833999999999996,29.662 +2020-05-09 00:45:00,58.41,42.702,42.833999999999996,29.662 +2020-05-09 01:00:00,56.44,42.744,37.859,29.662 +2020-05-09 01:15:00,57.02,41.898999999999994,37.859,29.662 +2020-05-09 01:30:00,54.39,39.994,37.859,29.662 +2020-05-09 01:45:00,57.37,40.054,37.859,29.662 +2020-05-09 02:00:00,56.57,40.662,35.327,29.662 +2020-05-09 02:15:00,57.32,38.695,35.327,29.662 +2020-05-09 02:30:00,56.09,40.938,35.327,29.662 +2020-05-09 02:45:00,56.5,41.725,35.327,29.662 +2020-05-09 03:00:00,56.74,44.101000000000006,34.908,29.662 +2020-05-09 03:15:00,57.04,42.413999999999994,34.908,29.662 +2020-05-09 03:30:00,57.73,41.23,34.908,29.662 +2020-05-09 03:45:00,58.65,43.236000000000004,34.908,29.662 +2020-05-09 04:00:00,56.1,51.325,34.84,29.662 +2020-05-09 04:15:00,58.39,59.455,34.84,29.662 +2020-05-09 04:30:00,58.62,57.167,34.84,29.662 +2020-05-09 04:45:00,58.93,57.523,34.84,29.662 +2020-05-09 05:00:00,59.13,72.622,34.222,29.662 +2020-05-09 05:15:00,58.29,83.84100000000001,34.222,29.662 +2020-05-09 05:30:00,61.65,74.814,34.222,29.662 +2020-05-09 05:45:00,64.49,71.96600000000001,34.222,29.662 +2020-05-09 06:00:00,67.16,89.139,35.515,29.662 +2020-05-09 06:15:00,68.9,104.09200000000001,35.515,29.662 +2020-05-09 06:30:00,67.17,96.552,35.515,29.662 +2020-05-09 06:45:00,72.7,91.306,35.515,29.662 +2020-05-09 07:00:00,71.59,89.74700000000001,39.687,29.662 +2020-05-09 07:15:00,75.04,89.178,39.687,29.662 +2020-05-09 07:30:00,77.56,87.242,39.687,29.662 +2020-05-09 07:45:00,81.67,86.36399999999999,39.687,29.662 +2020-05-09 08:00:00,83.62,83.985,44.9,29.662 +2020-05-09 08:15:00,84.5,85.53399999999999,44.9,29.662 +2020-05-09 08:30:00,84.0,83.471,44.9,29.662 +2020-05-09 08:45:00,81.6,84.695,44.9,29.662 +2020-05-09 09:00:00,79.98,79.499,45.724,29.662 +2020-05-09 09:15:00,82.69,79.999,45.724,29.662 +2020-05-09 09:30:00,76.88,82.469,45.724,29.662 +2020-05-09 09:45:00,80.32,82.73200000000001,45.724,29.662 +2020-05-09 10:00:00,74.68,78.143,43.123999999999995,29.662 +2020-05-09 10:15:00,75.87,79.402,43.123999999999995,29.662 +2020-05-09 10:30:00,75.83,78.999,43.123999999999995,29.662 +2020-05-09 10:45:00,76.14,79.484,43.123999999999995,29.662 +2020-05-09 11:00:00,72.08,75.645,40.255,29.662 +2020-05-09 11:15:00,70.62,75.997,40.255,29.662 +2020-05-09 11:30:00,73.05,77.406,40.255,29.662 +2020-05-09 11:45:00,75.89,76.999,40.255,29.662 +2020-05-09 12:00:00,76.78,74.229,38.582,29.662 +2020-05-09 12:15:00,71.51,74.643,38.582,29.662 +2020-05-09 12:30:00,73.06,73.529,38.582,29.662 +2020-05-09 12:45:00,72.57,74.273,38.582,29.662 +2020-05-09 13:00:00,70.0,75.922,36.043,29.662 +2020-05-09 13:15:00,68.54,75.10600000000001,36.043,29.662 +2020-05-09 13:30:00,62.42,73.637,36.043,29.662 +2020-05-09 13:45:00,66.38,71.518,36.043,29.662 +2020-05-09 14:00:00,70.51,73.04899999999999,35.216,29.662 +2020-05-09 14:15:00,77.12,71.347,35.216,29.662 +2020-05-09 14:30:00,70.75,70.837,35.216,29.662 +2020-05-09 14:45:00,67.87,71.348,35.216,29.662 +2020-05-09 15:00:00,69.68,72.88600000000001,36.759,29.662 +2020-05-09 15:15:00,71.0,71.407,36.759,29.662 +2020-05-09 15:30:00,71.64,70.027,36.759,29.662 +2020-05-09 15:45:00,74.81,68.809,36.759,29.662 +2020-05-09 16:00:00,75.54,69.814,40.086,29.662 +2020-05-09 16:15:00,71.72,69.282,40.086,29.662 +2020-05-09 16:30:00,75.34,69.111,40.086,29.662 +2020-05-09 16:45:00,76.17,65.67,40.086,29.662 +2020-05-09 17:00:00,77.72,66.625,44.876999999999995,29.662 +2020-05-09 17:15:00,79.16,66.865,44.876999999999995,29.662 +2020-05-09 17:30:00,79.96,67.304,44.876999999999995,29.662 +2020-05-09 17:45:00,80.99,67.714,44.876999999999995,29.662 +2020-05-09 18:00:00,80.0,68.844,47.056000000000004,29.662 +2020-05-09 18:15:00,82.8,71.342,47.056000000000004,29.662 +2020-05-09 18:30:00,81.66,71.592,47.056000000000004,29.662 +2020-05-09 18:45:00,82.41,73.399,47.056000000000004,29.662 +2020-05-09 19:00:00,80.39,70.936,45.57,29.662 +2020-05-09 19:15:00,77.08,70.343,45.57,29.662 +2020-05-09 19:30:00,74.71,71.15100000000001,45.57,29.662 +2020-05-09 19:45:00,78.23,72.253,45.57,29.662 +2020-05-09 20:00:00,78.77,71.929,41.685,29.662 +2020-05-09 20:15:00,79.72,70.954,41.685,29.662 +2020-05-09 20:30:00,78.87,68.374,41.685,29.662 +2020-05-09 20:45:00,78.23,69.122,41.685,29.662 +2020-05-09 21:00:00,76.02,68.109,39.576,29.662 +2020-05-09 21:15:00,74.79,70.21300000000001,39.576,29.662 +2020-05-09 21:30:00,70.34,70.922,39.576,29.662 +2020-05-09 21:45:00,72.16,71.14399999999999,39.576,29.662 +2020-05-09 22:00:00,69.44,69.848,39.068000000000005,29.662 +2020-05-09 22:15:00,66.81,70.223,39.068000000000005,29.662 +2020-05-09 22:30:00,65.84,69.253,39.068000000000005,29.662 +2020-05-09 22:45:00,65.64,68.2,39.068000000000005,29.662 +2020-05-09 23:00:00,58.1,63.147,32.06,29.662 +2020-05-09 23:15:00,61.56,59.51,32.06,29.662 +2020-05-09 23:30:00,59.59,58.13,32.06,29.662 +2020-05-09 23:45:00,61.11,57.242,32.06,29.662 +2020-05-10 00:00:00,56.26,47.231,28.825,29.662 +2020-05-10 00:15:00,56.19,44.821000000000005,28.825,29.662 +2020-05-10 00:30:00,56.2,43.593999999999994,28.825,29.662 +2020-05-10 00:45:00,56.93,42.525,28.825,29.662 +2020-05-10 01:00:00,54.42,42.729,25.995,29.662 +2020-05-10 01:15:00,55.57,42.137,25.995,29.662 +2020-05-10 01:30:00,54.8,40.296,25.995,29.662 +2020-05-10 01:45:00,54.72,39.944,25.995,29.662 +2020-05-10 02:00:00,53.8,40.336,24.394000000000002,29.662 +2020-05-10 02:15:00,53.93,38.604,24.394000000000002,29.662 +2020-05-10 02:30:00,54.27,41.316,24.394000000000002,29.662 +2020-05-10 02:45:00,53.34,42.047,24.394000000000002,29.662 +2020-05-10 03:00:00,53.35,45.059,22.916999999999998,29.662 +2020-05-10 03:15:00,53.77,43.37,22.916999999999998,29.662 +2020-05-10 03:30:00,55.05,42.111999999999995,22.916999999999998,29.662 +2020-05-10 03:45:00,55.75,43.489,22.916999999999998,29.662 +2020-05-10 04:00:00,53.43,51.42,23.576999999999998,29.662 +2020-05-10 04:15:00,53.2,58.799,23.576999999999998,29.662 +2020-05-10 04:30:00,52.95,57.58,23.576999999999998,29.662 +2020-05-10 04:45:00,53.86,57.659,23.576999999999998,29.662 +2020-05-10 05:00:00,53.48,71.578,22.730999999999998,29.662 +2020-05-10 05:15:00,54.32,81.235,22.730999999999998,29.662 +2020-05-10 05:30:00,53.89,71.875,22.730999999999998,29.662 +2020-05-10 05:45:00,54.74,68.944,22.730999999999998,29.662 +2020-05-10 06:00:00,56.84,84.185,22.34,29.662 +2020-05-10 06:15:00,56.42,99.195,22.34,29.662 +2020-05-10 06:30:00,57.71,90.72200000000001,22.34,29.662 +2020-05-10 06:45:00,58.61,84.32700000000001,22.34,29.662 +2020-05-10 07:00:00,59.27,83.804,24.691999999999997,29.662 +2020-05-10 07:15:00,59.67,81.642,24.691999999999997,29.662 +2020-05-10 07:30:00,59.68,80.24,24.691999999999997,29.662 +2020-05-10 07:45:00,60.06,79.082,24.691999999999997,29.662 +2020-05-10 08:00:00,59.38,77.918,29.340999999999998,29.662 +2020-05-10 08:15:00,59.29,80.388,29.340999999999998,29.662 +2020-05-10 08:30:00,59.52,79.59,29.340999999999998,29.662 +2020-05-10 08:45:00,58.7,81.51899999999999,29.340999999999998,29.662 +2020-05-10 09:00:00,56.6,76.072,30.788,29.662 +2020-05-10 09:15:00,57.08,76.41,30.788,29.662 +2020-05-10 09:30:00,57.77,79.16199999999999,30.788,29.662 +2020-05-10 09:45:00,60.07,80.18,30.788,29.662 +2020-05-10 10:00:00,59.67,76.94800000000001,30.158,29.662 +2020-05-10 10:15:00,60.32,78.554,30.158,29.662 +2020-05-10 10:30:00,60.97,78.569,30.158,29.662 +2020-05-10 10:45:00,61.22,79.084,30.158,29.662 +2020-05-10 11:00:00,58.46,75.331,32.056,29.662 +2020-05-10 11:15:00,58.18,75.423,32.056,29.662 +2020-05-10 11:30:00,55.24,76.867,32.056,29.662 +2020-05-10 11:45:00,55.54,76.905,32.056,29.662 +2020-05-10 12:00:00,53.33,74.672,28.671999999999997,29.662 +2020-05-10 12:15:00,54.51,75.421,28.671999999999997,29.662 +2020-05-10 12:30:00,50.53,73.825,28.671999999999997,29.662 +2020-05-10 12:45:00,50.54,73.71300000000001,28.671999999999997,29.662 +2020-05-10 13:00:00,47.4,74.889,23.171,29.662 +2020-05-10 13:15:00,45.4,74.87899999999999,23.171,29.662 +2020-05-10 13:30:00,46.65,72.51899999999999,23.171,29.662 +2020-05-10 13:45:00,48.47,70.95,23.171,29.662 +2020-05-10 14:00:00,49.27,73.501,19.11,29.662 +2020-05-10 14:15:00,48.56,72.59100000000001,19.11,29.662 +2020-05-10 14:30:00,47.46,71.642,19.11,29.662 +2020-05-10 14:45:00,47.02,71.206,19.11,29.662 +2020-05-10 15:00:00,46.47,72.26899999999999,19.689,29.662 +2020-05-10 15:15:00,45.86,70.492,19.689,29.662 +2020-05-10 15:30:00,47.51,69.187,19.689,29.662 +2020-05-10 15:45:00,49.12,68.445,19.689,29.662 +2020-05-10 16:00:00,54.26,68.936,22.875,29.662 +2020-05-10 16:15:00,59.43,68.202,22.875,29.662 +2020-05-10 16:30:00,61.12,68.893,22.875,29.662 +2020-05-10 16:45:00,64.01,65.495,22.875,29.662 +2020-05-10 17:00:00,66.15,66.755,33.884,29.662 +2020-05-10 17:15:00,65.55,67.977,33.884,29.662 +2020-05-10 17:30:00,68.01,69.122,33.884,29.662 +2020-05-10 17:45:00,69.14,70.696,33.884,29.662 +2020-05-10 18:00:00,71.22,72.01899999999999,38.453,29.662 +2020-05-10 18:15:00,71.38,74.692,38.453,29.662 +2020-05-10 18:30:00,71.35,73.967,38.453,29.662 +2020-05-10 18:45:00,72.02,76.55,38.453,29.662 +2020-05-10 19:00:00,71.05,75.596,39.221,29.662 +2020-05-10 19:15:00,71.32,74.34100000000001,39.221,29.662 +2020-05-10 19:30:00,76.1,74.89699999999999,39.221,29.662 +2020-05-10 19:45:00,79.65,76.161,39.221,29.662 +2020-05-10 20:00:00,78.39,75.993,37.871,29.662 +2020-05-10 20:15:00,77.6,75.27199999999999,37.871,29.662 +2020-05-10 20:30:00,79.29,73.798,37.871,29.662 +2020-05-10 20:45:00,79.19,72.845,37.871,29.662 +2020-05-10 21:00:00,79.14,70.771,36.465,29.662 +2020-05-10 21:15:00,79.16,72.43,36.465,29.662 +2020-05-10 21:30:00,76.86,72.72,36.465,29.662 +2020-05-10 21:45:00,77.71,73.296,36.465,29.662 +2020-05-10 22:00:00,72.23,73.175,36.092,29.662 +2020-05-10 22:15:00,73.28,71.986,36.092,29.662 +2020-05-10 22:30:00,71.29,69.47399999999999,36.092,29.662 +2020-05-10 22:45:00,72.19,67.111,36.092,29.662 +2020-05-10 23:00:00,66.3,60.795,31.013,29.662 +2020-05-10 23:15:00,67.7,58.853,31.013,29.662 +2020-05-10 23:30:00,66.26,57.341,31.013,29.662 +2020-05-10 23:45:00,66.87,56.893,31.013,29.662 +2020-05-11 00:00:00,68.36,49.586999999999996,31.174,29.775 +2020-05-11 00:15:00,71.71,48.857,31.174,29.775 +2020-05-11 00:30:00,71.93,47.37,31.174,29.775 +2020-05-11 00:45:00,68.14,45.806999999999995,31.174,29.775 +2020-05-11 01:00:00,62.71,46.325,29.663,29.775 +2020-05-11 01:15:00,63.59,45.534,29.663,29.775 +2020-05-11 01:30:00,63.09,43.979,29.663,29.775 +2020-05-11 01:45:00,66.91,43.59,29.663,29.775 +2020-05-11 02:00:00,71.05,44.325,28.793000000000003,29.775 +2020-05-11 02:15:00,71.22,42.299,28.793000000000003,29.775 +2020-05-11 02:30:00,67.29,45.268,28.793000000000003,29.775 +2020-05-11 02:45:00,64.47,45.673,28.793000000000003,29.775 +2020-05-11 03:00:00,65.55,49.538999999999994,27.728,29.775 +2020-05-11 03:15:00,66.79,48.979,27.728,29.775 +2020-05-11 03:30:00,67.95,48.162,27.728,29.775 +2020-05-11 03:45:00,74.12,49.006,27.728,29.775 +2020-05-11 04:00:00,80.04,60.865,29.266,29.775 +2020-05-11 04:15:00,81.67,71.962,29.266,29.775 +2020-05-11 04:30:00,80.33,71.131,29.266,29.775 +2020-05-11 04:45:00,81.18,71.55199999999999,29.266,29.775 +2020-05-11 05:00:00,89.03,96.06299999999999,37.889,29.775 +2020-05-11 05:15:00,93.69,120.575,37.889,29.775 +2020-05-11 05:30:00,95.84,110.10700000000001,37.889,29.775 +2020-05-11 05:45:00,103.12,102.885,37.889,29.775 +2020-05-11 06:00:00,109.88,104.07799999999999,55.485,29.775 +2020-05-11 06:15:00,110.72,106.779,55.485,29.775 +2020-05-11 06:30:00,107.23,104.19200000000001,55.485,29.775 +2020-05-11 06:45:00,105.05,105.00299999999999,55.485,29.775 +2020-05-11 07:00:00,107.77,106.0,65.765,29.775 +2020-05-11 07:15:00,109.31,106.066,65.765,29.775 +2020-05-11 07:30:00,106.64,103.68700000000001,65.765,29.775 +2020-05-11 07:45:00,108.08,101.87200000000001,65.765,29.775 +2020-05-11 08:00:00,112.88,97.49700000000001,56.745,29.775 +2020-05-11 08:15:00,110.39,98.29700000000001,56.745,29.775 +2020-05-11 08:30:00,108.45,95.244,56.745,29.775 +2020-05-11 08:45:00,105.26,96.137,56.745,29.775 +2020-05-11 09:00:00,105.56,89.617,53.321999999999996,29.775 +2020-05-11 09:15:00,103.65,87.292,53.321999999999996,29.775 +2020-05-11 09:30:00,103.56,88.97200000000001,53.321999999999996,29.775 +2020-05-11 09:45:00,106.22,88.381,53.321999999999996,29.775 +2020-05-11 10:00:00,102.45,85.273,51.309,29.775 +2020-05-11 10:15:00,103.67,86.678,51.309,29.775 +2020-05-11 10:30:00,103.32,86.024,51.309,29.775 +2020-05-11 10:45:00,101.26,85.666,51.309,29.775 +2020-05-11 11:00:00,99.43,81.113,50.415,29.775 +2020-05-11 11:15:00,98.15,82.15100000000001,50.415,29.775 +2020-05-11 11:30:00,98.64,84.656,50.415,29.775 +2020-05-11 11:45:00,96.79,84.916,50.415,29.775 +2020-05-11 12:00:00,96.52,82.369,48.273,29.775 +2020-05-11 12:15:00,96.68,83.20299999999999,48.273,29.775 +2020-05-11 12:30:00,95.66,80.89699999999999,48.273,29.775 +2020-05-11 12:45:00,95.11,81.42,48.273,29.775 +2020-05-11 13:00:00,94.53,83.427,48.452,29.775 +2020-05-11 13:15:00,101.79,82.205,48.452,29.775 +2020-05-11 13:30:00,101.81,79.762,48.452,29.775 +2020-05-11 13:45:00,100.1,78.861,48.452,29.775 +2020-05-11 14:00:00,93.14,80.579,48.35,29.775 +2020-05-11 14:15:00,93.8,79.82300000000001,48.35,29.775 +2020-05-11 14:30:00,92.72,78.492,48.35,29.775 +2020-05-11 14:45:00,95.44,79.55,48.35,29.775 +2020-05-11 15:00:00,93.18,81.13600000000001,48.838,29.775 +2020-05-11 15:15:00,91.76,78.321,48.838,29.775 +2020-05-11 15:30:00,93.36,77.204,48.838,29.775 +2020-05-11 15:45:00,94.72,75.90899999999999,48.838,29.775 +2020-05-11 16:00:00,95.88,77.11399999999999,50.873000000000005,29.775 +2020-05-11 16:15:00,96.96,76.11,50.873000000000005,29.775 +2020-05-11 16:30:00,99.03,75.937,50.873000000000005,29.775 +2020-05-11 16:45:00,101.19,72.03,50.873000000000005,29.775 +2020-05-11 17:00:00,103.98,72.402,56.637,29.775 +2020-05-11 17:15:00,104.09,73.51,56.637,29.775 +2020-05-11 17:30:00,105.88,74.139,56.637,29.775 +2020-05-11 17:45:00,105.66,74.80199999999999,56.637,29.775 +2020-05-11 18:00:00,106.65,75.36399999999999,56.35,29.775 +2020-05-11 18:15:00,105.32,75.806,56.35,29.775 +2020-05-11 18:30:00,112.4,74.733,56.35,29.775 +2020-05-11 18:45:00,113.9,80.008,56.35,29.775 +2020-05-11 19:00:00,103.24,78.153,56.023,29.775 +2020-05-11 19:15:00,99.4,77.52,56.023,29.775 +2020-05-11 19:30:00,100.03,77.965,56.023,29.775 +2020-05-11 19:45:00,99.47,78.46300000000001,56.023,29.775 +2020-05-11 20:00:00,100.76,76.457,62.372,29.775 +2020-05-11 20:15:00,98.44,75.921,62.372,29.775 +2020-05-11 20:30:00,97.74,74.20100000000001,62.372,29.775 +2020-05-11 20:45:00,96.87,74.044,62.372,29.775 +2020-05-11 21:00:00,95.03,71.687,57.516999999999996,29.775 +2020-05-11 21:15:00,93.92,73.28399999999999,57.516999999999996,29.775 +2020-05-11 21:30:00,95.99,73.58,57.516999999999996,29.775 +2020-05-11 21:45:00,94.93,73.828,57.516999999999996,29.775 +2020-05-11 22:00:00,89.61,71.101,51.823,29.775 +2020-05-11 22:15:00,82.35,71.01100000000001,51.823,29.775 +2020-05-11 22:30:00,85.45,61.678999999999995,51.823,29.775 +2020-05-11 22:45:00,85.91,57.555,51.823,29.775 +2020-05-11 23:00:00,82.2,51.543,43.832,29.775 +2020-05-11 23:15:00,78.11,49.298,43.832,29.775 +2020-05-11 23:30:00,80.04,48.743,43.832,29.775 +2020-05-11 23:45:00,81.09,48.815,43.832,29.775 +2020-05-12 00:00:00,78.75,47.239,42.371,29.775 +2020-05-12 00:15:00,74.73,47.705,42.371,29.775 +2020-05-12 00:30:00,75.86,46.446000000000005,42.371,29.775 +2020-05-12 00:45:00,79.25,45.168,42.371,29.775 +2020-05-12 01:00:00,77.81,45.195,39.597,29.775 +2020-05-12 01:15:00,76.91,44.31100000000001,39.597,29.775 +2020-05-12 01:30:00,71.67,42.696999999999996,39.597,29.775 +2020-05-12 01:45:00,78.35,42.011,39.597,29.775 +2020-05-12 02:00:00,78.21,42.357,38.298,29.775 +2020-05-12 02:15:00,77.57,41.191,38.298,29.775 +2020-05-12 02:30:00,70.31,43.653,38.298,29.775 +2020-05-12 02:45:00,70.29,44.328,38.298,29.775 +2020-05-12 03:00:00,71.96,47.341,37.884,29.775 +2020-05-12 03:15:00,73.12,47.2,37.884,29.775 +2020-05-12 03:30:00,74.65,46.527,37.884,29.775 +2020-05-12 03:45:00,76.43,46.586000000000006,37.884,29.775 +2020-05-12 04:00:00,82.71,57.331,39.442,29.775 +2020-05-12 04:15:00,87.45,68.267,39.442,29.775 +2020-05-12 04:30:00,90.35,67.226,39.442,29.775 +2020-05-12 04:45:00,87.36,68.453,39.442,29.775 +2020-05-12 05:00:00,92.61,95.80799999999999,43.608000000000004,29.775 +2020-05-12 05:15:00,95.01,120.62100000000001,43.608000000000004,29.775 +2020-05-12 05:30:00,97.1,109.846,43.608000000000004,29.775 +2020-05-12 05:45:00,99.06,102.089,43.608000000000004,29.775 +2020-05-12 06:00:00,104.0,103.76,54.99100000000001,29.775 +2020-05-12 06:15:00,104.83,107.14399999999999,54.99100000000001,29.775 +2020-05-12 06:30:00,107.38,104.08200000000001,54.99100000000001,29.775 +2020-05-12 06:45:00,107.97,104.059,54.99100000000001,29.775 +2020-05-12 07:00:00,108.87,105.101,66.217,29.775 +2020-05-12 07:15:00,110.38,104.91,66.217,29.775 +2020-05-12 07:30:00,109.05,102.322,66.217,29.775 +2020-05-12 07:45:00,113.65,99.833,66.217,29.775 +2020-05-12 08:00:00,108.2,95.444,60.151,29.775 +2020-05-12 08:15:00,103.72,95.56299999999999,60.151,29.775 +2020-05-12 08:30:00,103.77,92.559,60.151,29.775 +2020-05-12 08:45:00,104.97,92.65899999999999,60.151,29.775 +2020-05-12 09:00:00,109.3,86.09100000000001,53.873000000000005,29.775 +2020-05-12 09:15:00,106.31,84.264,53.873000000000005,29.775 +2020-05-12 09:30:00,107.3,86.738,53.873000000000005,29.775 +2020-05-12 09:45:00,105.8,87.12700000000001,53.873000000000005,29.775 +2020-05-12 10:00:00,103.34,82.756,51.417,29.775 +2020-05-12 10:15:00,109.67,83.676,51.417,29.775 +2020-05-12 10:30:00,105.75,83.12700000000001,51.417,29.775 +2020-05-12 10:45:00,108.2,83.65,51.417,29.775 +2020-05-12 11:00:00,106.71,79.682,50.43600000000001,29.775 +2020-05-12 11:15:00,107.35,80.905,50.43600000000001,29.775 +2020-05-12 11:30:00,110.29,82.086,50.43600000000001,29.775 +2020-05-12 11:45:00,120.56,82.305,50.43600000000001,29.775 +2020-05-12 12:00:00,127.19,79.093,47.468,29.775 +2020-05-12 12:15:00,125.28,80.00399999999999,47.468,29.775 +2020-05-12 12:30:00,115.59,78.583,47.468,29.775 +2020-05-12 12:45:00,116.61,79.493,47.468,29.775 +2020-05-12 13:00:00,118.57,81.10600000000001,48.453,29.775 +2020-05-12 13:15:00,118.1,81.064,48.453,29.775 +2020-05-12 13:30:00,109.12,79.085,48.453,29.775 +2020-05-12 13:45:00,111.48,77.554,48.453,29.775 +2020-05-12 14:00:00,124.56,79.757,48.435,29.775 +2020-05-12 14:15:00,123.29,78.90899999999999,48.435,29.775 +2020-05-12 14:30:00,118.12,78.03399999999999,48.435,29.775 +2020-05-12 14:45:00,118.02,78.485,48.435,29.775 +2020-05-12 15:00:00,121.42,79.819,49.966,29.775 +2020-05-12 15:15:00,120.43,77.8,49.966,29.775 +2020-05-12 15:30:00,116.21,76.589,49.966,29.775 +2020-05-12 15:45:00,114.12,75.368,49.966,29.775 +2020-05-12 16:00:00,113.27,76.267,51.184,29.775 +2020-05-12 16:15:00,110.03,75.498,51.184,29.775 +2020-05-12 16:30:00,113.31,75.309,51.184,29.775 +2020-05-12 16:45:00,110.3,71.995,51.184,29.775 +2020-05-12 17:00:00,113.37,72.785,56.138999999999996,29.775 +2020-05-12 17:15:00,113.49,74.193,56.138999999999996,29.775 +2020-05-12 17:30:00,116.69,74.74,56.138999999999996,29.775 +2020-05-12 17:45:00,114.28,75.071,56.138999999999996,29.775 +2020-05-12 18:00:00,119.23,74.863,57.038000000000004,29.775 +2020-05-12 18:15:00,118.62,76.265,57.038000000000004,29.775 +2020-05-12 18:30:00,115.63,74.863,57.038000000000004,29.775 +2020-05-12 18:45:00,111.37,80.283,57.038000000000004,29.775 +2020-05-12 19:00:00,112.68,77.525,56.492,29.775 +2020-05-12 19:15:00,112.7,76.933,56.492,29.775 +2020-05-12 19:30:00,108.78,76.973,56.492,29.775 +2020-05-12 19:45:00,103.96,77.756,56.492,29.775 +2020-05-12 20:00:00,102.83,76.097,62.534,29.775 +2020-05-12 20:15:00,99.27,74.208,62.534,29.775 +2020-05-12 20:30:00,100.18,72.914,62.534,29.775 +2020-05-12 20:45:00,106.01,72.91199999999999,62.534,29.775 +2020-05-12 21:00:00,98.91,70.986,55.506,29.775 +2020-05-12 21:15:00,97.97,71.77199999999999,55.506,29.775 +2020-05-12 21:30:00,90.65,71.925,55.506,29.775 +2020-05-12 21:45:00,87.9,72.434,55.506,29.775 +2020-05-12 22:00:00,89.08,70.472,51.472,29.775 +2020-05-12 22:15:00,88.65,70.043,51.472,29.775 +2020-05-12 22:30:00,85.02,60.961000000000006,51.472,29.775 +2020-05-12 22:45:00,79.11,56.92100000000001,51.472,29.775 +2020-05-12 23:00:00,74.3,50.28,44.593,29.775 +2020-05-12 23:15:00,77.56,48.982,44.593,29.775 +2020-05-12 23:30:00,74.7,48.299,44.593,29.775 +2020-05-12 23:45:00,74.02,48.355,44.593,29.775 +2020-05-13 00:00:00,77.65,46.93,41.978,29.775 +2020-05-13 00:15:00,79.78,47.406000000000006,41.978,29.775 +2020-05-13 00:30:00,79.02,46.145,41.978,29.775 +2020-05-13 00:45:00,74.67,44.873000000000005,41.978,29.775 +2020-05-13 01:00:00,70.45,44.906000000000006,38.59,29.775 +2020-05-13 01:15:00,75.07,43.998999999999995,38.59,29.775 +2020-05-13 01:30:00,76.59,42.369,38.59,29.775 +2020-05-13 01:45:00,78.3,41.681000000000004,38.59,29.775 +2020-05-13 02:00:00,73.41,42.021,36.23,29.775 +2020-05-13 02:15:00,70.67,40.841,36.23,29.775 +2020-05-13 02:30:00,69.26,43.31399999999999,36.23,29.775 +2020-05-13 02:45:00,70.7,43.997,36.23,29.775 +2020-05-13 03:00:00,72.64,47.019,35.867,29.775 +2020-05-13 03:15:00,72.13,46.858999999999995,35.867,29.775 +2020-05-13 03:30:00,73.68,46.188,35.867,29.775 +2020-05-13 03:45:00,77.49,46.271,35.867,29.775 +2020-05-13 04:00:00,80.21,56.97,36.75,29.775 +2020-05-13 04:15:00,87.8,67.86,36.75,29.775 +2020-05-13 04:30:00,91.75,66.811,36.75,29.775 +2020-05-13 04:45:00,96.22,68.03,36.75,29.775 +2020-05-13 05:00:00,96.43,95.272,40.461,29.775 +2020-05-13 05:15:00,98.98,119.954,40.461,29.775 +2020-05-13 05:30:00,102.79,109.227,40.461,29.775 +2020-05-13 05:45:00,105.22,101.527,40.461,29.775 +2020-05-13 06:00:00,111.07,103.219,55.481,29.775 +2020-05-13 06:15:00,111.37,106.58,55.481,29.775 +2020-05-13 06:30:00,115.97,103.52,55.481,29.775 +2020-05-13 06:45:00,119.83,103.505,55.481,29.775 +2020-05-13 07:00:00,123.06,104.54,68.45,29.775 +2020-05-13 07:15:00,124.82,104.348,68.45,29.775 +2020-05-13 07:30:00,124.87,101.734,68.45,29.775 +2020-05-13 07:45:00,126.18,99.264,68.45,29.775 +2020-05-13 08:00:00,124.84,94.87200000000001,60.885,29.775 +2020-05-13 08:15:00,125.61,95.03299999999999,60.885,29.775 +2020-05-13 08:30:00,127.05,92.01299999999999,60.885,29.775 +2020-05-13 08:45:00,130.24,92.135,60.885,29.775 +2020-05-13 09:00:00,129.22,85.56700000000001,56.887,29.775 +2020-05-13 09:15:00,132.69,83.745,56.887,29.775 +2020-05-13 09:30:00,132.0,86.23,56.887,29.775 +2020-05-13 09:45:00,133.26,86.65,56.887,29.775 +2020-05-13 10:00:00,132.15,82.288,54.401,29.775 +2020-05-13 10:15:00,133.61,83.245,54.401,29.775 +2020-05-13 10:30:00,132.7,82.713,54.401,29.775 +2020-05-13 10:45:00,129.86,83.25200000000001,54.401,29.775 +2020-05-13 11:00:00,125.77,79.27600000000001,53.678000000000004,29.775 +2020-05-13 11:15:00,124.22,80.518,53.678000000000004,29.775 +2020-05-13 11:30:00,132.15,81.693,53.678000000000004,29.775 +2020-05-13 11:45:00,134.23,81.925,53.678000000000004,29.775 +2020-05-13 12:00:00,133.92,78.745,51.68,29.775 +2020-05-13 12:15:00,126.49,79.66,51.68,29.775 +2020-05-13 12:30:00,126.47,78.203,51.68,29.775 +2020-05-13 12:45:00,126.1,79.117,51.68,29.775 +2020-05-13 13:00:00,122.35,80.75399999999999,51.263000000000005,29.775 +2020-05-13 13:15:00,121.6,80.712,51.263000000000005,29.775 +2020-05-13 13:30:00,115.7,78.74,51.263000000000005,29.775 +2020-05-13 13:45:00,116.2,77.212,51.263000000000005,29.775 +2020-05-13 14:00:00,122.46,79.46,51.107,29.775 +2020-05-13 14:15:00,118.59,78.6,51.107,29.775 +2020-05-13 14:30:00,107.28,77.684,51.107,29.775 +2020-05-13 14:45:00,98.18,78.138,51.107,29.775 +2020-05-13 15:00:00,97.87,79.513,51.498000000000005,29.775 +2020-05-13 15:15:00,105.19,77.479,51.498000000000005,29.775 +2020-05-13 15:30:00,108.25,76.236,51.498000000000005,29.775 +2020-05-13 15:45:00,112.75,74.999,51.498000000000005,29.775 +2020-05-13 16:00:00,115.28,75.942,53.376999999999995,29.775 +2020-05-13 16:15:00,121.08,75.155,53.376999999999995,29.775 +2020-05-13 16:30:00,127.19,74.976,53.376999999999995,29.775 +2020-05-13 16:45:00,124.65,71.605,53.376999999999995,29.775 +2020-05-13 17:00:00,116.0,72.441,56.965,29.775 +2020-05-13 17:15:00,118.64,73.824,56.965,29.775 +2020-05-13 17:30:00,119.77,74.36,56.965,29.775 +2020-05-13 17:45:00,121.03,74.661,56.965,29.775 +2020-05-13 18:00:00,116.2,74.469,58.231,29.775 +2020-05-13 18:15:00,110.61,75.866,58.231,29.775 +2020-05-13 18:30:00,111.68,74.452,58.231,29.775 +2020-05-13 18:45:00,115.74,79.872,58.231,29.775 +2020-05-13 19:00:00,115.72,77.115,58.865,29.775 +2020-05-13 19:15:00,108.85,76.52199999999999,58.865,29.775 +2020-05-13 19:30:00,105.3,76.563,58.865,29.775 +2020-05-13 19:45:00,108.65,77.355,58.865,29.775 +2020-05-13 20:00:00,108.52,75.672,65.605,29.775 +2020-05-13 20:15:00,107.64,73.78399999999999,65.605,29.775 +2020-05-13 20:30:00,103.12,72.518,65.605,29.775 +2020-05-13 20:45:00,101.58,72.55199999999999,65.605,29.775 +2020-05-13 21:00:00,99.72,70.63,58.083999999999996,29.775 +2020-05-13 21:15:00,98.92,71.432,58.083999999999996,29.775 +2020-05-13 21:30:00,96.57,71.565,58.083999999999996,29.775 +2020-05-13 21:45:00,89.44,72.09899999999999,58.083999999999996,29.775 +2020-05-13 22:00:00,85.38,70.156,53.243,29.775 +2020-05-13 22:15:00,89.96,69.749,53.243,29.775 +2020-05-13 22:30:00,86.78,60.653,53.243,29.775 +2020-05-13 22:45:00,85.75,56.601000000000006,53.243,29.775 +2020-05-13 23:00:00,75.39,49.934,44.283,29.775 +2020-05-13 23:15:00,74.31,48.676,44.283,29.775 +2020-05-13 23:30:00,74.53,47.994,44.283,29.775 +2020-05-13 23:45:00,72.86,48.048,44.283,29.775 +2020-05-14 00:00:00,69.32,46.623000000000005,40.219,29.775 +2020-05-14 00:15:00,70.84,47.11,40.219,29.775 +2020-05-14 00:30:00,69.79,45.847,40.219,29.775 +2020-05-14 00:45:00,70.68,44.582,40.219,29.775 +2020-05-14 01:00:00,70.16,44.619,37.959,29.775 +2020-05-14 01:15:00,69.63,43.68899999999999,37.959,29.775 +2020-05-14 01:30:00,70.15,42.043,37.959,29.775 +2020-05-14 01:45:00,70.69,41.355,37.959,29.775 +2020-05-14 02:00:00,69.83,41.687,36.113,29.775 +2020-05-14 02:15:00,70.46,40.495,36.113,29.775 +2020-05-14 02:30:00,71.03,42.979,36.113,29.775 +2020-05-14 02:45:00,70.72,43.668,36.113,29.775 +2020-05-14 03:00:00,71.24,46.7,35.546,29.775 +2020-05-14 03:15:00,72.5,46.523999999999994,35.546,29.775 +2020-05-14 03:30:00,73.71,45.853,35.546,29.775 +2020-05-14 03:45:00,75.65,45.958999999999996,35.546,29.775 +2020-05-14 04:00:00,79.12,56.613,37.169000000000004,29.775 +2020-05-14 04:15:00,79.46,67.456,37.169000000000004,29.775 +2020-05-14 04:30:00,82.89,66.40100000000001,37.169000000000004,29.775 +2020-05-14 04:45:00,85.82,67.613,37.169000000000004,29.775 +2020-05-14 05:00:00,95.94,94.743,41.233000000000004,29.775 +2020-05-14 05:15:00,98.55,119.294,41.233000000000004,29.775 +2020-05-14 05:30:00,99.72,108.61399999999999,41.233000000000004,29.775 +2020-05-14 05:45:00,102.92,100.969,41.233000000000004,29.775 +2020-05-14 06:00:00,110.05,102.68299999999999,52.57,29.775 +2020-05-14 06:15:00,112.59,106.021,52.57,29.775 +2020-05-14 06:30:00,116.11,102.962,52.57,29.775 +2020-05-14 06:45:00,118.57,102.956,52.57,29.775 +2020-05-14 07:00:00,121.86,103.98200000000001,64.53,29.775 +2020-05-14 07:15:00,122.71,103.792,64.53,29.775 +2020-05-14 07:30:00,123.27,101.15100000000001,64.53,29.775 +2020-05-14 07:45:00,125.08,98.7,64.53,29.775 +2020-05-14 08:00:00,124.66,94.306,55.911,29.775 +2020-05-14 08:15:00,124.04,94.51,55.911,29.775 +2020-05-14 08:30:00,126.3,91.473,55.911,29.775 +2020-05-14 08:45:00,127.75,91.618,55.911,29.775 +2020-05-14 09:00:00,125.81,85.04799999999999,50.949,29.775 +2020-05-14 09:15:00,127.78,83.234,50.949,29.775 +2020-05-14 09:30:00,127.51,85.727,50.949,29.775 +2020-05-14 09:45:00,129.58,86.179,50.949,29.775 +2020-05-14 10:00:00,129.11,81.82600000000001,48.136,29.775 +2020-05-14 10:15:00,129.29,82.819,48.136,29.775 +2020-05-14 10:30:00,129.5,82.303,48.136,29.775 +2020-05-14 10:45:00,130.85,82.85600000000001,48.136,29.775 +2020-05-14 11:00:00,127.65,78.875,46.643,29.775 +2020-05-14 11:15:00,126.84,80.135,46.643,29.775 +2020-05-14 11:30:00,124.7,81.304,46.643,29.775 +2020-05-14 11:45:00,126.76,81.54899999999999,46.643,29.775 +2020-05-14 12:00:00,124.85,78.4,44.098,29.775 +2020-05-14 12:15:00,122.87,79.321,44.098,29.775 +2020-05-14 12:30:00,122.4,77.82600000000001,44.098,29.775 +2020-05-14 12:45:00,118.93,78.745,44.098,29.775 +2020-05-14 13:00:00,117.21,80.405,43.717,29.775 +2020-05-14 13:15:00,120.11,80.363,43.717,29.775 +2020-05-14 13:30:00,119.68,78.4,43.717,29.775 +2020-05-14 13:45:00,123.45,76.874,43.717,29.775 +2020-05-14 14:00:00,125.18,79.166,44.218999999999994,29.775 +2020-05-14 14:15:00,124.9,78.294,44.218999999999994,29.775 +2020-05-14 14:30:00,118.8,77.33800000000001,44.218999999999994,29.775 +2020-05-14 14:45:00,111.7,77.794,44.218999999999994,29.775 +2020-05-14 15:00:00,111.77,79.21,46.159,29.775 +2020-05-14 15:15:00,115.84,77.15899999999999,46.159,29.775 +2020-05-14 15:30:00,116.18,75.887,46.159,29.775 +2020-05-14 15:45:00,115.11,74.634,46.159,29.775 +2020-05-14 16:00:00,111.0,75.619,47.115,29.775 +2020-05-14 16:15:00,111.54,74.814,47.115,29.775 +2020-05-14 16:30:00,116.27,74.64699999999999,47.115,29.775 +2020-05-14 16:45:00,116.72,71.22,47.115,29.775 +2020-05-14 17:00:00,114.96,72.101,50.827,29.775 +2020-05-14 17:15:00,108.11,73.459,50.827,29.775 +2020-05-14 17:30:00,108.02,73.984,50.827,29.775 +2020-05-14 17:45:00,108.53,74.25399999999999,50.827,29.775 +2020-05-14 18:00:00,109.63,74.078,52.586000000000006,29.775 +2020-05-14 18:15:00,108.24,75.471,52.586000000000006,29.775 +2020-05-14 18:30:00,113.52,74.045,52.586000000000006,29.775 +2020-05-14 18:45:00,109.72,79.467,52.586000000000006,29.775 +2020-05-14 19:00:00,112.3,76.709,51.886,29.775 +2020-05-14 19:15:00,106.96,76.115,51.886,29.775 +2020-05-14 19:30:00,106.72,76.158,51.886,29.775 +2020-05-14 19:45:00,107.78,76.959,51.886,29.775 +2020-05-14 20:00:00,105.58,75.251,56.162,29.775 +2020-05-14 20:15:00,99.35,73.365,56.162,29.775 +2020-05-14 20:30:00,102.93,72.126,56.162,29.775 +2020-05-14 20:45:00,105.28,72.195,56.162,29.775 +2020-05-14 21:00:00,103.12,70.278,53.023,29.775 +2020-05-14 21:15:00,99.97,71.095,53.023,29.775 +2020-05-14 21:30:00,93.22,71.209,53.023,29.775 +2020-05-14 21:45:00,93.97,71.766,53.023,29.775 +2020-05-14 22:00:00,90.52,69.846,49.303999999999995,29.775 +2020-05-14 22:15:00,90.9,69.455,49.303999999999995,29.775 +2020-05-14 22:30:00,84.63,60.348,49.303999999999995,29.775 +2020-05-14 22:45:00,88.26,56.285,49.303999999999995,29.775 +2020-05-14 23:00:00,82.53,49.589,43.409,29.775 +2020-05-14 23:15:00,81.52,48.373000000000005,43.409,29.775 +2020-05-14 23:30:00,75.59,47.691,43.409,29.775 +2020-05-14 23:45:00,81.48,47.743,43.409,29.775 +2020-05-15 00:00:00,78.17,44.512,39.884,29.775 +2020-05-15 00:15:00,78.44,45.251999999999995,39.884,29.775 +2020-05-15 00:30:00,73.49,44.153,39.884,29.775 +2020-05-15 00:45:00,78.17,43.27,39.884,29.775 +2020-05-15 01:00:00,78.18,42.896,37.658,29.775 +2020-05-15 01:15:00,78.15,41.803000000000004,37.658,29.775 +2020-05-15 01:30:00,71.38,40.624,37.658,29.775 +2020-05-15 01:45:00,77.87,39.775999999999996,37.658,29.775 +2020-05-15 02:00:00,77.41,40.869,36.707,29.775 +2020-05-15 02:15:00,78.35,39.583,36.707,29.775 +2020-05-15 02:30:00,73.66,42.92,36.707,29.775 +2020-05-15 02:45:00,70.89,43.092,36.707,29.775 +2020-05-15 03:00:00,72.47,46.383,37.025,29.775 +2020-05-15 03:15:00,72.41,45.57899999999999,37.025,29.775 +2020-05-15 03:30:00,73.92,44.714,37.025,29.775 +2020-05-15 03:45:00,77.06,45.655,37.025,29.775 +2020-05-15 04:00:00,81.51,56.457,38.349000000000004,29.775 +2020-05-15 04:15:00,79.37,65.932,38.349000000000004,29.775 +2020-05-15 04:30:00,82.59,65.705,38.349000000000004,29.775 +2020-05-15 04:45:00,85.66,65.982,38.349000000000004,29.775 +2020-05-15 05:00:00,95.76,92.154,41.565,29.775 +2020-05-15 05:15:00,97.85,117.869,41.565,29.775 +2020-05-15 05:30:00,101.38,107.76899999999999,41.565,29.775 +2020-05-15 05:45:00,103.44,99.796,41.565,29.775 +2020-05-15 06:00:00,108.63,101.87700000000001,53.861000000000004,29.775 +2020-05-15 06:15:00,111.05,104.807,53.861000000000004,29.775 +2020-05-15 06:30:00,114.06,101.404,53.861000000000004,29.775 +2020-05-15 06:45:00,116.5,101.876,53.861000000000004,29.775 +2020-05-15 07:00:00,118.58,103.118,63.497,29.775 +2020-05-15 07:15:00,119.62,104.01899999999999,63.497,29.775 +2020-05-15 07:30:00,123.22,99.75399999999999,63.497,29.775 +2020-05-15 07:45:00,121.51,96.83,63.497,29.775 +2020-05-15 08:00:00,120.07,92.62799999999999,55.43899999999999,29.775 +2020-05-15 08:15:00,120.52,93.241,55.43899999999999,29.775 +2020-05-15 08:30:00,121.85,90.48700000000001,55.43899999999999,29.775 +2020-05-15 08:45:00,123.22,89.939,55.43899999999999,29.775 +2020-05-15 09:00:00,120.17,81.768,52.132,29.775 +2020-05-15 09:15:00,120.79,81.646,52.132,29.775 +2020-05-15 09:30:00,120.3,83.456,52.132,29.775 +2020-05-15 09:45:00,121.98,84.19,52.132,29.775 +2020-05-15 10:00:00,122.01,79.236,49.881,29.775 +2020-05-15 10:15:00,122.16,80.387,49.881,29.775 +2020-05-15 10:30:00,125.08,80.267,49.881,29.775 +2020-05-15 10:45:00,121.4,80.583,49.881,29.775 +2020-05-15 11:00:00,118.56,76.764,49.396,29.775 +2020-05-15 11:15:00,117.53,76.866,49.396,29.775 +2020-05-15 11:30:00,115.03,78.49,49.396,29.775 +2020-05-15 11:45:00,117.11,78.059,49.396,29.775 +2020-05-15 12:00:00,113.62,75.738,46.7,29.775 +2020-05-15 12:15:00,112.54,75.296,46.7,29.775 +2020-05-15 12:30:00,109.67,73.919,46.7,29.775 +2020-05-15 12:45:00,110.33,74.495,46.7,29.775 +2020-05-15 13:00:00,105.34,76.998,44.05,29.775 +2020-05-15 13:15:00,104.26,77.437,44.05,29.775 +2020-05-15 13:30:00,101.89,76.069,44.05,29.775 +2020-05-15 13:45:00,103.1,74.741,44.05,29.775 +2020-05-15 14:00:00,99.61,76.006,42.805,29.775 +2020-05-15 14:15:00,96.97,75.365,42.805,29.775 +2020-05-15 14:30:00,94.75,75.683,42.805,29.775 +2020-05-15 14:45:00,93.73,75.768,42.805,29.775 +2020-05-15 15:00:00,96.11,76.961,44.36600000000001,29.775 +2020-05-15 15:15:00,94.15,74.533,44.36600000000001,29.775 +2020-05-15 15:30:00,94.02,72.171,44.36600000000001,29.775 +2020-05-15 15:45:00,94.72,71.51899999999999,44.36600000000001,29.775 +2020-05-15 16:00:00,96.97,71.47399999999999,46.928999999999995,29.775 +2020-05-15 16:15:00,96.61,71.137,46.928999999999995,29.775 +2020-05-15 16:30:00,99.35,70.885,46.928999999999995,29.775 +2020-05-15 16:45:00,100.89,66.767,46.928999999999995,29.775 +2020-05-15 17:00:00,107.94,69.078,51.468,29.775 +2020-05-15 17:15:00,103.98,70.115,51.468,29.775 +2020-05-15 17:30:00,103.89,70.623,51.468,29.775 +2020-05-15 17:45:00,105.25,70.645,51.468,29.775 +2020-05-15 18:00:00,106.7,70.852,52.58,29.775 +2020-05-15 18:15:00,104.83,71.363,52.58,29.775 +2020-05-15 18:30:00,111.99,69.997,52.58,29.775 +2020-05-15 18:45:00,112.9,75.74,52.58,29.775 +2020-05-15 19:00:00,111.75,74.04899999999999,52.183,29.775 +2020-05-15 19:15:00,103.98,74.419,52.183,29.775 +2020-05-15 19:30:00,101.92,74.359,52.183,29.775 +2020-05-15 19:45:00,98.71,74.2,52.183,29.775 +2020-05-15 20:00:00,97.62,72.35,58.497,29.775 +2020-05-15 20:15:00,103.82,71.09,58.497,29.775 +2020-05-15 20:30:00,102.75,69.482,58.497,29.775 +2020-05-15 20:45:00,100.04,69.16199999999999,58.497,29.775 +2020-05-15 21:00:00,88.18,68.469,54.731,29.775 +2020-05-15 21:15:00,88.72,70.771,54.731,29.775 +2020-05-15 21:30:00,85.61,70.765,54.731,29.775 +2020-05-15 21:45:00,83.9,71.708,54.731,29.775 +2020-05-15 22:00:00,82.78,70.062,51.386,29.775 +2020-05-15 22:15:00,84.75,69.445,51.386,29.775 +2020-05-15 22:30:00,82.82,66.408,51.386,29.775 +2020-05-15 22:45:00,80.16,64.311,51.386,29.775 +2020-05-15 23:00:00,71.33,58.746,44.463,29.775 +2020-05-15 23:15:00,70.6,55.622,44.463,29.775 +2020-05-15 23:30:00,69.36,52.972,44.463,29.775 +2020-05-15 23:45:00,70.97,52.681000000000004,44.463,29.775 +2020-05-16 00:00:00,74.15,36.794000000000004,42.833999999999996,29.662 +2020-05-16 00:15:00,73.98,35.758,42.833999999999996,29.662 +2020-05-16 00:30:00,71.46,34.895,42.833999999999996,29.662 +2020-05-16 00:45:00,69.22,33.669000000000004,42.833999999999996,29.662 +2020-05-16 01:00:00,72.75,33.125,37.859,29.662 +2020-05-16 01:15:00,75.46,32.548,37.859,29.662 +2020-05-16 01:30:00,69.76,30.769000000000002,37.859,29.662 +2020-05-16 01:45:00,65.29,30.721,37.859,29.662 +2020-05-16 02:00:00,67.91,30.855,35.327,29.662 +2020-05-16 02:15:00,70.65,28.666,35.327,29.662 +2020-05-16 02:30:00,70.38,30.735,35.327,29.662 +2020-05-16 02:45:00,64.65,31.566,35.327,29.662 +2020-05-16 03:00:00,63.24,33.628,34.908,29.662 +2020-05-16 03:15:00,64.63,30.669,34.908,29.662 +2020-05-16 03:30:00,65.46,29.659000000000002,34.908,29.662 +2020-05-16 03:45:00,65.23,31.218000000000004,34.908,29.662 +2020-05-16 04:00:00,62.15,39.24,34.84,29.662 +2020-05-16 04:15:00,61.52,47.104,34.84,29.662 +2020-05-16 04:30:00,61.86,43.983999999999995,34.84,29.662 +2020-05-16 04:45:00,62.99,44.071999999999996,34.84,29.662 +2020-05-16 05:00:00,64.2,57.34,34.222,29.662 +2020-05-16 05:15:00,64.4,64.827,34.222,29.662 +2020-05-16 05:30:00,63.92,55.413999999999994,34.222,29.662 +2020-05-16 05:45:00,66.28,53.871,34.222,29.662 +2020-05-16 06:00:00,69.14,69.734,35.515,29.662 +2020-05-16 06:15:00,69.45,82.39299999999999,35.515,29.662 +2020-05-16 06:30:00,71.81,75.32300000000001,35.515,29.662 +2020-05-16 06:45:00,72.47,70.584,35.515,29.662 +2020-05-16 07:00:00,73.5,68.16199999999999,39.687,29.662 +2020-05-16 07:15:00,74.27,67.108,39.687,29.662 +2020-05-16 07:30:00,75.61,64.484,39.687,29.662 +2020-05-16 07:45:00,78.65,63.838,39.687,29.662 +2020-05-16 08:00:00,77.98,59.902,44.9,29.662 +2020-05-16 08:15:00,77.32,62.077,44.9,29.662 +2020-05-16 08:30:00,76.53,61.327,44.9,29.662 +2020-05-16 08:45:00,77.34,63.946000000000005,44.9,29.662 +2020-05-16 09:00:00,78.57,61.705,45.724,29.662 +2020-05-16 09:15:00,78.98,62.68899999999999,45.724,29.662 +2020-05-16 09:30:00,74.87,65.62899999999999,45.724,29.662 +2020-05-16 09:45:00,76.27,66.71600000000001,45.724,29.662 +2020-05-16 10:00:00,78.95,63.416000000000004,43.123999999999995,29.662 +2020-05-16 10:15:00,81.25,64.873,43.123999999999995,29.662 +2020-05-16 10:30:00,84.8,64.884,43.123999999999995,29.662 +2020-05-16 10:45:00,82.3,65.693,43.123999999999995,29.662 +2020-05-16 11:00:00,82.79,61.979,40.255,29.662 +2020-05-16 11:15:00,83.53,62.608000000000004,40.255,29.662 +2020-05-16 11:30:00,79.48,64.295,40.255,29.662 +2020-05-16 11:45:00,74.97,64.98,40.255,29.662 +2020-05-16 12:00:00,68.37,60.761,38.582,29.662 +2020-05-16 12:15:00,68.21,60.949,38.582,29.662 +2020-05-16 12:30:00,66.19,60.332,38.582,29.662 +2020-05-16 12:45:00,65.99,61.598,38.582,29.662 +2020-05-16 13:00:00,65.29,62.047,36.043,29.662 +2020-05-16 13:15:00,66.66,62.114,36.043,29.662 +2020-05-16 13:30:00,65.2,60.93,36.043,29.662 +2020-05-16 13:45:00,65.57,58.292,36.043,29.662 +2020-05-16 14:00:00,65.77,58.663999999999994,35.216,29.662 +2020-05-16 14:15:00,65.43,56.394,35.216,29.662 +2020-05-16 14:30:00,67.17,56.395,35.216,29.662 +2020-05-16 14:45:00,69.52,56.943000000000005,35.216,29.662 +2020-05-16 15:00:00,68.76,57.736000000000004,36.759,29.662 +2020-05-16 15:15:00,70.34,55.881,36.759,29.662 +2020-05-16 15:30:00,68.44,53.98,36.759,29.662 +2020-05-16 15:45:00,69.99,51.562,36.759,29.662 +2020-05-16 16:00:00,72.09,54.672,40.086,29.662 +2020-05-16 16:15:00,70.51,54.178000000000004,40.086,29.662 +2020-05-16 16:30:00,72.95,53.538000000000004,40.086,29.662 +2020-05-16 16:45:00,72.75,49.865,40.086,29.662 +2020-05-16 17:00:00,75.24,52.239,44.876999999999995,29.662 +2020-05-16 17:15:00,76.28,51.114,44.876999999999995,29.662 +2020-05-16 17:30:00,78.78,50.931000000000004,44.876999999999995,29.662 +2020-05-16 17:45:00,80.15,50.091,44.876999999999995,29.662 +2020-05-16 18:00:00,84.39,54.299,47.056000000000004,29.662 +2020-05-16 18:15:00,81.11,56.321000000000005,47.056000000000004,29.662 +2020-05-16 18:30:00,81.76,55.763000000000005,47.056000000000004,29.662 +2020-05-16 18:45:00,84.97,57.4,47.056000000000004,29.662 +2020-05-16 19:00:00,81.21,57.782,45.57,29.662 +2020-05-16 19:15:00,78.11,56.823,45.57,29.662 +2020-05-16 19:30:00,77.61,57.25899999999999,45.57,29.662 +2020-05-16 19:45:00,76.99,58.424,45.57,29.662 +2020-05-16 20:00:00,76.78,57.797,41.685,29.662 +2020-05-16 20:15:00,77.96,57.528,41.685,29.662 +2020-05-16 20:30:00,77.7,55.493,41.685,29.662 +2020-05-16 20:45:00,79.72,56.248000000000005,41.685,29.662 +2020-05-16 21:00:00,74.4,54.181999999999995,39.576,29.662 +2020-05-16 21:15:00,73.65,57.01,39.576,29.662 +2020-05-16 21:30:00,70.78,58.263000000000005,39.576,29.662 +2020-05-16 21:45:00,71.05,58.636,39.576,29.662 +2020-05-16 22:00:00,66.23,56.073,39.068000000000005,29.662 +2020-05-16 22:15:00,66.59,57.24100000000001,39.068000000000005,29.662 +2020-05-16 22:30:00,64.33,56.806000000000004,39.068000000000005,29.662 +2020-05-16 22:45:00,63.23,55.815,39.068000000000005,29.662 +2020-05-16 23:00:00,59.53,51.784,32.06,29.662 +2020-05-16 23:15:00,59.33,47.72,32.06,29.662 +2020-05-16 23:30:00,58.61,46.766000000000005,32.06,29.662 +2020-05-16 23:45:00,58.55,45.958,32.06,29.662 +2020-05-17 00:00:00,55.99,37.921,28.825,29.662 +2020-05-17 00:15:00,56.4,35.736999999999995,28.825,29.662 +2020-05-17 00:30:00,55.85,34.657,28.825,29.662 +2020-05-17 00:45:00,55.56,33.474000000000004,28.825,29.662 +2020-05-17 01:00:00,53.93,33.169000000000004,25.995,29.662 +2020-05-17 01:15:00,54.71,32.666,25.995,29.662 +2020-05-17 01:30:00,54.49,30.851,25.995,29.662 +2020-05-17 01:45:00,53.85,30.375,25.995,29.662 +2020-05-17 02:00:00,53.53,30.426,24.394000000000002,29.662 +2020-05-17 02:15:00,54.42,28.711,24.394000000000002,29.662 +2020-05-17 02:30:00,54.27,31.146,24.394000000000002,29.662 +2020-05-17 02:45:00,53.9,31.805999999999997,24.394000000000002,29.662 +2020-05-17 03:00:00,54.33,34.554,22.916999999999998,29.662 +2020-05-17 03:15:00,54.73,31.721,22.916999999999998,29.662 +2020-05-17 03:30:00,56.27,30.311,22.916999999999998,29.662 +2020-05-17 03:45:00,54.77,31.133000000000003,22.916999999999998,29.662 +2020-05-17 04:00:00,51.22,39.014,23.576999999999998,29.662 +2020-05-17 04:15:00,51.74,46.203,23.576999999999998,29.662 +2020-05-17 04:30:00,50.38,44.345,23.576999999999998,29.662 +2020-05-17 04:45:00,50.63,44.044,23.576999999999998,29.662 +2020-05-17 05:00:00,49.16,56.647,22.730999999999998,29.662 +2020-05-17 05:15:00,49.68,62.77,22.730999999999998,29.662 +2020-05-17 05:30:00,49.0,53.008,22.730999999999998,29.662 +2020-05-17 05:45:00,49.52,51.318999999999996,22.730999999999998,29.662 +2020-05-17 06:00:00,50.22,64.92699999999999,22.34,29.662 +2020-05-17 06:15:00,50.56,78.017,22.34,29.662 +2020-05-17 06:30:00,50.65,70.086,22.34,29.662 +2020-05-17 06:45:00,51.26,64.217,22.34,29.662 +2020-05-17 07:00:00,51.72,62.525,24.691999999999997,29.662 +2020-05-17 07:15:00,52.05,59.75899999999999,24.691999999999997,29.662 +2020-05-17 07:30:00,52.11,58.062,24.691999999999997,29.662 +2020-05-17 07:45:00,51.69,57.25899999999999,24.691999999999997,29.662 +2020-05-17 08:00:00,52.0,54.4,29.340999999999998,29.662 +2020-05-17 08:15:00,51.74,57.711000000000006,29.340999999999998,29.662 +2020-05-17 08:30:00,50.65,58.111000000000004,29.340999999999998,29.662 +2020-05-17 08:45:00,50.24,61.091,29.340999999999998,29.662 +2020-05-17 09:00:00,49.9,58.648999999999994,30.788,29.662 +2020-05-17 09:15:00,50.08,59.297,30.788,29.662 +2020-05-17 09:30:00,49.35,62.619,30.788,29.662 +2020-05-17 09:45:00,49.73,64.649,30.788,29.662 +2020-05-17 10:00:00,51.82,62.388000000000005,30.158,29.662 +2020-05-17 10:15:00,54.08,64.119,30.158,29.662 +2020-05-17 10:30:00,54.15,64.479,30.158,29.662 +2020-05-17 10:45:00,54.12,65.82300000000001,30.158,29.662 +2020-05-17 11:00:00,50.69,61.978,32.056,29.662 +2020-05-17 11:15:00,49.03,62.24,32.056,29.662 +2020-05-17 11:30:00,44.51,64.207,32.056,29.662 +2020-05-17 11:45:00,46.44,65.27199999999999,32.056,29.662 +2020-05-17 12:00:00,43.04,61.916000000000004,28.671999999999997,29.662 +2020-05-17 12:15:00,42.65,61.971000000000004,28.671999999999997,29.662 +2020-05-17 12:30:00,41.87,61.183,28.671999999999997,29.662 +2020-05-17 12:45:00,41.91,61.653999999999996,28.671999999999997,29.662 +2020-05-17 13:00:00,41.33,61.688,23.171,29.662 +2020-05-17 13:15:00,41.6,61.905,23.171,29.662 +2020-05-17 13:30:00,42.2,59.663000000000004,23.171,29.662 +2020-05-17 13:45:00,41.31,57.903999999999996,23.171,29.662 +2020-05-17 14:00:00,42.39,59.47,19.11,29.662 +2020-05-17 14:15:00,42.99,57.854,19.11,29.662 +2020-05-17 14:30:00,43.09,56.96,19.11,29.662 +2020-05-17 14:45:00,43.63,56.438,19.11,29.662 +2020-05-17 15:00:00,44.32,57.077,19.689,29.662 +2020-05-17 15:15:00,44.44,54.631,19.689,29.662 +2020-05-17 15:30:00,44.86,52.67100000000001,19.689,29.662 +2020-05-17 15:45:00,47.06,50.652,19.689,29.662 +2020-05-17 16:00:00,49.45,52.661,22.875,29.662 +2020-05-17 16:15:00,50.16,52.172,22.875,29.662 +2020-05-17 16:30:00,52.1,52.534,22.875,29.662 +2020-05-17 16:45:00,55.36,48.893,22.875,29.662 +2020-05-17 17:00:00,60.0,51.653,33.884,29.662 +2020-05-17 17:15:00,60.6,51.835,33.884,29.662 +2020-05-17 17:30:00,65.61,52.448,33.884,29.662 +2020-05-17 17:45:00,64.53,52.451,33.884,29.662 +2020-05-17 18:00:00,68.54,57.073,38.453,29.662 +2020-05-17 18:15:00,66.66,58.964,38.453,29.662 +2020-05-17 18:30:00,69.58,57.735,38.453,29.662 +2020-05-17 18:45:00,66.98,59.857,38.453,29.662 +2020-05-17 19:00:00,68.34,62.187,39.221,29.662 +2020-05-17 19:15:00,67.78,60.285,39.221,29.662 +2020-05-17 19:30:00,66.98,60.448,39.221,29.662 +2020-05-17 19:45:00,67.29,61.458999999999996,39.221,29.662 +2020-05-17 20:00:00,68.66,61.008,37.871,29.662 +2020-05-17 20:15:00,68.42,60.806000000000004,37.871,29.662 +2020-05-17 20:30:00,71.45,59.808,37.871,29.662 +2020-05-17 20:45:00,70.32,58.785,37.871,29.662 +2020-05-17 21:00:00,68.35,56.067,36.465,29.662 +2020-05-17 21:15:00,67.62,58.501999999999995,36.465,29.662 +2020-05-17 21:30:00,65.42,59.177,36.465,29.662 +2020-05-17 21:45:00,64.94,59.942,36.465,29.662 +2020-05-17 22:00:00,61.02,59.086000000000006,36.092,29.662 +2020-05-17 22:15:00,61.21,58.535,36.092,29.662 +2020-05-17 22:30:00,59.51,56.996,36.092,29.662 +2020-05-17 22:45:00,58.72,54.622,36.092,29.662 +2020-05-17 23:00:00,70.52,49.718,31.013,29.662 +2020-05-17 23:15:00,70.67,47.265,31.013,29.662 +2020-05-17 23:30:00,71.07,45.965,31.013,29.662 +2020-05-17 23:45:00,71.64,45.471000000000004,31.013,29.662 +2020-05-18 00:00:00,69.55,39.926,31.174,29.775 +2020-05-18 00:15:00,70.53,39.082,31.174,29.775 +2020-05-18 00:30:00,69.8,37.664,31.174,29.775 +2020-05-18 00:45:00,70.41,36.007,31.174,29.775 +2020-05-18 01:00:00,68.21,36.088,29.663,29.775 +2020-05-18 01:15:00,68.54,35.468,29.663,29.775 +2020-05-18 01:30:00,70.13,33.989000000000004,29.663,29.775 +2020-05-18 01:45:00,71.47,33.439,29.663,29.775 +2020-05-18 02:00:00,71.14,33.912,28.793000000000003,29.775 +2020-05-18 02:15:00,70.93,31.521,28.793000000000003,29.775 +2020-05-18 02:30:00,70.27,34.186,28.793000000000003,29.775 +2020-05-18 02:45:00,71.97,34.594,28.793000000000003,29.775 +2020-05-18 03:00:00,71.3,38.082,27.728,29.775 +2020-05-18 03:15:00,70.0,36.243,27.728,29.775 +2020-05-18 03:30:00,70.97,35.424,27.728,29.775 +2020-05-18 03:45:00,73.44,35.727,27.728,29.775 +2020-05-18 04:00:00,77.39,47.346000000000004,29.266,29.775 +2020-05-18 04:15:00,78.56,58.068999999999996,29.266,29.775 +2020-05-18 04:30:00,79.66,56.18600000000001,29.266,29.775 +2020-05-18 04:45:00,83.67,56.26,29.266,29.775 +2020-05-18 05:00:00,91.78,78.305,37.889,29.775 +2020-05-18 05:15:00,95.73,97.86399999999999,37.889,29.775 +2020-05-18 05:30:00,97.75,86.74700000000001,37.889,29.775 +2020-05-18 05:45:00,99.8,81.139,37.889,29.775 +2020-05-18 06:00:00,105.24,81.69,55.485,29.775 +2020-05-18 06:15:00,107.97,83.287,55.485,29.775 +2020-05-18 06:30:00,108.48,80.672,55.485,29.775 +2020-05-18 06:45:00,108.17,81.521,55.485,29.775 +2020-05-18 07:00:00,108.28,80.867,65.765,29.775 +2020-05-18 07:15:00,108.08,80.506,65.765,29.775 +2020-05-18 07:30:00,108.87,77.82,65.765,29.775 +2020-05-18 07:45:00,109.39,76.815,65.765,29.775 +2020-05-18 08:00:00,109.39,71.055,56.745,29.775 +2020-05-18 08:15:00,107.51,72.851,56.745,29.775 +2020-05-18 08:30:00,106.6,71.499,56.745,29.775 +2020-05-18 08:45:00,105.11,74.01100000000001,56.745,29.775 +2020-05-18 09:00:00,104.7,70.43,53.321999999999996,29.775 +2020-05-18 09:15:00,104.4,68.692,53.321999999999996,29.775 +2020-05-18 09:30:00,104.55,70.94800000000001,53.321999999999996,29.775 +2020-05-18 09:45:00,105.86,70.934,53.321999999999996,29.775 +2020-05-18 10:00:00,104.56,68.959,51.309,29.775 +2020-05-18 10:15:00,105.63,70.516,51.309,29.775 +2020-05-18 10:30:00,106.46,70.271,51.309,29.775 +2020-05-18 10:45:00,105.73,70.328,51.309,29.775 +2020-05-18 11:00:00,103.62,66.137,50.415,29.775 +2020-05-18 11:15:00,102.43,67.071,50.415,29.775 +2020-05-18 11:30:00,102.2,69.97800000000001,50.415,29.775 +2020-05-18 11:45:00,100.97,71.42699999999999,50.415,29.775 +2020-05-18 12:00:00,99.99,67.039,48.273,29.775 +2020-05-18 12:15:00,99.53,67.203,48.273,29.775 +2020-05-18 12:30:00,97.76,65.457,48.273,29.775 +2020-05-18 12:45:00,98.7,66.28,48.273,29.775 +2020-05-18 13:00:00,96.87,67.222,48.452,29.775 +2020-05-18 13:15:00,99.09,66.321,48.452,29.775 +2020-05-18 13:30:00,97.98,64.128,48.452,29.775 +2020-05-18 13:45:00,98.34,63.196999999999996,48.452,29.775 +2020-05-18 14:00:00,96.84,63.848,48.35,29.775 +2020-05-18 14:15:00,97.88,62.621,48.35,29.775 +2020-05-18 14:30:00,97.61,61.413999999999994,48.35,29.775 +2020-05-18 14:45:00,97.53,62.787,48.35,29.775 +2020-05-18 15:00:00,95.98,63.553000000000004,48.838,29.775 +2020-05-18 15:15:00,94.89,60.226000000000006,48.838,29.775 +2020-05-18 15:30:00,96.64,58.75899999999999,48.838,29.775 +2020-05-18 15:45:00,97.73,56.178000000000004,48.838,29.775 +2020-05-18 16:00:00,99.47,59.169,50.873000000000005,29.775 +2020-05-18 16:15:00,100.16,58.563,50.873000000000005,29.775 +2020-05-18 16:30:00,103.58,58.114,50.873000000000005,29.775 +2020-05-18 16:45:00,103.44,54.181999999999995,50.873000000000005,29.775 +2020-05-18 17:00:00,105.33,55.906000000000006,56.637,29.775 +2020-05-18 17:15:00,106.15,56.206,56.637,29.775 +2020-05-18 17:30:00,106.58,56.32899999999999,56.637,29.775 +2020-05-18 17:45:00,107.9,55.603,56.637,29.775 +2020-05-18 18:00:00,107.24,59.253,56.35,29.775 +2020-05-18 18:15:00,106.38,58.951,56.35,29.775 +2020-05-18 18:30:00,111.23,57.136,56.35,29.775 +2020-05-18 18:45:00,112.74,62.372,56.35,29.775 +2020-05-18 19:00:00,107.03,64.04899999999999,56.023,29.775 +2020-05-18 19:15:00,99.04,63.17100000000001,56.023,29.775 +2020-05-18 19:30:00,100.51,63.076,56.023,29.775 +2020-05-18 19:45:00,98.68,63.343999999999994,56.023,29.775 +2020-05-18 20:00:00,97.75,61.221000000000004,62.372,29.775 +2020-05-18 20:15:00,100.39,61.84,62.372,29.775 +2020-05-18 20:30:00,101.96,60.983000000000004,62.372,29.775 +2020-05-18 20:45:00,101.2,60.535,62.372,29.775 +2020-05-18 21:00:00,94.45,57.338,57.516999999999996,29.775 +2020-05-18 21:15:00,93.17,59.972,57.516999999999996,29.775 +2020-05-18 21:30:00,92.14,60.852,57.516999999999996,29.775 +2020-05-18 21:45:00,93.04,61.336000000000006,57.516999999999996,29.775 +2020-05-18 22:00:00,87.62,58.005,51.823,29.775 +2020-05-18 22:15:00,84.34,59.095,51.823,29.775 +2020-05-18 22:30:00,84.14,51.568999999999996,51.823,29.775 +2020-05-18 22:45:00,84.14,48.211000000000006,51.823,29.775 +2020-05-18 23:00:00,65.96,43.48,43.832,29.775 +2020-05-18 23:15:00,68.87,40.029,43.832,29.775 +2020-05-18 23:30:00,68.41,39.236,43.832,29.775 +2020-05-18 23:45:00,64.7,38.741,43.832,29.775 +2020-05-19 00:00:00,62.8,37.38,42.371,29.775 +2020-05-19 00:15:00,66.9,37.645,42.371,29.775 +2020-05-19 00:30:00,66.25,36.715,42.371,29.775 +2020-05-19 00:45:00,63.82,35.619,42.371,29.775 +2020-05-19 01:00:00,61.03,35.165,39.597,29.775 +2020-05-19 01:15:00,66.87,34.537,39.597,29.775 +2020-05-19 01:30:00,67.1,32.953,39.597,29.775 +2020-05-19 01:45:00,66.73,31.971999999999998,39.597,29.775 +2020-05-19 02:00:00,63.92,31.985,38.298,29.775 +2020-05-19 02:15:00,67.6,30.656,38.298,29.775 +2020-05-19 02:30:00,66.14,32.842,38.298,29.775 +2020-05-19 02:45:00,65.87,33.571999999999996,38.298,29.775 +2020-05-19 03:00:00,63.27,36.298,37.884,29.775 +2020-05-19 03:15:00,69.81,35.164,37.884,29.775 +2020-05-19 03:30:00,72.41,34.414,37.884,29.775 +2020-05-19 03:45:00,66.55,33.728,37.884,29.775 +2020-05-19 04:00:00,67.08,44.04600000000001,39.442,29.775 +2020-05-19 04:15:00,68.59,54.656000000000006,39.442,29.775 +2020-05-19 04:30:00,73.0,52.583,39.442,29.775 +2020-05-19 04:45:00,76.72,53.357,39.442,29.775 +2020-05-19 05:00:00,85.35,77.723,43.608000000000004,29.775 +2020-05-19 05:15:00,88.34,97.675,43.608000000000004,29.775 +2020-05-19 05:30:00,91.29,86.52,43.608000000000004,29.775 +2020-05-19 05:45:00,94.03,80.278,43.608000000000004,29.775 +2020-05-19 06:00:00,99.55,81.65100000000001,54.99100000000001,29.775 +2020-05-19 06:15:00,99.82,83.715,54.99100000000001,29.775 +2020-05-19 06:30:00,100.65,80.687,54.99100000000001,29.775 +2020-05-19 06:45:00,100.88,80.633,54.99100000000001,29.775 +2020-05-19 07:00:00,103.61,80.07600000000001,66.217,29.775 +2020-05-19 07:15:00,103.66,79.44800000000001,66.217,29.775 +2020-05-19 07:30:00,103.58,76.64399999999999,66.217,29.775 +2020-05-19 07:45:00,101.83,74.78399999999999,66.217,29.775 +2020-05-19 08:00:00,102.87,68.985,60.151,29.775 +2020-05-19 08:15:00,101.22,70.19800000000001,60.151,29.775 +2020-05-19 08:30:00,101.29,68.957,60.151,29.775 +2020-05-19 08:45:00,100.42,70.559,60.151,29.775 +2020-05-19 09:00:00,103.99,67.12100000000001,53.873000000000005,29.775 +2020-05-19 09:15:00,100.42,65.592,53.873000000000005,29.775 +2020-05-19 09:30:00,100.05,68.646,53.873000000000005,29.775 +2020-05-19 09:45:00,101.34,69.88,53.873000000000005,29.775 +2020-05-19 10:00:00,100.74,66.505,51.417,29.775 +2020-05-19 10:15:00,102.9,67.732,51.417,29.775 +2020-05-19 10:30:00,103.08,67.561,51.417,29.775 +2020-05-19 10:45:00,102.7,68.632,51.417,29.775 +2020-05-19 11:00:00,103.54,64.76,50.43600000000001,29.775 +2020-05-19 11:15:00,99.89,66.014,50.43600000000001,29.775 +2020-05-19 11:30:00,99.47,67.587,50.43600000000001,29.775 +2020-05-19 11:45:00,100.32,68.785,50.43600000000001,29.775 +2020-05-19 12:00:00,97.16,63.95399999999999,47.468,29.775 +2020-05-19 12:15:00,97.84,64.34,47.468,29.775 +2020-05-19 12:30:00,97.67,63.512,47.468,29.775 +2020-05-19 12:45:00,98.46,64.913,47.468,29.775 +2020-05-19 13:00:00,96.91,65.453,48.453,29.775 +2020-05-19 13:15:00,99.44,66.146,48.453,29.775 +2020-05-19 13:30:00,97.7,64.196,48.453,29.775 +2020-05-19 13:45:00,98.33,62.413000000000004,48.453,29.775 +2020-05-19 14:00:00,97.24,63.581,48.435,29.775 +2020-05-19 14:15:00,95.83,62.196000000000005,48.435,29.775 +2020-05-19 14:30:00,96.03,61.394,48.435,29.775 +2020-05-19 14:45:00,95.48,62.022,48.435,29.775 +2020-05-19 15:00:00,94.77,62.601000000000006,49.966,29.775 +2020-05-19 15:15:00,94.41,60.18600000000001,49.966,29.775 +2020-05-19 15:30:00,96.22,58.551,49.966,29.775 +2020-05-19 15:45:00,94.47,56.185,49.966,29.775 +2020-05-19 16:00:00,94.85,58.66,51.184,29.775 +2020-05-19 16:15:00,96.76,58.223,51.184,29.775 +2020-05-19 16:30:00,99.86,57.58,51.184,29.775 +2020-05-19 16:45:00,104.67,54.323,51.184,29.775 +2020-05-19 17:00:00,103.7,56.407,56.138999999999996,29.775 +2020-05-19 17:15:00,102.17,57.08,56.138999999999996,29.775 +2020-05-19 17:30:00,106.22,56.923,56.138999999999996,29.775 +2020-05-19 17:45:00,104.6,55.821999999999996,56.138999999999996,29.775 +2020-05-19 18:00:00,106.74,58.563,57.038000000000004,29.775 +2020-05-19 18:15:00,105.62,59.54600000000001,57.038000000000004,29.775 +2020-05-19 18:30:00,113.35,57.408,57.038000000000004,29.775 +2020-05-19 18:45:00,112.87,62.621,57.038000000000004,29.775 +2020-05-19 19:00:00,108.17,63.18899999999999,56.492,29.775 +2020-05-19 19:15:00,98.13,62.423,56.492,29.775 +2020-05-19 19:30:00,97.16,61.989,56.492,29.775 +2020-05-19 19:45:00,103.23,62.583999999999996,56.492,29.775 +2020-05-19 20:00:00,95.97,60.846000000000004,62.534,29.775 +2020-05-19 20:15:00,100.72,59.967,62.534,29.775 +2020-05-19 20:30:00,104.92,59.372,62.534,29.775 +2020-05-19 20:45:00,104.57,59.255,62.534,29.775 +2020-05-19 21:00:00,94.39,56.748999999999995,55.506,29.775 +2020-05-19 21:15:00,97.34,58.178999999999995,55.506,29.775 +2020-05-19 21:30:00,94.26,59.06,55.506,29.775 +2020-05-19 21:45:00,93.63,59.801,55.506,29.775 +2020-05-19 22:00:00,84.19,56.99,51.472,29.775 +2020-05-19 22:15:00,82.79,57.728,51.472,29.775 +2020-05-19 22:30:00,80.26,50.5,51.472,29.775 +2020-05-19 22:45:00,86.14,47.177,51.472,29.775 +2020-05-19 23:00:00,84.68,41.677,44.593,29.775 +2020-05-19 23:15:00,80.74,39.588,44.593,29.775 +2020-05-19 23:30:00,76.55,38.729,44.593,29.775 +2020-05-19 23:45:00,76.25,38.312,44.593,29.775 +2020-05-20 00:00:00,78.51,37.128,41.978,29.775 +2020-05-20 00:15:00,80.48,37.399,41.978,29.775 +2020-05-20 00:30:00,78.67,36.468,41.978,29.775 +2020-05-20 00:45:00,72.51,35.376,41.978,29.775 +2020-05-20 01:00:00,71.79,34.94,38.59,29.775 +2020-05-20 01:15:00,77.65,34.286,38.59,29.775 +2020-05-20 01:30:00,78.37,32.689,38.59,29.775 +2020-05-20 01:45:00,79.71,31.704,38.59,29.775 +2020-05-20 02:00:00,73.24,31.713,36.23,29.775 +2020-05-20 02:15:00,78.37,30.373,36.23,29.775 +2020-05-20 02:30:00,79.12,32.566,36.23,29.775 +2020-05-20 02:45:00,77.08,33.304,36.23,29.775 +2020-05-20 03:00:00,75.21,36.037,35.867,29.775 +2020-05-20 03:15:00,78.88,34.891999999999996,35.867,29.775 +2020-05-20 03:30:00,81.38,34.144,35.867,29.775 +2020-05-20 03:45:00,80.54,33.484,35.867,29.775 +2020-05-20 04:00:00,75.73,43.74100000000001,36.75,29.775 +2020-05-20 04:15:00,76.72,54.297,36.75,29.775 +2020-05-20 04:30:00,80.9,52.211000000000006,36.75,29.775 +2020-05-20 04:45:00,86.16,52.978,36.75,29.775 +2020-05-20 05:00:00,93.21,77.21,40.461,29.775 +2020-05-20 05:15:00,95.42,96.999,40.461,29.775 +2020-05-20 05:30:00,99.77,85.91,40.461,29.775 +2020-05-20 05:45:00,101.34,79.735,40.461,29.775 +2020-05-20 06:00:00,101.76,81.133,55.481,29.775 +2020-05-20 06:15:00,101.28,83.171,55.481,29.775 +2020-05-20 06:30:00,104.28,80.161,55.481,29.775 +2020-05-20 06:45:00,105.05,80.12899999999999,55.481,29.775 +2020-05-20 07:00:00,108.2,79.56,68.45,29.775 +2020-05-20 07:15:00,107.22,78.939,68.45,29.775 +2020-05-20 07:30:00,107.19,76.111,68.45,29.775 +2020-05-20 07:45:00,106.77,74.281,68.45,29.775 +2020-05-20 08:00:00,105.12,68.487,60.885,29.775 +2020-05-20 08:15:00,105.19,69.749,60.885,29.775 +2020-05-20 08:30:00,106.79,68.49600000000001,60.885,29.775 +2020-05-20 08:45:00,108.41,70.115,60.885,29.775 +2020-05-20 09:00:00,104.53,66.671,56.887,29.775 +2020-05-20 09:15:00,106.56,65.149,56.887,29.775 +2020-05-20 09:30:00,106.98,68.21300000000001,56.887,29.775 +2020-05-20 09:45:00,110.81,69.48,56.887,29.775 +2020-05-20 10:00:00,109.87,66.115,54.401,29.775 +2020-05-20 10:15:00,111.92,67.375,54.401,29.775 +2020-05-20 10:30:00,110.2,67.215,54.401,29.775 +2020-05-20 10:45:00,106.45,68.29899999999999,54.401,29.775 +2020-05-20 11:00:00,111.94,64.417,53.678000000000004,29.775 +2020-05-20 11:15:00,116.47,65.687,53.678000000000004,29.775 +2020-05-20 11:30:00,120.29,67.253,53.678000000000004,29.775 +2020-05-20 11:45:00,127.5,68.46,53.678000000000004,29.775 +2020-05-20 12:00:00,130.45,63.667,51.68,29.775 +2020-05-20 12:15:00,131.65,64.06,51.68,29.775 +2020-05-20 12:30:00,128.54,63.196000000000005,51.68,29.775 +2020-05-20 12:45:00,128.89,64.604,51.68,29.775 +2020-05-20 13:00:00,124.02,65.156,51.263000000000005,29.775 +2020-05-20 13:15:00,125.19,65.852,51.263000000000005,29.775 +2020-05-20 13:30:00,125.37,63.912,51.263000000000005,29.775 +2020-05-20 13:45:00,129.7,62.131,51.263000000000005,29.775 +2020-05-20 14:00:00,127.75,63.336000000000006,51.107,29.775 +2020-05-20 14:15:00,134.43,61.942,51.107,29.775 +2020-05-20 14:30:00,131.56,61.101000000000006,51.107,29.775 +2020-05-20 14:45:00,129.26,61.735,51.107,29.775 +2020-05-20 15:00:00,125.84,62.364,51.498000000000005,29.775 +2020-05-20 15:15:00,123.48,59.934,51.498000000000005,29.775 +2020-05-20 15:30:00,119.44,58.276,51.498000000000005,29.775 +2020-05-20 15:45:00,121.38,55.893,51.498000000000005,29.775 +2020-05-20 16:00:00,119.62,58.416000000000004,53.376999999999995,29.775 +2020-05-20 16:15:00,114.06,57.964,53.376999999999995,29.775 +2020-05-20 16:30:00,110.26,57.338,53.376999999999995,29.775 +2020-05-20 16:45:00,113.71,54.028,53.376999999999995,29.775 +2020-05-20 17:00:00,121.74,56.156000000000006,56.965,29.775 +2020-05-20 17:15:00,122.25,56.806999999999995,56.965,29.775 +2020-05-20 17:30:00,123.94,56.637,56.965,29.775 +2020-05-20 17:45:00,119.22,55.5,56.965,29.775 +2020-05-20 18:00:00,119.16,58.25899999999999,58.231,29.775 +2020-05-20 18:15:00,116.54,59.221000000000004,58.231,29.775 +2020-05-20 18:30:00,114.14,57.071000000000005,58.231,29.775 +2020-05-20 18:45:00,115.86,62.284,58.231,29.775 +2020-05-20 19:00:00,116.25,62.854,58.865,29.775 +2020-05-20 19:15:00,109.47,62.081,58.865,29.775 +2020-05-20 19:30:00,102.19,61.643,58.865,29.775 +2020-05-20 19:45:00,100.8,62.236000000000004,58.865,29.775 +2020-05-20 20:00:00,104.57,60.473,65.605,29.775 +2020-05-20 20:15:00,101.8,59.593999999999994,65.605,29.775 +2020-05-20 20:30:00,104.21,59.021,65.605,29.775 +2020-05-20 20:45:00,107.81,58.949,65.605,29.775 +2020-05-20 21:00:00,100.78,56.446999999999996,58.083999999999996,29.775 +2020-05-20 21:15:00,99.92,57.891999999999996,58.083999999999996,29.775 +2020-05-20 21:30:00,96.61,58.748000000000005,58.083999999999996,29.775 +2020-05-20 21:45:00,94.94,59.515,58.083999999999996,29.775 +2020-05-20 22:00:00,89.31,56.731,53.243,29.775 +2020-05-20 22:15:00,87.09,57.488,53.243,29.775 +2020-05-20 22:30:00,87.82,50.25899999999999,53.243,29.775 +2020-05-20 22:45:00,88.26,46.924,53.243,29.775 +2020-05-20 23:00:00,63.05,41.388999999999996,44.283,29.775 +2020-05-20 23:15:00,62.09,39.345,44.283,29.775 +2020-05-20 23:30:00,60.51,38.489000000000004,44.283,29.775 +2020-05-20 23:45:00,60.15,38.064,44.283,29.775 +2020-05-21 00:00:00,58.01,36.91,18.527,29.662 +2020-05-21 00:15:00,57.28,34.745,18.527,29.662 +2020-05-21 00:30:00,56.86,33.663000000000004,18.527,29.662 +2020-05-21 00:45:00,56.38,32.498000000000005,18.527,29.662 +2020-05-21 01:00:00,54.83,32.263000000000005,16.348,29.662 +2020-05-21 01:15:00,54.78,31.66,16.348,29.662 +2020-05-21 01:30:00,54.4,29.784000000000002,16.348,29.662 +2020-05-21 01:45:00,54.27,29.289,16.348,29.662 +2020-05-21 02:00:00,53.37,29.329,12.581,29.662 +2020-05-21 02:15:00,53.99,27.565,12.581,29.662 +2020-05-21 02:30:00,52.98,30.034000000000002,12.581,29.662 +2020-05-21 02:45:00,55.47,30.726999999999997,12.581,29.662 +2020-05-21 03:00:00,53.56,33.503,10.712,29.662 +2020-05-21 03:15:00,54.49,30.621,10.712,29.662 +2020-05-21 03:30:00,57.11,29.22,10.712,29.662 +2020-05-21 03:45:00,55.48,30.15,10.712,29.662 +2020-05-21 04:00:00,58.06,37.786,9.084,29.662 +2020-05-21 04:15:00,53.75,44.758,9.084,29.662 +2020-05-21 04:30:00,52.67,42.848,9.084,29.662 +2020-05-21 04:45:00,55.12,42.523,9.084,29.662 +2020-05-21 05:00:00,51.91,54.581,9.388,29.662 +2020-05-21 05:15:00,52.73,60.053000000000004,9.388,29.662 +2020-05-21 05:30:00,55.46,50.553999999999995,9.388,29.662 +2020-05-21 05:45:00,54.71,49.13399999999999,9.388,29.662 +2020-05-21 06:00:00,55.97,62.847,11.109000000000002,29.662 +2020-05-21 06:15:00,58.32,75.834,11.109000000000002,29.662 +2020-05-21 06:30:00,57.09,67.965,11.109000000000002,29.662 +2020-05-21 06:45:00,58.87,62.187,11.109000000000002,29.662 +2020-05-21 07:00:00,60.26,60.449,13.77,29.662 +2020-05-21 07:15:00,63.31,57.713,13.77,29.662 +2020-05-21 07:30:00,59.48,55.919,13.77,29.662 +2020-05-21 07:45:00,59.47,55.233000000000004,13.77,29.662 +2020-05-21 08:00:00,59.73,52.391999999999996,12.868,29.662 +2020-05-21 08:15:00,58.67,55.902,12.868,29.662 +2020-05-21 08:30:00,63.81,56.254,12.868,29.662 +2020-05-21 08:45:00,58.75,59.305,12.868,29.662 +2020-05-21 09:00:00,58.3,56.836000000000006,12.804,29.662 +2020-05-21 09:15:00,58.24,57.516999999999996,12.804,29.662 +2020-05-21 09:30:00,57.56,60.875,12.804,29.662 +2020-05-21 09:45:00,61.45,63.036,12.804,29.662 +2020-05-21 10:00:00,57.46,60.818000000000005,11.029000000000002,29.662 +2020-05-21 10:15:00,57.46,62.676,11.029000000000002,29.662 +2020-05-21 10:30:00,60.42,63.08,11.029000000000002,29.662 +2020-05-21 10:45:00,58.0,64.479,11.029000000000002,29.662 +2020-05-21 11:00:00,58.01,60.6,11.681,29.662 +2020-05-21 11:15:00,54.19,60.924,11.681,29.662 +2020-05-21 11:30:00,54.54,62.856,11.681,29.662 +2020-05-21 11:45:00,55.05,63.966,11.681,29.662 +2020-05-21 12:00:00,55.16,60.76,8.915,29.662 +2020-05-21 12:15:00,53.37,60.842,8.915,29.662 +2020-05-21 12:30:00,51.32,59.909,8.915,29.662 +2020-05-21 12:45:00,50.61,60.408,8.915,29.662 +2020-05-21 13:00:00,50.98,60.489,5.4639999999999995,29.662 +2020-05-21 13:15:00,52.85,60.721000000000004,5.4639999999999995,29.662 +2020-05-21 13:30:00,48.58,58.523,5.4639999999999995,29.662 +2020-05-21 13:45:00,49.76,56.766000000000005,5.4639999999999995,29.662 +2020-05-21 14:00:00,51.52,58.485,3.2939999999999996,29.662 +2020-05-21 14:15:00,57.07,56.833999999999996,3.2939999999999996,29.662 +2020-05-21 14:30:00,64.25,55.785,3.2939999999999996,29.662 +2020-05-21 14:45:00,65.85,55.278999999999996,3.2939999999999996,29.662 +2020-05-21 15:00:00,66.73,56.126999999999995,4.689,29.662 +2020-05-21 15:15:00,60.8,53.618,4.689,29.662 +2020-05-21 15:30:00,61.9,51.562,4.689,29.662 +2020-05-21 15:45:00,64.07,49.477,4.689,29.662 +2020-05-21 16:00:00,65.13,51.675,7.732,29.662 +2020-05-21 16:15:00,66.81,51.131,7.732,29.662 +2020-05-21 16:30:00,64.74,51.558,7.732,29.662 +2020-05-21 16:45:00,64.19,47.702,7.732,29.662 +2020-05-21 17:00:00,65.06,50.641999999999996,17.558,29.662 +2020-05-21 17:15:00,66.43,50.731,17.558,29.662 +2020-05-21 17:30:00,69.39,51.294,17.558,29.662 +2020-05-21 17:45:00,71.08,51.156000000000006,17.558,29.662 +2020-05-21 18:00:00,71.37,55.849,24.763,29.662 +2020-05-21 18:15:00,70.43,57.653999999999996,24.763,29.662 +2020-05-21 18:30:00,73.17,56.379,24.763,29.662 +2020-05-21 18:45:00,70.48,58.501000000000005,24.763,29.662 +2020-05-21 19:00:00,69.9,60.832,29.633000000000003,29.662 +2020-05-21 19:15:00,68.76,58.906000000000006,29.633000000000003,29.662 +2020-05-21 19:30:00,69.06,59.05,29.633000000000003,29.662 +2020-05-21 19:45:00,71.41,60.059,29.633000000000003,29.662 +2020-05-21 20:00:00,69.15,59.508,38.826,29.662 +2020-05-21 20:15:00,71.39,59.303999999999995,38.826,29.662 +2020-05-21 20:30:00,74.95,58.396,38.826,29.662 +2020-05-21 20:45:00,71.98,57.552,38.826,29.662 +2020-05-21 21:00:00,71.11,54.851000000000006,37.751,29.662 +2020-05-21 21:15:00,67.66,57.343999999999994,37.751,29.662 +2020-05-21 21:30:00,65.62,57.926,37.751,29.662 +2020-05-21 21:45:00,65.41,58.792,37.751,29.662 +2020-05-21 22:00:00,62.1,58.045,39.799,29.662 +2020-05-21 22:15:00,64.81,57.568999999999996,39.799,29.662 +2020-05-21 22:30:00,60.51,56.025,39.799,29.662 +2020-05-21 22:45:00,60.78,53.603,39.799,29.662 +2020-05-21 23:00:00,83.53,48.56,33.686,29.662 +2020-05-21 23:15:00,79.71,46.288999999999994,33.686,29.662 +2020-05-21 23:30:00,76.64,45.003,33.686,29.662 +2020-05-21 23:45:00,81.28,44.475,33.686,29.662 +2020-05-22 00:00:00,78.78,34.703,39.884,29.662 +2020-05-22 00:15:00,78.38,35.232,39.884,29.662 +2020-05-22 00:30:00,75.8,34.53,39.884,29.662 +2020-05-22 00:45:00,78.88,33.875,39.884,29.662 +2020-05-22 01:00:00,78.43,33.044000000000004,37.658,29.662 +2020-05-22 01:15:00,76.96,31.963,37.658,29.662 +2020-05-22 01:30:00,72.09,30.969,37.658,29.662 +2020-05-22 01:45:00,77.86,29.756999999999998,37.658,29.662 +2020-05-22 02:00:00,78.9,30.668000000000003,36.707,29.662 +2020-05-22 02:15:00,79.75,29.229,36.707,29.662 +2020-05-22 02:30:00,75.51,32.335,36.707,29.662 +2020-05-22 02:45:00,79.05,32.449,36.707,29.662 +2020-05-22 03:00:00,80.35,35.719,37.025,29.662 +2020-05-22 03:15:00,80.81,33.605,37.025,29.662 +2020-05-22 03:30:00,76.91,32.632,37.025,29.662 +2020-05-22 03:45:00,75.74,32.927,37.025,29.662 +2020-05-22 04:00:00,76.89,43.244,38.349000000000004,29.662 +2020-05-22 04:15:00,77.58,52.16,38.349000000000004,29.662 +2020-05-22 04:30:00,80.22,51.0,38.349000000000004,29.662 +2020-05-22 04:45:00,84.77,50.898,38.349000000000004,29.662 +2020-05-22 05:00:00,93.82,74.133,41.565,29.662 +2020-05-22 05:15:00,95.9,94.82,41.565,29.662 +2020-05-22 05:30:00,97.7,84.27,41.565,29.662 +2020-05-22 05:45:00,97.74,77.786,41.565,29.662 +2020-05-22 06:00:00,104.46,79.546,53.861000000000004,29.662 +2020-05-22 06:15:00,105.11,81.39,53.861000000000004,29.662 +2020-05-22 06:30:00,106.02,78.183,53.861000000000004,29.662 +2020-05-22 06:45:00,108.42,78.407,53.861000000000004,29.662 +2020-05-22 07:00:00,109.92,78.263,63.497,29.662 +2020-05-22 07:15:00,110.85,78.738,63.497,29.662 +2020-05-22 07:30:00,113.49,73.98100000000001,63.497,29.662 +2020-05-22 07:45:00,111.86,71.819,63.497,29.662 +2020-05-22 08:00:00,107.87,66.528,55.43899999999999,29.662 +2020-05-22 08:15:00,106.09,68.429,55.43899999999999,29.662 +2020-05-22 08:30:00,104.97,67.28,55.43899999999999,29.662 +2020-05-22 08:45:00,109.12,68.445,55.43899999999999,29.662 +2020-05-22 09:00:00,104.41,62.928000000000004,52.132,29.662 +2020-05-22 09:15:00,102.77,63.343,52.132,29.662 +2020-05-22 09:30:00,103.2,65.684,52.132,29.662 +2020-05-22 09:45:00,105.1,67.34899999999999,52.132,29.662 +2020-05-22 10:00:00,108.85,63.57,49.881,29.662 +2020-05-22 10:15:00,114.42,64.851,49.881,29.662 +2020-05-22 10:30:00,116.06,65.205,49.881,29.662 +2020-05-22 10:45:00,112.9,66.123,49.881,29.662 +2020-05-22 11:00:00,116.37,62.451,49.396,29.662 +2020-05-22 11:15:00,116.42,62.535,49.396,29.662 +2020-05-22 11:30:00,115.65,64.168,49.396,29.662 +2020-05-22 11:45:00,114.92,64.525,49.396,29.662 +2020-05-22 12:00:00,111.62,60.479,46.7,29.662 +2020-05-22 12:15:00,106.84,59.778,46.7,29.662 +2020-05-22 12:30:00,102.56,58.99100000000001,46.7,29.662 +2020-05-22 12:45:00,98.4,59.821000000000005,46.7,29.662 +2020-05-22 13:00:00,94.96,61.16,44.05,29.662 +2020-05-22 13:15:00,96.61,62.23,44.05,29.662 +2020-05-22 13:30:00,93.79,61.045,44.05,29.662 +2020-05-22 13:45:00,92.84,59.531000000000006,44.05,29.662 +2020-05-22 14:00:00,91.24,59.794,42.805,29.662 +2020-05-22 14:15:00,93.29,58.739,42.805,29.662 +2020-05-22 14:30:00,91.88,59.32899999999999,42.805,29.662 +2020-05-22 14:45:00,93.09,59.412,42.805,29.662 +2020-05-22 15:00:00,93.45,59.95399999999999,44.36600000000001,29.662 +2020-05-22 15:15:00,95.82,57.167,44.36600000000001,29.662 +2020-05-22 15:30:00,97.12,54.571000000000005,44.36600000000001,29.662 +2020-05-22 15:45:00,97.8,52.891000000000005,44.36600000000001,29.662 +2020-05-22 16:00:00,96.53,54.466,46.928999999999995,29.662 +2020-05-22 16:15:00,103.21,54.513999999999996,46.928999999999995,29.662 +2020-05-22 16:30:00,104.3,53.775,46.928999999999995,29.662 +2020-05-22 16:45:00,104.51,49.604,46.928999999999995,29.662 +2020-05-22 17:00:00,103.54,53.474,51.468,29.662 +2020-05-22 17:15:00,101.79,53.823,51.468,29.662 +2020-05-22 17:30:00,102.58,53.708,51.468,29.662 +2020-05-22 17:45:00,104.14,52.295,51.468,29.662 +2020-05-22 18:00:00,106.35,55.326,52.58,29.662 +2020-05-22 18:15:00,103.1,55.269,52.58,29.662 +2020-05-22 18:30:00,102.56,53.086000000000006,52.58,29.662 +2020-05-22 18:45:00,105.35,58.692,52.58,29.662 +2020-05-22 19:00:00,107.38,60.31399999999999,52.183,29.662 +2020-05-22 19:15:00,103.6,60.376000000000005,52.183,29.662 +2020-05-22 19:30:00,98.21,59.898999999999994,52.183,29.662 +2020-05-22 19:45:00,98.34,59.427,52.183,29.662 +2020-05-22 20:00:00,94.72,57.468999999999994,58.497,29.662 +2020-05-22 20:15:00,95.77,57.34,58.497,29.662 +2020-05-22 20:30:00,98.16,56.342,58.497,29.662 +2020-05-22 20:45:00,96.69,55.707,58.497,29.662 +2020-05-22 21:00:00,97.63,54.568000000000005,54.731,29.662 +2020-05-22 21:15:00,97.68,57.72,54.731,29.662 +2020-05-22 21:30:00,90.83,58.396,54.731,29.662 +2020-05-22 21:45:00,86.47,59.52,54.731,29.662 +2020-05-22 22:00:00,80.55,56.864,51.386,29.662 +2020-05-22 22:15:00,84.38,57.391000000000005,51.386,29.662 +2020-05-22 22:30:00,86.27,56.013000000000005,51.386,29.662 +2020-05-22 22:45:00,84.62,54.208999999999996,51.386,29.662 +2020-05-22 23:00:00,74.96,50.117,44.463,29.662 +2020-05-22 23:15:00,74.35,46.266000000000005,44.463,29.662 +2020-05-22 23:30:00,71.21,43.38,44.463,29.662 +2020-05-22 23:45:00,76.73,42.683,44.463,29.662 +2020-05-23 00:00:00,75.7,35.035,42.833999999999996,29.662 +2020-05-23 00:15:00,76.89,34.034,42.833999999999996,29.662 +2020-05-23 00:30:00,71.1,33.168,42.833999999999996,29.662 +2020-05-23 00:45:00,75.4,31.974,42.833999999999996,29.662 +2020-05-23 01:00:00,73.52,31.552,37.859,29.662 +2020-05-23 01:15:00,74.62,30.799,37.859,29.662 +2020-05-23 01:30:00,68.8,28.918000000000003,37.859,29.662 +2020-05-23 01:45:00,73.9,28.836,37.859,29.662 +2020-05-23 02:00:00,72.13,28.951,35.327,29.662 +2020-05-23 02:15:00,73.48,26.677,35.327,29.662 +2020-05-23 02:30:00,66.65,28.805,35.327,29.662 +2020-05-23 02:45:00,65.13,29.693,35.327,29.662 +2020-05-23 03:00:00,66.59,31.802,34.908,29.662 +2020-05-23 03:15:00,64.97,28.761,34.908,29.662 +2020-05-23 03:30:00,65.86,27.765,34.908,29.662 +2020-05-23 03:45:00,65.45,29.511999999999997,34.908,29.662 +2020-05-23 04:00:00,63.35,37.108000000000004,34.84,29.662 +2020-05-23 04:15:00,63.76,44.592,34.84,29.662 +2020-05-23 04:30:00,62.96,41.382,34.84,29.662 +2020-05-23 04:45:00,64.33,41.43,34.84,29.662 +2020-05-23 05:00:00,67.52,53.748000000000005,34.222,29.662 +2020-05-23 05:15:00,67.85,60.097,34.222,29.662 +2020-05-23 05:30:00,68.35,51.146,34.222,29.662 +2020-05-23 05:45:00,68.82,50.068999999999996,34.222,29.662 +2020-05-23 06:00:00,72.79,66.115,35.515,29.662 +2020-05-23 06:15:00,71.8,78.594,35.515,29.662 +2020-05-23 06:30:00,73.05,71.634,35.515,29.662 +2020-05-23 06:45:00,75.09,67.053,35.515,29.662 +2020-05-23 07:00:00,76.2,64.55199999999999,39.687,29.662 +2020-05-23 07:15:00,76.2,63.553000000000004,39.687,29.662 +2020-05-23 07:30:00,77.52,60.75899999999999,39.687,29.662 +2020-05-23 07:45:00,79.58,60.32,39.687,29.662 +2020-05-23 08:00:00,78.67,56.416000000000004,44.9,29.662 +2020-05-23 08:15:00,78.07,58.93600000000001,44.9,29.662 +2020-05-23 08:30:00,78.21,58.104,44.9,29.662 +2020-05-23 08:45:00,78.09,60.843,44.9,29.662 +2020-05-23 09:00:00,76.63,58.558,45.724,29.662 +2020-05-23 09:15:00,76.47,59.595,45.724,29.662 +2020-05-23 09:30:00,75.4,62.603,45.724,29.662 +2020-05-23 09:45:00,75.15,63.916000000000004,45.724,29.662 +2020-05-23 10:00:00,75.82,60.69,43.123999999999995,29.662 +2020-05-23 10:15:00,76.15,62.368,43.123999999999995,29.662 +2020-05-23 10:30:00,76.53,62.45399999999999,43.123999999999995,29.662 +2020-05-23 10:45:00,75.37,63.36,43.123999999999995,29.662 +2020-05-23 11:00:00,75.31,59.586999999999996,40.255,29.662 +2020-05-23 11:15:00,77.04,60.324,40.255,29.662 +2020-05-23 11:30:00,77.44,61.949,40.255,29.662 +2020-05-23 11:45:00,74.8,62.708999999999996,40.255,29.662 +2020-05-23 12:00:00,69.86,58.754,38.582,29.662 +2020-05-23 12:15:00,69.32,58.985,38.582,29.662 +2020-05-23 12:30:00,71.91,58.121,38.582,29.662 +2020-05-23 12:45:00,70.49,59.434,38.582,29.662 +2020-05-23 13:00:00,69.63,59.963,36.043,29.662 +2020-05-23 13:15:00,73.6,60.055,36.043,29.662 +2020-05-23 13:30:00,72.67,58.946999999999996,36.043,29.662 +2020-05-23 13:45:00,69.34,56.317,36.043,29.662 +2020-05-23 14:00:00,67.9,56.952,35.216,29.662 +2020-05-23 14:15:00,68.87,54.621,35.216,29.662 +2020-05-23 14:30:00,69.82,54.353,35.216,29.662 +2020-05-23 14:45:00,66.18,54.928999999999995,35.216,29.662 +2020-05-23 15:00:00,71.76,56.085,36.759,29.662 +2020-05-23 15:15:00,72.55,54.121,36.759,29.662 +2020-05-23 15:30:00,72.31,52.053000000000004,36.759,29.662 +2020-05-23 15:45:00,71.95,49.521,36.759,29.662 +2020-05-23 16:00:00,71.37,52.958,40.086,29.662 +2020-05-23 16:15:00,71.22,52.37,40.086,29.662 +2020-05-23 16:30:00,71.77,51.843999999999994,40.086,29.662 +2020-05-23 16:45:00,72.93,47.797,40.086,29.662 +2020-05-23 17:00:00,75.81,50.486000000000004,44.876999999999995,29.662 +2020-05-23 17:15:00,77.15,49.199,44.876999999999995,29.662 +2020-05-23 17:30:00,81.73,48.926,44.876999999999995,29.662 +2020-05-23 17:45:00,83.0,47.845,44.876999999999995,29.662 +2020-05-23 18:00:00,82.3,52.175,47.056000000000004,29.662 +2020-05-23 18:15:00,80.93,54.047,47.056000000000004,29.662 +2020-05-23 18:30:00,81.66,53.409,47.056000000000004,29.662 +2020-05-23 18:45:00,81.46,55.044,47.056000000000004,29.662 +2020-05-23 19:00:00,79.51,55.43,45.57,29.662 +2020-05-23 19:15:00,76.63,54.428999999999995,45.57,29.662 +2020-05-23 19:30:00,75.8,54.83,45.57,29.662 +2020-05-23 19:45:00,75.75,55.99,45.57,29.662 +2020-05-23 20:00:00,75.77,55.193000000000005,41.685,29.662 +2020-05-23 20:15:00,75.2,54.916000000000004,41.685,29.662 +2020-05-23 20:30:00,78.48,53.038999999999994,41.685,29.662 +2020-05-23 20:45:00,77.87,54.102,41.685,29.662 +2020-05-23 21:00:00,75.6,52.068999999999996,39.576,29.662 +2020-05-23 21:15:00,75.41,54.998999999999995,39.576,29.662 +2020-05-23 21:30:00,72.51,56.086999999999996,39.576,29.662 +2020-05-23 21:45:00,71.96,56.635,39.576,29.662 +2020-05-23 22:00:00,68.93,54.263000000000005,39.068000000000005,29.662 +2020-05-23 22:15:00,68.33,55.56,39.068000000000005,29.662 +2020-05-23 22:30:00,65.26,55.118,39.068000000000005,29.662 +2020-05-23 22:45:00,65.06,54.041000000000004,39.068000000000005,29.662 +2020-05-23 23:00:00,61.85,49.77,32.06,29.662 +2020-05-23 23:15:00,61.41,46.022,32.06,29.662 +2020-05-23 23:30:00,61.34,45.095,32.06,29.662 +2020-05-23 23:45:00,59.43,44.225,32.06,29.662 +2020-05-24 00:00:00,57.13,36.184,28.825,29.662 +2020-05-24 00:15:00,57.97,34.035,28.825,29.662 +2020-05-24 00:30:00,57.26,32.953,28.825,29.662 +2020-05-24 00:45:00,57.6,31.803,28.825,29.662 +2020-05-24 01:00:00,55.3,31.62,25.995,29.662 +2020-05-24 01:15:00,56.86,30.941999999999997,25.995,29.662 +2020-05-24 01:30:00,56.68,29.026999999999997,25.995,29.662 +2020-05-24 01:45:00,56.48,28.518,25.995,29.662 +2020-05-24 02:00:00,54.98,28.549,24.394000000000002,29.662 +2020-05-24 02:15:00,55.67,26.753,24.394000000000002,29.662 +2020-05-24 02:30:00,55.71,29.241999999999997,24.394000000000002,29.662 +2020-05-24 02:45:00,54.51,29.96,24.394000000000002,29.662 +2020-05-24 03:00:00,54.2,32.754,22.916999999999998,29.662 +2020-05-24 03:15:00,54.33,29.838,22.916999999999998,29.662 +2020-05-24 03:30:00,55.39,28.445999999999998,22.916999999999998,29.662 +2020-05-24 03:45:00,53.97,29.454,22.916999999999998,29.662 +2020-05-24 04:00:00,51.59,36.912,23.576999999999998,29.662 +2020-05-24 04:15:00,52.43,43.722,23.576999999999998,29.662 +2020-05-24 04:30:00,52.07,41.773999999999994,23.576999999999998,29.662 +2020-05-24 04:45:00,52.18,41.433,23.576999999999998,29.662 +2020-05-24 05:00:00,52.38,53.093999999999994,22.730999999999998,29.662 +2020-05-24 05:15:00,51.01,58.086999999999996,22.730999999999998,29.662 +2020-05-24 05:30:00,51.44,48.788000000000004,22.730999999999998,29.662 +2020-05-24 05:45:00,50.26,47.56100000000001,22.730999999999998,29.662 +2020-05-24 06:00:00,55.4,61.348,22.34,29.662 +2020-05-24 06:15:00,56.11,74.26,22.34,29.662 +2020-05-24 06:30:00,56.5,66.44,22.34,29.662 +2020-05-24 06:45:00,58.7,60.73,22.34,29.662 +2020-05-24 07:00:00,60.0,58.957,24.691999999999997,29.662 +2020-05-24 07:15:00,62.28,56.248999999999995,24.691999999999997,29.662 +2020-05-24 07:30:00,63.86,54.387,24.691999999999997,29.662 +2020-05-24 07:45:00,66.28,53.792,24.691999999999997,29.662 +2020-05-24 08:00:00,68.02,50.965,29.340999999999998,29.662 +2020-05-24 08:15:00,69.85,54.619,29.340999999999998,29.662 +2020-05-24 08:30:00,70.34,54.93600000000001,29.340999999999998,29.662 +2020-05-24 08:45:00,69.19,58.036,29.340999999999998,29.662 +2020-05-24 09:00:00,64.38,55.551,30.788,29.662 +2020-05-24 09:15:00,64.98,56.251999999999995,30.788,29.662 +2020-05-24 09:30:00,61.61,59.635,30.788,29.662 +2020-05-24 09:45:00,58.24,61.888999999999996,30.788,29.662 +2020-05-24 10:00:00,59.37,59.70399999999999,30.158,29.662 +2020-05-24 10:15:00,60.65,61.652,30.158,29.662 +2020-05-24 10:30:00,62.09,62.085,30.158,29.662 +2020-05-24 10:45:00,62.05,63.525,30.158,29.662 +2020-05-24 11:00:00,62.17,59.622,32.056,29.662 +2020-05-24 11:15:00,59.81,59.99100000000001,32.056,29.662 +2020-05-24 11:30:00,55.31,61.896,32.056,29.662 +2020-05-24 11:45:00,55.01,63.036,32.056,29.662 +2020-05-24 12:00:00,56.12,59.94,28.671999999999997,29.662 +2020-05-24 12:15:00,58.46,60.038000000000004,28.671999999999997,29.662 +2020-05-24 12:30:00,58.98,59.003,28.671999999999997,29.662 +2020-05-24 12:45:00,61.98,59.52,28.671999999999997,29.662 +2020-05-24 13:00:00,60.62,59.632,23.171,29.662 +2020-05-24 13:15:00,61.12,59.873000000000005,23.171,29.662 +2020-05-24 13:30:00,56.46,57.708999999999996,23.171,29.662 +2020-05-24 13:45:00,56.65,55.956,23.171,29.662 +2020-05-24 14:00:00,56.1,57.781000000000006,19.11,29.662 +2020-05-24 14:15:00,58.99,56.107,19.11,29.662 +2020-05-24 14:30:00,64.84,54.945,19.11,29.662 +2020-05-24 14:45:00,70.09,54.452,19.11,29.662 +2020-05-24 15:00:00,72.58,55.449,19.689,29.662 +2020-05-24 15:15:00,73.17,52.895,19.689,29.662 +2020-05-24 15:30:00,74.8,50.771,19.689,29.662 +2020-05-24 15:45:00,76.05,48.638999999999996,19.689,29.662 +2020-05-24 16:00:00,84.67,50.972,22.875,29.662 +2020-05-24 16:15:00,87.35,50.39,22.875,29.662 +2020-05-24 16:30:00,87.69,50.867,22.875,29.662 +2020-05-24 16:45:00,83.03,46.857,22.875,29.662 +2020-05-24 17:00:00,83.2,49.927,33.884,29.662 +2020-05-24 17:15:00,80.98,49.951,33.884,29.662 +2020-05-24 17:30:00,82.32,50.475,33.884,29.662 +2020-05-24 17:45:00,86.46,50.239,33.884,29.662 +2020-05-24 18:00:00,88.05,54.983000000000004,38.453,29.662 +2020-05-24 18:15:00,84.96,56.722,38.453,29.662 +2020-05-24 18:30:00,81.33,55.413999999999994,38.453,29.662 +2020-05-24 18:45:00,80.49,57.535,38.453,29.662 +2020-05-24 19:00:00,83.33,59.87,39.221,29.662 +2020-05-24 19:15:00,81.36,57.926,39.221,29.662 +2020-05-24 19:30:00,79.76,58.052,39.221,29.662 +2020-05-24 19:45:00,80.78,59.06,39.221,29.662 +2020-05-24 20:00:00,82.2,58.438,37.871,29.662 +2020-05-24 20:15:00,82.3,58.229,37.871,29.662 +2020-05-24 20:30:00,83.51,57.387,37.871,29.662 +2020-05-24 20:45:00,83.49,56.669,37.871,29.662 +2020-05-24 21:00:00,81.41,53.983999999999995,36.465,29.662 +2020-05-24 21:15:00,82.26,56.519,36.465,29.662 +2020-05-24 21:30:00,75.32,57.03,36.465,29.662 +2020-05-24 21:45:00,76.43,57.966,36.465,29.662 +2020-05-24 22:00:00,74.5,57.299,36.092,29.662 +2020-05-24 22:15:00,78.91,56.873999999999995,36.092,29.662 +2020-05-24 22:30:00,80.16,55.325,36.092,29.662 +2020-05-24 22:45:00,79.85,52.865,36.092,29.662 +2020-05-24 23:00:00,71.49,47.725,31.013,29.662 +2020-05-24 23:15:00,69.19,45.586999999999996,31.013,29.662 +2020-05-24 23:30:00,73.1,44.315,31.013,29.662 +2020-05-24 23:45:00,75.16,43.761,31.013,29.662 +2020-05-25 00:00:00,73.04,38.211999999999996,31.174,29.775 +2020-05-25 00:15:00,71.45,37.404,31.174,29.775 +2020-05-25 00:30:00,65.82,35.985,31.174,29.775 +2020-05-25 00:45:00,65.94,34.361,31.174,29.775 +2020-05-25 01:00:00,68.21,34.563,29.663,29.775 +2020-05-25 01:15:00,73.16,33.77,29.663,29.775 +2020-05-25 01:30:00,73.49,32.193000000000005,29.663,29.775 +2020-05-25 01:45:00,70.92,31.61,29.663,29.775 +2020-05-25 02:00:00,67.31,32.065,28.793000000000003,29.775 +2020-05-25 02:15:00,72.78,29.594,28.793000000000003,29.775 +2020-05-25 02:30:00,74.32,32.31,28.793000000000003,29.775 +2020-05-25 02:45:00,74.35,32.775999999999996,28.793000000000003,29.775 +2020-05-25 03:00:00,69.78,36.306999999999995,27.728,29.775 +2020-05-25 03:15:00,68.47,34.389,27.728,29.775 +2020-05-25 03:30:00,69.86,33.588,27.728,29.775 +2020-05-25 03:45:00,70.1,34.076,27.728,29.775 +2020-05-25 04:00:00,73.53,45.273999999999994,29.266,29.775 +2020-05-25 04:15:00,73.54,55.622,29.266,29.775 +2020-05-25 04:30:00,77.68,53.648,29.266,29.775 +2020-05-25 04:45:00,81.3,53.683,29.266,29.775 +2020-05-25 05:00:00,89.03,74.794,37.889,29.775 +2020-05-25 05:15:00,93.31,93.23,37.889,29.775 +2020-05-25 05:30:00,95.72,82.57700000000001,37.889,29.775 +2020-05-25 05:45:00,98.49,77.42699999999999,37.889,29.775 +2020-05-25 06:00:00,99.46,78.153,55.485,29.775 +2020-05-25 06:15:00,104.22,79.572,55.485,29.775 +2020-05-25 06:30:00,105.6,77.07,55.485,29.775 +2020-05-25 06:45:00,106.6,78.078,55.485,29.775 +2020-05-25 07:00:00,108.35,77.343,65.765,29.775 +2020-05-25 07:15:00,107.73,77.042,65.765,29.775 +2020-05-25 07:30:00,107.92,74.193,65.765,29.775 +2020-05-25 07:45:00,108.55,73.4,65.765,29.775 +2020-05-25 08:00:00,106.16,67.673,56.745,29.775 +2020-05-25 08:15:00,106.57,69.807,56.745,29.775 +2020-05-25 08:30:00,107.31,68.376,56.745,29.775 +2020-05-25 08:45:00,106.93,71.00399999999999,56.745,29.775 +2020-05-25 09:00:00,106.44,67.383,53.321999999999996,29.775 +2020-05-25 09:15:00,106.7,65.695,53.321999999999996,29.775 +2020-05-25 09:30:00,105.96,68.01100000000001,53.321999999999996,29.775 +2020-05-25 09:45:00,105.74,68.217,53.321999999999996,29.775 +2020-05-25 10:00:00,106.53,66.318,51.309,29.775 +2020-05-25 10:15:00,109.6,68.087,51.309,29.775 +2020-05-25 10:30:00,108.78,67.915,51.309,29.775 +2020-05-25 10:45:00,107.84,68.066,51.309,29.775 +2020-05-25 11:00:00,105.05,63.818000000000005,50.415,29.775 +2020-05-25 11:15:00,103.48,64.858,50.415,29.775 +2020-05-25 11:30:00,101.34,67.70100000000001,50.415,29.775 +2020-05-25 11:45:00,101.35,69.223,50.415,29.775 +2020-05-25 12:00:00,100.54,65.095,48.273,29.775 +2020-05-25 12:15:00,101.38,65.3,48.273,29.775 +2020-05-25 12:30:00,99.35,63.31,48.273,29.775 +2020-05-25 12:45:00,101.48,64.178,48.273,29.775 +2020-05-25 13:00:00,100.53,65.195,48.452,29.775 +2020-05-25 13:15:00,101.37,64.317,48.452,29.775 +2020-05-25 13:30:00,99.07,62.199,48.452,29.775 +2020-05-25 13:45:00,100.17,61.278999999999996,48.452,29.775 +2020-05-25 14:00:00,99.77,62.183,48.35,29.775 +2020-05-25 14:15:00,102.24,60.898999999999994,48.35,29.775 +2020-05-25 14:30:00,103.48,59.428000000000004,48.35,29.775 +2020-05-25 14:45:00,101.43,60.82899999999999,48.35,29.775 +2020-05-25 15:00:00,101.0,61.948,48.838,29.775 +2020-05-25 15:15:00,97.03,58.513000000000005,48.838,29.775 +2020-05-25 15:30:00,98.64,56.886,48.838,29.775 +2020-05-25 15:45:00,101.34,54.193999999999996,48.838,29.775 +2020-05-25 16:00:00,101.37,57.505,50.873000000000005,29.775 +2020-05-25 16:15:00,103.25,56.806999999999995,50.873000000000005,29.775 +2020-05-25 16:30:00,106.69,56.475,50.873000000000005,29.775 +2020-05-25 16:45:00,106.5,52.178000000000004,50.873000000000005,29.775 +2020-05-25 17:00:00,107.1,54.208999999999996,56.637,29.775 +2020-05-25 17:15:00,111.13,54.353,56.637,29.775 +2020-05-25 17:30:00,110.99,54.388000000000005,56.637,29.775 +2020-05-25 17:45:00,111.14,53.427,56.637,29.775 +2020-05-25 18:00:00,108.96,57.196999999999996,56.35,29.775 +2020-05-25 18:15:00,105.76,56.744,56.35,29.775 +2020-05-25 18:30:00,113.14,54.85,56.35,29.775 +2020-05-25 18:45:00,116.77,60.083,56.35,29.775 +2020-05-25 19:00:00,108.64,61.768,56.023,29.775 +2020-05-25 19:15:00,98.73,60.848,56.023,29.775 +2020-05-25 19:30:00,103.11,60.716,56.023,29.775 +2020-05-25 19:45:00,99.07,60.978,56.023,29.775 +2020-05-25 20:00:00,96.44,58.687,62.372,29.775 +2020-05-25 20:15:00,103.82,59.299,62.372,29.775 +2020-05-25 20:30:00,106.27,58.593999999999994,62.372,29.775 +2020-05-25 20:45:00,103.64,58.45,62.372,29.775 +2020-05-25 21:00:00,96.06,55.285,57.516999999999996,29.775 +2020-05-25 21:15:00,96.57,58.018,57.516999999999996,29.775 +2020-05-25 21:30:00,96.34,58.732,57.516999999999996,29.775 +2020-05-25 21:45:00,96.09,59.38399999999999,57.516999999999996,29.775 +2020-05-25 22:00:00,89.25,56.24,51.823,29.775 +2020-05-25 22:15:00,83.2,57.45399999999999,51.823,29.775 +2020-05-25 22:30:00,87.35,49.917,51.823,29.775 +2020-05-25 22:45:00,89.63,46.472,51.823,29.775 +2020-05-25 23:00:00,82.93,41.511,43.832,29.775 +2020-05-25 23:15:00,75.55,38.372,43.832,29.775 +2020-05-25 23:30:00,79.33,37.609,43.832,29.775 +2020-05-25 23:45:00,82.15,37.052,43.832,29.775 +2020-05-26 00:00:00,77.88,35.689,42.371,29.775 +2020-05-26 00:15:00,75.1,35.991,42.371,29.775 +2020-05-26 00:30:00,78.04,35.06,42.371,29.775 +2020-05-26 00:45:00,78.89,33.999,42.371,29.775 +2020-05-26 01:00:00,77.82,33.665,39.597,29.775 +2020-05-26 01:15:00,76.09,32.865,39.597,29.775 +2020-05-26 01:30:00,78.26,31.186,39.597,29.775 +2020-05-26 01:45:00,79.73,30.171999999999997,39.597,29.775 +2020-05-26 02:00:00,79.78,30.166999999999998,38.298,29.775 +2020-05-26 02:15:00,76.12,28.761999999999997,38.298,29.775 +2020-05-26 02:30:00,79.45,30.995,38.298,29.775 +2020-05-26 02:45:00,81.63,31.781999999999996,38.298,29.775 +2020-05-26 03:00:00,78.81,34.551,37.884,29.775 +2020-05-26 03:15:00,75.75,33.34,37.884,29.775 +2020-05-26 03:30:00,80.4,32.609,37.884,29.775 +2020-05-26 03:45:00,82.09,32.106,37.884,29.775 +2020-05-26 04:00:00,81.49,42.005,39.442,29.775 +2020-05-26 04:15:00,78.42,52.242,39.442,29.775 +2020-05-26 04:30:00,81.46,50.07899999999999,39.442,29.775 +2020-05-26 04:45:00,86.44,50.81399999999999,39.442,29.775 +2020-05-26 05:00:00,92.93,74.25399999999999,43.608000000000004,29.775 +2020-05-26 05:15:00,95.98,93.09100000000001,43.608000000000004,29.775 +2020-05-26 05:30:00,97.87,82.4,43.608000000000004,29.775 +2020-05-26 05:45:00,100.97,76.611,43.608000000000004,29.775 +2020-05-26 06:00:00,104.23,78.155,54.99100000000001,29.775 +2020-05-26 06:15:00,106.17,80.044,54.99100000000001,29.775 +2020-05-26 06:30:00,107.29,77.12899999999999,54.99100000000001,29.775 +2020-05-26 06:45:00,108.62,77.236,54.99100000000001,29.775 +2020-05-26 07:00:00,111.63,76.596,66.217,29.775 +2020-05-26 07:15:00,109.37,76.031,66.217,29.775 +2020-05-26 07:30:00,108.15,73.069,66.217,29.775 +2020-05-26 07:45:00,107.28,71.422,66.217,29.775 +2020-05-26 08:00:00,105.84,65.657,60.151,29.775 +2020-05-26 08:15:00,107.1,67.205,60.151,29.775 +2020-05-26 08:30:00,107.63,65.885,60.151,29.775 +2020-05-26 08:45:00,105.85,67.601,60.151,29.775 +2020-05-26 09:00:00,106.2,64.122,53.873000000000005,29.775 +2020-05-26 09:15:00,106.76,62.643,53.873000000000005,29.775 +2020-05-26 09:30:00,106.29,65.755,53.873000000000005,29.775 +2020-05-26 09:45:00,107.29,67.207,53.873000000000005,29.775 +2020-05-26 10:00:00,107.68,63.907,51.417,29.775 +2020-05-26 10:15:00,108.29,65.344,51.417,29.775 +2020-05-26 10:30:00,107.02,65.244,51.417,29.775 +2020-05-26 10:45:00,106.79,66.407,51.417,29.775 +2020-05-26 11:00:00,103.4,62.48,50.43600000000001,29.775 +2020-05-26 11:15:00,103.94,63.838,50.43600000000001,29.775 +2020-05-26 11:30:00,107.61,65.347,50.43600000000001,29.775 +2020-05-26 11:45:00,110.51,66.61399999999999,50.43600000000001,29.775 +2020-05-26 12:00:00,108.4,62.042,47.468,29.775 +2020-05-26 12:15:00,103.09,62.467,47.468,29.775 +2020-05-26 12:30:00,104.04,61.398,47.468,29.775 +2020-05-26 12:45:00,101.49,62.843,47.468,29.775 +2020-05-26 13:00:00,102.78,63.45399999999999,48.453,29.775 +2020-05-26 13:15:00,107.92,64.171,48.453,29.775 +2020-05-26 13:30:00,112.42,62.297,48.453,29.775 +2020-05-26 13:45:00,112.14,60.523999999999994,48.453,29.775 +2020-05-26 14:00:00,110.26,61.941,48.435,29.775 +2020-05-26 14:15:00,105.71,60.5,48.435,29.775 +2020-05-26 14:30:00,111.81,59.43600000000001,48.435,29.775 +2020-05-26 14:45:00,112.96,60.092,48.435,29.775 +2020-05-26 15:00:00,112.99,61.018,49.966,29.775 +2020-05-26 15:15:00,110.34,58.498000000000005,49.966,29.775 +2020-05-26 15:30:00,113.39,56.705,49.966,29.775 +2020-05-26 15:45:00,116.68,54.229,49.966,29.775 +2020-05-26 16:00:00,118.92,57.022,51.184,29.775 +2020-05-26 16:15:00,115.17,56.494,51.184,29.775 +2020-05-26 16:30:00,114.9,55.968,51.184,29.775 +2020-05-26 16:45:00,120.29,52.354,51.184,29.775 +2020-05-26 17:00:00,120.37,54.74,56.138999999999996,29.775 +2020-05-26 17:15:00,116.21,55.26,56.138999999999996,29.775 +2020-05-26 17:30:00,111.59,55.015,56.138999999999996,29.775 +2020-05-26 17:45:00,120.21,53.681000000000004,56.138999999999996,29.775 +2020-05-26 18:00:00,122.33,56.542,57.038000000000004,29.775 +2020-05-26 18:15:00,115.55,57.373000000000005,57.038000000000004,29.775 +2020-05-26 18:30:00,110.43,55.159,57.038000000000004,29.775 +2020-05-26 18:45:00,113.6,60.367,57.038000000000004,29.775 +2020-05-26 19:00:00,113.25,60.945,56.492,29.775 +2020-05-26 19:15:00,105.86,60.135,56.492,29.775 +2020-05-26 19:30:00,105.79,59.665,56.492,29.775 +2020-05-26 19:45:00,100.27,60.254,56.492,29.775 +2020-05-26 20:00:00,101.3,58.349,62.534,29.775 +2020-05-26 20:15:00,105.86,57.463,62.534,29.775 +2020-05-26 20:30:00,107.47,57.018,62.534,29.775 +2020-05-26 20:45:00,104.42,57.199,62.534,29.775 +2020-05-26 21:00:00,97.23,54.727,55.506,29.775 +2020-05-26 21:15:00,102.65,56.256,55.506,29.775 +2020-05-26 21:30:00,99.18,56.97,55.506,29.775 +2020-05-26 21:45:00,96.09,57.873999999999995,55.506,29.775 +2020-05-26 22:00:00,85.21,55.248000000000005,51.472,29.775 +2020-05-26 22:15:00,90.23,56.108999999999995,51.472,29.775 +2020-05-26 22:30:00,91.74,48.865,51.472,29.775 +2020-05-26 22:45:00,88.76,45.456,51.472,29.775 +2020-05-26 23:00:00,79.55,39.731,44.593,29.775 +2020-05-26 23:15:00,79.85,37.953,44.593,29.775 +2020-05-26 23:30:00,86.2,37.123000000000005,44.593,29.775 +2020-05-26 23:45:00,87.91,36.645,44.593,29.775 +2020-05-27 00:00:00,78.67,35.461999999999996,41.978,29.775 +2020-05-27 00:15:00,76.21,35.769,41.978,29.775 +2020-05-27 00:30:00,74.83,34.839,41.978,29.775 +2020-05-27 00:45:00,82.21,33.784,41.978,29.775 +2020-05-27 01:00:00,79.98,33.466,38.59,29.775 +2020-05-27 01:15:00,79.27,32.641999999999996,38.59,29.775 +2020-05-27 01:30:00,75.92,30.951,38.59,29.775 +2020-05-27 01:45:00,75.33,29.930999999999997,38.59,29.775 +2020-05-27 02:00:00,79.93,29.924,36.23,29.775 +2020-05-27 02:15:00,81.78,28.51,36.23,29.775 +2020-05-27 02:30:00,78.49,30.748,36.23,29.775 +2020-05-27 02:45:00,76.39,31.541999999999998,36.23,29.775 +2020-05-27 03:00:00,74.78,34.317,35.867,29.775 +2020-05-27 03:15:00,81.81,33.095,35.867,29.775 +2020-05-27 03:30:00,83.72,32.368,35.867,29.775 +2020-05-27 03:45:00,84.2,31.891,35.867,29.775 +2020-05-27 04:00:00,80.31,41.731,36.75,29.775 +2020-05-27 04:15:00,79.3,51.916000000000004,36.75,29.775 +2020-05-27 04:30:00,80.69,49.742,36.75,29.775 +2020-05-27 04:45:00,85.87,50.47,36.75,29.775 +2020-05-27 05:00:00,92.63,73.78399999999999,40.461,29.775 +2020-05-27 05:15:00,97.73,92.46600000000001,40.461,29.775 +2020-05-27 05:30:00,101.04,81.842,40.461,29.775 +2020-05-27 05:45:00,102.83,76.115,40.461,29.775 +2020-05-27 06:00:00,108.55,77.681,55.481,29.775 +2020-05-27 06:15:00,111.03,79.547,55.481,29.775 +2020-05-27 06:30:00,114.05,76.648,55.481,29.775 +2020-05-27 06:45:00,116.14,76.777,55.481,29.775 +2020-05-27 07:00:00,118.55,76.126,68.45,29.775 +2020-05-27 07:15:00,117.45,75.571,68.45,29.775 +2020-05-27 07:30:00,116.41,72.589,68.45,29.775 +2020-05-27 07:45:00,115.84,70.972,68.45,29.775 +2020-05-27 08:00:00,115.67,65.21300000000001,60.885,29.775 +2020-05-27 08:15:00,117.03,66.808,60.885,29.775 +2020-05-27 08:30:00,117.65,65.476,60.885,29.775 +2020-05-27 08:45:00,116.56,67.208,60.885,29.775 +2020-05-27 09:00:00,113.89,63.724,56.887,29.775 +2020-05-27 09:15:00,113.51,62.251000000000005,56.887,29.775 +2020-05-27 09:30:00,110.46,65.37,56.887,29.775 +2020-05-27 09:45:00,113.38,66.851,56.887,29.775 +2020-05-27 10:00:00,111.51,63.56100000000001,54.401,29.775 +2020-05-27 10:15:00,109.58,65.025,54.401,29.775 +2020-05-27 10:30:00,110.08,64.936,54.401,29.775 +2020-05-27 10:45:00,111.45,66.111,54.401,29.775 +2020-05-27 11:00:00,116.34,62.177,53.678000000000004,29.775 +2020-05-27 11:15:00,116.28,63.548,53.678000000000004,29.775 +2020-05-27 11:30:00,120.85,65.04899999999999,53.678000000000004,29.775 +2020-05-27 11:45:00,119.2,66.324,53.678000000000004,29.775 +2020-05-27 12:00:00,113.31,61.788000000000004,51.68,29.775 +2020-05-27 12:15:00,114.74,62.218,51.68,29.775 +2020-05-27 12:30:00,110.65,61.11600000000001,51.68,29.775 +2020-05-27 12:45:00,108.47,62.567,51.68,29.775 +2020-05-27 13:00:00,109.49,63.18600000000001,51.263000000000005,29.775 +2020-05-27 13:15:00,113.95,63.903999999999996,51.263000000000005,29.775 +2020-05-27 13:30:00,118.69,62.041000000000004,51.263000000000005,29.775 +2020-05-27 13:45:00,116.61,60.272,51.263000000000005,29.775 +2020-05-27 14:00:00,106.92,61.72,51.107,29.775 +2020-05-27 14:15:00,103.29,60.273,51.107,29.775 +2020-05-27 14:30:00,103.58,59.173,51.107,29.775 +2020-05-27 14:45:00,101.55,59.833999999999996,51.107,29.775 +2020-05-27 15:00:00,101.02,60.805,51.498000000000005,29.775 +2020-05-27 15:15:00,106.26,58.272,51.498000000000005,29.775 +2020-05-27 15:30:00,108.1,56.458,51.498000000000005,29.775 +2020-05-27 15:45:00,106.97,53.967,51.498000000000005,29.775 +2020-05-27 16:00:00,104.58,56.803000000000004,53.376999999999995,29.775 +2020-05-27 16:15:00,112.71,56.263000000000005,53.376999999999995,29.775 +2020-05-27 16:30:00,115.07,55.753,53.376999999999995,29.775 +2020-05-27 16:45:00,117.58,52.091,53.376999999999995,29.775 +2020-05-27 17:00:00,113.92,54.519,56.965,29.775 +2020-05-27 17:15:00,109.55,55.019,56.965,29.775 +2020-05-27 17:30:00,110.79,54.761,56.965,29.775 +2020-05-27 17:45:00,117.21,53.397,56.965,29.775 +2020-05-27 18:00:00,119.07,56.273,58.231,29.775 +2020-05-27 18:15:00,116.37,57.083,58.231,29.775 +2020-05-27 18:30:00,110.78,54.858000000000004,58.231,29.775 +2020-05-27 18:45:00,111.25,60.065,58.231,29.775 +2020-05-27 19:00:00,112.51,60.646,58.865,29.775 +2020-05-27 19:15:00,110.24,59.83,58.865,29.775 +2020-05-27 19:30:00,106.93,59.354,58.865,29.775 +2020-05-27 19:45:00,104.44,59.942,58.865,29.775 +2020-05-27 20:00:00,106.22,58.015,65.605,29.775 +2020-05-27 20:15:00,109.79,57.128,65.605,29.775 +2020-05-27 20:30:00,105.72,56.702,65.605,29.775 +2020-05-27 20:45:00,106.65,56.923,65.605,29.775 +2020-05-27 21:00:00,103.35,54.456,58.083999999999996,29.775 +2020-05-27 21:15:00,102.42,55.998999999999995,58.083999999999996,29.775 +2020-05-27 21:30:00,96.73,56.688,58.083999999999996,29.775 +2020-05-27 21:45:00,90.01,57.614,58.083999999999996,29.775 +2020-05-27 22:00:00,84.55,55.013999999999996,53.243,29.775 +2020-05-27 22:15:00,92.14,55.89,53.243,29.775 +2020-05-27 22:30:00,90.68,48.641999999999996,53.243,29.775 +2020-05-27 22:45:00,91.0,45.221000000000004,53.243,29.775 +2020-05-27 23:00:00,84.6,39.466,44.283,29.775 +2020-05-27 23:15:00,85.5,37.732,44.283,29.775 +2020-05-27 23:30:00,85.54,36.907,44.283,29.775 +2020-05-27 23:45:00,79.34,36.42,44.283,29.775 +2020-05-28 00:00:00,77.91,35.239000000000004,40.219,29.775 +2020-05-28 00:15:00,80.31,35.55,40.219,29.775 +2020-05-28 00:30:00,82.35,34.621,40.219,29.775 +2020-05-28 00:45:00,82.17,33.571,40.219,29.775 +2020-05-28 01:00:00,73.42,33.269,37.959,29.775 +2020-05-28 01:15:00,73.91,32.423,37.959,29.775 +2020-05-28 01:30:00,80.25,30.72,37.959,29.775 +2020-05-28 01:45:00,80.75,29.695,37.959,29.775 +2020-05-28 02:00:00,78.89,29.686,36.113,29.775 +2020-05-28 02:15:00,76.48,28.261999999999997,36.113,29.775 +2020-05-28 02:30:00,73.17,30.505,36.113,29.775 +2020-05-28 02:45:00,71.76,31.307,36.113,29.775 +2020-05-28 03:00:00,82.48,34.086,35.546,29.775 +2020-05-28 03:15:00,81.95,32.857,35.546,29.775 +2020-05-28 03:30:00,83.28,32.132,35.546,29.775 +2020-05-28 03:45:00,79.02,31.68,35.546,29.775 +2020-05-28 04:00:00,76.6,41.464,37.169000000000004,29.775 +2020-05-28 04:15:00,77.69,51.595,37.169000000000004,29.775 +2020-05-28 04:30:00,81.45,49.409,37.169000000000004,29.775 +2020-05-28 04:45:00,85.59,50.132,37.169000000000004,29.775 +2020-05-28 05:00:00,91.9,73.321,41.233000000000004,29.775 +2020-05-28 05:15:00,94.79,91.84899999999999,41.233000000000004,29.775 +2020-05-28 05:30:00,97.76,81.291,41.233000000000004,29.775 +2020-05-28 05:45:00,101.42,75.626,41.233000000000004,29.775 +2020-05-28 06:00:00,106.7,77.21300000000001,52.57,29.775 +2020-05-28 06:15:00,107.83,79.056,52.57,29.775 +2020-05-28 06:30:00,107.22,76.17399999999999,52.57,29.775 +2020-05-28 06:45:00,108.72,76.32600000000001,52.57,29.775 +2020-05-28 07:00:00,112.97,75.663,64.53,29.775 +2020-05-28 07:15:00,113.42,75.118,64.53,29.775 +2020-05-28 07:30:00,111.88,72.117,64.53,29.775 +2020-05-28 07:45:00,110.47,70.532,64.53,29.775 +2020-05-28 08:00:00,108.25,64.778,55.911,29.775 +2020-05-28 08:15:00,109.2,66.418,55.911,29.775 +2020-05-28 08:30:00,108.89,65.07600000000001,55.911,29.775 +2020-05-28 08:45:00,108.36,66.82300000000001,55.911,29.775 +2020-05-28 09:00:00,108.84,63.333999999999996,50.949,29.775 +2020-05-28 09:15:00,109.6,61.867,50.949,29.775 +2020-05-28 09:30:00,109.68,64.991,50.949,29.775 +2020-05-28 09:45:00,109.89,66.501,50.949,29.775 +2020-05-28 10:00:00,109.06,63.222,48.136,29.775 +2020-05-28 10:15:00,109.02,64.71300000000001,48.136,29.775 +2020-05-28 10:30:00,108.21,64.633,48.136,29.775 +2020-05-28 10:45:00,110.76,65.82,48.136,29.775 +2020-05-28 11:00:00,110.74,61.88,46.643,29.775 +2020-05-28 11:15:00,114.54,63.265,46.643,29.775 +2020-05-28 11:30:00,116.21,64.755,46.643,29.775 +2020-05-28 11:45:00,112.65,66.04,46.643,29.775 +2020-05-28 12:00:00,108.35,61.538999999999994,44.098,29.775 +2020-05-28 12:15:00,107.22,61.972,44.098,29.775 +2020-05-28 12:30:00,101.85,60.839,44.098,29.775 +2020-05-28 12:45:00,104.76,62.294,44.098,29.775 +2020-05-28 13:00:00,104.61,62.92100000000001,43.717,29.775 +2020-05-28 13:15:00,106.42,63.643,43.717,29.775 +2020-05-28 13:30:00,104.67,61.792,43.717,29.775 +2020-05-28 13:45:00,106.86,60.025,43.717,29.775 +2020-05-28 14:00:00,109.05,61.505,44.218999999999994,29.775 +2020-05-28 14:15:00,109.96,60.051,44.218999999999994,29.775 +2020-05-28 14:30:00,111.04,58.915,44.218999999999994,29.775 +2020-05-28 14:45:00,111.45,59.58,44.218999999999994,29.775 +2020-05-28 15:00:00,114.19,60.596000000000004,46.159,29.775 +2020-05-28 15:15:00,116.88,58.05,46.159,29.775 +2020-05-28 15:30:00,117.36,56.215,46.159,29.775 +2020-05-28 15:45:00,118.28,53.708999999999996,46.159,29.775 +2020-05-28 16:00:00,120.09,56.588,47.115,29.775 +2020-05-28 16:15:00,117.02,56.037,47.115,29.775 +2020-05-28 16:30:00,117.12,55.543,47.115,29.775 +2020-05-28 16:45:00,115.03,51.835,47.115,29.775 +2020-05-28 17:00:00,115.21,54.302,50.827,29.775 +2020-05-28 17:15:00,114.26,54.784,50.827,29.775 +2020-05-28 17:30:00,113.32,54.513999999999996,50.827,29.775 +2020-05-28 17:45:00,113.31,53.118,50.827,29.775 +2020-05-28 18:00:00,114.29,56.00899999999999,52.586000000000006,29.775 +2020-05-28 18:15:00,111.81,56.798,52.586000000000006,29.775 +2020-05-28 18:30:00,113.58,54.565,52.586000000000006,29.775 +2020-05-28 18:45:00,111.62,59.769,52.586000000000006,29.775 +2020-05-28 19:00:00,107.16,60.352,51.886,29.775 +2020-05-28 19:15:00,104.15,59.531000000000006,51.886,29.775 +2020-05-28 19:30:00,103.5,59.049,51.886,29.775 +2020-05-28 19:45:00,102.04,59.635,51.886,29.775 +2020-05-28 20:00:00,100.18,57.68600000000001,56.162,29.775 +2020-05-28 20:15:00,101.47,56.798,56.162,29.775 +2020-05-28 20:30:00,103.36,56.391000000000005,56.162,29.775 +2020-05-28 20:45:00,99.21,56.652,56.162,29.775 +2020-05-28 21:00:00,100.34,54.191,53.023,29.775 +2020-05-28 21:15:00,99.7,55.746,53.023,29.775 +2020-05-28 21:30:00,97.4,56.413000000000004,53.023,29.775 +2020-05-28 21:45:00,98.85,57.358000000000004,53.023,29.775 +2020-05-28 22:00:00,89.45,54.783,49.303999999999995,29.775 +2020-05-28 22:15:00,90.82,55.674,49.303999999999995,29.775 +2020-05-28 22:30:00,91.85,48.423,49.303999999999995,29.775 +2020-05-28 22:45:00,92.06,44.99,49.303999999999995,29.775 +2020-05-28 23:00:00,82.98,39.205999999999996,43.409,29.775 +2020-05-28 23:15:00,78.65,37.515,43.409,29.775 +2020-05-28 23:30:00,84.66,36.694,43.409,29.775 +2020-05-28 23:45:00,83.7,36.199,43.409,29.775 +2020-05-29 00:00:00,82.83,33.088,39.884,29.775 +2020-05-29 00:15:00,79.0,33.650999999999996,39.884,29.775 +2020-05-29 00:30:00,76.64,32.953,39.884,29.775 +2020-05-29 00:45:00,75.71,32.335,39.884,29.775 +2020-05-29 01:00:00,75.93,31.62,37.658,29.775 +2020-05-29 01:15:00,75.57,30.374000000000002,37.658,29.775 +2020-05-29 01:30:00,74.04,29.29,37.658,29.775 +2020-05-29 01:45:00,79.8,28.044,37.658,29.775 +2020-05-29 02:00:00,80.93,28.939,36.707,29.775 +2020-05-29 02:15:00,81.07,27.433000000000003,36.707,29.775 +2020-05-29 02:30:00,74.85,30.576,36.707,29.775 +2020-05-29 02:45:00,77.58,30.746,36.707,29.775 +2020-05-29 03:00:00,83.0,34.054,37.025,29.775 +2020-05-29 03:15:00,79.28,31.87,37.025,29.775 +2020-05-29 03:30:00,78.44,30.916999999999998,37.025,29.775 +2020-05-29 03:45:00,80.42,31.393,37.025,29.775 +2020-05-29 04:00:00,86.4,41.3,38.349000000000004,29.775 +2020-05-29 04:15:00,88.81,49.849,38.349000000000004,29.775 +2020-05-29 04:30:00,85.87,48.602,38.349000000000004,29.775 +2020-05-29 04:45:00,86.7,48.461000000000006,38.349000000000004,29.775 +2020-05-29 05:00:00,92.38,70.797,41.565,29.775 +2020-05-29 05:15:00,96.58,90.395,41.565,29.775 +2020-05-29 05:30:00,99.6,80.309,41.565,29.775 +2020-05-29 05:45:00,103.24,74.263,41.565,29.775 +2020-05-29 06:00:00,109.02,76.183,53.861000000000004,29.775 +2020-05-29 06:15:00,107.63,77.858,53.861000000000004,29.775 +2020-05-29 06:30:00,108.54,74.766,53.861000000000004,29.775 +2020-05-29 06:45:00,109.06,75.15100000000001,53.861000000000004,29.775 +2020-05-29 07:00:00,110.54,74.923,63.497,29.775 +2020-05-29 07:15:00,108.76,75.47,63.497,29.775 +2020-05-29 07:30:00,108.91,70.567,63.497,29.775 +2020-05-29 07:45:00,107.71,68.62,63.497,29.775 +2020-05-29 08:00:00,107.34,63.365,55.43899999999999,29.775 +2020-05-29 08:15:00,109.81,65.593,55.43899999999999,29.775 +2020-05-29 08:30:00,113.11,64.368,55.43899999999999,29.775 +2020-05-29 08:45:00,111.74,65.642,55.43899999999999,29.775 +2020-05-29 09:00:00,109.3,60.086999999999996,52.132,29.775 +2020-05-29 09:15:00,107.72,60.547,52.132,29.775 +2020-05-29 09:30:00,107.65,62.937,52.132,29.775 +2020-05-29 09:45:00,106.88,64.81,52.132,29.775 +2020-05-29 10:00:00,108.37,61.106,49.881,29.775 +2020-05-29 10:15:00,108.93,62.585,49.881,29.775 +2020-05-29 10:30:00,106.62,63.006,49.881,29.775 +2020-05-29 10:45:00,106.59,64.012,49.881,29.775 +2020-05-29 11:00:00,105.28,60.29,49.396,29.775 +2020-05-29 11:15:00,105.09,60.472,49.396,29.775 +2020-05-29 11:30:00,105.21,62.038999999999994,49.396,29.775 +2020-05-29 11:45:00,107.75,62.458999999999996,49.396,29.775 +2020-05-29 12:00:00,107.33,58.667,46.7,29.775 +2020-05-29 12:15:00,107.68,57.998999999999995,46.7,29.775 +2020-05-29 12:30:00,104.99,56.981,46.7,29.775 +2020-05-29 12:45:00,108.45,57.851000000000006,46.7,29.775 +2020-05-29 13:00:00,103.8,59.25,44.05,29.775 +2020-05-29 13:15:00,104.04,60.341,44.05,29.775 +2020-05-29 13:30:00,98.47,59.233000000000004,44.05,29.775 +2020-05-29 13:45:00,98.7,57.733999999999995,44.05,29.775 +2020-05-29 14:00:00,101.02,58.231,42.805,29.775 +2020-05-29 14:15:00,107.75,57.125,42.805,29.775 +2020-05-29 14:30:00,112.27,57.461000000000006,42.805,29.775 +2020-05-29 14:45:00,112.96,57.571000000000005,42.805,29.775 +2020-05-29 15:00:00,112.92,58.443000000000005,44.36600000000001,29.775 +2020-05-29 15:15:00,113.34,55.558,44.36600000000001,29.775 +2020-05-29 15:30:00,113.29,52.812,44.36600000000001,29.775 +2020-05-29 15:45:00,113.64,51.027,44.36600000000001,29.775 +2020-05-29 16:00:00,113.52,52.907,46.928999999999995,29.775 +2020-05-29 16:15:00,114.14,52.87,46.928999999999995,29.775 +2020-05-29 16:30:00,113.45,52.247,46.928999999999995,29.775 +2020-05-29 16:45:00,114.36,47.736000000000004,46.928999999999995,29.775 +2020-05-29 17:00:00,114.25,51.898,51.468,29.775 +2020-05-29 17:15:00,112.86,52.104,51.468,29.775 +2020-05-29 17:30:00,111.56,51.902,51.468,29.775 +2020-05-29 17:45:00,111.54,50.268,51.468,29.775 +2020-05-29 18:00:00,112.16,53.412,52.58,29.775 +2020-05-29 18:15:00,107.98,53.203,52.58,29.775 +2020-05-29 18:30:00,107.39,50.948,52.58,29.775 +2020-05-29 18:45:00,106.01,56.54600000000001,52.58,29.775 +2020-05-29 19:00:00,102.16,58.183,52.183,29.775 +2020-05-29 19:15:00,99.93,58.201,52.183,29.775 +2020-05-29 19:30:00,97.8,57.68600000000001,52.183,29.775 +2020-05-29 19:45:00,97.65,57.207,52.183,29.775 +2020-05-29 20:00:00,95.98,55.088,58.497,29.775 +2020-05-29 20:15:00,94.76,54.951,58.497,29.775 +2020-05-29 20:30:00,96.05,54.095,58.497,29.775 +2020-05-29 20:45:00,94.03,53.744,58.497,29.775 +2020-05-29 21:00:00,89.57,52.641999999999996,54.731,29.775 +2020-05-29 21:15:00,90.47,55.89,54.731,29.775 +2020-05-29 21:30:00,93.37,56.398999999999994,54.731,29.775 +2020-05-29 21:45:00,92.72,57.673,54.731,29.775 +2020-05-29 22:00:00,88.91,55.195,51.386,29.775 +2020-05-29 22:15:00,88.07,55.836999999999996,51.386,29.775 +2020-05-29 22:30:00,87.69,54.43600000000001,51.386,29.775 +2020-05-29 22:45:00,85.16,52.544,51.386,29.775 +2020-05-29 23:00:00,77.14,48.243,44.463,29.775 +2020-05-29 23:15:00,77.25,44.698,44.463,29.775 +2020-05-29 23:30:00,80.76,41.843999999999994,44.463,29.775 +2020-05-29 23:45:00,79.05,41.086000000000006,44.463,29.775 +2020-05-30 00:00:00,76.42,33.446,42.833999999999996,29.662 +2020-05-30 00:15:00,70.78,32.48,42.833999999999996,29.662 +2020-05-30 00:30:00,68.05,31.618000000000002,42.833999999999996,29.662 +2020-05-30 00:45:00,68.33,30.462,42.833999999999996,29.662 +2020-05-30 01:00:00,68.06,30.156,37.859,29.662 +2020-05-30 01:15:00,67.11,29.238000000000003,37.859,29.662 +2020-05-30 01:30:00,65.83,27.27,37.859,29.662 +2020-05-30 01:45:00,66.87,27.154,37.859,29.662 +2020-05-30 02:00:00,69.9,27.255,35.327,29.662 +2020-05-30 02:15:00,73.43,24.915,35.327,29.662 +2020-05-30 02:30:00,73.36,27.076999999999998,35.327,29.662 +2020-05-30 02:45:00,69.26,28.019000000000002,35.327,29.662 +2020-05-30 03:00:00,65.65,30.165,34.908,29.662 +2020-05-30 03:15:00,65.73,27.055,34.908,29.662 +2020-05-30 03:30:00,66.22,26.081999999999997,34.908,29.662 +2020-05-30 03:45:00,64.96,28.008000000000003,34.908,29.662 +2020-05-30 04:00:00,63.16,35.196999999999996,34.84,29.662 +2020-05-30 04:15:00,63.06,42.316,34.84,29.662 +2020-05-30 04:30:00,60.91,39.02,34.84,29.662 +2020-05-30 04:45:00,65.06,39.03,34.84,29.662 +2020-05-30 05:00:00,64.16,50.458,34.222,29.662 +2020-05-30 05:15:00,65.68,55.727,34.222,29.662 +2020-05-30 05:30:00,66.81,47.24,34.222,29.662 +2020-05-30 05:45:00,68.98,46.597,34.222,29.662 +2020-05-30 06:00:00,71.04,62.797,35.515,29.662 +2020-05-30 06:15:00,72.65,75.111,35.515,29.662 +2020-05-30 06:30:00,72.85,68.265,35.515,29.662 +2020-05-30 06:45:00,75.19,63.847,35.515,29.662 +2020-05-30 07:00:00,78.59,61.262,39.687,29.662 +2020-05-30 07:15:00,80.62,60.335,39.687,29.662 +2020-05-30 07:30:00,84.57,57.398999999999994,39.687,29.662 +2020-05-30 07:45:00,86.6,57.178000000000004,39.687,29.662 +2020-05-30 08:00:00,86.67,53.312,44.9,29.662 +2020-05-30 08:15:00,86.66,56.153999999999996,44.9,29.662 +2020-05-30 08:30:00,87.74,55.246,44.9,29.662 +2020-05-30 08:45:00,88.34,58.092,44.9,29.662 +2020-05-30 09:00:00,88.31,55.77,45.724,29.662 +2020-05-30 09:15:00,89.73,56.852,45.724,29.662 +2020-05-30 09:30:00,90.73,59.906000000000006,45.724,29.662 +2020-05-30 09:45:00,90.92,61.423,45.724,29.662 +2020-05-30 10:00:00,90.8,58.273999999999994,43.123999999999995,29.662 +2020-05-30 10:15:00,91.47,60.143,43.123999999999995,29.662 +2020-05-30 10:30:00,90.99,60.29600000000001,43.123999999999995,29.662 +2020-05-30 10:45:00,93.34,61.288000000000004,43.123999999999995,29.662 +2020-05-30 11:00:00,92.41,57.467,40.255,29.662 +2020-05-30 11:15:00,90.73,58.301,40.255,29.662 +2020-05-30 11:30:00,88.51,59.858999999999995,40.255,29.662 +2020-05-30 11:45:00,87.44,60.681000000000004,40.255,29.662 +2020-05-30 12:00:00,84.12,56.977,38.582,29.662 +2020-05-30 12:15:00,84.24,57.239,38.582,29.662 +2020-05-30 12:30:00,83.18,56.147,38.582,29.662 +2020-05-30 12:45:00,81.0,57.498999999999995,38.582,29.662 +2020-05-30 13:00:00,79.85,58.083999999999996,36.043,29.662 +2020-05-30 13:15:00,84.69,58.198,36.043,29.662 +2020-05-30 13:30:00,83.22,57.165,36.043,29.662 +2020-05-30 13:45:00,79.43,54.551,36.043,29.662 +2020-05-30 14:00:00,77.05,55.413999999999994,35.216,29.662 +2020-05-30 14:15:00,77.16,53.035,35.216,29.662 +2020-05-30 14:30:00,76.32,52.515,35.216,29.662 +2020-05-30 14:45:00,76.2,53.118,35.216,29.662 +2020-05-30 15:00:00,77.38,54.601000000000006,36.759,29.662 +2020-05-30 15:15:00,76.91,52.538000000000004,36.759,29.662 +2020-05-30 15:30:00,75.07,50.324,36.759,29.662 +2020-05-30 15:45:00,72.67,47.688,36.759,29.662 +2020-05-30 16:00:00,73.75,51.426,40.086,29.662 +2020-05-30 16:15:00,77.63,50.755,40.086,29.662 +2020-05-30 16:30:00,77.61,50.346000000000004,40.086,29.662 +2020-05-30 16:45:00,79.19,45.966,40.086,29.662 +2020-05-30 17:00:00,80.06,48.941,44.876999999999995,29.662 +2020-05-30 17:15:00,80.99,47.513999999999996,44.876999999999995,29.662 +2020-05-30 17:30:00,81.71,47.153999999999996,44.876999999999995,29.662 +2020-05-30 17:45:00,82.2,45.856,44.876999999999995,29.662 +2020-05-30 18:00:00,84.63,50.298,47.056000000000004,29.662 +2020-05-30 18:15:00,85.41,52.016999999999996,47.056000000000004,29.662 +2020-05-30 18:30:00,82.68,51.31,47.056000000000004,29.662 +2020-05-30 18:45:00,82.12,52.935,47.056000000000004,29.662 +2020-05-30 19:00:00,81.55,53.339,45.57,29.662 +2020-05-30 19:15:00,78.78,52.294,45.57,29.662 +2020-05-30 19:30:00,77.84,52.655,45.57,29.662 +2020-05-30 19:45:00,77.76,53.809,45.57,29.662 +2020-05-30 20:00:00,73.8,52.851000000000006,41.685,29.662 +2020-05-30 20:15:00,77.24,52.566,41.685,29.662 +2020-05-30 20:30:00,78.75,50.82899999999999,41.685,29.662 +2020-05-30 20:45:00,79.95,52.173,41.685,29.662 +2020-05-30 21:00:00,76.15,50.175,39.576,29.662 +2020-05-30 21:15:00,75.58,53.202,39.576,29.662 +2020-05-30 21:30:00,73.83,54.123000000000005,39.576,29.662 +2020-05-30 21:45:00,73.25,54.816,39.576,29.662 +2020-05-30 22:00:00,69.37,52.62,39.068000000000005,29.662 +2020-05-30 22:15:00,70.64,54.028999999999996,39.068000000000005,29.662 +2020-05-30 22:30:00,66.88,53.56100000000001,39.068000000000005,29.662 +2020-05-30 22:45:00,65.37,52.398999999999994,39.068000000000005,29.662 +2020-05-30 23:00:00,61.82,47.92100000000001,32.06,29.662 +2020-05-30 23:15:00,61.6,44.477,32.06,29.662 +2020-05-30 23:30:00,60.72,43.583999999999996,32.06,29.662 +2020-05-30 23:45:00,59.52,42.652,32.06,29.662 +2020-05-31 00:00:00,58.29,34.622,28.825,29.662 +2020-05-31 00:15:00,58.97,32.507,28.825,29.662 +2020-05-31 00:30:00,58.75,31.43,28.825,29.662 +2020-05-31 00:45:00,58.69,30.32,28.825,29.662 +2020-05-31 01:00:00,56.94,30.25,25.995,29.662 +2020-05-31 01:15:00,57.91,29.410999999999998,25.995,29.662 +2020-05-31 01:30:00,54.43,27.41,25.995,29.662 +2020-05-31 01:45:00,57.82,26.866,25.995,29.662 +2020-05-31 02:00:00,57.05,26.883000000000003,24.394000000000002,29.662 +2020-05-31 02:15:00,57.16,25.024,24.394000000000002,29.662 +2020-05-31 02:30:00,57.04,27.545,24.394000000000002,29.662 +2020-05-31 02:45:00,57.15,28.318,24.394000000000002,29.662 +2020-05-31 03:00:00,56.31,31.146,22.916999999999998,29.662 +2020-05-31 03:15:00,56.76,28.165,22.916999999999998,29.662 +2020-05-31 03:30:00,57.63,26.795,22.916999999999998,29.662 +2020-05-31 03:45:00,57.02,27.980999999999998,22.916999999999998,29.662 +2020-05-31 04:00:00,54.07,35.034,23.576999999999998,29.662 +2020-05-31 04:15:00,53.44,41.483000000000004,23.576999999999998,29.662 +2020-05-31 04:30:00,52.41,39.448,23.576999999999998,29.662 +2020-05-31 04:45:00,52.3,39.071999999999996,23.576999999999998,29.662 +2020-05-31 05:00:00,51.54,49.851000000000006,22.730999999999998,29.662 +2020-05-31 05:15:00,50.75,53.773999999999994,22.730999999999998,29.662 +2020-05-31 05:30:00,50.89,44.93899999999999,22.730999999999998,29.662 +2020-05-31 05:45:00,52.44,44.14,22.730999999999998,29.662 +2020-05-31 06:00:00,54.87,58.076,22.34,29.662 +2020-05-31 06:15:00,54.95,70.82600000000001,22.34,29.662 +2020-05-31 06:30:00,56.67,63.121,22.34,29.662 +2020-05-31 06:45:00,59.11,57.573,22.34,29.662 +2020-05-31 07:00:00,60.32,55.717,24.691999999999997,29.662 +2020-05-31 07:15:00,62.84,53.083,24.691999999999997,29.662 +2020-05-31 07:30:00,62.65,51.083999999999996,24.691999999999997,29.662 +2020-05-31 07:45:00,62.23,50.706,24.691999999999997,29.662 +2020-05-31 08:00:00,63.06,47.919,29.340999999999998,29.662 +2020-05-31 08:15:00,62.23,51.89,29.340999999999998,29.662 +2020-05-31 08:30:00,64.21,52.13399999999999,29.340999999999998,29.662 +2020-05-31 08:45:00,65.4,55.338,29.340999999999998,29.662 +2020-05-31 09:00:00,62.96,52.817,30.788,29.662 +2020-05-31 09:15:00,61.99,53.562,30.788,29.662 +2020-05-31 09:30:00,59.44,56.989,30.788,29.662 +2020-05-31 09:45:00,62.19,59.445,30.788,29.662 +2020-05-31 10:00:00,62.46,57.333999999999996,30.158,29.662 +2020-05-31 10:15:00,62.53,59.471000000000004,30.158,29.662 +2020-05-31 10:30:00,63.05,59.968999999999994,30.158,29.662 +2020-05-31 10:45:00,65.84,61.492,30.158,29.662 +2020-05-31 11:00:00,61.04,57.544,32.056,29.662 +2020-05-31 11:15:00,61.5,58.008,32.056,29.662 +2020-05-31 11:30:00,63.18,59.845,32.056,29.662 +2020-05-31 11:45:00,61.81,61.044,32.056,29.662 +2020-05-31 12:00:00,59.18,58.198,28.671999999999997,29.662 +2020-05-31 12:15:00,59.97,58.324,28.671999999999997,29.662 +2020-05-31 12:30:00,55.08,57.066,28.671999999999997,29.662 +2020-05-31 12:45:00,55.72,57.618,28.671999999999997,29.662 +2020-05-31 13:00:00,57.1,57.784,23.171,29.662 +2020-05-31 13:15:00,57.89,58.04600000000001,23.171,29.662 +2020-05-31 13:30:00,60.84,55.958,23.171,29.662 +2020-05-31 13:45:00,58.45,54.222,23.171,29.662 +2020-05-31 14:00:00,55.33,56.27,19.11,29.662 +2020-05-31 14:15:00,57.48,54.55,19.11,29.662 +2020-05-31 14:30:00,55.43,53.14,19.11,29.662 +2020-05-31 14:45:00,56.24,52.672,19.11,29.662 +2020-05-31 15:00:00,56.35,53.988,19.689,29.662 +2020-05-31 15:15:00,59.22,51.339,19.689,29.662 +2020-05-31 15:30:00,57.36,49.071999999999996,19.689,29.662 +2020-05-31 15:45:00,59.35,46.838,19.689,29.662 +2020-05-31 16:00:00,63.4,49.468,22.875,29.662 +2020-05-31 16:15:00,64.4,48.803999999999995,22.875,29.662 +2020-05-31 16:30:00,66.23,49.398999999999994,22.875,29.662 +2020-05-31 16:45:00,67.91,45.062,22.875,29.662 +2020-05-31 17:00:00,72.6,48.413999999999994,33.884,29.662 +2020-05-31 17:15:00,71.61,48.3,33.884,29.662 +2020-05-31 17:30:00,75.13,48.739,33.884,29.662 +2020-05-31 17:45:00,76.52,48.29,33.884,29.662 +2020-05-31 18:00:00,79.29,53.143,38.453,29.662 +2020-05-31 18:15:00,79.24,54.731,38.453,29.662 +2020-05-31 18:30:00,79.41,53.353,38.453,29.662 +2020-05-31 18:45:00,83.37,55.465,38.453,29.662 +2020-05-31 19:00:00,80.11,57.818000000000005,39.221,29.662 +2020-05-31 19:15:00,85.23,55.82899999999999,39.221,29.662 +2020-05-31 19:30:00,84.73,55.917,39.221,29.662 +2020-05-31 19:45:00,86.55,56.916000000000004,39.221,29.662 +2020-05-31 20:00:00,86.5,56.137,37.871,29.662 +2020-05-31 20:15:00,86.56,55.92,37.871,29.662 +2020-05-31 20:30:00,87.79,55.214,37.871,29.662 +2020-05-31 20:45:00,86.92,54.773999999999994,37.871,29.662 +2020-05-31 21:00:00,81.05,52.123999999999995,36.465,29.662 +2020-05-31 21:15:00,84.64,54.754,36.465,29.662 +2020-05-31 21:30:00,78.67,55.098,36.465,29.662 +2020-05-31 21:45:00,78.8,56.174,36.465,29.662 +2020-05-31 22:00:00,77.15,55.681999999999995,36.092,29.662 +2020-05-31 22:15:00,82.27,55.36600000000001,36.092,29.662 +2020-05-31 22:30:00,81.25,53.788000000000004,36.092,29.662 +2020-05-31 22:45:00,78.71,51.242,36.092,29.662 +2020-05-31 23:00:00,54.1,45.903999999999996,31.013,29.662 +2020-05-31 23:15:00,55.38,44.067,31.013,29.662 +2020-05-31 23:30:00,54.63,42.828,31.013,29.662 +2020-05-31 23:45:00,53.74,42.213,31.013,29.662 +2020-06-01 00:00:00,52.35,28.079,19.295,29.17 +2020-06-01 00:15:00,52.65,26.704,19.295,29.17 +2020-06-01 00:30:00,52.03,25.386999999999997,19.295,29.17 +2020-06-01 00:45:00,52.09,24.485,19.295,29.17 +2020-06-01 01:00:00,50.91,24.236,15.365,29.17 +2020-06-01 01:15:00,52.12,23.596,15.365,29.17 +2020-06-01 01:30:00,50.95,21.804000000000002,15.365,29.17 +2020-06-01 01:45:00,51.01,21.910999999999998,15.365,29.17 +2020-06-01 02:00:00,50.17,21.433000000000003,13.03,29.17 +2020-06-01 02:15:00,50.81,19.726,13.03,29.17 +2020-06-01 02:30:00,50.97,22.149,13.03,29.17 +2020-06-01 02:45:00,51.9,22.715999999999998,13.03,29.17 +2020-06-01 03:00:00,51.98,24.44,13.46,29.17 +2020-06-01 03:15:00,52.07,21.471,13.46,29.17 +2020-06-01 03:30:00,52.7,20.089000000000002,13.46,29.17 +2020-06-01 03:45:00,49.25,20.781,13.46,29.17 +2020-06-01 04:00:00,51.66,26.259,13.305,29.17 +2020-06-01 04:15:00,51.17,32.0,13.305,29.17 +2020-06-01 04:30:00,48.48,29.869,13.305,29.17 +2020-06-01 04:45:00,50.55,29.331999999999997,13.305,29.17 +2020-06-01 05:00:00,48.78,38.022,13.482000000000001,29.17 +2020-06-01 05:15:00,47.36,39.586,13.482000000000001,29.17 +2020-06-01 05:30:00,50.13,31.343000000000004,13.482000000000001,29.17 +2020-06-01 05:45:00,50.0,31.281,13.482000000000001,29.17 +2020-06-01 06:00:00,51.06,43.007,14.677999999999999,29.17 +2020-06-01 06:15:00,50.92,53.478,14.677999999999999,29.17 +2020-06-01 06:30:00,51.78,46.945,14.677999999999999,29.17 +2020-06-01 06:45:00,52.65,42.336999999999996,14.677999999999999,29.17 +2020-06-01 07:00:00,54.68,42.636,18.473,29.17 +2020-06-01 07:15:00,53.98,40.101,18.473,29.17 +2020-06-01 07:30:00,53.67,38.333,18.473,29.17 +2020-06-01 07:45:00,54.26,38.079,18.473,29.17 +2020-06-01 08:00:00,54.22,38.016,18.142,29.17 +2020-06-01 08:15:00,53.58,41.693999999999996,18.142,29.17 +2020-06-01 08:30:00,52.87,42.685,18.142,29.17 +2020-06-01 08:45:00,53.28,45.778999999999996,18.142,29.17 +2020-06-01 09:00:00,50.94,42.258,19.148,29.17 +2020-06-01 09:15:00,51.9,43.126999999999995,19.148,29.17 +2020-06-01 09:30:00,47.84,46.728,19.148,29.17 +2020-06-01 09:45:00,51.24,49.751999999999995,19.148,29.17 +2020-06-01 10:00:00,53.22,45.975,17.139,29.17 +2020-06-01 10:15:00,55.99,47.87,17.139,29.17 +2020-06-01 10:30:00,57.89,48.418,17.139,29.17 +2020-06-01 10:45:00,57.81,50.29600000000001,17.139,29.17 +2020-06-01 11:00:00,53.25,45.78,18.037,29.17 +2020-06-01 11:15:00,51.93,46.23,18.037,29.17 +2020-06-01 11:30:00,49.85,48.203,18.037,29.17 +2020-06-01 11:45:00,49.1,49.861000000000004,18.037,29.17 +2020-06-01 12:00:00,46.61,47.415,16.559,29.17 +2020-06-01 12:15:00,46.04,47.076,16.559,29.17 +2020-06-01 12:30:00,44.13,46.448,16.559,29.17 +2020-06-01 12:45:00,44.13,47.167,16.559,29.17 +2020-06-01 13:00:00,43.0,47.331,13.697000000000001,29.17 +2020-06-01 13:15:00,42.54,47.643,13.697000000000001,29.17 +2020-06-01 13:30:00,42.78,45.68899999999999,13.697000000000001,29.17 +2020-06-01 13:45:00,44.17,44.308,13.697000000000001,29.17 +2020-06-01 14:00:00,44.11,46.63399999999999,12.578,29.17 +2020-06-01 14:15:00,43.14,44.994,12.578,29.17 +2020-06-01 14:30:00,44.66,43.99,12.578,29.17 +2020-06-01 14:45:00,44.56,43.341,12.578,29.17 +2020-06-01 15:00:00,47.23,44.93899999999999,14.425999999999998,29.17 +2020-06-01 15:15:00,46.69,42.405,14.425999999999998,29.17 +2020-06-01 15:30:00,50.96,40.277,14.425999999999998,29.17 +2020-06-01 15:45:00,50.68,38.414,14.425999999999998,29.17 +2020-06-01 16:00:00,52.39,40.319,18.287,29.17 +2020-06-01 16:15:00,55.05,39.81,18.287,29.17 +2020-06-01 16:30:00,57.45,39.958,18.287,29.17 +2020-06-01 16:45:00,61.38,36.124,18.287,29.17 +2020-06-01 17:00:00,65.43,39.303000000000004,24.461,29.17 +2020-06-01 17:15:00,66.16,38.798,24.461,29.17 +2020-06-01 17:30:00,68.01,38.955999999999996,24.461,29.17 +2020-06-01 17:45:00,69.86,38.388000000000005,24.461,29.17 +2020-06-01 18:00:00,71.38,42.556000000000004,31.44,29.17 +2020-06-01 18:15:00,71.03,43.601000000000006,31.44,29.17 +2020-06-01 18:30:00,73.56,42.254,31.44,29.17 +2020-06-01 18:45:00,79.8,43.708999999999996,31.44,29.17 +2020-06-01 19:00:00,80.73,46.256,34.859,29.17 +2020-06-01 19:15:00,73.32,44.074,34.859,29.17 +2020-06-01 19:30:00,73.38,43.883,34.859,29.17 +2020-06-01 19:45:00,70.97,44.55,34.859,29.17 +2020-06-01 20:00:00,73.37,44.213,42.937,29.17 +2020-06-01 20:15:00,81.13,44.318999999999996,42.937,29.17 +2020-06-01 20:30:00,83.24,43.968,42.937,29.17 +2020-06-01 20:45:00,81.93,43.483999999999995,42.937,29.17 +2020-06-01 21:00:00,74.91,41.38399999999999,39.795,29.17 +2020-06-01 21:15:00,74.14,44.074,39.795,29.17 +2020-06-01 21:30:00,74.04,44.778999999999996,39.795,29.17 +2020-06-01 21:45:00,70.96,45.748999999999995,39.795,29.17 +2020-06-01 22:00:00,72.4,44.92100000000001,41.108000000000004,29.17 +2020-06-01 22:15:00,76.24,45.008,41.108000000000004,29.17 +2020-06-01 22:30:00,72.97,43.971000000000004,41.108000000000004,29.17 +2020-06-01 22:45:00,68.25,41.247,41.108000000000004,29.17 +2020-06-01 23:00:00,75.51,37.607,33.82,29.17 +2020-06-01 23:15:00,76.08,35.554,33.82,29.17 +2020-06-01 23:30:00,75.77,34.259,33.82,29.17 +2020-06-01 23:45:00,75.39,33.885,33.82,29.17 +2020-06-02 00:00:00,72.23,27.486,44.625,29.28 +2020-06-02 00:15:00,72.85,28.073,44.625,29.28 +2020-06-02 00:30:00,72.81,27.004,44.625,29.28 +2020-06-02 00:45:00,72.9,26.359,44.625,29.28 +2020-06-02 01:00:00,72.66,25.963,41.733000000000004,29.28 +2020-06-02 01:15:00,73.01,25.336,41.733000000000004,29.28 +2020-06-02 01:30:00,71.48,23.773000000000003,41.733000000000004,29.28 +2020-06-02 01:45:00,71.71,23.325,41.733000000000004,29.28 +2020-06-02 02:00:00,71.14,22.816,39.872,29.28 +2020-06-02 02:15:00,72.89,21.364,39.872,29.28 +2020-06-02 02:30:00,72.11,23.55,39.872,29.28 +2020-06-02 02:45:00,72.88,24.247,39.872,29.28 +2020-06-02 03:00:00,72.62,25.929000000000002,38.711,29.28 +2020-06-02 03:15:00,73.57,24.605999999999998,38.711,29.28 +2020-06-02 03:30:00,74.97,23.865,38.711,29.28 +2020-06-02 03:45:00,74.58,23.045,38.711,29.28 +2020-06-02 04:00:00,76.34,30.565,39.823,29.28 +2020-06-02 04:15:00,80.5,39.384,39.823,29.28 +2020-06-02 04:30:00,80.66,36.830999999999996,39.823,29.28 +2020-06-02 04:45:00,85.79,37.228,39.823,29.28 +2020-06-02 05:00:00,99.7,55.825,43.228,29.28 +2020-06-02 05:15:00,105.56,69.34,43.228,29.28 +2020-06-02 05:30:00,108.46,59.775,43.228,29.28 +2020-06-02 05:45:00,108.08,55.667,43.228,29.28 +2020-06-02 06:00:00,115.07,57.045,54.316,29.28 +2020-06-02 06:15:00,119.43,57.893,54.316,29.28 +2020-06-02 06:30:00,122.79,55.489,54.316,29.28 +2020-06-02 06:45:00,121.32,55.802,54.316,29.28 +2020-06-02 07:00:00,119.92,56.843,65.758,29.28 +2020-06-02 07:15:00,127.93,56.339,65.758,29.28 +2020-06-02 07:30:00,129.28,53.638999999999996,65.758,29.28 +2020-06-02 07:45:00,129.07,52.556999999999995,65.758,29.28 +2020-06-02 08:00:00,127.22,49.989,57.983000000000004,29.28 +2020-06-02 08:15:00,131.77,51.858999999999995,57.983000000000004,29.28 +2020-06-02 08:30:00,134.36,51.706,57.983000000000004,29.28 +2020-06-02 08:45:00,133.94,53.798,57.983000000000004,29.28 +2020-06-02 09:00:00,128.31,49.465,52.653,29.28 +2020-06-02 09:15:00,126.41,48.339,52.653,29.28 +2020-06-02 09:30:00,129.84,51.693999999999996,52.653,29.28 +2020-06-02 09:45:00,130.52,53.861000000000004,52.653,29.28 +2020-06-02 10:00:00,129.02,49.056999999999995,51.408,29.28 +2020-06-02 10:15:00,128.01,50.575,51.408,29.28 +2020-06-02 10:30:00,124.54,50.652,51.408,29.28 +2020-06-02 10:45:00,130.5,52.098,51.408,29.28 +2020-06-02 11:00:00,130.58,47.683,51.913000000000004,29.28 +2020-06-02 11:15:00,129.85,48.934,51.913000000000004,29.28 +2020-06-02 11:30:00,124.12,50.481,51.913000000000004,29.28 +2020-06-02 11:45:00,123.31,52.216,51.913000000000004,29.28 +2020-06-02 12:00:00,125.14,48.06100000000001,49.508,29.28 +2020-06-02 12:15:00,122.36,48.11600000000001,49.508,29.28 +2020-06-02 12:30:00,118.37,47.354,49.508,29.28 +2020-06-02 12:45:00,115.76,48.853,49.508,29.28 +2020-06-02 13:00:00,120.83,49.547,50.007,29.28 +2020-06-02 13:15:00,121.9,50.588,50.007,29.28 +2020-06-02 13:30:00,116.48,48.832,50.007,29.28 +2020-06-02 13:45:00,110.09,47.396,50.007,29.28 +2020-06-02 14:00:00,109.79,49.284,49.778999999999996,29.28 +2020-06-02 14:15:00,111.56,47.966,49.778999999999996,29.28 +2020-06-02 14:30:00,112.09,47.093999999999994,49.778999999999996,29.28 +2020-06-02 14:45:00,114.29,47.645,49.778999999999996,29.28 +2020-06-02 15:00:00,110.48,48.917,51.559,29.28 +2020-06-02 15:15:00,103.02,46.593999999999994,51.559,29.28 +2020-06-02 15:30:00,100.93,44.923,51.559,29.28 +2020-06-02 15:45:00,105.33,42.842,51.559,29.28 +2020-06-02 16:00:00,105.0,45.167,53.531000000000006,29.28 +2020-06-02 16:15:00,105.3,44.769,53.531000000000006,29.28 +2020-06-02 16:30:00,106.76,43.912,53.531000000000006,29.28 +2020-06-02 16:45:00,111.6,40.665,53.531000000000006,29.28 +2020-06-02 17:00:00,113.3,43.038999999999994,59.497,29.28 +2020-06-02 17:15:00,109.25,43.193000000000005,59.497,29.28 +2020-06-02 17:30:00,106.26,42.56,59.497,29.28 +2020-06-02 17:45:00,108.2,41.12,59.497,29.28 +2020-06-02 18:00:00,110.8,43.38,59.861999999999995,29.28 +2020-06-02 18:15:00,114.43,43.85,59.861999999999995,29.28 +2020-06-02 18:30:00,111.78,41.553999999999995,59.861999999999995,29.28 +2020-06-02 18:45:00,107.85,45.961000000000006,59.861999999999995,29.28 +2020-06-02 19:00:00,102.8,46.961999999999996,60.989,29.28 +2020-06-02 19:15:00,105.39,46.086000000000006,60.989,29.28 +2020-06-02 19:30:00,98.65,45.317,60.989,29.28 +2020-06-02 19:45:00,98.03,45.648,60.989,29.28 +2020-06-02 20:00:00,95.94,44.276,68.35600000000001,29.28 +2020-06-02 20:15:00,101.74,44.063,68.35600000000001,29.28 +2020-06-02 20:30:00,101.49,44.187,68.35600000000001,29.28 +2020-06-02 20:45:00,102.03,44.443000000000005,68.35600000000001,29.28 +2020-06-02 21:00:00,92.62,42.57,59.251000000000005,29.28 +2020-06-02 21:15:00,92.31,44.255,59.251000000000005,29.28 +2020-06-02 21:30:00,87.58,45.371,59.251000000000005,29.28 +2020-06-02 21:45:00,86.58,46.31399999999999,59.251000000000005,29.28 +2020-06-02 22:00:00,81.88,43.613,54.736999999999995,29.28 +2020-06-02 22:15:00,80.55,45.158,54.736999999999995,29.28 +2020-06-02 22:30:00,78.5,39.465,54.736999999999995,29.28 +2020-06-02 22:45:00,77.43,36.318000000000005,54.736999999999995,29.28 +2020-06-02 23:00:00,74.39,32.014,46.806999999999995,29.28 +2020-06-02 23:15:00,74.81,30.081,46.806999999999995,29.28 +2020-06-02 23:30:00,74.8,28.956999999999997,46.806999999999995,29.28 +2020-06-02 23:45:00,73.41,28.416999999999998,46.806999999999995,29.28 +2020-06-03 00:00:00,70.04,27.325,43.824,29.28 +2020-06-03 00:15:00,71.56,27.914,43.824,29.28 +2020-06-03 00:30:00,68.67,26.846,43.824,29.28 +2020-06-03 00:45:00,70.95,26.205,43.824,29.28 +2020-06-03 01:00:00,71.49,25.829,39.86,29.28 +2020-06-03 01:15:00,71.44,25.180999999999997,39.86,29.28 +2020-06-03 01:30:00,70.05,23.608,39.86,29.28 +2020-06-03 01:45:00,70.5,23.154,39.86,29.28 +2020-06-03 02:00:00,70.07,22.645,37.931999999999995,29.28 +2020-06-03 02:15:00,71.93,21.186999999999998,37.931999999999995,29.28 +2020-06-03 02:30:00,70.28,23.375,37.931999999999995,29.28 +2020-06-03 02:45:00,70.45,24.079,37.931999999999995,29.28 +2020-06-03 03:00:00,71.64,25.763,37.579,29.28 +2020-06-03 03:15:00,72.7,24.436999999999998,37.579,29.28 +2020-06-03 03:30:00,72.09,23.701,37.579,29.28 +2020-06-03 03:45:00,72.31,22.904,37.579,29.28 +2020-06-03 04:00:00,76.85,30.365,37.931999999999995,29.28 +2020-06-03 04:15:00,80.4,39.134,37.931999999999995,29.28 +2020-06-03 04:30:00,88.31,36.568000000000005,37.931999999999995,29.28 +2020-06-03 04:45:00,93.4,36.96,37.931999999999995,29.28 +2020-06-03 05:00:00,94.29,55.43600000000001,40.942,29.28 +2020-06-03 05:15:00,98.54,68.80199999999999,40.942,29.28 +2020-06-03 05:30:00,101.92,59.305,40.942,29.28 +2020-06-03 05:45:00,106.14,55.255,40.942,29.28 +2020-06-03 06:00:00,113.26,56.653,56.516999999999996,29.28 +2020-06-03 06:15:00,112.2,57.481,56.516999999999996,29.28 +2020-06-03 06:30:00,106.99,55.099,56.516999999999996,29.28 +2020-06-03 06:45:00,110.19,55.44,56.516999999999996,29.28 +2020-06-03 07:00:00,113.12,56.468999999999994,71.707,29.28 +2020-06-03 07:15:00,116.27,55.978,71.707,29.28 +2020-06-03 07:30:00,115.88,53.263000000000005,71.707,29.28 +2020-06-03 07:45:00,113.15,52.215,71.707,29.28 +2020-06-03 08:00:00,113.28,49.655,61.17,29.28 +2020-06-03 08:15:00,115.37,51.568000000000005,61.17,29.28 +2020-06-03 08:30:00,116.39,51.406000000000006,61.17,29.28 +2020-06-03 08:45:00,112.02,53.507,61.17,29.28 +2020-06-03 09:00:00,110.21,49.167,57.282,29.28 +2020-06-03 09:15:00,113.3,48.047,57.282,29.28 +2020-06-03 09:30:00,110.77,51.406000000000006,57.282,29.28 +2020-06-03 09:45:00,112.52,53.597,57.282,29.28 +2020-06-03 10:00:00,114.37,48.805,54.026,29.28 +2020-06-03 10:15:00,114.82,50.343,54.026,29.28 +2020-06-03 10:30:00,116.42,50.426,54.026,29.28 +2020-06-03 10:45:00,116.13,51.88,54.026,29.28 +2020-06-03 11:00:00,109.87,47.458,54.277,29.28 +2020-06-03 11:15:00,104.76,48.72,54.277,29.28 +2020-06-03 11:30:00,108.82,50.256,54.277,29.28 +2020-06-03 11:45:00,108.87,51.997,54.277,29.28 +2020-06-03 12:00:00,106.62,47.876000000000005,52.552,29.28 +2020-06-03 12:15:00,102.67,47.937,52.552,29.28 +2020-06-03 12:30:00,98.68,47.147,52.552,29.28 +2020-06-03 12:45:00,105.85,48.65,52.552,29.28 +2020-06-03 13:00:00,107.79,49.343999999999994,52.111999999999995,29.28 +2020-06-03 13:15:00,108.61,50.388000000000005,52.111999999999995,29.28 +2020-06-03 13:30:00,102.26,48.643,52.111999999999995,29.28 +2020-06-03 13:45:00,104.72,47.208,52.111999999999995,29.28 +2020-06-03 14:00:00,111.64,49.123000000000005,52.066,29.28 +2020-06-03 14:15:00,108.26,47.799,52.066,29.28 +2020-06-03 14:30:00,108.8,46.897,52.066,29.28 +2020-06-03 14:45:00,103.39,47.453,52.066,29.28 +2020-06-03 15:00:00,103.63,48.77,52.523999999999994,29.28 +2020-06-03 15:15:00,105.44,46.435,52.523999999999994,29.28 +2020-06-03 15:30:00,104.72,44.748999999999995,52.523999999999994,29.28 +2020-06-03 15:45:00,100.41,42.653999999999996,52.523999999999994,29.28 +2020-06-03 16:00:00,100.42,45.018,54.101000000000006,29.28 +2020-06-03 16:15:00,110.51,44.611000000000004,54.101000000000006,29.28 +2020-06-03 16:30:00,111.07,43.773999999999994,54.101000000000006,29.28 +2020-06-03 16:45:00,109.26,40.488,54.101000000000006,29.28 +2020-06-03 17:00:00,104.98,42.896,58.155,29.28 +2020-06-03 17:15:00,111.12,43.037,58.155,29.28 +2020-06-03 17:30:00,114.79,42.395,58.155,29.28 +2020-06-03 17:45:00,115.69,40.927,58.155,29.28 +2020-06-03 18:00:00,108.75,43.202,60.205,29.28 +2020-06-03 18:15:00,112.12,43.644,60.205,29.28 +2020-06-03 18:30:00,114.87,41.341,60.205,29.28 +2020-06-03 18:45:00,113.03,45.747,60.205,29.28 +2020-06-03 19:00:00,104.74,46.748999999999995,61.568999999999996,29.28 +2020-06-03 19:15:00,99.58,45.865,61.568999999999996,29.28 +2020-06-03 19:30:00,104.58,45.088,61.568999999999996,29.28 +2020-06-03 19:45:00,104.49,45.413999999999994,61.568999999999996,29.28 +2020-06-03 20:00:00,102.46,44.022,68.145,29.28 +2020-06-03 20:15:00,95.88,43.805,68.145,29.28 +2020-06-03 20:30:00,95.33,43.943999999999996,68.145,29.28 +2020-06-03 20:45:00,95.16,44.238,68.145,29.28 +2020-06-03 21:00:00,92.82,42.37,59.696000000000005,29.28 +2020-06-03 21:15:00,92.05,44.066,59.696000000000005,29.28 +2020-06-03 21:30:00,89.64,45.158,59.696000000000005,29.28 +2020-06-03 21:45:00,88.21,46.118,59.696000000000005,29.28 +2020-06-03 22:00:00,83.59,43.44,54.861999999999995,29.28 +2020-06-03 22:15:00,82.97,44.998000000000005,54.861999999999995,29.28 +2020-06-03 22:30:00,81.26,39.305,54.861999999999995,29.28 +2020-06-03 22:45:00,80.33,36.147,54.861999999999995,29.28 +2020-06-03 23:00:00,75.58,31.816999999999997,45.568000000000005,29.28 +2020-06-03 23:15:00,75.33,29.924,45.568000000000005,29.28 +2020-06-03 23:30:00,74.44,28.808000000000003,45.568000000000005,29.28 +2020-06-03 23:45:00,73.46,28.256999999999998,45.568000000000005,29.28 +2020-06-04 00:00:00,73.36,27.168000000000003,40.181,29.28 +2020-06-04 00:15:00,73.36,27.758000000000003,40.181,29.28 +2020-06-04 00:30:00,72.26,26.691999999999997,40.181,29.28 +2020-06-04 00:45:00,74.73,26.054000000000002,40.181,29.28 +2020-06-04 01:00:00,71.88,25.698,38.296,29.28 +2020-06-04 01:15:00,72.5,25.03,38.296,29.28 +2020-06-04 01:30:00,71.31,23.448,38.296,29.28 +2020-06-04 01:45:00,71.5,22.986,38.296,29.28 +2020-06-04 02:00:00,71.83,22.479,36.575,29.28 +2020-06-04 02:15:00,71.45,21.015,36.575,29.28 +2020-06-04 02:30:00,71.1,23.204,36.575,29.28 +2020-06-04 02:45:00,71.42,23.915,36.575,29.28 +2020-06-04 03:00:00,73.16,25.601,36.394,29.28 +2020-06-04 03:15:00,72.99,24.271,36.394,29.28 +2020-06-04 03:30:00,71.02,23.54,36.394,29.28 +2020-06-04 03:45:00,73.01,22.767,36.394,29.28 +2020-06-04 04:00:00,77.6,30.171,37.207,29.28 +2020-06-04 04:15:00,77.75,38.889,37.207,29.28 +2020-06-04 04:30:00,88.08,36.309,37.207,29.28 +2020-06-04 04:45:00,93.23,36.698,37.207,29.28 +2020-06-04 05:00:00,94.94,55.053999999999995,40.713,29.28 +2020-06-04 05:15:00,96.67,68.271,40.713,29.28 +2020-06-04 05:30:00,104.59,58.843999999999994,40.713,29.28 +2020-06-04 05:45:00,107.94,54.852,40.713,29.28 +2020-06-04 06:00:00,112.46,56.268,50.952,29.28 +2020-06-04 06:15:00,109.5,57.075,50.952,29.28 +2020-06-04 06:30:00,108.67,54.715,50.952,29.28 +2020-06-04 06:45:00,109.33,55.085,50.952,29.28 +2020-06-04 07:00:00,110.44,56.102,64.88,29.28 +2020-06-04 07:15:00,109.83,55.625,64.88,29.28 +2020-06-04 07:30:00,113.96,52.897,64.88,29.28 +2020-06-04 07:45:00,116.37,51.881,64.88,29.28 +2020-06-04 08:00:00,114.22,49.33,55.133,29.28 +2020-06-04 08:15:00,107.51,51.283,55.133,29.28 +2020-06-04 08:30:00,112.36,51.113,55.133,29.28 +2020-06-04 08:45:00,109.8,53.223,55.133,29.28 +2020-06-04 09:00:00,114.08,48.876000000000005,48.912,29.28 +2020-06-04 09:15:00,119.39,47.761,48.912,29.28 +2020-06-04 09:30:00,116.25,51.125,48.912,29.28 +2020-06-04 09:45:00,110.92,53.34,48.912,29.28 +2020-06-04 10:00:00,107.25,48.559,45.968999999999994,29.28 +2020-06-04 10:15:00,107.81,50.117,45.968999999999994,29.28 +2020-06-04 10:30:00,113.72,50.20399999999999,45.968999999999994,29.28 +2020-06-04 10:45:00,116.8,51.668,45.968999999999994,29.28 +2020-06-04 11:00:00,117.17,47.239,44.067,29.28 +2020-06-04 11:15:00,109.05,48.511,44.067,29.28 +2020-06-04 11:30:00,110.66,50.037,44.067,29.28 +2020-06-04 11:45:00,105.85,51.783,44.067,29.28 +2020-06-04 12:00:00,111.23,47.696999999999996,41.501000000000005,29.28 +2020-06-04 12:15:00,113.61,47.762,41.501000000000005,29.28 +2020-06-04 12:30:00,110.92,46.945,41.501000000000005,29.28 +2020-06-04 12:45:00,102.91,48.453,41.501000000000005,29.28 +2020-06-04 13:00:00,102.85,49.145,41.117,29.28 +2020-06-04 13:15:00,107.65,50.192,41.117,29.28 +2020-06-04 13:30:00,107.87,48.458,41.117,29.28 +2020-06-04 13:45:00,112.9,47.026,41.117,29.28 +2020-06-04 14:00:00,99.93,48.964,41.492,29.28 +2020-06-04 14:15:00,105.4,47.635,41.492,29.28 +2020-06-04 14:30:00,103.85,46.70399999999999,41.492,29.28 +2020-06-04 14:45:00,104.92,47.266000000000005,41.492,29.28 +2020-06-04 15:00:00,109.26,48.626000000000005,43.711999999999996,29.28 +2020-06-04 15:15:00,110.94,46.278,43.711999999999996,29.28 +2020-06-04 15:30:00,109.79,44.57899999999999,43.711999999999996,29.28 +2020-06-04 15:45:00,112.69,42.47,43.711999999999996,29.28 +2020-06-04 16:00:00,114.07,44.871,45.446000000000005,29.28 +2020-06-04 16:15:00,109.19,44.458,45.446000000000005,29.28 +2020-06-04 16:30:00,114.59,43.638999999999996,45.446000000000005,29.28 +2020-06-04 16:45:00,116.07,40.316,45.446000000000005,29.28 +2020-06-04 17:00:00,115.34,42.75899999999999,48.803000000000004,29.28 +2020-06-04 17:15:00,110.62,42.886,48.803000000000004,29.28 +2020-06-04 17:30:00,108.75,42.233999999999995,48.803000000000004,29.28 +2020-06-04 17:45:00,116.09,40.74,48.803000000000004,29.28 +2020-06-04 18:00:00,113.07,43.028,51.167,29.28 +2020-06-04 18:15:00,111.11,43.445,51.167,29.28 +2020-06-04 18:30:00,108.92,41.133,51.167,29.28 +2020-06-04 18:45:00,115.29,45.537,51.167,29.28 +2020-06-04 19:00:00,113.15,46.541000000000004,52.486000000000004,29.28 +2020-06-04 19:15:00,104.97,45.648999999999994,52.486000000000004,29.28 +2020-06-04 19:30:00,105.27,44.864,52.486000000000004,29.28 +2020-06-04 19:45:00,105.77,45.185,52.486000000000004,29.28 +2020-06-04 20:00:00,104.54,43.773999999999994,59.635,29.28 +2020-06-04 20:15:00,99.31,43.553999999999995,59.635,29.28 +2020-06-04 20:30:00,96.07,43.706,59.635,29.28 +2020-06-04 20:45:00,96.72,44.038000000000004,59.635,29.28 +2020-06-04 21:00:00,93.51,42.174,54.353,29.28 +2020-06-04 21:15:00,91.29,43.881,54.353,29.28 +2020-06-04 21:30:00,89.16,44.949,54.353,29.28 +2020-06-04 21:45:00,87.45,45.925,54.353,29.28 +2020-06-04 22:00:00,83.88,43.271,49.431999999999995,29.28 +2020-06-04 22:15:00,83.67,44.842,49.431999999999995,29.28 +2020-06-04 22:30:00,80.79,39.148,49.431999999999995,29.28 +2020-06-04 22:45:00,82.95,35.979,49.431999999999995,29.28 +2020-06-04 23:00:00,77.09,31.623,42.872,29.28 +2020-06-04 23:15:00,76.4,29.77,42.872,29.28 +2020-06-04 23:30:00,76.03,28.66,42.872,29.28 +2020-06-04 23:45:00,75.13,28.1,42.872,29.28 +2020-06-05 00:00:00,72.68,25.186999999999998,39.819,29.28 +2020-06-05 00:15:00,74.39,26.006,39.819,29.28 +2020-06-05 00:30:00,73.39,25.189,39.819,29.28 +2020-06-05 00:45:00,72.85,24.973000000000003,39.819,29.28 +2020-06-05 01:00:00,73.16,24.238000000000003,37.797,29.28 +2020-06-05 01:15:00,73.41,23.063000000000002,37.797,29.28 +2020-06-05 01:30:00,71.51,22.136999999999997,37.797,29.28 +2020-06-05 01:45:00,72.07,21.439,37.797,29.28 +2020-06-05 02:00:00,71.82,21.836,36.905,29.28 +2020-06-05 02:15:00,72.76,20.305,36.905,29.28 +2020-06-05 02:30:00,72.71,23.339000000000002,36.905,29.28 +2020-06-05 02:45:00,73.05,23.405,36.905,29.28 +2020-06-05 03:00:00,73.18,25.739,37.1,29.28 +2020-06-05 03:15:00,74.33,23.340999999999998,37.1,29.28 +2020-06-05 03:30:00,73.58,22.383000000000003,37.1,29.28 +2020-06-05 03:45:00,74.46,22.509,37.1,29.28 +2020-06-05 04:00:00,78.21,30.009,37.882,29.28 +2020-06-05 04:15:00,84.39,37.158,37.882,29.28 +2020-06-05 04:30:00,85.9,35.499,37.882,29.28 +2020-06-05 04:45:00,92.12,35.150999999999996,37.882,29.28 +2020-06-05 05:00:00,94.15,52.801,40.777,29.28 +2020-06-05 05:15:00,96.53,66.937,40.777,29.28 +2020-06-05 05:30:00,98.78,57.873000000000005,40.777,29.28 +2020-06-05 05:45:00,105.4,53.503,40.777,29.28 +2020-06-05 06:00:00,110.45,55.2,55.528,29.28 +2020-06-05 06:15:00,115.64,55.998999999999995,55.528,29.28 +2020-06-05 06:30:00,113.37,53.525,55.528,29.28 +2020-06-05 06:45:00,113.78,53.961000000000006,55.528,29.28 +2020-06-05 07:00:00,117.75,55.501999999999995,67.749,29.28 +2020-06-05 07:15:00,116.94,56.013000000000005,67.749,29.28 +2020-06-05 07:30:00,113.93,51.391999999999996,67.749,29.28 +2020-06-05 07:45:00,110.35,50.114,67.749,29.28 +2020-06-05 08:00:00,109.2,48.199,57.55,29.28 +2020-06-05 08:15:00,110.52,50.788000000000004,57.55,29.28 +2020-06-05 08:30:00,122.31,50.622,57.55,29.28 +2020-06-05 08:45:00,120.12,52.435,57.55,29.28 +2020-06-05 09:00:00,121.23,45.938,52.588,29.28 +2020-06-05 09:15:00,117.3,46.71,52.588,29.28 +2020-06-05 09:30:00,116.81,49.378,52.588,29.28 +2020-06-05 09:45:00,121.98,51.972,52.588,29.28 +2020-06-05 10:00:00,119.87,46.911,49.772,29.28 +2020-06-05 10:15:00,116.15,48.356,49.772,29.28 +2020-06-05 10:30:00,115.85,48.961999999999996,49.772,29.28 +2020-06-05 10:45:00,117.93,50.29600000000001,49.772,29.28 +2020-06-05 11:00:00,114.69,46.1,49.226000000000006,29.28 +2020-06-05 11:15:00,113.17,46.253,49.226000000000006,29.28 +2020-06-05 11:30:00,105.0,47.621,49.226000000000006,29.28 +2020-06-05 11:45:00,103.88,48.471000000000004,49.226000000000006,29.28 +2020-06-05 12:00:00,100.79,44.951,45.705,29.28 +2020-06-05 12:15:00,102.92,44.177,45.705,29.28 +2020-06-05 12:30:00,99.51,43.463,45.705,29.28 +2020-06-05 12:45:00,100.37,44.29,45.705,29.28 +2020-06-05 13:00:00,99.89,45.646,43.133,29.28 +2020-06-05 13:15:00,99.79,46.958,43.133,29.28 +2020-06-05 13:30:00,100.88,45.986999999999995,43.133,29.28 +2020-06-05 13:45:00,99.87,44.84,43.133,29.28 +2020-06-05 14:00:00,99.86,45.912,41.989,29.28 +2020-06-05 14:15:00,97.26,44.972,41.989,29.28 +2020-06-05 14:30:00,96.34,45.495,41.989,29.28 +2020-06-05 14:45:00,94.99,45.443999999999996,41.989,29.28 +2020-06-05 15:00:00,95.46,46.718,43.728,29.28 +2020-06-05 15:15:00,98.1,44.083999999999996,43.728,29.28 +2020-06-05 15:30:00,95.79,41.667,43.728,29.28 +2020-06-05 15:45:00,95.92,40.283,43.728,29.28 +2020-06-05 16:00:00,97.42,41.795,45.93899999999999,29.28 +2020-06-05 16:15:00,100.06,41.875,45.93899999999999,29.28 +2020-06-05 16:30:00,99.13,40.913000000000004,45.93899999999999,29.28 +2020-06-05 16:45:00,101.84,36.795,45.93899999999999,29.28 +2020-06-05 17:00:00,103.81,40.939,50.488,29.28 +2020-06-05 17:15:00,103.47,40.846,50.488,29.28 +2020-06-05 17:30:00,103.83,40.308,50.488,29.28 +2020-06-05 17:45:00,104.89,38.606,50.488,29.28 +2020-06-05 18:00:00,103.73,41.031000000000006,52.408,29.28 +2020-06-05 18:15:00,103.19,40.478,52.408,29.28 +2020-06-05 18:30:00,102.75,38.1,52.408,29.28 +2020-06-05 18:45:00,103.29,42.903999999999996,52.408,29.28 +2020-06-05 19:00:00,98.83,44.846000000000004,52.736000000000004,29.28 +2020-06-05 19:15:00,95.61,44.645,52.736000000000004,29.28 +2020-06-05 19:30:00,93.94,43.871,52.736000000000004,29.28 +2020-06-05 19:45:00,93.79,43.167,52.736000000000004,29.28 +2020-06-05 20:00:00,92.15,41.589,59.68,29.28 +2020-06-05 20:15:00,92.31,42.13,59.68,29.28 +2020-06-05 20:30:00,91.82,41.824,59.68,29.28 +2020-06-05 20:45:00,92.41,41.468999999999994,59.68,29.28 +2020-06-05 21:00:00,89.29,40.918,54.343999999999994,29.28 +2020-06-05 21:15:00,87.29,44.281000000000006,54.343999999999994,29.28 +2020-06-05 21:30:00,84.11,45.185,54.343999999999994,29.28 +2020-06-05 21:45:00,83.05,46.424,54.343999999999994,29.28 +2020-06-05 22:00:00,79.44,43.754,49.672,29.28 +2020-06-05 22:15:00,78.6,45.085,49.672,29.28 +2020-06-05 22:30:00,77.06,44.566,49.672,29.28 +2020-06-05 22:45:00,75.8,42.548,49.672,29.28 +2020-06-05 23:00:00,72.07,39.743,42.065,29.28 +2020-06-05 23:15:00,72.18,36.24,42.065,29.28 +2020-06-05 23:30:00,71.31,33.249,42.065,29.28 +2020-06-05 23:45:00,70.36,32.497,42.065,29.28 +2020-06-06 00:00:00,67.51,25.941999999999997,38.829,29.17 +2020-06-06 00:15:00,67.86,25.7,38.829,29.17 +2020-06-06 00:30:00,67.41,24.565,38.829,29.17 +2020-06-06 00:45:00,67.72,23.725,38.829,29.17 +2020-06-06 01:00:00,66.82,23.335,34.63,29.17 +2020-06-06 01:15:00,67.25,22.605999999999998,34.63,29.17 +2020-06-06 01:30:00,65.85,20.85,34.63,29.17 +2020-06-06 01:45:00,66.09,21.315,34.63,29.17 +2020-06-06 02:00:00,64.68,20.839000000000002,32.465,29.17 +2020-06-06 02:15:00,64.57,18.511,32.465,29.17 +2020-06-06 02:30:00,64.68,20.68,32.465,29.17 +2020-06-06 02:45:00,64.25,21.514,32.465,29.17 +2020-06-06 03:00:00,64.14,22.599,31.925,29.17 +2020-06-06 03:15:00,65.0,19.414,31.925,29.17 +2020-06-06 03:30:00,65.09,18.62,31.925,29.17 +2020-06-06 03:45:00,67.6,20.195,31.925,29.17 +2020-06-06 04:00:00,70.16,25.434,31.309,29.17 +2020-06-06 04:15:00,70.62,31.435,31.309,29.17 +2020-06-06 04:30:00,63.11,27.958000000000002,31.309,29.17 +2020-06-06 04:45:00,65.22,27.819000000000003,31.309,29.17 +2020-06-06 05:00:00,67.91,36.066,30.323,29.17 +2020-06-06 05:15:00,67.72,37.842,30.323,29.17 +2020-06-06 05:30:00,69.31,30.338,30.323,29.17 +2020-06-06 05:45:00,71.04,30.802,30.323,29.17 +2020-06-06 06:00:00,75.65,44.887,31.438000000000002,29.17 +2020-06-06 06:15:00,80.98,54.621,31.438000000000002,29.17 +2020-06-06 06:30:00,84.8,48.958999999999996,31.438000000000002,29.17 +2020-06-06 06:45:00,83.47,45.533,31.438000000000002,29.17 +2020-06-06 07:00:00,78.85,45.275,34.891999999999996,29.17 +2020-06-06 07:15:00,78.38,44.446999999999996,34.891999999999996,29.17 +2020-06-06 07:30:00,79.02,41.507,34.891999999999996,29.17 +2020-06-06 07:45:00,80.34,41.519,34.891999999999996,29.17 +2020-06-06 08:00:00,81.61,40.611999999999995,39.608000000000004,29.17 +2020-06-06 08:15:00,82.54,43.39,39.608000000000004,29.17 +2020-06-06 08:30:00,81.42,43.358000000000004,39.608000000000004,29.17 +2020-06-06 08:45:00,81.09,46.385,39.608000000000004,29.17 +2020-06-06 09:00:00,79.87,42.973,40.894,29.17 +2020-06-06 09:15:00,87.56,44.285,40.894,29.17 +2020-06-06 09:30:00,87.68,47.515,40.894,29.17 +2020-06-06 09:45:00,83.66,49.722,40.894,29.17 +2020-06-06 10:00:00,80.88,45.253,39.525,29.17 +2020-06-06 10:15:00,87.29,47.07,39.525,29.17 +2020-06-06 10:30:00,83.73,47.373999999999995,39.525,29.17 +2020-06-06 10:45:00,83.72,48.521,39.525,29.17 +2020-06-06 11:00:00,84.12,44.211000000000006,36.718,29.17 +2020-06-06 11:15:00,90.83,45.122,36.718,29.17 +2020-06-06 11:30:00,94.67,46.63,36.718,29.17 +2020-06-06 11:45:00,94.56,48.016999999999996,36.718,29.17 +2020-06-06 12:00:00,91.99,44.79600000000001,35.688,29.17 +2020-06-06 12:15:00,90.46,44.895,35.688,29.17 +2020-06-06 12:30:00,88.8,44.06399999999999,35.688,29.17 +2020-06-06 12:45:00,87.54,45.498999999999995,35.688,29.17 +2020-06-06 13:00:00,85.74,46.00899999999999,32.858000000000004,29.17 +2020-06-06 13:15:00,87.07,46.6,32.858000000000004,29.17 +2020-06-06 13:30:00,87.33,45.77,32.858000000000004,29.17 +2020-06-06 13:45:00,86.83,43.407,32.858000000000004,29.17 +2020-06-06 14:00:00,86.15,44.693999999999996,31.738000000000003,29.17 +2020-06-06 14:15:00,84.56,42.516000000000005,31.738000000000003,29.17 +2020-06-06 14:30:00,81.7,42.419,31.738000000000003,29.17 +2020-06-06 14:45:00,80.31,42.846000000000004,31.738000000000003,29.17 +2020-06-06 15:00:00,80.41,44.662,34.35,29.17 +2020-06-06 15:15:00,79.49,42.766000000000005,34.35,29.17 +2020-06-06 15:30:00,79.47,40.687,34.35,29.17 +2020-06-06 15:45:00,79.76,38.425,34.35,29.17 +2020-06-06 16:00:00,80.07,41.907,37.522,29.17 +2020-06-06 16:15:00,79.03,41.225,37.522,29.17 +2020-06-06 16:30:00,80.08,40.488,37.522,29.17 +2020-06-06 16:45:00,81.23,36.4,37.522,29.17 +2020-06-06 17:00:00,82.36,39.404,42.498000000000005,29.17 +2020-06-06 17:15:00,81.67,37.425,42.498000000000005,29.17 +2020-06-06 17:30:00,81.82,36.743,42.498000000000005,29.17 +2020-06-06 17:45:00,84.04,35.443000000000005,42.498000000000005,29.17 +2020-06-06 18:00:00,84.14,39.175,44.701,29.17 +2020-06-06 18:15:00,83.83,40.357,44.701,29.17 +2020-06-06 18:30:00,84.61,39.39,44.701,29.17 +2020-06-06 18:45:00,84.06,40.567,44.701,29.17 +2020-06-06 19:00:00,81.29,41.091,45.727,29.17 +2020-06-06 19:15:00,78.62,39.875,45.727,29.17 +2020-06-06 19:30:00,76.78,39.896,45.727,29.17 +2020-06-06 19:45:00,75.72,40.85,45.727,29.17 +2020-06-06 20:00:00,74.89,40.242,43.391000000000005,29.17 +2020-06-06 20:15:00,74.17,40.381,43.391000000000005,29.17 +2020-06-06 20:30:00,74.7,39.22,43.391000000000005,29.17 +2020-06-06 20:45:00,74.33,40.624,43.391000000000005,29.17 +2020-06-06 21:00:00,71.59,38.898,41.231,29.17 +2020-06-06 21:15:00,69.93,41.982,41.231,29.17 +2020-06-06 21:30:00,68.91,43.161,41.231,29.17 +2020-06-06 21:45:00,68.03,43.858999999999995,41.231,29.17 +2020-06-06 22:00:00,64.54,41.316,40.798,29.17 +2020-06-06 22:15:00,64.33,43.133,40.798,29.17 +2020-06-06 22:30:00,62.24,42.849,40.798,29.17 +2020-06-06 22:45:00,62.62,41.352,40.798,29.17 +2020-06-06 23:00:00,58.66,38.109,34.402,29.17 +2020-06-06 23:15:00,58.11,34.889,34.402,29.17 +2020-06-06 23:30:00,56.97,34.075,34.402,29.17 +2020-06-06 23:45:00,56.18,33.421,34.402,29.17 +2020-06-07 00:00:00,54.78,27.147,30.171,29.17 +2020-06-07 00:15:00,55.23,25.78,30.171,29.17 +2020-06-07 00:30:00,54.07,24.471999999999998,30.171,29.17 +2020-06-07 00:45:00,54.14,23.596999999999998,30.171,29.17 +2020-06-07 01:00:00,52.67,23.464000000000002,27.15,29.17 +2020-06-07 01:15:00,52.85,22.699,27.15,29.17 +2020-06-07 01:30:00,52.59,20.854,27.15,29.17 +2020-06-07 01:45:00,52.21,20.92,27.15,29.17 +2020-06-07 02:00:00,50.98,20.448,25.403000000000002,29.17 +2020-06-07 02:15:00,51.38,18.707,25.403000000000002,29.17 +2020-06-07 02:30:00,50.88,21.136,25.403000000000002,29.17 +2020-06-07 02:45:00,51.13,21.745,25.403000000000002,29.17 +2020-06-07 03:00:00,50.84,23.48,23.386999999999997,29.17 +2020-06-07 03:15:00,51.71,20.49,23.386999999999997,29.17 +2020-06-07 03:30:00,52.75,19.136,23.386999999999997,29.17 +2020-06-07 03:45:00,50.92,19.973,23.386999999999997,29.17 +2020-06-07 04:00:00,50.55,25.101,23.941999999999997,29.17 +2020-06-07 04:15:00,51.55,30.546,23.941999999999997,29.17 +2020-06-07 04:30:00,51.6,28.333000000000002,23.941999999999997,29.17 +2020-06-07 04:45:00,52.09,27.774,23.941999999999997,29.17 +2020-06-07 05:00:00,53.72,35.75,23.026,29.17 +2020-06-07 05:15:00,54.12,36.433,23.026,29.17 +2020-06-07 05:30:00,55.59,28.6,23.026,29.17 +2020-06-07 05:45:00,57.85,28.881,23.026,29.17 +2020-06-07 06:00:00,59.26,40.721,23.223000000000003,29.17 +2020-06-07 06:15:00,60.63,51.071999999999996,23.223000000000003,29.17 +2020-06-07 06:30:00,62.83,44.669,23.223000000000003,29.17 +2020-06-07 06:45:00,64.38,40.23,23.223000000000003,29.17 +2020-06-07 07:00:00,65.6,40.458,24.968000000000004,29.17 +2020-06-07 07:15:00,66.44,38.01,24.968000000000004,29.17 +2020-06-07 07:30:00,68.26,36.156,24.968000000000004,29.17 +2020-06-07 07:45:00,68.77,36.098,24.968000000000004,29.17 +2020-06-07 08:00:00,69.95,36.086,29.131,29.17 +2020-06-07 08:15:00,69.53,40.012,29.131,29.17 +2020-06-07 08:30:00,69.54,40.953,29.131,29.17 +2020-06-07 08:45:00,69.78,44.098,29.131,29.17 +2020-06-07 09:00:00,70.2,40.535,29.904,29.17 +2020-06-07 09:15:00,72.11,41.438,29.904,29.17 +2020-06-07 09:30:00,74.06,45.06399999999999,29.904,29.17 +2020-06-07 09:45:00,74.87,48.231,29.904,29.17 +2020-06-07 10:00:00,77.19,44.516000000000005,28.943,29.17 +2020-06-07 10:15:00,78.39,46.53,28.943,29.17 +2020-06-07 10:30:00,77.62,47.106,28.943,29.17 +2020-06-07 10:45:00,75.28,49.04,28.943,29.17 +2020-06-07 11:00:00,71.31,44.483999999999995,31.682,29.17 +2020-06-07 11:15:00,70.31,44.994,31.682,29.17 +2020-06-07 11:30:00,70.0,46.903,31.682,29.17 +2020-06-07 11:45:00,67.85,48.593,31.682,29.17 +2020-06-07 12:00:00,64.35,46.353,27.315,29.17 +2020-06-07 12:15:00,61.2,46.038000000000004,27.315,29.17 +2020-06-07 12:30:00,57.59,45.248999999999995,27.315,29.17 +2020-06-07 12:45:00,59.18,45.997,27.315,29.17 +2020-06-07 13:00:00,59.01,46.153,23.894000000000002,29.17 +2020-06-07 13:15:00,55.2,46.481,23.894000000000002,29.17 +2020-06-07 13:30:00,57.81,44.589,23.894000000000002,29.17 +2020-06-07 13:45:00,60.26,43.223,23.894000000000002,29.17 +2020-06-07 14:00:00,57.88,45.69,21.148000000000003,29.17 +2020-06-07 14:15:00,64.49,44.026,21.148000000000003,29.17 +2020-06-07 14:30:00,66.8,42.846000000000004,21.148000000000003,29.17 +2020-06-07 14:45:00,67.65,42.228,21.148000000000003,29.17 +2020-06-07 15:00:00,67.87,44.083,21.229,29.17 +2020-06-07 15:15:00,65.72,41.477,21.229,29.17 +2020-06-07 15:30:00,59.62,39.268,21.229,29.17 +2020-06-07 15:45:00,60.76,37.325,21.229,29.17 +2020-06-07 16:00:00,62.45,39.453,25.037,29.17 +2020-06-07 16:15:00,63.37,38.900999999999996,25.037,29.17 +2020-06-07 16:30:00,66.59,39.158,25.037,29.17 +2020-06-07 16:45:00,70.11,35.108000000000004,25.037,29.17 +2020-06-07 17:00:00,72.96,38.486,37.11,29.17 +2020-06-07 17:15:00,76.34,37.903,37.11,29.17 +2020-06-07 17:30:00,79.24,38.003,37.11,29.17 +2020-06-07 17:45:00,81.88,37.28,37.11,29.17 +2020-06-07 18:00:00,83.72,41.528999999999996,42.215,29.17 +2020-06-07 18:15:00,79.56,42.415,42.215,29.17 +2020-06-07 18:30:00,76.0,41.022,42.215,29.17 +2020-06-07 18:45:00,78.65,42.47,42.215,29.17 +2020-06-07 19:00:00,79.38,45.026,44.383,29.17 +2020-06-07 19:15:00,79.12,42.795,44.383,29.17 +2020-06-07 19:30:00,79.25,42.556999999999995,44.383,29.17 +2020-06-07 19:45:00,78.61,43.195,44.383,29.17 +2020-06-07 20:00:00,79.46,42.74,43.426,29.17 +2020-06-07 20:15:00,79.03,42.831,43.426,29.17 +2020-06-07 20:30:00,79.41,42.56100000000001,43.426,29.17 +2020-06-07 20:45:00,81.82,42.298,43.426,29.17 +2020-06-07 21:00:00,81.38,40.224000000000004,42.265,29.17 +2020-06-07 21:15:00,80.43,42.977,42.265,29.17 +2020-06-07 21:30:00,78.77,43.54,42.265,29.17 +2020-06-07 21:45:00,77.29,44.605,42.265,29.17 +2020-06-07 22:00:00,73.8,43.917,42.26,29.17 +2020-06-07 22:15:00,72.46,44.08,42.26,29.17 +2020-06-07 22:30:00,70.72,43.038999999999994,42.26,29.17 +2020-06-07 22:45:00,70.73,40.247,42.26,29.17 +2020-06-07 23:00:00,68.68,36.454,36.609,29.17 +2020-06-07 23:15:00,69.16,34.64,36.609,29.17 +2020-06-07 23:30:00,67.02,33.389,36.609,29.17 +2020-06-07 23:45:00,67.56,32.952,36.609,29.17 +2020-06-08 00:00:00,65.18,28.81,34.611,29.28 +2020-06-08 00:15:00,65.79,28.454,34.611,29.28 +2020-06-08 00:30:00,66.1,26.794,34.611,29.28 +2020-06-08 00:45:00,66.13,25.502,34.611,29.28 +2020-06-08 01:00:00,64.79,25.755,33.552,29.28 +2020-06-08 01:15:00,65.06,24.938000000000002,33.552,29.28 +2020-06-08 01:30:00,65.24,23.433000000000003,33.552,29.28 +2020-06-08 01:45:00,65.35,23.410999999999998,33.552,29.28 +2020-06-08 02:00:00,65.58,23.37,32.351,29.28 +2020-06-08 02:15:00,67.8,20.785999999999998,32.351,29.28 +2020-06-08 02:30:00,73.54,23.4,32.351,29.28 +2020-06-08 02:45:00,74.8,23.823,32.351,29.28 +2020-06-08 03:00:00,71.75,26.151999999999997,30.793000000000003,29.28 +2020-06-08 03:15:00,68.21,23.986,30.793000000000003,29.28 +2020-06-08 03:30:00,68.19,23.261999999999997,30.793000000000003,29.28 +2020-06-08 03:45:00,75.1,23.634,30.793000000000003,29.28 +2020-06-08 04:00:00,76.21,32.04,31.274,29.28 +2020-06-08 04:15:00,81.8,40.582,31.274,29.28 +2020-06-08 04:30:00,81.13,38.092,31.274,29.28 +2020-06-08 04:45:00,84.34,37.895,31.274,29.28 +2020-06-08 05:00:00,91.59,53.857,37.75,29.28 +2020-06-08 05:15:00,94.96,65.925,37.75,29.28 +2020-06-08 05:30:00,96.35,56.716,37.75,29.28 +2020-06-08 05:45:00,103.22,53.645,37.75,29.28 +2020-06-08 06:00:00,115.44,54.214,55.36,29.28 +2020-06-08 06:15:00,117.66,54.621,55.36,29.28 +2020-06-08 06:30:00,119.09,52.708999999999996,55.36,29.28 +2020-06-08 06:45:00,118.2,54.078,55.36,29.28 +2020-06-08 07:00:00,122.59,54.916000000000004,65.87,29.28 +2020-06-08 07:15:00,126.53,54.75899999999999,65.87,29.28 +2020-06-08 07:30:00,126.58,52.008,65.87,29.28 +2020-06-08 07:45:00,117.45,52.041000000000004,65.87,29.28 +2020-06-08 08:00:00,116.82,49.583,55.695,29.28 +2020-06-08 08:15:00,124.15,52.214,55.695,29.28 +2020-06-08 08:30:00,127.09,51.86600000000001,55.695,29.28 +2020-06-08 08:45:00,129.96,54.918,55.695,29.28 +2020-06-08 09:00:00,127.9,50.291000000000004,50.881,29.28 +2020-06-08 09:15:00,127.73,49.188,50.881,29.28 +2020-06-08 09:30:00,129.88,51.846000000000004,50.881,29.28 +2020-06-08 09:45:00,128.32,52.891000000000005,50.881,29.28 +2020-06-08 10:00:00,125.16,49.513999999999996,49.138000000000005,29.28 +2020-06-08 10:15:00,119.84,51.376000000000005,49.138000000000005,29.28 +2020-06-08 10:30:00,116.84,51.43899999999999,49.138000000000005,29.28 +2020-06-08 10:45:00,124.11,51.953,49.138000000000005,29.28 +2020-06-08 11:00:00,126.44,47.358000000000004,49.178000000000004,29.28 +2020-06-08 11:15:00,121.8,48.31100000000001,49.178000000000004,29.28 +2020-06-08 11:30:00,117.6,50.997,49.178000000000004,29.28 +2020-06-08 11:45:00,113.89,53.128,49.178000000000004,29.28 +2020-06-08 12:00:00,106.1,49.479,47.698,29.28 +2020-06-08 12:15:00,110.89,49.273999999999994,47.698,29.28 +2020-06-08 12:30:00,105.06,47.468999999999994,47.698,29.28 +2020-06-08 12:45:00,103.24,48.36,47.698,29.28 +2020-06-08 13:00:00,104.28,49.425,48.104,29.28 +2020-06-08 13:15:00,105.35,48.79,48.104,29.28 +2020-06-08 13:30:00,102.99,47.021,48.104,29.28 +2020-06-08 13:45:00,106.17,46.507,48.104,29.28 +2020-06-08 14:00:00,104.77,48.076,48.53,29.28 +2020-06-08 14:15:00,99.17,46.91,48.53,29.28 +2020-06-08 14:30:00,100.05,45.497,48.53,29.28 +2020-06-08 14:45:00,101.1,46.841,48.53,29.28 +2020-06-08 15:00:00,100.5,48.551,49.351000000000006,29.28 +2020-06-08 15:15:00,97.86,45.243,49.351000000000006,29.28 +2020-06-08 15:30:00,98.11,43.674,49.351000000000006,29.28 +2020-06-08 15:45:00,102.17,41.217,49.351000000000006,29.28 +2020-06-08 16:00:00,104.3,44.405,51.44,29.28 +2020-06-08 16:15:00,106.98,43.84,51.44,29.28 +2020-06-08 16:30:00,105.78,43.396,51.44,29.28 +2020-06-08 16:45:00,107.49,39.226,51.44,29.28 +2020-06-08 17:00:00,108.2,41.544,56.868,29.28 +2020-06-08 17:15:00,108.56,41.22,56.868,29.28 +2020-06-08 17:30:00,108.83,40.895,56.868,29.28 +2020-06-08 17:45:00,109.18,39.635,56.868,29.28 +2020-06-08 18:00:00,108.41,42.896,57.229,29.28 +2020-06-08 18:15:00,107.9,41.818999999999996,57.229,29.28 +2020-06-08 18:30:00,107.3,39.758,57.229,29.28 +2020-06-08 18:45:00,105.82,44.278,57.229,29.28 +2020-06-08 19:00:00,103.74,46.408,57.744,29.28 +2020-06-08 19:15:00,100.22,45.333999999999996,57.744,29.28 +2020-06-08 19:30:00,98.33,44.78,57.744,29.28 +2020-06-08 19:45:00,97.62,44.755,57.744,29.28 +2020-06-08 20:00:00,95.68,42.876000000000005,66.05199999999999,29.28 +2020-06-08 20:15:00,95.32,44.086999999999996,66.05199999999999,29.28 +2020-06-08 20:30:00,96.73,44.168,66.05199999999999,29.28 +2020-06-08 20:45:00,96.53,44.285,66.05199999999999,29.28 +2020-06-08 21:00:00,91.82,41.66,59.396,29.28 +2020-06-08 21:15:00,91.66,44.745,59.396,29.28 +2020-06-08 21:30:00,89.92,45.61,59.396,29.28 +2020-06-08 21:45:00,88.61,46.43600000000001,59.396,29.28 +2020-06-08 22:00:00,83.94,43.571000000000005,53.06,29.28 +2020-06-08 22:15:00,83.74,45.533,53.06,29.28 +2020-06-08 22:30:00,81.28,39.543,53.06,29.28 +2020-06-08 22:45:00,80.11,36.312,53.06,29.28 +2020-06-08 23:00:00,74.81,32.603,46.148,29.28 +2020-06-08 23:15:00,76.9,29.473000000000003,46.148,29.28 +2020-06-08 23:30:00,73.41,28.424,46.148,29.28 +2020-06-08 23:45:00,75.06,27.683000000000003,46.148,29.28 +2020-06-09 00:00:00,71.41,26.438000000000002,44.625,29.28 +2020-06-09 00:15:00,73.02,27.034000000000002,44.625,29.28 +2020-06-09 00:30:00,70.5,25.976999999999997,44.625,29.28 +2020-06-09 00:45:00,73.38,25.364,44.625,29.28 +2020-06-09 01:00:00,72.8,25.099,41.733000000000004,29.28 +2020-06-09 01:15:00,73.38,24.331999999999997,41.733000000000004,29.28 +2020-06-09 01:30:00,72.4,22.71,41.733000000000004,29.28 +2020-06-09 01:45:00,72.79,22.215,41.733000000000004,29.28 +2020-06-09 02:00:00,70.58,21.713,39.872,29.28 +2020-06-09 02:15:00,72.27,20.226,39.872,29.28 +2020-06-09 02:30:00,80.49,22.413,39.872,29.28 +2020-06-09 02:45:00,80.75,23.159000000000002,39.872,29.28 +2020-06-09 03:00:00,78.25,24.851,38.711,29.28 +2020-06-09 03:15:00,73.94,23.508000000000003,38.711,29.28 +2020-06-09 03:30:00,77.01,22.8,38.711,29.28 +2020-06-09 03:45:00,74.49,22.145,38.711,29.28 +2020-06-09 04:00:00,77.74,29.266,39.823,29.28 +2020-06-09 04:15:00,79.76,37.743,39.823,29.28 +2020-06-09 04:30:00,82.3,35.097,39.823,29.28 +2020-06-09 04:45:00,87.15,35.468,39.823,29.28 +2020-06-09 05:00:00,96.24,53.251000000000005,43.228,29.28 +2020-06-09 05:15:00,100.0,65.757,43.228,29.28 +2020-06-09 05:30:00,103.94,56.667,43.228,29.28 +2020-06-09 05:45:00,105.25,52.95,43.228,29.28 +2020-06-09 06:00:00,114.5,54.452,54.316,29.28 +2020-06-09 06:15:00,116.55,55.166000000000004,54.316,29.28 +2020-06-09 06:30:00,110.99,52.913000000000004,54.316,29.28 +2020-06-09 06:45:00,112.3,53.423,54.316,29.28 +2020-06-09 07:00:00,121.18,54.379,65.758,29.28 +2020-06-09 07:15:00,120.41,53.98,65.758,29.28 +2020-06-09 07:30:00,115.9,51.185,65.758,29.28 +2020-06-09 07:45:00,116.85,50.333999999999996,65.758,29.28 +2020-06-09 08:00:00,118.19,47.826,57.983000000000004,29.28 +2020-06-09 08:15:00,119.82,49.976000000000006,57.983000000000004,29.28 +2020-06-09 08:30:00,115.93,49.765,57.983000000000004,29.28 +2020-06-09 08:45:00,114.08,51.917,57.983000000000004,29.28 +2020-06-09 09:00:00,112.79,47.534,52.653,29.28 +2020-06-09 09:15:00,113.07,46.446999999999996,52.653,29.28 +2020-06-09 09:30:00,113.8,49.825,52.653,29.28 +2020-06-09 09:45:00,113.95,52.155,52.653,29.28 +2020-06-09 10:00:00,112.65,47.425,51.408,29.28 +2020-06-09 10:15:00,115.45,49.073,51.408,29.28 +2020-06-09 10:30:00,121.75,49.181999999999995,51.408,29.28 +2020-06-09 10:45:00,124.45,50.68899999999999,51.408,29.28 +2020-06-09 11:00:00,118.76,46.229,51.913000000000004,29.28 +2020-06-09 11:15:00,118.38,47.549,51.913000000000004,29.28 +2020-06-09 11:30:00,118.73,49.02,51.913000000000004,29.28 +2020-06-09 11:45:00,120.94,50.788999999999994,51.913000000000004,29.28 +2020-06-09 12:00:00,115.6,46.87,49.508,29.28 +2020-06-09 12:15:00,114.07,46.952,49.508,29.28 +2020-06-09 12:30:00,115.92,46.006,49.508,29.28 +2020-06-09 12:45:00,116.36,47.536,49.508,29.28 +2020-06-09 13:00:00,114.4,48.217,50.007,29.28 +2020-06-09 13:15:00,114.3,49.275,50.007,29.28 +2020-06-09 13:30:00,114.18,47.592,50.007,29.28 +2020-06-09 13:45:00,114.22,46.174,50.007,29.28 +2020-06-09 14:00:00,109.09,48.222,49.778999999999996,29.28 +2020-06-09 14:15:00,106.54,46.875,49.778999999999996,29.28 +2020-06-09 14:30:00,113.2,45.803999999999995,49.778999999999996,29.28 +2020-06-09 14:45:00,112.19,46.391000000000005,49.778999999999996,29.28 +2020-06-09 15:00:00,108.09,47.952,51.559,29.28 +2020-06-09 15:15:00,101.97,45.549,51.559,29.28 +2020-06-09 15:30:00,104.39,43.786,51.559,29.28 +2020-06-09 15:45:00,107.82,41.613,51.559,29.28 +2020-06-09 16:00:00,106.35,44.193000000000005,53.531000000000006,29.28 +2020-06-09 16:15:00,101.43,43.746,53.531000000000006,29.28 +2020-06-09 16:30:00,105.03,43.018,53.531000000000006,29.28 +2020-06-09 16:45:00,106.03,39.527,53.531000000000006,29.28 +2020-06-09 17:00:00,112.45,42.126999999999995,59.497,29.28 +2020-06-09 17:15:00,115.83,42.196000000000005,59.497,29.28 +2020-06-09 17:30:00,116.3,41.498000000000005,59.497,29.28 +2020-06-09 17:45:00,110.26,39.883,59.497,29.28 +2020-06-09 18:00:00,106.4,42.235,59.861999999999995,29.28 +2020-06-09 18:15:00,108.44,42.52,59.861999999999995,29.28 +2020-06-09 18:30:00,112.57,40.175,59.861999999999995,29.28 +2020-06-09 18:45:00,113.13,44.573,59.861999999999995,29.28 +2020-06-09 19:00:00,106.86,45.585,60.989,29.28 +2020-06-09 19:15:00,103.42,44.653,60.989,29.28 +2020-06-09 19:30:00,98.81,43.82899999999999,60.989,29.28 +2020-06-09 19:45:00,99.4,44.125,60.989,29.28 +2020-06-09 20:00:00,95.84,42.619,68.35600000000001,29.28 +2020-06-09 20:15:00,94.92,42.388000000000005,68.35600000000001,29.28 +2020-06-09 20:30:00,94.19,42.602,68.35600000000001,29.28 +2020-06-09 20:45:00,94.35,43.11,68.35600000000001,29.28 +2020-06-09 21:00:00,90.98,41.266999999999996,59.251000000000005,29.28 +2020-06-09 21:15:00,89.1,43.023999999999994,59.251000000000005,29.28 +2020-06-09 21:30:00,86.25,43.975,59.251000000000005,29.28 +2020-06-09 21:45:00,86.55,45.022,59.251000000000005,29.28 +2020-06-09 22:00:00,82.28,42.479,54.736999999999995,29.28 +2020-06-09 22:15:00,80.43,44.108999999999995,54.736999999999995,29.28 +2020-06-09 22:30:00,78.68,38.405,54.736999999999995,29.28 +2020-06-09 22:45:00,78.71,35.179,54.736999999999995,29.28 +2020-06-09 23:00:00,74.03,30.709,46.806999999999995,29.28 +2020-06-09 23:15:00,74.01,29.048000000000002,46.806999999999995,29.28 +2020-06-09 23:30:00,74.25,27.978,46.806999999999995,29.28 +2020-06-09 23:45:00,73.65,27.365,46.806999999999995,29.28 +2020-06-10 00:00:00,69.28,26.302,43.824,29.28 +2020-06-10 00:15:00,71.2,26.901,43.824,29.28 +2020-06-10 00:30:00,66.99,25.846,43.824,29.28 +2020-06-10 00:45:00,71.61,25.239,43.824,29.28 +2020-06-10 01:00:00,69.8,24.991,39.86,29.28 +2020-06-10 01:15:00,71.05,24.204,39.86,29.28 +2020-06-10 01:30:00,70.29,22.575,39.86,29.28 +2020-06-10 01:45:00,71.08,22.073,39.86,29.28 +2020-06-10 02:00:00,69.28,21.573,37.931999999999995,29.28 +2020-06-10 02:15:00,70.6,20.082,37.931999999999995,29.28 +2020-06-10 02:30:00,77.94,22.269000000000002,37.931999999999995,29.28 +2020-06-10 02:45:00,78.48,23.021,37.931999999999995,29.28 +2020-06-10 03:00:00,76.46,24.714000000000002,37.579,29.28 +2020-06-10 03:15:00,74.28,23.369,37.579,29.28 +2020-06-10 03:30:00,73.07,22.666,37.579,29.28 +2020-06-10 03:45:00,74.4,22.034000000000002,37.579,29.28 +2020-06-10 04:00:00,78.61,29.1,37.931999999999995,29.28 +2020-06-10 04:15:00,78.12,37.53,37.931999999999995,29.28 +2020-06-10 04:30:00,82.15,34.872,37.931999999999995,29.28 +2020-06-10 04:45:00,86.72,35.239000000000004,37.931999999999995,29.28 +2020-06-10 05:00:00,95.11,52.913000000000004,40.942,29.28 +2020-06-10 05:15:00,101.05,65.282,40.942,29.28 +2020-06-10 05:30:00,107.89,56.26,40.942,29.28 +2020-06-10 05:45:00,112.89,52.593,40.942,29.28 +2020-06-10 06:00:00,115.77,54.111999999999995,56.516999999999996,29.28 +2020-06-10 06:15:00,116.7,54.808,56.516999999999996,29.28 +2020-06-10 06:30:00,120.98,52.577,56.516999999999996,29.28 +2020-06-10 06:45:00,121.83,53.114,56.516999999999996,29.28 +2020-06-10 07:00:00,121.07,54.059,71.707,29.28 +2020-06-10 07:15:00,116.29,53.674,71.707,29.28 +2020-06-10 07:30:00,121.13,50.87,71.707,29.28 +2020-06-10 07:45:00,119.6,50.05,71.707,29.28 +2020-06-10 08:00:00,120.93,47.552,61.17,29.28 +2020-06-10 08:15:00,117.32,49.738,61.17,29.28 +2020-06-10 08:30:00,118.76,49.519,61.17,29.28 +2020-06-10 08:45:00,116.95,51.678000000000004,61.17,29.28 +2020-06-10 09:00:00,124.86,47.288999999999994,57.282,29.28 +2020-06-10 09:15:00,121.0,46.206,57.282,29.28 +2020-06-10 09:30:00,126.65,49.588,57.282,29.28 +2020-06-10 09:45:00,128.01,51.937,57.282,29.28 +2020-06-10 10:00:00,127.44,47.218,54.026,29.28 +2020-06-10 10:15:00,119.72,48.883,54.026,29.28 +2020-06-10 10:30:00,121.47,48.994,54.026,29.28 +2020-06-10 10:45:00,128.3,50.51,54.026,29.28 +2020-06-10 11:00:00,132.15,46.044,54.277,29.28 +2020-06-10 11:15:00,127.14,47.372,54.277,29.28 +2020-06-10 11:30:00,117.1,48.833999999999996,54.277,29.28 +2020-06-10 11:45:00,114.36,50.605,54.277,29.28 +2020-06-10 12:00:00,120.41,46.718999999999994,52.552,29.28 +2020-06-10 12:15:00,123.17,46.803999999999995,52.552,29.28 +2020-06-10 12:30:00,122.08,45.833999999999996,52.552,29.28 +2020-06-10 12:45:00,120.6,47.368,52.552,29.28 +2020-06-10 13:00:00,118.16,48.044,52.111999999999995,29.28 +2020-06-10 13:15:00,119.23,49.104,52.111999999999995,29.28 +2020-06-10 13:30:00,117.97,47.431000000000004,52.111999999999995,29.28 +2020-06-10 13:45:00,113.38,46.016999999999996,52.111999999999995,29.28 +2020-06-10 14:00:00,112.33,48.085,52.066,29.28 +2020-06-10 14:15:00,108.78,46.735,52.066,29.28 +2020-06-10 14:30:00,112.6,45.636,52.066,29.28 +2020-06-10 14:45:00,111.92,46.229,52.066,29.28 +2020-06-10 15:00:00,110.0,47.827,52.523999999999994,29.28 +2020-06-10 15:15:00,106.57,45.413999999999994,52.523999999999994,29.28 +2020-06-10 15:30:00,108.24,43.638999999999996,52.523999999999994,29.28 +2020-06-10 15:45:00,110.6,41.455,52.523999999999994,29.28 +2020-06-10 16:00:00,112.33,44.068000000000005,54.101000000000006,29.28 +2020-06-10 16:15:00,110.81,43.615,54.101000000000006,29.28 +2020-06-10 16:30:00,111.57,42.906000000000006,54.101000000000006,29.28 +2020-06-10 16:45:00,118.23,39.384,54.101000000000006,29.28 +2020-06-10 17:00:00,119.5,42.012,58.155,29.28 +2020-06-10 17:15:00,116.37,42.07,58.155,29.28 +2020-06-10 17:30:00,117.51,41.364,58.155,29.28 +2020-06-10 17:45:00,114.99,39.728,58.155,29.28 +2020-06-10 18:00:00,121.59,42.092,60.205,29.28 +2020-06-10 18:15:00,119.08,42.352,60.205,29.28 +2020-06-10 18:30:00,115.11,40.0,60.205,29.28 +2020-06-10 18:45:00,110.65,44.397,60.205,29.28 +2020-06-10 19:00:00,108.16,45.411,61.568999999999996,29.28 +2020-06-10 19:15:00,108.06,44.47,61.568999999999996,29.28 +2020-06-10 19:30:00,106.76,43.638999999999996,61.568999999999996,29.28 +2020-06-10 19:45:00,103.49,43.93,61.568999999999996,29.28 +2020-06-10 20:00:00,96.17,42.407,68.145,29.28 +2020-06-10 20:15:00,96.64,42.173,68.145,29.28 +2020-06-10 20:30:00,95.52,42.398999999999994,68.145,29.28 +2020-06-10 20:45:00,94.92,42.938,68.145,29.28 +2020-06-10 21:00:00,90.77,41.101000000000006,59.696000000000005,29.28 +2020-06-10 21:15:00,90.39,42.868,59.696000000000005,29.28 +2020-06-10 21:30:00,86.63,43.795,59.696000000000005,29.28 +2020-06-10 21:45:00,85.83,44.854,59.696000000000005,29.28 +2020-06-10 22:00:00,81.02,42.332,54.861999999999995,29.28 +2020-06-10 22:15:00,79.98,43.971000000000004,54.861999999999995,29.28 +2020-06-10 22:30:00,77.22,38.265,54.861999999999995,29.28 +2020-06-10 22:45:00,76.83,35.029,54.861999999999995,29.28 +2020-06-10 23:00:00,73.12,30.535999999999998,45.568000000000005,29.28 +2020-06-10 23:15:00,75.38,28.914,45.568000000000005,29.28 +2020-06-10 23:30:00,74.71,27.851999999999997,45.568000000000005,29.28 +2020-06-10 23:45:00,75.2,27.229,45.568000000000005,29.28 +2020-06-11 00:00:00,70.41,26.171999999999997,40.181,29.28 +2020-06-11 00:15:00,70.82,26.772,40.181,29.28 +2020-06-11 00:30:00,70.59,25.719,40.181,29.28 +2020-06-11 00:45:00,70.45,25.116999999999997,40.181,29.28 +2020-06-11 01:00:00,69.57,24.886,38.296,29.28 +2020-06-11 01:15:00,70.14,24.081,38.296,29.28 +2020-06-11 01:30:00,68.96,22.445,38.296,29.28 +2020-06-11 01:45:00,69.74,21.936999999999998,38.296,29.28 +2020-06-11 02:00:00,69.81,21.438000000000002,36.575,29.28 +2020-06-11 02:15:00,69.88,19.944000000000003,36.575,29.28 +2020-06-11 02:30:00,70.09,22.129,36.575,29.28 +2020-06-11 02:45:00,76.04,22.886999999999997,36.575,29.28 +2020-06-11 03:00:00,79.84,24.581,36.394,29.28 +2020-06-11 03:15:00,79.7,23.234,36.394,29.28 +2020-06-11 03:30:00,74.17,22.535,36.394,29.28 +2020-06-11 03:45:00,76.38,21.927,36.394,29.28 +2020-06-11 04:00:00,75.88,28.939,37.207,29.28 +2020-06-11 04:15:00,78.09,37.323,37.207,29.28 +2020-06-11 04:30:00,82.26,34.653,37.207,29.28 +2020-06-11 04:45:00,86.97,35.016,37.207,29.28 +2020-06-11 05:00:00,94.99,52.582,40.713,29.28 +2020-06-11 05:15:00,100.23,64.817,40.713,29.28 +2020-06-11 05:30:00,106.76,55.861000000000004,40.713,29.28 +2020-06-11 05:45:00,111.82,52.246,40.713,29.28 +2020-06-11 06:00:00,116.83,53.778999999999996,50.952,29.28 +2020-06-11 06:15:00,112.43,54.458,50.952,29.28 +2020-06-11 06:30:00,113.1,52.248000000000005,50.952,29.28 +2020-06-11 06:45:00,118.36,52.81399999999999,50.952,29.28 +2020-06-11 07:00:00,124.46,53.745,64.88,29.28 +2020-06-11 07:15:00,126.91,53.378,64.88,29.28 +2020-06-11 07:30:00,121.71,50.56100000000001,64.88,29.28 +2020-06-11 07:45:00,118.85,49.775,64.88,29.28 +2020-06-11 08:00:00,123.62,47.285,55.133,29.28 +2020-06-11 08:15:00,123.22,49.50899999999999,55.133,29.28 +2020-06-11 08:30:00,122.66,49.281000000000006,55.133,29.28 +2020-06-11 08:45:00,116.59,51.448,55.133,29.28 +2020-06-11 09:00:00,119.41,47.053000000000004,48.912,29.28 +2020-06-11 09:15:00,123.06,45.975,48.912,29.28 +2020-06-11 09:30:00,124.81,49.357,48.912,29.28 +2020-06-11 09:45:00,122.25,51.726000000000006,48.912,29.28 +2020-06-11 10:00:00,123.12,47.016999999999996,45.968999999999994,29.28 +2020-06-11 10:15:00,122.31,48.696999999999996,45.968999999999994,29.28 +2020-06-11 10:30:00,124.76,48.813,45.968999999999994,29.28 +2020-06-11 10:45:00,121.0,50.336999999999996,45.968999999999994,29.28 +2020-06-11 11:00:00,114.05,45.86600000000001,44.067,29.28 +2020-06-11 11:15:00,112.2,47.202,44.067,29.28 +2020-06-11 11:30:00,108.33,48.652,44.067,29.28 +2020-06-11 11:45:00,108.51,50.427,44.067,29.28 +2020-06-11 12:00:00,109.52,46.573,41.501000000000005,29.28 +2020-06-11 12:15:00,106.82,46.66,41.501000000000005,29.28 +2020-06-11 12:30:00,106.48,45.667,41.501000000000005,29.28 +2020-06-11 12:45:00,100.88,47.20399999999999,41.501000000000005,29.28 +2020-06-11 13:00:00,95.77,47.875,41.117,29.28 +2020-06-11 13:15:00,95.82,48.938,41.117,29.28 +2020-06-11 13:30:00,100.99,47.275,41.117,29.28 +2020-06-11 13:45:00,104.58,45.864,41.117,29.28 +2020-06-11 14:00:00,103.94,47.951,41.492,29.28 +2020-06-11 14:15:00,100.72,46.599,41.492,29.28 +2020-06-11 14:30:00,97.94,45.475,41.492,29.28 +2020-06-11 14:45:00,99.64,46.071000000000005,41.492,29.28 +2020-06-11 15:00:00,99.53,47.706,43.711999999999996,29.28 +2020-06-11 15:15:00,97.45,45.283,43.711999999999996,29.28 +2020-06-11 15:30:00,94.25,43.497,43.711999999999996,29.28 +2020-06-11 15:45:00,100.3,41.3,43.711999999999996,29.28 +2020-06-11 16:00:00,103.4,43.946000000000005,45.446000000000005,29.28 +2020-06-11 16:15:00,100.4,43.488,45.446000000000005,29.28 +2020-06-11 16:30:00,100.04,42.797,45.446000000000005,29.28 +2020-06-11 16:45:00,101.12,39.244,45.446000000000005,29.28 +2020-06-11 17:00:00,110.22,41.901,48.803000000000004,29.28 +2020-06-11 17:15:00,110.07,41.951,48.803000000000004,29.28 +2020-06-11 17:30:00,113.51,41.235,48.803000000000004,29.28 +2020-06-11 17:45:00,106.59,39.578,48.803000000000004,29.28 +2020-06-11 18:00:00,104.74,41.95399999999999,51.167,29.28 +2020-06-11 18:15:00,106.72,42.18899999999999,51.167,29.28 +2020-06-11 18:30:00,107.72,39.83,51.167,29.28 +2020-06-11 18:45:00,102.99,44.225,51.167,29.28 +2020-06-11 19:00:00,99.51,45.243,52.486000000000004,29.28 +2020-06-11 19:15:00,103.25,44.294,52.486000000000004,29.28 +2020-06-11 19:30:00,105.06,43.455,52.486000000000004,29.28 +2020-06-11 19:45:00,97.36,43.74100000000001,52.486000000000004,29.28 +2020-06-11 20:00:00,101.57,42.2,59.635,29.28 +2020-06-11 20:15:00,101.54,41.964,59.635,29.28 +2020-06-11 20:30:00,100.96,42.202,59.635,29.28 +2020-06-11 20:45:00,96.25,42.772,59.635,29.28 +2020-06-11 21:00:00,92.74,40.939,54.353,29.28 +2020-06-11 21:15:00,89.61,42.716,54.353,29.28 +2020-06-11 21:30:00,87.7,43.619,54.353,29.28 +2020-06-11 21:45:00,94.37,44.69,54.353,29.28 +2020-06-11 22:00:00,89.15,42.188,49.431999999999995,29.28 +2020-06-11 22:15:00,88.41,43.838,49.431999999999995,29.28 +2020-06-11 22:30:00,80.58,38.126999999999995,49.431999999999995,29.28 +2020-06-11 22:45:00,80.98,34.881,49.431999999999995,29.28 +2020-06-11 23:00:00,82.25,30.368000000000002,42.872,29.28 +2020-06-11 23:15:00,83.85,28.783,42.872,29.28 +2020-06-11 23:30:00,79.67,27.729,42.872,29.28 +2020-06-11 23:45:00,74.85,27.095,42.872,29.28 +2020-06-12 00:00:00,71.36,24.219,39.819,29.28 +2020-06-12 00:15:00,72.66,25.046999999999997,39.819,29.28 +2020-06-12 00:30:00,77.04,24.245,39.819,29.28 +2020-06-12 00:45:00,78.71,24.064,39.819,29.28 +2020-06-12 01:00:00,74.65,23.453000000000003,37.797,29.28 +2020-06-12 01:15:00,72.83,22.143,37.797,29.28 +2020-06-12 01:30:00,71.34,21.164,37.797,29.28 +2020-06-12 01:45:00,78.24,20.419,37.797,29.28 +2020-06-12 02:00:00,77.22,20.825,36.905,29.28 +2020-06-12 02:15:00,75.15,19.269000000000002,36.905,29.28 +2020-06-12 02:30:00,76.1,22.295,36.905,29.28 +2020-06-12 02:45:00,72.39,22.408,36.905,29.28 +2020-06-12 03:00:00,77.81,24.747,37.1,29.28 +2020-06-12 03:15:00,79.33,22.335,37.1,29.28 +2020-06-12 03:30:00,78.33,21.410999999999998,37.1,29.28 +2020-06-12 03:45:00,74.64,21.698,37.1,29.28 +2020-06-12 04:00:00,77.34,28.813000000000002,37.882,29.28 +2020-06-12 04:15:00,84.37,35.63,37.882,29.28 +2020-06-12 04:30:00,86.71,33.882,37.882,29.28 +2020-06-12 04:45:00,88.82,33.51,37.882,29.28 +2020-06-12 05:00:00,89.4,50.383,40.777,29.28 +2020-06-12 05:15:00,93.53,63.548,40.777,29.28 +2020-06-12 05:30:00,98.42,54.953,40.777,29.28 +2020-06-12 05:45:00,98.91,50.955,40.777,29.28 +2020-06-12 06:00:00,102.82,52.763000000000005,55.528,29.28 +2020-06-12 06:15:00,102.95,53.437,55.528,29.28 +2020-06-12 06:30:00,110.8,51.111999999999995,55.528,29.28 +2020-06-12 06:45:00,112.79,51.744,55.528,29.28 +2020-06-12 07:00:00,116.91,53.201,67.749,29.28 +2020-06-12 07:15:00,107.11,53.821000000000005,67.749,29.28 +2020-06-12 07:30:00,104.05,49.117,67.749,29.28 +2020-06-12 07:45:00,103.9,48.068999999999996,67.749,29.28 +2020-06-12 08:00:00,106.28,46.215,57.55,29.28 +2020-06-12 08:15:00,102.7,49.07,57.55,29.28 +2020-06-12 08:30:00,109.84,48.847,57.55,29.28 +2020-06-12 08:45:00,110.15,50.713,57.55,29.28 +2020-06-12 09:00:00,108.4,44.169,52.588,29.28 +2020-06-12 09:15:00,103.04,44.977,52.588,29.28 +2020-06-12 09:30:00,109.97,47.66,52.588,29.28 +2020-06-12 09:45:00,108.51,50.403999999999996,52.588,29.28 +2020-06-12 10:00:00,107.23,45.416000000000004,49.772,29.28 +2020-06-12 10:15:00,105.95,46.98,49.772,29.28 +2020-06-12 10:30:00,114.2,47.611999999999995,49.772,29.28 +2020-06-12 10:45:00,115.09,49.004,49.772,29.28 +2020-06-12 11:00:00,112.1,44.768,49.226000000000006,29.28 +2020-06-12 11:15:00,107.96,44.983999999999995,49.226000000000006,29.28 +2020-06-12 11:30:00,105.26,46.276,49.226000000000006,29.28 +2020-06-12 11:45:00,103.17,47.152,49.226000000000006,29.28 +2020-06-12 12:00:00,101.7,43.861000000000004,45.705,29.28 +2020-06-12 12:15:00,103.75,43.108999999999995,45.705,29.28 +2020-06-12 12:30:00,102.31,42.221000000000004,45.705,29.28 +2020-06-12 12:45:00,102.28,43.075,45.705,29.28 +2020-06-12 13:00:00,98.03,44.407,43.133,29.28 +2020-06-12 13:15:00,98.2,45.735,43.133,29.28 +2020-06-12 13:30:00,97.7,44.833999999999996,43.133,29.28 +2020-06-12 13:45:00,99.86,43.71,43.133,29.28 +2020-06-12 14:00:00,100.43,44.925,41.989,29.28 +2020-06-12 14:15:00,103.53,43.963,41.989,29.28 +2020-06-12 14:30:00,105.57,44.29600000000001,41.989,29.28 +2020-06-12 14:45:00,108.6,44.278999999999996,41.989,29.28 +2020-06-12 15:00:00,107.88,45.821999999999996,43.728,29.28 +2020-06-12 15:15:00,108.67,43.114,43.728,29.28 +2020-06-12 15:30:00,108.56,40.613,43.728,29.28 +2020-06-12 15:45:00,108.51,39.143,43.728,29.28 +2020-06-12 16:00:00,107.02,40.894,45.93899999999999,29.28 +2020-06-12 16:15:00,107.22,40.931,45.93899999999999,29.28 +2020-06-12 16:30:00,106.33,40.096,45.93899999999999,29.28 +2020-06-12 16:45:00,105.53,35.755,45.93899999999999,29.28 +2020-06-12 17:00:00,105.78,40.11,50.488,29.28 +2020-06-12 17:15:00,104.46,39.942,50.488,29.28 +2020-06-12 17:30:00,104.33,39.343,50.488,29.28 +2020-06-12 17:45:00,106.02,37.482,50.488,29.28 +2020-06-12 18:00:00,104.89,39.993,52.408,29.28 +2020-06-12 18:15:00,103.14,39.260999999999996,52.408,29.28 +2020-06-12 18:30:00,102.14,36.836999999999996,52.408,29.28 +2020-06-12 18:45:00,101.78,41.63,52.408,29.28 +2020-06-12 19:00:00,98.46,43.586999999999996,52.736000000000004,29.28 +2020-06-12 19:15:00,94.32,43.331,52.736000000000004,29.28 +2020-06-12 19:30:00,94.61,42.503,52.736000000000004,29.28 +2020-06-12 19:45:00,91.52,41.763999999999996,52.736000000000004,29.28 +2020-06-12 20:00:00,89.27,40.058,59.68,29.28 +2020-06-12 20:15:00,91.99,40.582,59.68,29.28 +2020-06-12 20:30:00,88.8,40.359,59.68,29.28 +2020-06-12 20:45:00,88.94,40.238,59.68,29.28 +2020-06-12 21:00:00,86.4,39.718,54.343999999999994,29.28 +2020-06-12 21:15:00,85.82,43.148,54.343999999999994,29.28 +2020-06-12 21:30:00,83.13,43.888999999999996,54.343999999999994,29.28 +2020-06-12 21:45:00,84.05,45.218,54.343999999999994,29.28 +2020-06-12 22:00:00,77.72,42.698,49.672,29.28 +2020-06-12 22:15:00,77.94,44.105,49.672,29.28 +2020-06-12 22:30:00,74.99,43.566,49.672,29.28 +2020-06-12 22:45:00,76.11,41.47,49.672,29.28 +2020-06-12 23:00:00,70.24,38.516,42.065,29.28 +2020-06-12 23:15:00,69.98,35.275999999999996,42.065,29.28 +2020-06-12 23:30:00,69.72,32.342,42.065,29.28 +2020-06-12 23:45:00,69.07,31.518,42.065,29.28 +2020-06-13 00:00:00,64.84,25.0,38.829,29.17 +2020-06-13 00:15:00,66.33,24.767,38.829,29.17 +2020-06-13 00:30:00,65.93,23.649,38.829,29.17 +2020-06-13 00:45:00,64.97,22.844,38.829,29.17 +2020-06-13 01:00:00,64.06,22.574,34.63,29.17 +2020-06-13 01:15:00,64.21,21.715,34.63,29.17 +2020-06-13 01:30:00,62.98,19.908,34.63,29.17 +2020-06-13 01:45:00,63.23,20.326,34.63,29.17 +2020-06-13 02:00:00,62.58,19.86,32.465,29.17 +2020-06-13 02:15:00,62.35,17.509,32.465,29.17 +2020-06-13 02:30:00,62.98,19.667,32.465,29.17 +2020-06-13 02:45:00,62.07,20.549,32.465,29.17 +2020-06-13 03:00:00,63.84,21.636999999999997,31.925,29.17 +2020-06-13 03:15:00,70.97,18.439,31.925,29.17 +2020-06-13 03:30:00,65.1,17.679000000000002,31.925,29.17 +2020-06-13 03:45:00,63.54,19.415,31.925,29.17 +2020-06-13 04:00:00,61.45,24.273000000000003,31.309,29.17 +2020-06-13 04:15:00,69.01,29.947,31.309,29.17 +2020-06-13 04:30:00,69.89,26.381,31.309,29.17 +2020-06-13 04:45:00,70.47,26.219,31.309,29.17 +2020-06-13 05:00:00,64.5,33.701,30.323,29.17 +2020-06-13 05:15:00,66.69,34.52,30.323,29.17 +2020-06-13 05:30:00,66.77,27.484,30.323,29.17 +2020-06-13 05:45:00,69.87,28.311999999999998,30.323,29.17 +2020-06-13 06:00:00,75.11,42.504,31.438000000000002,29.17 +2020-06-13 06:15:00,75.75,52.11600000000001,31.438000000000002,29.17 +2020-06-13 06:30:00,84.06,46.602,31.438000000000002,29.17 +2020-06-13 06:45:00,87.29,43.371,31.438000000000002,29.17 +2020-06-13 07:00:00,86.33,43.028,34.891999999999996,29.17 +2020-06-13 07:15:00,87.6,42.312,34.891999999999996,29.17 +2020-06-13 07:30:00,90.91,39.294000000000004,34.891999999999996,29.17 +2020-06-13 07:45:00,93.57,39.534,34.891999999999996,29.17 +2020-06-13 08:00:00,94.56,38.69,39.608000000000004,29.17 +2020-06-13 08:15:00,92.12,41.727,39.608000000000004,29.17 +2020-06-13 08:30:00,96.32,41.638999999999996,39.608000000000004,29.17 +2020-06-13 08:45:00,97.62,44.715,39.608000000000004,29.17 +2020-06-13 09:00:00,91.24,41.26,40.894,29.17 +2020-06-13 09:15:00,87.74,42.605,40.894,29.17 +2020-06-13 09:30:00,87.68,45.849,40.894,29.17 +2020-06-13 09:45:00,84.6,48.202,40.894,29.17 +2020-06-13 10:00:00,79.46,43.803999999999995,39.525,29.17 +2020-06-13 10:15:00,84.16,45.736000000000004,39.525,29.17 +2020-06-13 10:30:00,89.39,46.066,39.525,29.17 +2020-06-13 10:45:00,89.77,47.268,39.525,29.17 +2020-06-13 11:00:00,91.12,42.92,36.718,29.17 +2020-06-13 11:15:00,93.16,43.891999999999996,36.718,29.17 +2020-06-13 11:30:00,86.51,45.324,36.718,29.17 +2020-06-13 11:45:00,84.34,46.735,36.718,29.17 +2020-06-13 12:00:00,79.65,43.74,35.688,29.17 +2020-06-13 12:15:00,73.18,43.858000000000004,35.688,29.17 +2020-06-13 12:30:00,70.87,42.857,35.688,29.17 +2020-06-13 12:45:00,71.71,44.318999999999996,35.688,29.17 +2020-06-13 13:00:00,72.51,44.803000000000004,32.858000000000004,29.17 +2020-06-13 13:15:00,73.91,45.407,32.858000000000004,29.17 +2020-06-13 13:30:00,70.81,44.647,32.858000000000004,29.17 +2020-06-13 13:45:00,71.46,42.306000000000004,32.858000000000004,29.17 +2020-06-13 14:00:00,65.58,43.733000000000004,31.738000000000003,29.17 +2020-06-13 14:15:00,67.67,41.534,31.738000000000003,29.17 +2020-06-13 14:30:00,66.02,41.251000000000005,31.738000000000003,29.17 +2020-06-13 14:45:00,69.44,41.713,31.738000000000003,29.17 +2020-06-13 15:00:00,69.59,43.79,34.35,29.17 +2020-06-13 15:15:00,68.57,41.821000000000005,34.35,29.17 +2020-06-13 15:30:00,70.63,39.661,34.35,29.17 +2020-06-13 15:45:00,70.47,37.313,34.35,29.17 +2020-06-13 16:00:00,70.88,41.03,37.522,29.17 +2020-06-13 16:15:00,69.13,40.306999999999995,37.522,29.17 +2020-06-13 16:30:00,70.38,39.698,37.522,29.17 +2020-06-13 16:45:00,72.18,35.393,37.522,29.17 +2020-06-13 17:00:00,75.53,38.603,42.498000000000005,29.17 +2020-06-13 17:15:00,75.43,36.553000000000004,42.498000000000005,29.17 +2020-06-13 17:30:00,76.48,35.812,42.498000000000005,29.17 +2020-06-13 17:45:00,78.84,34.356,42.498000000000005,29.17 +2020-06-13 18:00:00,80.38,38.174,44.701,29.17 +2020-06-13 18:15:00,80.49,39.178000000000004,44.701,29.17 +2020-06-13 18:30:00,83.97,38.166,44.701,29.17 +2020-06-13 18:45:00,81.48,39.333,44.701,29.17 +2020-06-13 19:00:00,81.9,39.872,45.727,29.17 +2020-06-13 19:15:00,76.64,38.601,45.727,29.17 +2020-06-13 19:30:00,75.82,38.568000000000005,45.727,29.17 +2020-06-13 19:45:00,78.53,39.488,45.727,29.17 +2020-06-13 20:00:00,74.72,38.754,43.391000000000005,29.17 +2020-06-13 20:15:00,76.21,38.875,43.391000000000005,29.17 +2020-06-13 20:30:00,72.61,37.795,43.391000000000005,29.17 +2020-06-13 20:45:00,72.49,39.428000000000004,43.391000000000005,29.17 +2020-06-13 21:00:00,71.31,37.734,41.231,29.17 +2020-06-13 21:15:00,69.62,40.884,41.231,29.17 +2020-06-13 21:30:00,68.99,41.898999999999994,41.231,29.17 +2020-06-13 21:45:00,65.8,42.681000000000004,41.231,29.17 +2020-06-13 22:00:00,61.94,40.286,40.798,29.17 +2020-06-13 22:15:00,62.44,42.176,40.798,29.17 +2020-06-13 22:30:00,60.41,41.869,40.798,29.17 +2020-06-13 22:45:00,59.47,40.296,40.798,29.17 +2020-06-13 23:00:00,56.53,36.909,34.402,29.17 +2020-06-13 23:15:00,56.79,33.949,34.402,29.17 +2020-06-13 23:30:00,56.94,33.191,34.402,29.17 +2020-06-13 23:45:00,56.32,32.468,34.402,29.17 +2020-06-14 00:00:00,52.28,26.233,30.171,29.17 +2020-06-14 00:15:00,53.6,24.875,30.171,29.17 +2020-06-14 00:30:00,49.61,23.584,30.171,29.17 +2020-06-14 00:45:00,51.87,22.745,30.171,29.17 +2020-06-14 01:00:00,50.13,22.730999999999998,27.15,29.17 +2020-06-14 01:15:00,51.9,21.837,27.15,29.17 +2020-06-14 01:30:00,50.98,19.942999999999998,27.15,29.17 +2020-06-14 01:45:00,51.2,19.963,27.15,29.17 +2020-06-14 02:00:00,49.19,19.500999999999998,25.403000000000002,29.17 +2020-06-14 02:15:00,49.45,17.74,25.403000000000002,29.17 +2020-06-14 02:30:00,46.81,20.155,25.403000000000002,29.17 +2020-06-14 02:45:00,49.86,20.811,25.403000000000002,29.17 +2020-06-14 03:00:00,48.72,22.546999999999997,23.386999999999997,29.17 +2020-06-14 03:15:00,49.41,19.547,23.386999999999997,29.17 +2020-06-14 03:30:00,49.29,18.229,23.386999999999997,29.17 +2020-06-14 03:45:00,48.21,19.223,23.386999999999997,29.17 +2020-06-14 04:00:00,46.3,23.975,23.941999999999997,29.17 +2020-06-14 04:15:00,49.49,29.096999999999998,23.941999999999997,29.17 +2020-06-14 04:30:00,49.02,26.796999999999997,23.941999999999997,29.17 +2020-06-14 04:45:00,51.15,26.214000000000002,23.941999999999997,29.17 +2020-06-14 05:00:00,52.15,33.439,23.026,29.17 +2020-06-14 05:15:00,52.37,33.179,23.026,29.17 +2020-06-14 05:30:00,51.73,25.811,23.026,29.17 +2020-06-14 05:45:00,51.3,26.45,23.026,29.17 +2020-06-14 06:00:00,55.97,38.391,23.223000000000003,29.17 +2020-06-14 06:15:00,56.52,48.623999999999995,23.223000000000003,29.17 +2020-06-14 06:30:00,57.52,42.369,23.223000000000003,29.17 +2020-06-14 06:45:00,56.72,38.123000000000005,23.223000000000003,29.17 +2020-06-14 07:00:00,58.51,38.266999999999996,24.968000000000004,29.17 +2020-06-14 07:15:00,64.2,35.931999999999995,24.968000000000004,29.17 +2020-06-14 07:30:00,64.55,34.004,24.968000000000004,29.17 +2020-06-14 07:45:00,59.52,34.175,24.968000000000004,29.17 +2020-06-14 08:00:00,60.05,34.224000000000004,29.131,29.17 +2020-06-14 08:15:00,60.54,38.406,29.131,29.17 +2020-06-14 08:30:00,59.86,39.29,29.131,29.17 +2020-06-14 08:45:00,59.75,42.483999999999995,29.131,29.17 +2020-06-14 09:00:00,55.4,38.879,29.904,29.17 +2020-06-14 09:15:00,59.64,39.813,29.904,29.17 +2020-06-14 09:30:00,58.18,43.45,29.904,29.17 +2020-06-14 09:45:00,60.74,46.76,29.904,29.17 +2020-06-14 10:00:00,65.21,43.11600000000001,28.943,29.17 +2020-06-14 10:15:00,69.62,45.239,28.943,29.17 +2020-06-14 10:30:00,76.14,45.839,28.943,29.17 +2020-06-14 10:45:00,76.23,47.826,28.943,29.17 +2020-06-14 11:00:00,72.62,43.235,31.682,29.17 +2020-06-14 11:15:00,66.42,43.803999999999995,31.682,29.17 +2020-06-14 11:30:00,63.36,45.636,31.682,29.17 +2020-06-14 11:45:00,64.54,47.348,31.682,29.17 +2020-06-14 12:00:00,58.01,45.331,27.315,29.17 +2020-06-14 12:15:00,62.22,45.035,27.315,29.17 +2020-06-14 12:30:00,67.96,44.08,27.315,29.17 +2020-06-14 12:45:00,72.55,44.851000000000006,27.315,29.17 +2020-06-14 13:00:00,70.76,44.978,23.894000000000002,29.17 +2020-06-14 13:15:00,65.86,45.318000000000005,23.894000000000002,29.17 +2020-06-14 13:30:00,52.16,43.497,23.894000000000002,29.17 +2020-06-14 13:45:00,56.73,42.155,23.894000000000002,29.17 +2020-06-14 14:00:00,56.02,44.756,21.148000000000003,29.17 +2020-06-14 14:15:00,58.16,43.071999999999996,21.148000000000003,29.17 +2020-06-14 14:30:00,59.65,41.708999999999996,21.148000000000003,29.17 +2020-06-14 14:45:00,66.92,41.126999999999995,21.148000000000003,29.17 +2020-06-14 15:00:00,72.76,43.235,21.229,29.17 +2020-06-14 15:15:00,75.07,40.558,21.229,29.17 +2020-06-14 15:30:00,73.16,38.271,21.229,29.17 +2020-06-14 15:45:00,75.74,36.243,21.229,29.17 +2020-06-14 16:00:00,74.37,38.602,25.037,29.17 +2020-06-14 16:15:00,75.85,38.010999999999996,25.037,29.17 +2020-06-14 16:30:00,76.68,38.395,25.037,29.17 +2020-06-14 16:45:00,79.86,34.135,25.037,29.17 +2020-06-14 17:00:00,80.06,37.714,37.11,29.17 +2020-06-14 17:15:00,75.98,37.064,37.11,29.17 +2020-06-14 17:30:00,76.02,37.105,37.11,29.17 +2020-06-14 17:45:00,75.92,36.233000000000004,37.11,29.17 +2020-06-14 18:00:00,79.59,40.564,42.215,29.17 +2020-06-14 18:15:00,78.09,41.273999999999994,42.215,29.17 +2020-06-14 18:30:00,79.69,39.839,42.215,29.17 +2020-06-14 18:45:00,80.19,41.276,42.215,29.17 +2020-06-14 19:00:00,81.77,43.848,44.383,29.17 +2020-06-14 19:15:00,78.54,41.562,44.383,29.17 +2020-06-14 19:30:00,77.06,41.271,44.383,29.17 +2020-06-14 19:45:00,76.9,41.873999999999995,44.383,29.17 +2020-06-14 20:00:00,77.81,41.295,43.426,29.17 +2020-06-14 20:15:00,77.47,41.37,43.426,29.17 +2020-06-14 20:30:00,78.68,41.177,43.426,29.17 +2020-06-14 20:45:00,80.51,41.136,43.426,29.17 +2020-06-14 21:00:00,78.65,39.095,42.265,29.17 +2020-06-14 21:15:00,80.2,41.913999999999994,42.265,29.17 +2020-06-14 21:30:00,77.65,42.313,42.265,29.17 +2020-06-14 21:45:00,77.23,43.458,42.265,29.17 +2020-06-14 22:00:00,72.55,42.913999999999994,42.26,29.17 +2020-06-14 22:15:00,73.29,43.146,42.26,29.17 +2020-06-14 22:30:00,72.39,42.07899999999999,42.26,29.17 +2020-06-14 22:45:00,72.99,39.211,42.26,29.17 +2020-06-14 23:00:00,66.65,35.281,36.609,29.17 +2020-06-14 23:15:00,66.5,33.724000000000004,36.609,29.17 +2020-06-14 23:30:00,66.47,32.531,36.609,29.17 +2020-06-14 23:45:00,66.58,32.023,36.609,29.17 +2020-06-15 00:00:00,63.68,27.921999999999997,34.611,29.28 +2020-06-15 00:15:00,66.57,27.576,34.611,29.28 +2020-06-15 00:30:00,66.22,25.934,34.611,29.28 +2020-06-15 00:45:00,67.82,24.68,34.611,29.28 +2020-06-15 01:00:00,66.38,25.049,33.552,29.28 +2020-06-15 01:15:00,63.83,24.105,33.552,29.28 +2020-06-15 01:30:00,65.85,22.554000000000002,33.552,29.28 +2020-06-15 01:45:00,63.4,22.485,33.552,29.28 +2020-06-15 02:00:00,64.21,22.454,32.351,29.28 +2020-06-15 02:15:00,65.04,19.855,32.351,29.28 +2020-06-15 02:30:00,65.8,22.451,32.351,29.28 +2020-06-15 02:45:00,71.81,22.92,32.351,29.28 +2020-06-15 03:00:00,71.74,25.247,30.793000000000003,29.28 +2020-06-15 03:15:00,73.07,23.074,30.793000000000003,29.28 +2020-06-15 03:30:00,67.77,22.386999999999997,30.793000000000003,29.28 +2020-06-15 03:45:00,68.94,22.915,30.793000000000003,29.28 +2020-06-15 04:00:00,70.83,30.949,31.274,29.28 +2020-06-15 04:15:00,72.74,39.173,31.274,29.28 +2020-06-15 04:30:00,76.76,36.596,31.274,29.28 +2020-06-15 04:45:00,82.08,36.376999999999995,31.274,29.28 +2020-06-15 05:00:00,88.23,51.6,37.75,29.28 +2020-06-15 05:15:00,92.27,62.74100000000001,37.75,29.28 +2020-06-15 05:30:00,94.48,53.993,37.75,29.28 +2020-06-15 05:45:00,97.09,51.272,37.75,29.28 +2020-06-15 06:00:00,102.24,51.938,55.36,29.28 +2020-06-15 06:15:00,111.18,52.23,55.36,29.28 +2020-06-15 06:30:00,115.52,50.466,55.36,29.28 +2020-06-15 06:45:00,117.34,52.028,55.36,29.28 +2020-06-15 07:00:00,115.83,52.782,65.87,29.28 +2020-06-15 07:15:00,114.27,52.74100000000001,65.87,29.28 +2020-06-15 07:30:00,122.7,49.918,65.87,29.28 +2020-06-15 07:45:00,120.92,50.178999999999995,65.87,29.28 +2020-06-15 08:00:00,119.84,47.783,55.695,29.28 +2020-06-15 08:15:00,111.36,50.665,55.695,29.28 +2020-06-15 08:30:00,110.75,50.261,55.695,29.28 +2020-06-15 08:45:00,113.47,53.36,55.695,29.28 +2020-06-15 09:00:00,117.2,48.68899999999999,50.881,29.28 +2020-06-15 09:15:00,116.73,47.618,50.881,29.28 +2020-06-15 09:30:00,110.25,50.285,50.881,29.28 +2020-06-15 09:45:00,118.14,51.467,50.881,29.28 +2020-06-15 10:00:00,117.56,48.161,49.138000000000005,29.28 +2020-06-15 10:15:00,114.82,50.128,49.138000000000005,29.28 +2020-06-15 10:30:00,113.15,50.214,49.138000000000005,29.28 +2020-06-15 10:45:00,110.62,50.78,49.138000000000005,29.28 +2020-06-15 11:00:00,110.55,46.151,49.178000000000004,29.28 +2020-06-15 11:15:00,113.2,47.161,49.178000000000004,29.28 +2020-06-15 11:30:00,114.7,49.771,49.178000000000004,29.28 +2020-06-15 11:45:00,107.65,51.922,49.178000000000004,29.28 +2020-06-15 12:00:00,103.58,48.492,47.698,29.28 +2020-06-15 12:15:00,100.76,48.303000000000004,47.698,29.28 +2020-06-15 12:30:00,98.31,46.336000000000006,47.698,29.28 +2020-06-15 12:45:00,100.35,47.248000000000005,47.698,29.28 +2020-06-15 13:00:00,98.72,48.282,48.104,29.28 +2020-06-15 13:15:00,97.1,47.658,48.104,29.28 +2020-06-15 13:30:00,98.57,45.958,48.104,29.28 +2020-06-15 13:45:00,100.45,45.47,48.104,29.28 +2020-06-15 14:00:00,109.23,47.168,48.53,29.28 +2020-06-15 14:15:00,107.1,45.983999999999995,48.53,29.28 +2020-06-15 14:30:00,105.45,44.391000000000005,48.53,29.28 +2020-06-15 14:45:00,107.37,45.77,48.53,29.28 +2020-06-15 15:00:00,105.82,47.726000000000006,49.351000000000006,29.28 +2020-06-15 15:15:00,101.42,44.35,49.351000000000006,29.28 +2020-06-15 15:30:00,93.89,42.706,49.351000000000006,29.28 +2020-06-15 15:45:00,102.39,40.167,49.351000000000006,29.28 +2020-06-15 16:00:00,103.23,43.58,51.44,29.28 +2020-06-15 16:15:00,103.03,42.976000000000006,51.44,29.28 +2020-06-15 16:30:00,103.38,42.66,51.44,29.28 +2020-06-15 16:45:00,105.31,38.287,51.44,29.28 +2020-06-15 17:00:00,110.64,40.8,56.868,29.28 +2020-06-15 17:15:00,108.12,40.414,56.868,29.28 +2020-06-15 17:30:00,107.45,40.03,56.868,29.28 +2020-06-15 17:45:00,108.12,38.626,56.868,29.28 +2020-06-15 18:00:00,107.0,41.968,57.229,29.28 +2020-06-15 18:15:00,105.19,40.717,57.229,29.28 +2020-06-15 18:30:00,103.36,38.615,57.229,29.28 +2020-06-15 18:45:00,103.93,43.123999999999995,57.229,29.28 +2020-06-15 19:00:00,101.36,45.273,57.744,29.28 +2020-06-15 19:15:00,97.27,44.143,57.744,29.28 +2020-06-15 19:30:00,94.43,43.535,57.744,29.28 +2020-06-15 19:45:00,94.45,43.476000000000006,57.744,29.28 +2020-06-15 20:00:00,93.01,41.475,66.05199999999999,29.28 +2020-06-15 20:15:00,93.07,42.67,66.05199999999999,29.28 +2020-06-15 20:30:00,92.72,42.825,66.05199999999999,29.28 +2020-06-15 20:45:00,92.0,43.159,66.05199999999999,29.28 +2020-06-15 21:00:00,90.53,40.566,59.396,29.28 +2020-06-15 21:15:00,89.26,43.715,59.396,29.28 +2020-06-15 21:30:00,86.15,44.418,59.396,29.28 +2020-06-15 21:45:00,83.94,45.318999999999996,59.396,29.28 +2020-06-15 22:00:00,79.53,42.593999999999994,53.06,29.28 +2020-06-15 22:15:00,79.12,44.623999999999995,53.06,29.28 +2020-06-15 22:30:00,76.82,38.604,53.06,29.28 +2020-06-15 22:45:00,77.6,35.296,53.06,29.28 +2020-06-15 23:00:00,71.23,31.458000000000002,46.148,29.28 +2020-06-15 23:15:00,71.79,28.58,46.148,29.28 +2020-06-15 23:30:00,73.2,27.590999999999998,46.148,29.28 +2020-06-15 23:45:00,71.26,26.781,46.148,29.28 +2020-06-16 00:00:00,68.96,24.359,44.625,29.28 +2020-06-16 00:15:00,69.29,25.055999999999997,44.625,29.28 +2020-06-16 00:30:00,68.44,23.929000000000002,44.625,29.28 +2020-06-16 00:45:00,68.79,23.45,44.625,29.28 +2020-06-16 01:00:00,66.84,23.256,41.733000000000004,29.28 +2020-06-16 01:15:00,68.54,22.443,41.733000000000004,29.28 +2020-06-16 01:30:00,67.64,20.79,41.733000000000004,29.28 +2020-06-16 01:45:00,68.65,20.428,41.733000000000004,29.28 +2020-06-16 02:00:00,68.14,19.965999999999998,39.872,29.28 +2020-06-16 02:15:00,67.92,18.408,39.872,29.28 +2020-06-16 02:30:00,67.69,20.671999999999997,39.872,29.28 +2020-06-16 02:45:00,68.5,21.430999999999997,39.872,29.28 +2020-06-16 03:00:00,68.84,23.045,38.711,29.28 +2020-06-16 03:15:00,70.43,21.794,38.711,29.28 +2020-06-16 03:30:00,71.46,21.061999999999998,38.711,29.28 +2020-06-16 03:45:00,71.24,20.377,38.711,29.28 +2020-06-16 04:00:00,78.78,26.963,39.823,29.28 +2020-06-16 04:15:00,83.37,34.927,39.823,29.28 +2020-06-16 04:30:00,86.33,32.236999999999995,39.823,29.28 +2020-06-16 04:45:00,84.83,32.514,39.823,29.28 +2020-06-16 05:00:00,89.13,48.638999999999996,43.228,29.28 +2020-06-16 05:15:00,95.5,59.591,43.228,29.28 +2020-06-16 05:30:00,102.96,51.088,43.228,29.28 +2020-06-16 05:45:00,106.21,47.806999999999995,43.228,29.28 +2020-06-16 06:00:00,110.04,49.923,54.316,29.28 +2020-06-16 06:15:00,106.31,50.313,54.316,29.28 +2020-06-16 06:30:00,112.22,48.407,54.316,29.28 +2020-06-16 06:45:00,116.7,49.299,54.316,29.28 +2020-06-16 07:00:00,118.4,50.067,65.758,29.28 +2020-06-16 07:15:00,114.45,49.853,65.758,29.28 +2020-06-16 07:30:00,120.01,47.098,65.758,29.28 +2020-06-16 07:45:00,121.47,46.503,65.758,29.28 +2020-06-16 08:00:00,122.52,43.562,57.983000000000004,29.28 +2020-06-16 08:15:00,120.87,46.042,57.983000000000004,29.28 +2020-06-16 08:30:00,119.62,46.19,57.983000000000004,29.28 +2020-06-16 08:45:00,120.59,48.538000000000004,57.983000000000004,29.28 +2020-06-16 09:00:00,119.48,43.669,52.653,29.28 +2020-06-16 09:15:00,115.43,42.684,52.653,29.28 +2020-06-16 09:30:00,115.25,46.261,52.653,29.28 +2020-06-16 09:45:00,119.15,48.981,52.653,29.28 +2020-06-16 10:00:00,124.46,43.623000000000005,51.408,29.28 +2020-06-16 10:15:00,124.81,45.39,51.408,29.28 +2020-06-16 10:30:00,116.98,45.55,51.408,29.28 +2020-06-16 10:45:00,119.25,47.229,51.408,29.28 +2020-06-16 11:00:00,119.52,43.428000000000004,51.913000000000004,29.28 +2020-06-16 11:15:00,106.77,44.788000000000004,51.913000000000004,29.28 +2020-06-16 11:30:00,105.75,46.257,51.913000000000004,29.28 +2020-06-16 11:45:00,111.19,48.073,51.913000000000004,29.28 +2020-06-16 12:00:00,110.92,44.413000000000004,49.508,29.28 +2020-06-16 12:15:00,110.35,44.346000000000004,49.508,29.28 +2020-06-16 12:30:00,105.59,43.29,49.508,29.28 +2020-06-16 12:45:00,115.07,44.753,49.508,29.28 +2020-06-16 13:00:00,112.54,45.398,50.007,29.28 +2020-06-16 13:15:00,111.53,46.568999999999996,50.007,29.28 +2020-06-16 13:30:00,107.86,44.854,50.007,29.28 +2020-06-16 13:45:00,113.1,43.668,50.007,29.28 +2020-06-16 14:00:00,111.48,45.714,49.778999999999996,29.28 +2020-06-16 14:15:00,108.96,44.407,49.778999999999996,29.28 +2020-06-16 14:30:00,105.74,43.292,49.778999999999996,29.28 +2020-06-16 14:45:00,106.26,43.869,49.778999999999996,29.28 +2020-06-16 15:00:00,102.33,45.617,51.559,29.28 +2020-06-16 15:15:00,103.42,43.174,51.559,29.28 +2020-06-16 15:30:00,111.85,41.39,51.559,29.28 +2020-06-16 15:45:00,116.04,39.3,51.559,29.28 +2020-06-16 16:00:00,111.53,42.037,53.531000000000006,29.28 +2020-06-16 16:15:00,109.87,41.598,53.531000000000006,29.28 +2020-06-16 16:30:00,107.38,40.802,53.531000000000006,29.28 +2020-06-16 16:45:00,111.98,37.24,53.531000000000006,29.28 +2020-06-16 17:00:00,113.93,40.24,59.497,29.28 +2020-06-16 17:15:00,112.65,40.22,59.497,29.28 +2020-06-16 17:30:00,108.82,39.359,59.497,29.28 +2020-06-16 17:45:00,114.35,37.766,59.497,29.28 +2020-06-16 18:00:00,115.88,40.343,59.861999999999995,29.28 +2020-06-16 18:15:00,116.48,40.538000000000004,59.861999999999995,29.28 +2020-06-16 18:30:00,107.47,38.215,59.861999999999995,29.28 +2020-06-16 18:45:00,107.61,42.318000000000005,59.861999999999995,29.28 +2020-06-16 19:00:00,111.62,42.979,60.989,29.28 +2020-06-16 19:15:00,106.07,42.001000000000005,60.989,29.28 +2020-06-16 19:30:00,102.41,41.065,60.989,29.28 +2020-06-16 19:45:00,95.32,41.147,60.989,29.28 +2020-06-16 20:00:00,92.13,38.826,68.35600000000001,29.28 +2020-06-16 20:15:00,94.97,38.798,68.35600000000001,29.28 +2020-06-16 20:30:00,91.06,39.229,68.35600000000001,29.28 +2020-06-16 20:45:00,94.21,39.921,68.35600000000001,29.28 +2020-06-16 21:00:00,89.7,38.23,59.251000000000005,29.28 +2020-06-16 21:15:00,89.12,40.126999999999995,59.251000000000005,29.28 +2020-06-16 21:30:00,86.59,41.136,59.251000000000005,29.28 +2020-06-16 21:45:00,85.8,42.263999999999996,59.251000000000005,29.28 +2020-06-16 22:00:00,81.71,39.696,54.736999999999995,29.28 +2020-06-16 22:15:00,82.26,41.648,54.736999999999995,29.28 +2020-06-16 22:30:00,79.77,36.277,54.736999999999995,29.28 +2020-06-16 22:45:00,81.67,32.983000000000004,54.736999999999995,29.28 +2020-06-16 23:00:00,74.51,28.76,46.806999999999995,29.28 +2020-06-16 23:15:00,74.79,27.148000000000003,46.806999999999995,29.28 +2020-06-16 23:30:00,82.15,25.996,46.806999999999995,29.28 +2020-06-16 23:45:00,74.96,25.283,46.806999999999995,29.28 +2020-06-17 00:00:00,70.38,24.259,43.824,29.28 +2020-06-17 00:15:00,71.92,24.956,43.824,29.28 +2020-06-17 00:30:00,70.72,23.831999999999997,43.824,29.28 +2020-06-17 00:45:00,71.52,23.358,43.824,29.28 +2020-06-17 01:00:00,69.75,23.180999999999997,39.86,29.28 +2020-06-17 01:15:00,70.66,22.351999999999997,39.86,29.28 +2020-06-17 01:30:00,70.25,20.694000000000003,39.86,29.28 +2020-06-17 01:45:00,70.36,20.323,39.86,29.28 +2020-06-17 02:00:00,69.85,19.865,37.931999999999995,29.28 +2020-06-17 02:15:00,69.97,18.305999999999997,37.931999999999995,29.28 +2020-06-17 02:30:00,77.19,20.566,37.931999999999995,29.28 +2020-06-17 02:45:00,79.52,21.33,37.931999999999995,29.28 +2020-06-17 03:00:00,75.47,22.943,37.579,29.28 +2020-06-17 03:15:00,71.44,21.693,37.579,29.28 +2020-06-17 03:30:00,73.65,20.968000000000004,37.579,29.28 +2020-06-17 03:45:00,74.61,20.305,37.579,29.28 +2020-06-17 04:00:00,75.19,26.839000000000002,37.931999999999995,29.28 +2020-06-17 04:15:00,77.28,34.758,37.931999999999995,29.28 +2020-06-17 04:30:00,80.23,32.056,37.931999999999995,29.28 +2020-06-17 04:45:00,86.39,32.33,37.931999999999995,29.28 +2020-06-17 05:00:00,92.75,48.354,40.942,29.28 +2020-06-17 05:15:00,94.78,59.178000000000004,40.942,29.28 +2020-06-17 05:30:00,102.45,50.743,40.942,29.28 +2020-06-17 05:45:00,103.25,47.508,40.942,29.28 +2020-06-17 06:00:00,109.39,49.636,56.516999999999996,29.28 +2020-06-17 06:15:00,108.76,50.011,56.516999999999996,29.28 +2020-06-17 06:30:00,106.58,48.129,56.516999999999996,29.28 +2020-06-17 06:45:00,115.87,49.049,56.516999999999996,29.28 +2020-06-17 07:00:00,120.05,49.805,71.707,29.28 +2020-06-17 07:15:00,118.84,49.608999999999995,71.707,29.28 +2020-06-17 07:30:00,112.36,46.847,71.707,29.28 +2020-06-17 07:45:00,116.82,46.285,71.707,29.28 +2020-06-17 08:00:00,118.62,43.354,61.17,29.28 +2020-06-17 08:15:00,120.54,45.867,61.17,29.28 +2020-06-17 08:30:00,112.25,46.007,61.17,29.28 +2020-06-17 08:45:00,110.41,48.36,61.17,29.28 +2020-06-17 09:00:00,112.65,43.483999999999995,57.282,29.28 +2020-06-17 09:15:00,116.01,42.503,57.282,29.28 +2020-06-17 09:30:00,115.28,46.07899999999999,57.282,29.28 +2020-06-17 09:45:00,117.39,48.817,57.282,29.28 +2020-06-17 10:00:00,119.51,43.468,54.026,29.28 +2020-06-17 10:15:00,118.12,45.247,54.026,29.28 +2020-06-17 10:30:00,115.99,45.409,54.026,29.28 +2020-06-17 10:45:00,112.31,47.093999999999994,54.026,29.28 +2020-06-17 11:00:00,117.86,43.288999999999994,54.277,29.28 +2020-06-17 11:15:00,118.81,44.655,54.277,29.28 +2020-06-17 11:30:00,112.48,46.113,54.277,29.28 +2020-06-17 11:45:00,106.68,47.93,54.277,29.28 +2020-06-17 12:00:00,107.8,44.3,52.552,29.28 +2020-06-17 12:15:00,109.75,44.235,52.552,29.28 +2020-06-17 12:30:00,109.97,43.158,52.552,29.28 +2020-06-17 12:45:00,106.0,44.623000000000005,52.552,29.28 +2020-06-17 13:00:00,99.76,45.25899999999999,52.111999999999995,29.28 +2020-06-17 13:15:00,101.66,46.431999999999995,52.111999999999995,29.28 +2020-06-17 13:30:00,106.4,44.727,52.111999999999995,29.28 +2020-06-17 13:45:00,109.32,43.545,52.111999999999995,29.28 +2020-06-17 14:00:00,108.0,45.607,52.066,29.28 +2020-06-17 14:15:00,102.64,44.297,52.066,29.28 +2020-06-17 14:30:00,105.38,43.159,52.066,29.28 +2020-06-17 14:45:00,107.65,43.742,52.066,29.28 +2020-06-17 15:00:00,106.16,45.522,52.523999999999994,29.28 +2020-06-17 15:15:00,98.78,43.071000000000005,52.523999999999994,29.28 +2020-06-17 15:30:00,97.44,41.277,52.523999999999994,29.28 +2020-06-17 15:45:00,102.09,39.176,52.523999999999994,29.28 +2020-06-17 16:00:00,102.74,41.942,54.101000000000006,29.28 +2020-06-17 16:15:00,111.61,41.501000000000005,54.101000000000006,29.28 +2020-06-17 16:30:00,112.07,40.723,54.101000000000006,29.28 +2020-06-17 16:45:00,111.13,37.138000000000005,54.101000000000006,29.28 +2020-06-17 17:00:00,107.3,40.161,58.155,29.28 +2020-06-17 17:15:00,106.07,40.135,58.155,29.28 +2020-06-17 17:30:00,109.67,39.269,58.155,29.28 +2020-06-17 17:45:00,109.22,37.658,58.155,29.28 +2020-06-17 18:00:00,115.64,40.247,60.205,29.28 +2020-06-17 18:15:00,115.65,40.415,60.205,29.28 +2020-06-17 18:30:00,112.11,38.086999999999996,60.205,29.28 +2020-06-17 18:45:00,108.54,42.19,60.205,29.28 +2020-06-17 19:00:00,111.91,42.853,61.568999999999996,29.28 +2020-06-17 19:15:00,107.65,41.867,61.568999999999996,29.28 +2020-06-17 19:30:00,104.81,40.923,61.568999999999996,29.28 +2020-06-17 19:45:00,100.79,41.0,61.568999999999996,29.28 +2020-06-17 20:00:00,95.94,38.662,68.145,29.28 +2020-06-17 20:15:00,95.76,38.63,68.145,29.28 +2020-06-17 20:30:00,95.13,39.07,68.145,29.28 +2020-06-17 20:45:00,95.74,39.789,68.145,29.28 +2020-06-17 21:00:00,93.35,38.104,59.696000000000005,29.28 +2020-06-17 21:15:00,92.86,40.008,59.696000000000005,29.28 +2020-06-17 21:30:00,90.82,40.994,59.696000000000005,29.28 +2020-06-17 21:45:00,88.68,42.13,59.696000000000005,29.28 +2020-06-17 22:00:00,84.2,39.58,54.861999999999995,29.28 +2020-06-17 22:15:00,84.82,41.541000000000004,54.861999999999995,29.28 +2020-06-17 22:30:00,81.78,36.164,54.861999999999995,29.28 +2020-06-17 22:45:00,80.67,32.86,54.861999999999995,29.28 +2020-06-17 23:00:00,74.94,28.62,45.568000000000005,29.28 +2020-06-17 23:15:00,76.51,27.043000000000003,45.568000000000005,29.28 +2020-06-17 23:30:00,75.84,25.903000000000002,45.568000000000005,29.28 +2020-06-17 23:45:00,75.6,25.178,45.568000000000005,29.28 +2020-06-18 00:00:00,73.4,24.163,40.181,29.28 +2020-06-18 00:15:00,72.87,24.86,40.181,29.28 +2020-06-18 00:30:00,72.34,23.739,40.181,29.28 +2020-06-18 00:45:00,73.84,23.271,40.181,29.28 +2020-06-18 01:00:00,71.31,23.109,38.296,29.28 +2020-06-18 01:15:00,72.98,22.264,38.296,29.28 +2020-06-18 01:30:00,73.12,20.601999999999997,38.296,29.28 +2020-06-18 01:45:00,72.86,20.224,38.296,29.28 +2020-06-18 02:00:00,72.69,19.769000000000002,36.575,29.28 +2020-06-18 02:15:00,73.69,18.209,36.575,29.28 +2020-06-18 02:30:00,80.7,20.464000000000002,36.575,29.28 +2020-06-18 02:45:00,81.54,21.235,36.575,29.28 +2020-06-18 03:00:00,76.54,22.845,36.394,29.28 +2020-06-18 03:15:00,74.22,21.598000000000003,36.394,29.28 +2020-06-18 03:30:00,74.27,20.877,36.394,29.28 +2020-06-18 03:45:00,75.37,20.236,36.394,29.28 +2020-06-18 04:00:00,78.58,26.719,37.207,29.28 +2020-06-18 04:15:00,77.79,34.595,37.207,29.28 +2020-06-18 04:30:00,81.29,31.881,37.207,29.28 +2020-06-18 04:45:00,87.9,32.150999999999996,37.207,29.28 +2020-06-18 05:00:00,91.79,48.077,40.713,29.28 +2020-06-18 05:15:00,94.18,58.776,40.713,29.28 +2020-06-18 05:30:00,102.83,50.407,40.713,29.28 +2020-06-18 05:45:00,105.92,47.218999999999994,40.713,29.28 +2020-06-18 06:00:00,112.75,49.357,50.952,29.28 +2020-06-18 06:15:00,110.24,49.718999999999994,50.952,29.28 +2020-06-18 06:30:00,106.85,47.858000000000004,50.952,29.28 +2020-06-18 06:45:00,108.84,48.808,50.952,29.28 +2020-06-18 07:00:00,116.96,49.552,64.88,29.28 +2020-06-18 07:15:00,117.84,49.375,64.88,29.28 +2020-06-18 07:30:00,118.78,46.605,64.88,29.28 +2020-06-18 07:45:00,110.21,46.077,64.88,29.28 +2020-06-18 08:00:00,112.37,43.156000000000006,55.133,29.28 +2020-06-18 08:15:00,120.59,45.7,55.133,29.28 +2020-06-18 08:30:00,116.62,45.832,55.133,29.28 +2020-06-18 08:45:00,113.88,48.188,55.133,29.28 +2020-06-18 09:00:00,111.01,43.306000000000004,48.912,29.28 +2020-06-18 09:15:00,116.08,42.32899999999999,48.912,29.28 +2020-06-18 09:30:00,116.1,45.903999999999996,48.912,29.28 +2020-06-18 09:45:00,110.88,48.659,48.912,29.28 +2020-06-18 10:00:00,111.72,43.321000000000005,45.968999999999994,29.28 +2020-06-18 10:15:00,116.87,45.11,45.968999999999994,29.28 +2020-06-18 10:30:00,119.29,45.273999999999994,45.968999999999994,29.28 +2020-06-18 10:45:00,117.36,46.965,45.968999999999994,29.28 +2020-06-18 11:00:00,115.58,43.156000000000006,44.067,29.28 +2020-06-18 11:15:00,108.97,44.528,44.067,29.28 +2020-06-18 11:30:00,104.55,45.975,44.067,29.28 +2020-06-18 11:45:00,104.96,47.792,44.067,29.28 +2020-06-18 12:00:00,117.07,44.193000000000005,41.501000000000005,29.28 +2020-06-18 12:15:00,118.98,44.129,41.501000000000005,29.28 +2020-06-18 12:30:00,111.45,43.032,41.501000000000005,29.28 +2020-06-18 12:45:00,105.65,44.498999999999995,41.501000000000005,29.28 +2020-06-18 13:00:00,107.61,45.126000000000005,41.117,29.28 +2020-06-18 13:15:00,111.84,46.298,41.117,29.28 +2020-06-18 13:30:00,112.94,44.604,41.117,29.28 +2020-06-18 13:45:00,114.13,43.426,41.117,29.28 +2020-06-18 14:00:00,110.3,45.501999999999995,41.492,29.28 +2020-06-18 14:15:00,112.76,44.191,41.492,29.28 +2020-06-18 14:30:00,115.17,43.031000000000006,41.492,29.28 +2020-06-18 14:45:00,117.92,43.619,41.492,29.28 +2020-06-18 15:00:00,115.32,45.431000000000004,43.711999999999996,29.28 +2020-06-18 15:15:00,123.12,42.968999999999994,43.711999999999996,29.28 +2020-06-18 15:30:00,122.03,41.169,43.711999999999996,29.28 +2020-06-18 15:45:00,118.04,39.056999999999995,43.711999999999996,29.28 +2020-06-18 16:00:00,118.78,41.851000000000006,45.446000000000005,29.28 +2020-06-18 16:15:00,112.38,41.406000000000006,45.446000000000005,29.28 +2020-06-18 16:30:00,118.25,40.647,45.446000000000005,29.28 +2020-06-18 16:45:00,122.87,37.039,45.446000000000005,29.28 +2020-06-18 17:00:00,115.45,40.086999999999996,48.803000000000004,29.28 +2020-06-18 17:15:00,111.22,40.056,48.803000000000004,29.28 +2020-06-18 17:30:00,107.59,39.183,48.803000000000004,29.28 +2020-06-18 17:45:00,109.13,37.556999999999995,48.803000000000004,29.28 +2020-06-18 18:00:00,114.28,40.154,51.167,29.28 +2020-06-18 18:15:00,108.94,40.298,51.167,29.28 +2020-06-18 18:30:00,104.66,37.966,51.167,29.28 +2020-06-18 18:45:00,108.51,42.067,51.167,29.28 +2020-06-18 19:00:00,107.19,42.733000000000004,52.486000000000004,29.28 +2020-06-18 19:15:00,97.5,41.736999999999995,52.486000000000004,29.28 +2020-06-18 19:30:00,95.56,40.787,52.486000000000004,29.28 +2020-06-18 19:45:00,94.72,40.857,52.486000000000004,29.28 +2020-06-18 20:00:00,92.52,38.503,59.635,29.28 +2020-06-18 20:15:00,92.24,38.47,59.635,29.28 +2020-06-18 20:30:00,92.21,38.916,59.635,29.28 +2020-06-18 20:45:00,92.12,39.663000000000004,59.635,29.28 +2020-06-18 21:00:00,90.57,37.983000000000004,54.353,29.28 +2020-06-18 21:15:00,89.89,39.894,54.353,29.28 +2020-06-18 21:30:00,85.01,40.857,54.353,29.28 +2020-06-18 21:45:00,85.71,42.0,54.353,29.28 +2020-06-18 22:00:00,80.59,39.469,49.431999999999995,29.28 +2020-06-18 22:15:00,80.6,41.437,49.431999999999995,29.28 +2020-06-18 22:30:00,78.43,36.054,49.431999999999995,29.28 +2020-06-18 22:45:00,78.92,32.741,49.431999999999995,29.28 +2020-06-18 23:00:00,74.17,28.486,42.872,29.28 +2020-06-18 23:15:00,75.08,26.943,42.872,29.28 +2020-06-18 23:30:00,71.55,25.811,42.872,29.28 +2020-06-18 23:45:00,74.47,25.078000000000003,42.872,29.28 +2020-06-19 00:00:00,71.17,22.217,39.819,29.28 +2020-06-19 00:15:00,70.01,23.143,39.819,29.28 +2020-06-19 00:30:00,67.45,22.285999999999998,39.819,29.28 +2020-06-19 00:45:00,69.33,22.254,39.819,29.28 +2020-06-19 01:00:00,66.99,21.705,37.797,29.28 +2020-06-19 01:15:00,71.13,20.305,37.797,29.28 +2020-06-19 01:30:00,76.08,19.335,37.797,29.28 +2020-06-19 01:45:00,76.62,18.707,37.797,29.28 +2020-06-19 02:00:00,72.72,19.19,36.905,29.28 +2020-06-19 02:15:00,76.55,17.572,36.905,29.28 +2020-06-19 02:30:00,75.24,20.678,36.905,29.28 +2020-06-19 02:45:00,72.74,20.775,36.905,29.28 +2020-06-19 03:00:00,70.27,23.093000000000004,37.1,29.28 +2020-06-19 03:15:00,72.1,20.705,37.1,29.28 +2020-06-19 03:30:00,71.55,19.752,37.1,29.28 +2020-06-19 03:45:00,73.4,20.027,37.1,29.28 +2020-06-19 04:00:00,75.24,26.611,37.882,29.28 +2020-06-19 04:15:00,77.24,32.875,37.882,29.28 +2020-06-19 04:30:00,79.93,31.11,37.882,29.28 +2020-06-19 04:45:00,81.23,30.662,37.882,29.28 +2020-06-19 05:00:00,90.71,45.931000000000004,40.777,29.28 +2020-06-19 05:15:00,98.92,57.556000000000004,40.777,29.28 +2020-06-19 05:30:00,104.38,49.518,40.777,29.28 +2020-06-19 05:45:00,106.32,45.928999999999995,40.777,29.28 +2020-06-19 06:00:00,107.95,48.331,55.528,29.28 +2020-06-19 06:15:00,110.49,48.743,55.528,29.28 +2020-06-19 06:30:00,109.88,46.794,55.528,29.28 +2020-06-19 06:45:00,113.13,47.756,55.528,29.28 +2020-06-19 07:00:00,124.83,49.074,67.749,29.28 +2020-06-19 07:15:00,124.36,49.885,67.749,29.28 +2020-06-19 07:30:00,123.58,45.167,67.749,29.28 +2020-06-19 07:45:00,117.62,44.401,67.749,29.28 +2020-06-19 08:00:00,115.83,42.183,57.55,29.28 +2020-06-19 08:15:00,118.6,45.393,57.55,29.28 +2020-06-19 08:30:00,123.82,45.49100000000001,57.55,29.28 +2020-06-19 08:45:00,128.81,47.595,57.55,29.28 +2020-06-19 09:00:00,126.71,40.461999999999996,52.588,29.28 +2020-06-19 09:15:00,114.96,41.42100000000001,52.588,29.28 +2020-06-19 09:30:00,122.02,44.286,52.588,29.28 +2020-06-19 09:45:00,127.81,47.428999999999995,52.588,29.28 +2020-06-19 10:00:00,122.67,41.851000000000006,49.772,29.28 +2020-06-19 10:15:00,122.54,43.483000000000004,49.772,29.28 +2020-06-19 10:30:00,122.79,44.187,49.772,29.28 +2020-06-19 10:45:00,121.18,45.756,49.772,29.28 +2020-06-19 11:00:00,122.52,42.196999999999996,49.226000000000006,29.28 +2020-06-19 11:15:00,124.31,42.438,49.226000000000006,29.28 +2020-06-19 11:30:00,125.71,43.64,49.226000000000006,29.28 +2020-06-19 11:45:00,123.32,44.515,49.226000000000006,29.28 +2020-06-19 12:00:00,114.63,41.446999999999996,45.705,29.28 +2020-06-19 12:15:00,122.04,40.604,45.705,29.28 +2020-06-19 12:30:00,117.45,39.614000000000004,45.705,29.28 +2020-06-19 12:45:00,117.38,40.341,45.705,29.28 +2020-06-19 13:00:00,106.5,41.613,43.133,29.28 +2020-06-19 13:15:00,107.87,43.023,43.133,29.28 +2020-06-19 13:30:00,118.64,42.123999999999995,43.133,29.28 +2020-06-19 13:45:00,120.24,41.25,43.133,29.28 +2020-06-19 14:00:00,114.0,42.461999999999996,41.989,29.28 +2020-06-19 14:15:00,101.38,41.568000000000005,41.989,29.28 +2020-06-19 14:30:00,104.98,41.913000000000004,41.989,29.28 +2020-06-19 14:45:00,100.54,41.845,41.989,29.28 +2020-06-19 15:00:00,102.98,43.575,43.728,29.28 +2020-06-19 15:15:00,95.22,40.839,43.728,29.28 +2020-06-19 15:30:00,100.15,38.366,43.728,29.28 +2020-06-19 15:45:00,103.04,37.007,43.728,29.28 +2020-06-19 16:00:00,102.24,38.91,45.93899999999999,29.28 +2020-06-19 16:15:00,97.18,38.972,45.93899999999999,29.28 +2020-06-19 16:30:00,99.11,38.058,45.93899999999999,29.28 +2020-06-19 16:45:00,103.2,33.641999999999996,45.93899999999999,29.28 +2020-06-19 17:00:00,108.34,38.445,50.488,29.28 +2020-06-19 17:15:00,102.87,38.21,50.488,29.28 +2020-06-19 17:30:00,103.68,37.475,50.488,29.28 +2020-06-19 17:45:00,106.28,35.653,50.488,29.28 +2020-06-19 18:00:00,106.78,38.35,52.408,29.28 +2020-06-19 18:15:00,100.21,37.504,52.408,29.28 +2020-06-19 18:30:00,105.97,35.091,52.408,29.28 +2020-06-19 18:45:00,105.56,39.607,52.408,29.28 +2020-06-19 19:00:00,102.94,41.207,52.736000000000004,29.28 +2020-06-19 19:15:00,96.06,40.876999999999995,52.736000000000004,29.28 +2020-06-19 19:30:00,98.92,39.952,52.736000000000004,29.28 +2020-06-19 19:45:00,97.55,38.978,52.736000000000004,29.28 +2020-06-19 20:00:00,95.78,36.455999999999996,59.68,29.28 +2020-06-19 20:15:00,97.37,37.21,59.68,29.28 +2020-06-19 20:30:00,92.83,37.177,59.68,29.28 +2020-06-19 20:45:00,91.07,37.179,59.68,29.28 +2020-06-19 21:00:00,94.6,36.84,54.343999999999994,29.28 +2020-06-19 21:15:00,92.39,40.45,54.343999999999994,29.28 +2020-06-19 21:30:00,85.76,41.243,54.343999999999994,29.28 +2020-06-19 21:45:00,83.91,42.629,54.343999999999994,29.28 +2020-06-19 22:00:00,82.56,40.035,49.672,29.28 +2020-06-19 22:15:00,84.43,41.754,49.672,29.28 +2020-06-19 22:30:00,80.48,41.489,49.672,29.28 +2020-06-19 22:45:00,74.58,39.236,49.672,29.28 +2020-06-19 23:00:00,68.57,36.621,42.065,29.28 +2020-06-19 23:15:00,75.75,33.433,42.065,29.28 +2020-06-19 23:30:00,75.04,30.408,42.065,29.28 +2020-06-19 23:45:00,71.81,29.502,42.065,29.28 +2020-06-20 00:00:00,66.09,23.177,38.829,29.17 +2020-06-20 00:15:00,61.95,23.165,38.829,29.17 +2020-06-20 00:30:00,69.46,21.93,38.829,29.17 +2020-06-20 00:45:00,70.54,21.221,38.829,29.17 +2020-06-20 01:00:00,69.8,21.002,34.63,29.17 +2020-06-20 01:15:00,64.85,20.109,34.63,29.17 +2020-06-20 01:30:00,62.76,18.302,34.63,29.17 +2020-06-20 01:45:00,68.84,18.891,34.63,29.17 +2020-06-20 02:00:00,66.79,18.442,32.465,29.17 +2020-06-20 02:15:00,62.57,16.019000000000002,32.465,29.17 +2020-06-20 02:30:00,62.89,18.262,32.465,29.17 +2020-06-20 02:45:00,62.21,19.153,32.465,29.17 +2020-06-20 03:00:00,63.7,20.147000000000002,31.925,29.17 +2020-06-20 03:15:00,68.4,16.994,31.925,29.17 +2020-06-20 03:30:00,69.28,16.274,31.925,29.17 +2020-06-20 03:45:00,66.18,18.046,31.925,29.17 +2020-06-20 04:00:00,64.28,22.455,31.309,29.17 +2020-06-20 04:15:00,67.18,27.634,31.309,29.17 +2020-06-20 04:30:00,67.69,24.068,31.309,29.17 +2020-06-20 04:45:00,65.96,23.858,31.309,29.17 +2020-06-20 05:00:00,64.27,29.932,30.323,29.17 +2020-06-20 05:15:00,63.38,29.461,30.323,29.17 +2020-06-20 05:30:00,66.59,23.021,30.323,29.17 +2020-06-20 05:45:00,73.18,24.226,30.323,29.17 +2020-06-20 06:00:00,77.68,38.798,31.438000000000002,29.17 +2020-06-20 06:15:00,75.5,47.883,31.438000000000002,29.17 +2020-06-20 06:30:00,73.46,42.826,31.438000000000002,29.17 +2020-06-20 06:45:00,70.8,40.108000000000004,31.438000000000002,29.17 +2020-06-20 07:00:00,78.74,39.754,34.891999999999996,29.17 +2020-06-20 07:15:00,82.01,39.227,34.891999999999996,29.17 +2020-06-20 07:30:00,82.18,36.159,34.891999999999996,29.17 +2020-06-20 07:45:00,82.18,36.577,34.891999999999996,29.17 +2020-06-20 08:00:00,80.59,35.275,39.608000000000004,29.17 +2020-06-20 08:15:00,79.69,38.524,39.608000000000004,29.17 +2020-06-20 08:30:00,85.25,38.698,39.608000000000004,29.17 +2020-06-20 08:45:00,92.26,41.931000000000004,39.608000000000004,29.17 +2020-06-20 09:00:00,94.83,37.946,40.894,29.17 +2020-06-20 09:15:00,91.28,39.433,40.894,29.17 +2020-06-20 09:30:00,90.28,42.839,40.894,29.17 +2020-06-20 09:45:00,90.39,45.566,40.894,29.17 +2020-06-20 10:00:00,96.46,40.611,39.525,29.17 +2020-06-20 10:15:00,102.27,42.614,39.525,29.17 +2020-06-20 10:30:00,103.51,42.993,39.525,29.17 +2020-06-20 10:45:00,99.42,44.306000000000004,39.525,29.17 +2020-06-20 11:00:00,94.12,40.628,36.718,29.17 +2020-06-20 11:15:00,91.99,41.68600000000001,36.718,29.17 +2020-06-20 11:30:00,90.43,43.083999999999996,36.718,29.17 +2020-06-20 11:45:00,91.18,44.558,36.718,29.17 +2020-06-20 12:00:00,88.9,41.86600000000001,35.688,29.17 +2020-06-20 12:15:00,87.4,41.9,35.688,29.17 +2020-06-20 12:30:00,86.31,40.782,35.688,29.17 +2020-06-20 12:45:00,85.13,42.178999999999995,35.688,29.17 +2020-06-20 13:00:00,84.77,42.562,32.858000000000004,29.17 +2020-06-20 13:15:00,85.22,43.309,32.858000000000004,29.17 +2020-06-20 13:30:00,84.09,42.575,32.858000000000004,29.17 +2020-06-20 13:45:00,82.67,40.41,32.858000000000004,29.17 +2020-06-20 14:00:00,81.43,41.786,31.738000000000003,29.17 +2020-06-20 14:15:00,81.85,39.626,31.738000000000003,29.17 +2020-06-20 14:30:00,81.06,39.414,31.738000000000003,29.17 +2020-06-20 14:45:00,82.67,39.839,31.738000000000003,29.17 +2020-06-20 15:00:00,82.96,42.092,34.35,29.17 +2020-06-20 15:15:00,80.52,40.094,34.35,29.17 +2020-06-20 15:30:00,81.22,37.909,34.35,29.17 +2020-06-20 15:45:00,82.66,35.633,34.35,29.17 +2020-06-20 16:00:00,85.43,39.605,37.522,29.17 +2020-06-20 16:15:00,84.06,38.836999999999996,37.522,29.17 +2020-06-20 16:30:00,83.74,38.161,37.522,29.17 +2020-06-20 16:45:00,84.53,33.751999999999995,37.522,29.17 +2020-06-20 17:00:00,86.97,37.381,42.498000000000005,29.17 +2020-06-20 17:15:00,87.28,35.118,42.498000000000005,29.17 +2020-06-20 17:30:00,88.27,34.24,42.498000000000005,29.17 +2020-06-20 17:45:00,87.85,32.866,42.498000000000005,29.17 +2020-06-20 18:00:00,88.24,36.915,44.701,29.17 +2020-06-20 18:15:00,86.6,37.805,44.701,29.17 +2020-06-20 18:30:00,88.94,36.808,44.701,29.17 +2020-06-20 18:45:00,84.9,37.689,44.701,29.17 +2020-06-20 19:00:00,81.63,37.759,45.727,29.17 +2020-06-20 19:15:00,76.92,36.394,45.727,29.17 +2020-06-20 19:30:00,78.37,36.266999999999996,45.727,29.17 +2020-06-20 19:45:00,75.8,37.018,45.727,29.17 +2020-06-20 20:00:00,74.14,35.438,43.391000000000005,29.17 +2020-06-20 20:15:00,71.76,35.691,43.391000000000005,29.17 +2020-06-20 20:30:00,72.4,34.777,43.391000000000005,29.17 +2020-06-20 20:45:00,71.24,36.609,43.391000000000005,29.17 +2020-06-20 21:00:00,70.11,34.961,41.231,29.17 +2020-06-20 21:15:00,68.91,38.260999999999996,41.231,29.17 +2020-06-20 21:30:00,66.55,39.293,41.231,29.17 +2020-06-20 21:45:00,64.82,40.121,41.231,29.17 +2020-06-20 22:00:00,64.11,37.602,40.798,29.17 +2020-06-20 22:15:00,62.69,39.724000000000004,40.798,29.17 +2020-06-20 22:30:00,62.16,39.457,40.798,29.17 +2020-06-20 22:45:00,58.77,37.674,40.798,29.17 +2020-06-20 23:00:00,54.05,34.529,34.402,29.17 +2020-06-20 23:15:00,54.66,31.689,34.402,29.17 +2020-06-20 23:30:00,53.59,30.991,34.402,29.17 +2020-06-20 23:45:00,53.51,30.281999999999996,34.402,29.17 +2020-06-21 00:00:00,51.37,24.479,30.171,29.17 +2020-06-21 00:15:00,51.6,23.31,30.171,29.17 +2020-06-21 00:30:00,51.28,21.910999999999998,30.171,29.17 +2020-06-21 00:45:00,50.78,21.142,30.171,29.17 +2020-06-21 01:00:00,47.84,21.191,27.15,29.17 +2020-06-21 01:15:00,50.1,20.225,27.15,29.17 +2020-06-21 01:30:00,49.76,18.312,27.15,29.17 +2020-06-21 01:45:00,49.33,18.498,27.15,29.17 +2020-06-21 02:00:00,48.37,18.084,25.403000000000002,29.17 +2020-06-21 02:15:00,48.73,16.308,25.403000000000002,29.17 +2020-06-21 02:30:00,48.89,18.782,25.403000000000002,29.17 +2020-06-21 02:45:00,48.92,19.419,25.403000000000002,29.17 +2020-06-21 03:00:00,48.71,21.072,23.386999999999997,29.17 +2020-06-21 03:15:00,49.4,18.149,23.386999999999997,29.17 +2020-06-21 03:30:00,50.71,16.795,23.386999999999997,29.17 +2020-06-21 03:45:00,50.44,17.798,23.386999999999997,29.17 +2020-06-21 04:00:00,49.72,22.112,23.941999999999997,29.17 +2020-06-21 04:15:00,50.86,26.76,23.941999999999997,29.17 +2020-06-21 04:30:00,51.2,24.508000000000003,23.941999999999997,29.17 +2020-06-21 04:45:00,52.17,23.851999999999997,23.941999999999997,29.17 +2020-06-21 05:00:00,52.67,29.799,23.026,29.17 +2020-06-21 05:15:00,53.27,28.311,23.026,29.17 +2020-06-21 05:30:00,54.64,21.53,23.026,29.17 +2020-06-21 05:45:00,56.86,22.523000000000003,23.026,29.17 +2020-06-21 06:00:00,58.47,34.766999999999996,23.223000000000003,29.17 +2020-06-21 06:15:00,59.86,44.56100000000001,23.223000000000003,29.17 +2020-06-21 06:30:00,61.63,38.775,23.223000000000003,29.17 +2020-06-21 06:45:00,63.3,35.046,23.223000000000003,29.17 +2020-06-21 07:00:00,66.04,35.114000000000004,24.968000000000004,29.17 +2020-06-21 07:15:00,66.82,32.942,24.968000000000004,29.17 +2020-06-21 07:30:00,67.37,31.054000000000002,24.968000000000004,29.17 +2020-06-21 07:45:00,67.58,31.428,24.968000000000004,29.17 +2020-06-21 08:00:00,66.37,30.988000000000003,29.131,29.17 +2020-06-21 08:15:00,68.59,35.423,29.131,29.17 +2020-06-21 08:30:00,65.93,36.542,29.131,29.17 +2020-06-21 08:45:00,64.07,39.815,29.131,29.17 +2020-06-21 09:00:00,66.26,35.691,29.904,29.17 +2020-06-21 09:15:00,65.59,36.727,29.904,29.17 +2020-06-21 09:30:00,63.46,40.547,29.904,29.17 +2020-06-21 09:45:00,64.99,44.266000000000005,29.904,29.17 +2020-06-21 10:00:00,69.22,39.995,28.943,29.17 +2020-06-21 10:15:00,70.76,42.17,28.943,29.17 +2020-06-21 10:30:00,72.73,42.803000000000004,28.943,29.17 +2020-06-21 10:45:00,73.48,45.013999999999996,28.943,29.17 +2020-06-21 11:00:00,70.43,41.044,31.682,29.17 +2020-06-21 11:15:00,67.81,41.674,31.682,29.17 +2020-06-21 11:30:00,68.94,43.528999999999996,31.682,29.17 +2020-06-21 11:45:00,70.2,45.287,31.682,29.17 +2020-06-21 12:00:00,72.15,43.645,27.315,29.17 +2020-06-21 12:15:00,71.91,43.158,27.315,29.17 +2020-06-21 12:30:00,70.77,42.159,27.315,29.17 +2020-06-21 12:45:00,71.96,42.878,27.315,29.17 +2020-06-21 13:00:00,67.38,42.913000000000004,23.894000000000002,29.17 +2020-06-21 13:15:00,68.77,43.246,23.894000000000002,29.17 +2020-06-21 13:30:00,67.21,41.412,23.894000000000002,29.17 +2020-06-21 13:45:00,64.71,40.32,23.894000000000002,29.17 +2020-06-21 14:00:00,59.95,42.907,21.148000000000003,29.17 +2020-06-21 14:15:00,61.83,41.231,21.148000000000003,29.17 +2020-06-21 14:30:00,64.05,39.84,21.148000000000003,29.17 +2020-06-21 14:45:00,66.51,39.191,21.148000000000003,29.17 +2020-06-21 15:00:00,65.17,41.54,21.229,29.17 +2020-06-21 15:15:00,65.5,38.769,21.229,29.17 +2020-06-21 15:30:00,67.43,36.427,21.229,29.17 +2020-06-21 15:45:00,68.84,34.457,21.229,29.17 +2020-06-21 16:00:00,69.3,36.935,25.037,29.17 +2020-06-21 16:15:00,70.82,36.345,25.037,29.17 +2020-06-21 16:30:00,76.2,36.694,25.037,29.17 +2020-06-21 16:45:00,75.99,32.335,25.037,29.17 +2020-06-21 17:00:00,79.94,36.341,37.11,29.17 +2020-06-21 17:15:00,79.54,35.556,37.11,29.17 +2020-06-21 17:30:00,79.33,35.486,37.11,29.17 +2020-06-21 17:45:00,79.29,34.625,37.11,29.17 +2020-06-21 18:00:00,82.67,39.243,42.215,29.17 +2020-06-21 18:15:00,79.05,39.774,42.215,29.17 +2020-06-21 18:30:00,78.46,38.423,42.215,29.17 +2020-06-21 18:45:00,78.31,39.51,42.215,29.17 +2020-06-21 19:00:00,79.89,41.713,44.383,29.17 +2020-06-21 19:15:00,78.41,39.275,44.383,29.17 +2020-06-21 19:30:00,76.51,38.885999999999996,44.383,29.17 +2020-06-21 19:45:00,76.7,39.255,44.383,29.17 +2020-06-21 20:00:00,77.36,37.827,43.426,29.17 +2020-06-21 20:15:00,76.94,37.992,43.426,29.17 +2020-06-21 20:30:00,78.36,37.949,43.426,29.17 +2020-06-21 20:45:00,78.33,38.084,43.426,29.17 +2020-06-21 21:00:00,77.45,36.18,42.265,29.17 +2020-06-21 21:15:00,76.37,39.157,42.265,29.17 +2020-06-21 21:30:00,74.61,39.539,42.265,29.17 +2020-06-21 21:45:00,74.02,40.735,42.265,29.17 +2020-06-21 22:00:00,72.94,40.181,42.26,29.17 +2020-06-21 22:15:00,71.13,40.609,42.26,29.17 +2020-06-21 22:30:00,69.5,39.675,42.26,29.17 +2020-06-21 22:45:00,73.05,36.58,42.26,29.17 +2020-06-21 23:00:00,65.27,32.983000000000004,36.609,29.17 +2020-06-21 23:15:00,66.55,31.525,36.609,29.17 +2020-06-21 23:30:00,65.95,30.346,36.609,29.17 +2020-06-21 23:45:00,64.51,29.828000000000003,36.609,29.17 +2020-06-22 00:00:00,62.06,26.113000000000003,34.611,29.28 +2020-06-22 00:15:00,63.43,25.878,34.611,29.28 +2020-06-22 00:30:00,63.24,24.111,34.611,29.28 +2020-06-22 00:45:00,62.85,22.93,34.611,29.28 +2020-06-22 01:00:00,61.06,23.374000000000002,33.552,29.28 +2020-06-22 01:15:00,62.06,22.379,33.552,29.28 +2020-06-22 01:30:00,62.07,20.820999999999998,33.552,29.28 +2020-06-22 01:45:00,62.34,20.910999999999998,33.552,29.28 +2020-06-22 02:00:00,61.38,20.945,32.351,29.28 +2020-06-22 02:15:00,62.36,18.246,32.351,29.28 +2020-06-22 02:30:00,62.99,20.894000000000002,32.351,29.28 +2020-06-22 02:45:00,69.91,21.36,32.351,29.28 +2020-06-22 03:00:00,71.83,23.574,30.793000000000003,29.28 +2020-06-22 03:15:00,74.38,21.448,30.793000000000003,29.28 +2020-06-22 03:30:00,68.44,20.76,30.793000000000003,29.28 +2020-06-22 03:45:00,72.17,21.3,30.793000000000003,29.28 +2020-06-22 04:00:00,73.67,28.855999999999998,31.274,29.28 +2020-06-22 04:15:00,81.48,36.568000000000005,31.274,29.28 +2020-06-22 04:30:00,85.31,33.944,31.274,29.28 +2020-06-22 04:45:00,87.89,33.66,31.274,29.28 +2020-06-22 05:00:00,90.15,47.393,37.75,29.28 +2020-06-22 05:15:00,92.28,57.013000000000005,37.75,29.28 +2020-06-22 05:30:00,98.06,48.794,37.75,29.28 +2020-06-22 05:45:00,104.24,46.501000000000005,37.75,29.28 +2020-06-22 06:00:00,114.07,47.684,55.36,29.28 +2020-06-22 06:15:00,113.59,47.718999999999994,55.36,29.28 +2020-06-22 06:30:00,120.64,46.295,55.36,29.28 +2020-06-22 06:45:00,117.35,48.265,55.36,29.28 +2020-06-22 07:00:00,125.32,48.82,65.87,29.28 +2020-06-22 07:15:00,124.38,48.982,65.87,29.28 +2020-06-22 07:30:00,123.8,46.201,65.87,29.28 +2020-06-22 07:45:00,120.42,46.763999999999996,65.87,29.28 +2020-06-22 08:00:00,127.99,43.946999999999996,55.695,29.28 +2020-06-22 08:15:00,124.81,47.11,55.695,29.28 +2020-06-22 08:30:00,122.97,47.053000000000004,55.695,29.28 +2020-06-22 08:45:00,126.89,50.353,55.695,29.28 +2020-06-22 09:00:00,125.76,45.153999999999996,50.881,29.28 +2020-06-22 09:15:00,128.65,44.243,50.881,29.28 +2020-06-22 09:30:00,129.68,47.093,50.881,29.28 +2020-06-22 09:45:00,120.54,48.583999999999996,50.881,29.28 +2020-06-22 10:00:00,114.39,44.678999999999995,49.138000000000005,29.28 +2020-06-22 10:15:00,122.56,46.699,49.138000000000005,29.28 +2020-06-22 10:30:00,122.96,46.832,49.138000000000005,29.28 +2020-06-22 10:45:00,125.29,47.528,49.138000000000005,29.28 +2020-06-22 11:00:00,120.65,43.623000000000005,49.178000000000004,29.28 +2020-06-22 11:15:00,114.96,44.631,49.178000000000004,29.28 +2020-06-22 11:30:00,112.14,47.236000000000004,49.178000000000004,29.28 +2020-06-22 11:45:00,104.36,49.468999999999994,49.178000000000004,29.28 +2020-06-22 12:00:00,104.38,46.236000000000004,47.698,29.28 +2020-06-22 12:15:00,103.64,45.858000000000004,47.698,29.28 +2020-06-22 12:30:00,103.64,43.793,47.698,29.28 +2020-06-22 12:45:00,102.45,44.586999999999996,47.698,29.28 +2020-06-22 13:00:00,96.63,45.56100000000001,48.104,29.28 +2020-06-22 13:15:00,99.66,44.949,48.104,29.28 +2020-06-22 13:30:00,103.52,43.265,48.104,29.28 +2020-06-22 13:45:00,107.28,43.06399999999999,48.104,29.28 +2020-06-22 14:00:00,103.1,44.723,48.53,29.28 +2020-06-22 14:15:00,98.24,43.601000000000006,48.53,29.28 +2020-06-22 14:30:00,97.96,41.998999999999995,48.53,29.28 +2020-06-22 14:45:00,98.66,43.403,48.53,29.28 +2020-06-22 15:00:00,96.96,45.501000000000005,49.351000000000006,29.28 +2020-06-22 15:15:00,95.96,42.066,49.351000000000006,29.28 +2020-06-22 15:30:00,103.76,40.439,49.351000000000006,29.28 +2020-06-22 15:45:00,101.27,37.955999999999996,49.351000000000006,29.28 +2020-06-22 16:00:00,100.46,41.551,51.44,29.28 +2020-06-22 16:15:00,102.13,40.983999999999995,51.44,29.28 +2020-06-22 16:30:00,101.46,40.643,51.44,29.28 +2020-06-22 16:45:00,101.72,36.227,51.44,29.28 +2020-06-22 17:00:00,104.77,39.12,56.868,29.28 +2020-06-22 17:15:00,101.5,38.658,56.868,29.28 +2020-06-22 17:30:00,104.47,38.171,56.868,29.28 +2020-06-22 17:45:00,106.24,36.828,56.868,29.28 +2020-06-22 18:00:00,107.49,40.416,57.229,29.28 +2020-06-22 18:15:00,104.01,38.998000000000005,57.229,29.28 +2020-06-22 18:30:00,102.52,36.928000000000004,57.229,29.28 +2020-06-22 18:45:00,102.38,41.181000000000004,57.229,29.28 +2020-06-22 19:00:00,100.87,43.022,57.744,29.28 +2020-06-22 19:15:00,100.34,41.828,57.744,29.28 +2020-06-22 19:30:00,96.04,41.091,57.744,29.28 +2020-06-22 19:45:00,97.08,40.805,57.744,29.28 +2020-06-22 20:00:00,93.07,37.99,66.05199999999999,29.28 +2020-06-22 20:15:00,93.95,39.416,66.05199999999999,29.28 +2020-06-22 20:30:00,95.77,39.804,66.05199999999999,29.28 +2020-06-22 20:45:00,99.45,40.259,66.05199999999999,29.28 +2020-06-22 21:00:00,94.07,37.760999999999996,59.396,29.28 +2020-06-22 21:15:00,93.7,41.123999999999995,59.396,29.28 +2020-06-22 21:30:00,90.26,41.853,59.396,29.28 +2020-06-22 21:45:00,88.36,42.81,59.396,29.28 +2020-06-22 22:00:00,84.6,40.099000000000004,53.06,29.28 +2020-06-22 22:15:00,85.31,42.441,53.06,29.28 +2020-06-22 22:30:00,82.83,36.732,53.06,29.28 +2020-06-22 22:45:00,81.6,33.37,53.06,29.28 +2020-06-22 23:00:00,76.8,29.838,46.148,29.28 +2020-06-22 23:15:00,77.46,26.904,46.148,29.28 +2020-06-22 23:30:00,78.9,25.831999999999997,46.148,29.28 +2020-06-22 23:45:00,78.36,24.899,46.148,29.28 +2020-06-23 00:00:00,73.53,23.746,44.625,29.28 +2020-06-23 00:15:00,75.33,24.444000000000003,44.625,29.28 +2020-06-23 00:30:00,74.92,23.34,44.625,29.28 +2020-06-23 00:45:00,74.6,22.901,44.625,29.28 +2020-06-23 01:00:00,73.27,22.811999999999998,41.733000000000004,29.28 +2020-06-23 01:15:00,75.07,21.89,41.733000000000004,29.28 +2020-06-23 01:30:00,73.73,20.209,41.733000000000004,29.28 +2020-06-23 01:45:00,74.21,19.797,41.733000000000004,29.28 +2020-06-23 02:00:00,73.89,19.355999999999998,39.872,29.28 +2020-06-23 02:15:00,73.43,17.804000000000002,39.872,29.28 +2020-06-23 02:30:00,80.85,20.026,39.872,29.28 +2020-06-23 02:45:00,82.26,20.824,39.872,29.28 +2020-06-23 03:00:00,78.93,22.423000000000002,38.711,29.28 +2020-06-23 03:15:00,75.46,21.189,38.711,29.28 +2020-06-23 03:30:00,78.81,20.5,38.711,29.28 +2020-06-23 03:45:00,76.4,19.961,38.711,29.28 +2020-06-23 04:00:00,84.82,26.201,39.823,29.28 +2020-06-23 04:15:00,90.66,33.869,39.823,29.28 +2020-06-23 04:30:00,92.85,31.098000000000003,39.823,29.28 +2020-06-23 04:45:00,92.53,31.357,39.823,29.28 +2020-06-23 05:00:00,96.07,46.823,43.228,29.28 +2020-06-23 05:15:00,98.92,56.934,43.228,29.28 +2020-06-23 05:30:00,106.98,48.886,43.228,29.28 +2020-06-23 05:45:00,113.0,45.909,43.228,29.28 +2020-06-23 06:00:00,118.57,48.091,54.316,29.28 +2020-06-23 06:15:00,120.37,48.391999999999996,54.316,29.28 +2020-06-23 06:30:00,119.5,46.638999999999996,54.316,29.28 +2020-06-23 06:45:00,123.82,47.731,54.316,29.28 +2020-06-23 07:00:00,124.88,48.418,65.758,29.28 +2020-06-23 07:15:00,122.94,48.336000000000006,65.758,29.28 +2020-06-23 07:30:00,120.91,45.54,65.758,29.28 +2020-06-23 07:45:00,125.1,45.174,65.758,29.28 +2020-06-23 08:00:00,122.4,42.302,57.983000000000004,29.28 +2020-06-23 08:15:00,118.74,44.994,57.983000000000004,29.28 +2020-06-23 08:30:00,118.28,45.086000000000006,57.983000000000004,29.28 +2020-06-23 08:45:00,122.7,47.455,57.983000000000004,29.28 +2020-06-23 09:00:00,122.76,42.547,52.653,29.28 +2020-06-23 09:15:00,122.01,41.583999999999996,52.653,29.28 +2020-06-23 09:30:00,120.53,45.15,52.653,29.28 +2020-06-23 09:45:00,118.95,47.977,52.653,29.28 +2020-06-23 10:00:00,130.1,42.687,51.408,29.28 +2020-06-23 10:15:00,123.77,44.523,51.408,29.28 +2020-06-23 10:30:00,123.38,44.691,51.408,29.28 +2020-06-23 10:45:00,116.88,46.407,51.408,29.28 +2020-06-23 11:00:00,116.32,42.581,51.913000000000004,29.28 +2020-06-23 11:15:00,111.31,43.983000000000004,51.913000000000004,29.28 +2020-06-23 11:30:00,115.49,45.371,51.913000000000004,29.28 +2020-06-23 11:45:00,107.36,47.188,51.913000000000004,29.28 +2020-06-23 12:00:00,107.78,43.729,49.508,29.28 +2020-06-23 12:15:00,112.97,43.67,49.508,29.28 +2020-06-23 12:30:00,115.46,42.479,49.508,29.28 +2020-06-23 12:45:00,111.06,43.953,49.508,29.28 +2020-06-23 13:00:00,110.37,44.533,50.007,29.28 +2020-06-23 13:15:00,106.59,45.705,50.007,29.28 +2020-06-23 13:30:00,108.56,44.053999999999995,50.007,29.28 +2020-06-23 13:45:00,117.29,42.903,50.007,29.28 +2020-06-23 14:00:00,107.17,45.04,49.778999999999996,29.28 +2020-06-23 14:15:00,109.31,43.724,49.778999999999996,29.28 +2020-06-23 14:30:00,107.16,42.458999999999996,49.778999999999996,29.28 +2020-06-23 14:45:00,97.6,43.075,49.778999999999996,29.28 +2020-06-23 15:00:00,108.88,45.023,51.559,29.28 +2020-06-23 15:15:00,109.4,42.522,51.559,29.28 +2020-06-23 15:30:00,106.06,40.688,51.559,29.28 +2020-06-23 15:45:00,108.71,38.524,51.559,29.28 +2020-06-23 16:00:00,110.94,41.452,53.531000000000006,29.28 +2020-06-23 16:15:00,110.74,40.99100000000001,53.531000000000006,29.28 +2020-06-23 16:30:00,113.66,40.326,53.531000000000006,29.28 +2020-06-23 16:45:00,109.97,36.621,53.531000000000006,29.28 +2020-06-23 17:00:00,116.03,39.773,59.497,29.28 +2020-06-23 17:15:00,115.14,39.727,59.497,29.28 +2020-06-23 17:30:00,117.22,38.826,59.497,29.28 +2020-06-23 17:45:00,112.66,37.13,59.497,29.28 +2020-06-23 18:00:00,114.14,39.775,59.861999999999995,29.28 +2020-06-23 18:15:00,114.54,39.8,59.861999999999995,29.28 +2020-06-23 18:30:00,116.69,37.45,59.861999999999995,29.28 +2020-06-23 18:45:00,112.08,41.543,59.861999999999995,29.28 +2020-06-23 19:00:00,104.05,42.224,60.989,29.28 +2020-06-23 19:15:00,100.72,41.187,60.989,29.28 +2020-06-23 19:30:00,98.01,40.196999999999996,60.989,29.28 +2020-06-23 19:45:00,97.06,40.241,60.989,29.28 +2020-06-23 20:00:00,96.0,37.812,68.35600000000001,29.28 +2020-06-23 20:15:00,96.1,37.766,68.35600000000001,29.28 +2020-06-23 20:30:00,95.0,38.244,68.35600000000001,29.28 +2020-06-23 20:45:00,98.48,39.116,68.35600000000001,29.28 +2020-06-23 21:00:00,95.1,37.458,59.251000000000005,29.28 +2020-06-23 21:15:00,93.78,39.403,59.251000000000005,29.28 +2020-06-23 21:30:00,89.52,40.253,59.251000000000005,29.28 +2020-06-23 21:45:00,88.7,41.42,59.251000000000005,29.28 +2020-06-23 22:00:00,83.01,38.971,54.736999999999995,29.28 +2020-06-23 22:15:00,84.33,40.971000000000004,54.736999999999995,29.28 +2020-06-23 22:30:00,82.74,35.55,54.736999999999995,29.28 +2020-06-23 22:45:00,81.15,32.186,54.736999999999995,29.28 +2020-06-23 23:00:00,78.29,27.874000000000002,46.806999999999995,29.28 +2020-06-23 23:15:00,77.68,26.494,46.806999999999995,29.28 +2020-06-23 23:30:00,76.77,25.414,46.806999999999995,29.28 +2020-06-23 23:45:00,75.36,24.633000000000003,46.806999999999995,29.28 +2020-06-24 00:00:00,72.75,23.675,43.824,29.28 +2020-06-24 00:15:00,73.59,24.374000000000002,43.824,29.28 +2020-06-24 00:30:00,73.6,23.273000000000003,43.824,29.28 +2020-06-24 00:45:00,73.92,22.840999999999998,43.824,29.28 +2020-06-24 01:00:00,72.73,22.764,39.86,29.28 +2020-06-24 01:15:00,73.28,21.828000000000003,39.86,29.28 +2020-06-24 01:30:00,72.51,20.145,39.86,29.28 +2020-06-24 01:45:00,73.33,19.726,39.86,29.28 +2020-06-24 02:00:00,77.46,19.287,37.931999999999995,29.28 +2020-06-24 02:15:00,82.15,17.739,37.931999999999995,29.28 +2020-06-24 02:30:00,81.13,19.952,37.931999999999995,29.28 +2020-06-24 02:45:00,74.48,20.756999999999998,37.931999999999995,29.28 +2020-06-24 03:00:00,77.13,22.351999999999997,37.579,29.28 +2020-06-24 03:15:00,76.09,21.122,37.579,29.28 +2020-06-24 03:30:00,76.54,20.439,37.579,29.28 +2020-06-24 03:45:00,76.21,19.92,37.579,29.28 +2020-06-24 04:00:00,84.58,26.114,37.931999999999995,29.28 +2020-06-24 04:15:00,88.31,33.742,37.931999999999995,29.28 +2020-06-24 04:30:00,92.93,30.961,37.931999999999995,29.28 +2020-06-24 04:45:00,91.92,31.218000000000004,37.931999999999995,29.28 +2020-06-24 05:00:00,95.34,46.599,40.942,29.28 +2020-06-24 05:15:00,98.24,56.599,40.942,29.28 +2020-06-24 05:30:00,101.16,48.613,40.942,29.28 +2020-06-24 05:45:00,103.36,45.676,40.942,29.28 +2020-06-24 06:00:00,111.68,47.864,56.516999999999996,29.28 +2020-06-24 06:15:00,116.87,48.153999999999996,56.516999999999996,29.28 +2020-06-24 06:30:00,116.87,46.42100000000001,56.516999999999996,29.28 +2020-06-24 06:45:00,117.03,47.542,56.516999999999996,29.28 +2020-06-24 07:00:00,116.69,48.217,71.707,29.28 +2020-06-24 07:15:00,116.48,48.155,71.707,29.28 +2020-06-24 07:30:00,116.84,45.356,71.707,29.28 +2020-06-24 07:45:00,112.07,45.023,71.707,29.28 +2020-06-24 08:00:00,109.37,42.161,61.17,29.28 +2020-06-24 08:15:00,108.4,44.878,61.17,29.28 +2020-06-24 08:30:00,114.26,44.963,61.17,29.28 +2020-06-24 08:45:00,114.72,47.333,61.17,29.28 +2020-06-24 09:00:00,112.86,42.42,57.282,29.28 +2020-06-24 09:15:00,108.55,41.458999999999996,57.282,29.28 +2020-06-24 09:30:00,118.09,45.023,57.282,29.28 +2020-06-24 09:45:00,123.97,47.863,57.282,29.28 +2020-06-24 10:00:00,122.12,42.582,54.026,29.28 +2020-06-24 10:15:00,117.01,44.424,54.026,29.28 +2020-06-24 10:30:00,123.16,44.593,54.026,29.28 +2020-06-24 10:45:00,121.18,46.31399999999999,54.026,29.28 +2020-06-24 11:00:00,123.52,42.483999999999995,54.277,29.28 +2020-06-24 11:15:00,117.69,43.891000000000005,54.277,29.28 +2020-06-24 11:30:00,115.43,45.269,54.277,29.28 +2020-06-24 11:45:00,119.14,47.083999999999996,54.277,29.28 +2020-06-24 12:00:00,124.99,43.653,52.552,29.28 +2020-06-24 12:15:00,127.32,43.593,52.552,29.28 +2020-06-24 12:30:00,126.8,42.385,52.552,29.28 +2020-06-24 12:45:00,117.73,43.86,52.552,29.28 +2020-06-24 13:00:00,101.49,44.428999999999995,52.111999999999995,29.28 +2020-06-24 13:15:00,105.29,45.601000000000006,52.111999999999995,29.28 +2020-06-24 13:30:00,116.19,43.958,52.111999999999995,29.28 +2020-06-24 13:45:00,110.54,42.81100000000001,52.111999999999995,29.28 +2020-06-24 14:00:00,99.9,44.958999999999996,52.066,29.28 +2020-06-24 14:15:00,102.05,43.643,52.066,29.28 +2020-06-24 14:30:00,110.45,42.358000000000004,52.066,29.28 +2020-06-24 14:45:00,109.15,42.979,52.066,29.28 +2020-06-24 15:00:00,108.94,44.952,52.523999999999994,29.28 +2020-06-24 15:15:00,101.7,42.445,52.523999999999994,29.28 +2020-06-24 15:30:00,107.34,40.605,52.523999999999994,29.28 +2020-06-24 15:45:00,106.1,38.431999999999995,52.523999999999994,29.28 +2020-06-24 16:00:00,104.67,41.382,54.101000000000006,29.28 +2020-06-24 16:15:00,105.58,40.921,54.101000000000006,29.28 +2020-06-24 16:30:00,112.42,40.273,54.101000000000006,29.28 +2020-06-24 16:45:00,110.04,36.552,54.101000000000006,29.28 +2020-06-24 17:00:00,111.19,39.722,58.155,29.28 +2020-06-24 17:15:00,108.5,39.675,58.155,29.28 +2020-06-24 17:30:00,112.41,38.77,58.155,29.28 +2020-06-24 17:45:00,117.25,37.063,58.155,29.28 +2020-06-24 18:00:00,116.0,39.716,60.205,29.28 +2020-06-24 18:15:00,111.3,39.717,60.205,29.28 +2020-06-24 18:30:00,108.34,37.365,60.205,29.28 +2020-06-24 18:45:00,107.8,41.457,60.205,29.28 +2020-06-24 19:00:00,103.84,42.141000000000005,61.568999999999996,29.28 +2020-06-24 19:15:00,101.09,41.096000000000004,61.568999999999996,29.28 +2020-06-24 19:30:00,102.04,40.098,61.568999999999996,29.28 +2020-06-24 19:45:00,97.68,40.138000000000005,61.568999999999996,29.28 +2020-06-24 20:00:00,95.61,37.694,68.145,29.28 +2020-06-24 20:15:00,94.76,37.645,68.145,29.28 +2020-06-24 20:30:00,94.42,38.129,68.145,29.28 +2020-06-24 20:45:00,94.83,39.023,68.145,29.28 +2020-06-24 21:00:00,96.89,37.37,59.696000000000005,29.28 +2020-06-24 21:15:00,94.06,39.32,59.696000000000005,29.28 +2020-06-24 21:30:00,90.13,40.148,59.696000000000005,29.28 +2020-06-24 21:45:00,88.79,41.318000000000005,59.696000000000005,29.28 +2020-06-24 22:00:00,84.68,38.884,54.861999999999995,29.28 +2020-06-24 22:15:00,87.11,40.889,54.861999999999995,29.28 +2020-06-24 22:30:00,84.09,35.459,54.861999999999995,29.28 +2020-06-24 22:45:00,84.16,32.086,54.861999999999995,29.28 +2020-06-24 23:00:00,79.54,27.765,45.568000000000005,29.28 +2020-06-24 23:15:00,76.73,26.414,45.568000000000005,29.28 +2020-06-24 23:30:00,75.86,25.346,45.568000000000005,29.28 +2020-06-24 23:45:00,78.52,24.555999999999997,45.568000000000005,29.28 +2020-06-25 00:00:00,75.55,23.608,40.181,29.28 +2020-06-25 00:15:00,75.77,24.307,40.181,29.28 +2020-06-25 00:30:00,72.44,23.21,40.181,29.28 +2020-06-25 00:45:00,75.39,22.784000000000002,40.181,29.28 +2020-06-25 01:00:00,72.29,22.72,38.296,29.28 +2020-06-25 01:15:00,74.01,21.771,38.296,29.28 +2020-06-25 01:30:00,72.67,20.085,38.296,29.28 +2020-06-25 01:45:00,72.74,19.66,38.296,29.28 +2020-06-25 02:00:00,73.25,19.224,36.575,29.28 +2020-06-25 02:15:00,74.48,17.678,36.575,29.28 +2020-06-25 02:30:00,74.61,19.884,36.575,29.28 +2020-06-25 02:45:00,74.51,20.693,36.575,29.28 +2020-06-25 03:00:00,75.05,22.285,36.394,29.28 +2020-06-25 03:15:00,76.2,21.059,36.394,29.28 +2020-06-25 03:30:00,83.01,20.384,36.394,29.28 +2020-06-25 03:45:00,85.84,19.883,36.394,29.28 +2020-06-25 04:00:00,80.84,26.031999999999996,37.207,29.28 +2020-06-25 04:15:00,81.65,33.622,37.207,29.28 +2020-06-25 04:30:00,84.58,30.829,37.207,29.28 +2020-06-25 04:45:00,88.67,31.086,37.207,29.28 +2020-06-25 05:00:00,97.63,46.383,40.713,29.28 +2020-06-25 05:15:00,98.52,56.276,40.713,29.28 +2020-06-25 05:30:00,107.02,48.352,40.713,29.28 +2020-06-25 05:45:00,107.47,45.452,40.713,29.28 +2020-06-25 06:00:00,115.01,47.646,50.952,29.28 +2020-06-25 06:15:00,111.77,47.926,50.952,29.28 +2020-06-25 06:30:00,117.15,46.214,50.952,29.28 +2020-06-25 06:45:00,118.68,47.363,50.952,29.28 +2020-06-25 07:00:00,115.38,48.026,64.88,29.28 +2020-06-25 07:15:00,114.08,47.983000000000004,64.88,29.28 +2020-06-25 07:30:00,113.71,45.181999999999995,64.88,29.28 +2020-06-25 07:45:00,114.99,44.88,64.88,29.28 +2020-06-25 08:00:00,113.05,42.027,55.133,29.28 +2020-06-25 08:15:00,112.54,44.771,55.133,29.28 +2020-06-25 08:30:00,110.54,44.848,55.133,29.28 +2020-06-25 08:45:00,116.65,47.22,55.133,29.28 +2020-06-25 09:00:00,118.67,42.302,48.912,29.28 +2020-06-25 09:15:00,119.01,41.343,48.912,29.28 +2020-06-25 09:30:00,116.77,44.903999999999996,48.912,29.28 +2020-06-25 09:45:00,122.25,47.756,48.912,29.28 +2020-06-25 10:00:00,123.53,42.483000000000004,45.968999999999994,29.28 +2020-06-25 10:15:00,129.33,44.333,45.968999999999994,29.28 +2020-06-25 10:30:00,122.87,44.501000000000005,45.968999999999994,29.28 +2020-06-25 10:45:00,119.98,46.227,45.968999999999994,29.28 +2020-06-25 11:00:00,120.33,42.395,44.067,29.28 +2020-06-25 11:15:00,118.71,43.806000000000004,44.067,29.28 +2020-06-25 11:30:00,112.17,45.172,44.067,29.28 +2020-06-25 11:45:00,113.06,46.986999999999995,44.067,29.28 +2020-06-25 12:00:00,123.41,43.581,41.501000000000005,29.28 +2020-06-25 12:15:00,127.98,43.521,41.501000000000005,29.28 +2020-06-25 12:30:00,129.51,42.29600000000001,41.501000000000005,29.28 +2020-06-25 12:45:00,125.72,43.772,41.501000000000005,29.28 +2020-06-25 13:00:00,119.23,44.33,41.117,29.28 +2020-06-25 13:15:00,120.12,45.5,41.117,29.28 +2020-06-25 13:30:00,125.64,43.867,41.117,29.28 +2020-06-25 13:45:00,128.35,42.726000000000006,41.117,29.28 +2020-06-25 14:00:00,128.81,44.882,41.492,29.28 +2020-06-25 14:15:00,125.95,43.567,41.492,29.28 +2020-06-25 14:30:00,120.48,42.263999999999996,41.492,29.28 +2020-06-25 14:45:00,120.88,42.89,41.492,29.28 +2020-06-25 15:00:00,123.63,44.88399999999999,43.711999999999996,29.28 +2020-06-25 15:15:00,121.05,42.37,43.711999999999996,29.28 +2020-06-25 15:30:00,122.08,40.525999999999996,43.711999999999996,29.28 +2020-06-25 15:45:00,120.83,38.344,43.711999999999996,29.28 +2020-06-25 16:00:00,127.83,41.317,45.446000000000005,29.28 +2020-06-25 16:15:00,122.67,40.853,45.446000000000005,29.28 +2020-06-25 16:30:00,122.29,40.224000000000004,45.446000000000005,29.28 +2020-06-25 16:45:00,126.06,36.488,45.446000000000005,29.28 +2020-06-25 17:00:00,123.94,39.675,48.803000000000004,29.28 +2020-06-25 17:15:00,123.31,39.628,48.803000000000004,29.28 +2020-06-25 17:30:00,120.34,38.719,48.803000000000004,29.28 +2020-06-25 17:45:00,114.21,37.001,48.803000000000004,29.28 +2020-06-25 18:00:00,118.99,39.661,51.167,29.28 +2020-06-25 18:15:00,118.56,39.641,51.167,29.28 +2020-06-25 18:30:00,119.87,37.286,51.167,29.28 +2020-06-25 18:45:00,112.47,41.376000000000005,51.167,29.28 +2020-06-25 19:00:00,107.94,42.06399999999999,52.486000000000004,29.28 +2020-06-25 19:15:00,104.55,41.011,52.486000000000004,29.28 +2020-06-25 19:30:00,102.42,40.006,52.486000000000004,29.28 +2020-06-25 19:45:00,103.35,40.04,52.486000000000004,29.28 +2020-06-25 20:00:00,97.55,37.582,59.635,29.28 +2020-06-25 20:15:00,97.58,37.531,59.635,29.28 +2020-06-25 20:30:00,98.76,38.021,59.635,29.28 +2020-06-25 20:45:00,98.12,38.935,59.635,29.28 +2020-06-25 21:00:00,96.92,37.287,54.353,29.28 +2020-06-25 21:15:00,92.89,39.243,54.353,29.28 +2020-06-25 21:30:00,90.17,40.049,54.353,29.28 +2020-06-25 21:45:00,88.89,41.221000000000004,54.353,29.28 +2020-06-25 22:00:00,85.17,38.8,49.431999999999995,29.28 +2020-06-25 22:15:00,84.56,40.81,49.431999999999995,29.28 +2020-06-25 22:30:00,81.54,35.371,49.431999999999995,29.28 +2020-06-25 22:45:00,80.23,31.987,49.431999999999995,29.28 +2020-06-25 23:00:00,78.24,27.659000000000002,42.872,29.28 +2020-06-25 23:15:00,77.2,26.339000000000002,42.872,29.28 +2020-06-25 23:30:00,76.09,25.281999999999996,42.872,29.28 +2020-06-25 23:45:00,74.95,24.483,42.872,29.28 +2020-06-26 00:00:00,73.91,21.691,39.819,29.28 +2020-06-26 00:15:00,73.66,22.618000000000002,39.819,29.28 +2020-06-26 00:30:00,73.5,21.787,39.819,29.28 +2020-06-26 00:45:00,73.41,21.796999999999997,39.819,29.28 +2020-06-26 01:00:00,72.61,21.344,37.797,29.28 +2020-06-26 01:15:00,73.69,19.842,37.797,29.28 +2020-06-26 01:30:00,73.2,18.852,37.797,29.28 +2020-06-26 01:45:00,73.02,18.176,37.797,29.28 +2020-06-26 02:00:00,77.1,18.678,36.905,29.28 +2020-06-26 02:15:00,81.05,17.077,36.905,29.28 +2020-06-26 02:30:00,79.47,20.13,36.905,29.28 +2020-06-26 02:45:00,73.65,20.267,36.905,29.28 +2020-06-26 03:00:00,76.43,22.564,37.1,29.28 +2020-06-26 03:15:00,75.3,20.2,37.1,29.28 +2020-06-26 03:30:00,76.44,19.292,37.1,29.28 +2020-06-26 03:45:00,77.02,19.705,37.1,29.28 +2020-06-26 04:00:00,77.12,25.962,37.882,29.28 +2020-06-26 04:15:00,84.19,31.944000000000003,37.882,29.28 +2020-06-26 04:30:00,91.07,30.104,37.882,29.28 +2020-06-26 04:45:00,88.15,29.641,37.882,29.28 +2020-06-26 05:00:00,93.96,44.298,40.777,29.28 +2020-06-26 05:15:00,96.44,55.133,40.777,29.28 +2020-06-26 05:30:00,99.55,47.538000000000004,40.777,29.28 +2020-06-26 05:45:00,102.21,44.23,40.777,29.28 +2020-06-26 06:00:00,105.49,46.681999999999995,55.528,29.28 +2020-06-26 06:15:00,106.56,47.016000000000005,55.528,29.28 +2020-06-26 06:30:00,111.38,45.214,55.528,29.28 +2020-06-26 06:45:00,113.94,46.373000000000005,55.528,29.28 +2020-06-26 07:00:00,119.24,47.61,67.749,29.28 +2020-06-26 07:15:00,120.18,48.556999999999995,67.749,29.28 +2020-06-26 07:30:00,118.22,43.81100000000001,67.749,29.28 +2020-06-26 07:45:00,111.2,43.27,67.749,29.28 +2020-06-26 08:00:00,111.58,41.121,57.55,29.28 +2020-06-26 08:15:00,114.37,44.523999999999994,57.55,29.28 +2020-06-26 08:30:00,114.99,44.568000000000005,57.55,29.28 +2020-06-26 08:45:00,113.74,46.685,57.55,29.28 +2020-06-26 09:00:00,109.16,39.516999999999996,52.588,29.28 +2020-06-26 09:15:00,113.26,40.493,52.588,29.28 +2020-06-26 09:30:00,112.71,43.34,52.588,29.28 +2020-06-26 09:45:00,110.41,46.576,52.588,29.28 +2020-06-26 10:00:00,105.72,41.063,49.772,29.28 +2020-06-26 10:15:00,112.85,42.751000000000005,49.772,29.28 +2020-06-26 10:30:00,112.6,43.458,49.772,29.28 +2020-06-26 10:45:00,111.95,45.062,49.772,29.28 +2020-06-26 11:00:00,106.15,41.48,49.226000000000006,29.28 +2020-06-26 11:15:00,102.95,41.75899999999999,49.226000000000006,29.28 +2020-06-26 11:30:00,108.99,42.881,49.226000000000006,29.28 +2020-06-26 11:45:00,107.29,43.751000000000005,49.226000000000006,29.28 +2020-06-26 12:00:00,102.93,40.873000000000005,45.705,29.28 +2020-06-26 12:15:00,97.81,40.031,45.705,29.28 +2020-06-26 12:30:00,99.61,38.917,45.705,29.28 +2020-06-26 12:45:00,101.18,39.65,45.705,29.28 +2020-06-26 13:00:00,99.27,40.851,43.133,29.28 +2020-06-26 13:15:00,96.97,42.258,43.133,29.28 +2020-06-26 13:30:00,91.8,41.42,43.133,29.28 +2020-06-26 13:45:00,90.58,40.583,43.133,29.28 +2020-06-26 14:00:00,89.92,41.871,41.989,29.28 +2020-06-26 14:15:00,91.04,40.973,41.989,29.28 +2020-06-26 14:30:00,96.86,41.178999999999995,41.989,29.28 +2020-06-26 14:45:00,96.54,41.148999999999994,41.989,29.28 +2020-06-26 15:00:00,96.12,43.053999999999995,43.728,29.28 +2020-06-26 15:15:00,92.58,40.266,43.728,29.28 +2020-06-26 15:30:00,92.76,37.751999999999995,43.728,29.28 +2020-06-26 15:45:00,99.14,36.326,43.728,29.28 +2020-06-26 16:00:00,102.55,38.402,45.93899999999999,29.28 +2020-06-26 16:15:00,102.02,38.446,45.93899999999999,29.28 +2020-06-26 16:30:00,97.83,37.661,45.93899999999999,29.28 +2020-06-26 16:45:00,104.14,33.126,45.93899999999999,29.28 +2020-06-26 17:00:00,107.5,38.062,50.488,29.28 +2020-06-26 17:15:00,108.79,37.815,50.488,29.28 +2020-06-26 17:30:00,102.7,37.045,50.488,29.28 +2020-06-26 17:45:00,108.93,35.137,50.488,29.28 +2020-06-26 18:00:00,110.72,37.895,52.408,29.28 +2020-06-26 18:15:00,106.92,36.888000000000005,52.408,29.28 +2020-06-26 18:30:00,100.88,34.454,52.408,29.28 +2020-06-26 18:45:00,99.23,38.957,52.408,29.28 +2020-06-26 19:00:00,92.97,40.580999999999996,52.736000000000004,29.28 +2020-06-26 19:15:00,93.69,40.194,52.736000000000004,29.28 +2020-06-26 19:30:00,98.71,39.216,52.736000000000004,29.28 +2020-06-26 19:45:00,97.1,38.205,52.736000000000004,29.28 +2020-06-26 20:00:00,93.03,35.582,59.68,29.28 +2020-06-26 20:15:00,87.14,36.32,59.68,29.28 +2020-06-26 20:30:00,89.87,36.326,59.68,29.28 +2020-06-26 20:45:00,87.72,36.488,59.68,29.28 +2020-06-26 21:00:00,86.85,36.183,54.343999999999994,29.28 +2020-06-26 21:15:00,86.7,39.836,54.343999999999994,29.28 +2020-06-26 21:30:00,87.8,40.472,54.343999999999994,29.28 +2020-06-26 21:45:00,87.75,41.882,54.343999999999994,29.28 +2020-06-26 22:00:00,84.36,39.395,49.672,29.28 +2020-06-26 22:15:00,81.7,41.153,49.672,29.28 +2020-06-26 22:30:00,76.68,40.829,49.672,29.28 +2020-06-26 22:45:00,74.71,38.507,49.672,29.28 +2020-06-26 23:00:00,71.08,35.825,42.065,29.28 +2020-06-26 23:15:00,71.04,32.855,42.065,29.28 +2020-06-26 23:30:00,66.37,29.904,42.065,29.28 +2020-06-26 23:45:00,69.55,28.935,42.065,29.28 +2020-06-27 00:00:00,65.06,22.680999999999997,38.829,29.17 +2020-06-27 00:15:00,66.26,22.67,38.829,29.17 +2020-06-27 00:30:00,67.1,21.462,38.829,29.17 +2020-06-27 00:45:00,66.18,20.795,38.829,29.17 +2020-06-27 01:00:00,63.32,20.668000000000003,34.63,29.17 +2020-06-27 01:15:00,71.54,19.678,34.63,29.17 +2020-06-27 01:30:00,69.12,17.852,34.63,29.17 +2020-06-27 01:45:00,70.08,18.394000000000002,34.63,29.17 +2020-06-27 02:00:00,62.8,17.964000000000002,32.465,29.17 +2020-06-27 02:15:00,62.11,15.561,32.465,29.17 +2020-06-27 02:30:00,65.37,17.749000000000002,32.465,29.17 +2020-06-27 02:45:00,68.43,18.676,32.465,29.17 +2020-06-27 03:00:00,68.92,19.649,31.925,29.17 +2020-06-27 03:15:00,68.83,16.522000000000002,31.925,29.17 +2020-06-27 03:30:00,65.01,15.847999999999999,31.925,29.17 +2020-06-27 03:45:00,60.82,17.756,31.925,29.17 +2020-06-27 04:00:00,61.0,21.845,31.309,29.17 +2020-06-27 04:15:00,68.68,26.747,31.309,29.17 +2020-06-27 04:30:00,68.89,23.107,31.309,29.17 +2020-06-27 04:45:00,67.89,22.884,31.309,29.17 +2020-06-27 05:00:00,63.25,28.361,30.323,29.17 +2020-06-27 05:15:00,62.79,27.12,30.323,29.17 +2020-06-27 05:30:00,67.71,21.116,30.323,29.17 +2020-06-27 05:45:00,71.77,22.594,30.323,29.17 +2020-06-27 06:00:00,75.97,37.208,31.438000000000002,29.17 +2020-06-27 06:15:00,71.83,46.221000000000004,31.438000000000002,29.17 +2020-06-27 06:30:00,70.47,41.309,31.438000000000002,29.17 +2020-06-27 06:45:00,72.75,38.786,31.438000000000002,29.17 +2020-06-27 07:00:00,76.92,38.353,34.891999999999996,29.17 +2020-06-27 07:15:00,77.53,37.963,34.891999999999996,29.17 +2020-06-27 07:30:00,75.96,34.871,34.891999999999996,29.17 +2020-06-27 07:45:00,74.03,35.513000000000005,34.891999999999996,29.17 +2020-06-27 08:00:00,75.68,34.279,39.608000000000004,29.17 +2020-06-27 08:15:00,76.83,37.715,39.608000000000004,29.17 +2020-06-27 08:30:00,81.14,37.835,39.608000000000004,29.17 +2020-06-27 08:45:00,76.55,41.08,39.608000000000004,29.17 +2020-06-27 09:00:00,72.19,37.061,40.894,29.17 +2020-06-27 09:15:00,77.15,38.563,40.894,29.17 +2020-06-27 09:30:00,77.14,41.949,40.894,29.17 +2020-06-27 09:45:00,75.67,44.763999999999996,40.894,29.17 +2020-06-27 10:00:00,75.84,39.874,39.525,29.17 +2020-06-27 10:15:00,73.35,41.927,39.525,29.17 +2020-06-27 10:30:00,70.92,42.309,39.525,29.17 +2020-06-27 10:45:00,72.03,43.653999999999996,39.525,29.17 +2020-06-27 11:00:00,67.93,39.957,36.718,29.17 +2020-06-27 11:15:00,67.39,41.049,36.718,29.17 +2020-06-27 11:30:00,68.26,42.368,36.718,29.17 +2020-06-27 11:45:00,65.04,43.833,36.718,29.17 +2020-06-27 12:00:00,61.59,41.327,35.688,29.17 +2020-06-27 12:15:00,61.46,41.361999999999995,35.688,29.17 +2020-06-27 12:30:00,59.79,40.125,35.688,29.17 +2020-06-27 12:45:00,61.07,41.525,35.688,29.17 +2020-06-27 13:00:00,56.5,41.835,32.858000000000004,29.17 +2020-06-27 13:15:00,57.59,42.577,32.858000000000004,29.17 +2020-06-27 13:30:00,57.29,41.902,32.858000000000004,29.17 +2020-06-27 13:45:00,57.27,39.775999999999996,32.858000000000004,29.17 +2020-06-27 14:00:00,56.82,41.222,31.738000000000003,29.17 +2020-06-27 14:15:00,57.12,39.06,31.738000000000003,29.17 +2020-06-27 14:30:00,57.83,38.715,31.738000000000003,29.17 +2020-06-27 14:45:00,60.56,39.176,31.738000000000003,29.17 +2020-06-27 15:00:00,59.67,41.595,34.35,29.17 +2020-06-27 15:15:00,59.31,39.548,34.35,29.17 +2020-06-27 15:30:00,60.2,37.325,34.35,29.17 +2020-06-27 15:45:00,61.46,34.984,34.35,29.17 +2020-06-27 16:00:00,62.44,39.122,37.522,29.17 +2020-06-27 16:15:00,63.64,38.339,37.522,29.17 +2020-06-27 16:30:00,66.31,37.792,37.522,29.17 +2020-06-27 16:45:00,67.86,33.27,37.522,29.17 +2020-06-27 17:00:00,70.65,37.027,42.498000000000005,29.17 +2020-06-27 17:15:00,71.24,34.755,42.498000000000005,29.17 +2020-06-27 17:30:00,73.98,33.844,42.498000000000005,29.17 +2020-06-27 17:45:00,75.04,32.39,42.498000000000005,29.17 +2020-06-27 18:00:00,77.44,36.498000000000005,44.701,29.17 +2020-06-27 18:15:00,76.21,37.229,44.701,29.17 +2020-06-27 18:30:00,76.3,36.213,44.701,29.17 +2020-06-27 18:45:00,76.3,37.082,44.701,29.17 +2020-06-27 19:00:00,76.28,37.175,45.727,29.17 +2020-06-27 19:15:00,73.26,35.755,45.727,29.17 +2020-06-27 19:30:00,72.4,35.575,45.727,29.17 +2020-06-27 19:45:00,70.94,36.29,45.727,29.17 +2020-06-27 20:00:00,71.82,34.611,43.391000000000005,29.17 +2020-06-27 20:15:00,71.23,34.848,43.391000000000005,29.17 +2020-06-27 20:30:00,71.26,33.97,43.391000000000005,29.17 +2020-06-27 20:45:00,72.04,35.955999999999996,43.391000000000005,29.17 +2020-06-27 21:00:00,74.06,34.341,41.231,29.17 +2020-06-27 21:15:00,74.57,37.683,41.231,29.17 +2020-06-27 21:30:00,70.48,38.56,41.231,29.17 +2020-06-27 21:45:00,70.41,39.407,41.231,29.17 +2020-06-27 22:00:00,62.13,36.991,40.798,29.17 +2020-06-27 22:15:00,64.93,39.149,40.798,29.17 +2020-06-27 22:30:00,60.41,38.819,40.798,29.17 +2020-06-27 22:45:00,62.8,36.968,40.798,29.17 +2020-06-27 23:00:00,59.35,33.760999999999996,34.402,29.17 +2020-06-27 23:15:00,59.05,31.136999999999997,34.402,29.17 +2020-06-27 23:30:00,58.0,30.514,34.402,29.17 +2020-06-27 23:45:00,57.81,29.741999999999997,34.402,29.17 +2020-06-28 00:00:00,52.34,24.013,30.171,29.17 +2020-06-28 00:15:00,55.36,22.846,30.171,29.17 +2020-06-28 00:30:00,54.45,21.474,30.171,29.17 +2020-06-28 00:45:00,54.19,20.747,30.171,29.17 +2020-06-28 01:00:00,53.18,20.885,27.15,29.17 +2020-06-28 01:15:00,50.44,19.824,27.15,29.17 +2020-06-28 01:30:00,52.82,17.895,27.15,29.17 +2020-06-28 01:45:00,53.02,18.034000000000002,27.15,29.17 +2020-06-28 02:00:00,52.68,17.64,25.403000000000002,29.17 +2020-06-28 02:15:00,52.39,15.887,25.403000000000002,29.17 +2020-06-28 02:30:00,52.08,18.303,25.403000000000002,29.17 +2020-06-28 02:45:00,52.53,18.977,25.403000000000002,29.17 +2020-06-28 03:00:00,52.73,20.605999999999998,23.386999999999997,29.17 +2020-06-28 03:15:00,53.01,17.711,23.386999999999997,29.17 +2020-06-28 03:30:00,52.27,16.403,23.386999999999997,29.17 +2020-06-28 03:45:00,52.48,17.54,23.386999999999997,29.17 +2020-06-28 04:00:00,51.85,21.539,23.941999999999997,29.17 +2020-06-28 04:15:00,51.64,25.916999999999998,23.941999999999997,29.17 +2020-06-28 04:30:00,52.18,23.593000000000004,23.941999999999997,29.17 +2020-06-28 04:45:00,52.88,22.923000000000002,23.941999999999997,29.17 +2020-06-28 05:00:00,52.47,28.29,23.026,29.17 +2020-06-28 05:15:00,52.43,26.049,23.026,29.17 +2020-06-28 05:30:00,49.33,19.701,23.026,29.17 +2020-06-28 05:45:00,52.47,20.958000000000002,23.026,29.17 +2020-06-28 06:00:00,53.34,33.241,23.223000000000003,29.17 +2020-06-28 06:15:00,53.67,42.965,23.223000000000003,29.17 +2020-06-28 06:30:00,54.36,37.324,23.223000000000003,29.17 +2020-06-28 06:45:00,54.97,33.788000000000004,23.223000000000003,29.17 +2020-06-28 07:00:00,54.97,33.775,24.968000000000004,29.17 +2020-06-28 07:15:00,54.23,31.741999999999997,24.968000000000004,29.17 +2020-06-28 07:30:00,53.95,29.835,24.968000000000004,29.17 +2020-06-28 07:45:00,53.99,30.430999999999997,24.968000000000004,29.17 +2020-06-28 08:00:00,54.31,30.06,29.131,29.17 +2020-06-28 08:15:00,53.99,34.674,29.131,29.17 +2020-06-28 08:30:00,54.29,35.741,29.131,29.17 +2020-06-28 08:45:00,55.38,39.021,29.131,29.17 +2020-06-28 09:00:00,53.19,34.866,29.904,29.17 +2020-06-28 09:15:00,54.67,35.915,29.904,29.17 +2020-06-28 09:30:00,54.92,39.713,29.904,29.17 +2020-06-28 09:45:00,55.03,43.516000000000005,29.904,29.17 +2020-06-28 10:00:00,56.5,39.309,28.943,29.17 +2020-06-28 10:15:00,57.14,41.528999999999996,28.943,29.17 +2020-06-28 10:30:00,58.29,42.163000000000004,28.943,29.17 +2020-06-28 10:45:00,58.46,44.405,28.943,29.17 +2020-06-28 11:00:00,57.77,40.417,31.682,29.17 +2020-06-28 11:15:00,55.17,41.08,31.682,29.17 +2020-06-28 11:30:00,54.11,42.855,31.682,29.17 +2020-06-28 11:45:00,54.05,44.603,31.682,29.17 +2020-06-28 12:00:00,52.24,43.143,27.315,29.17 +2020-06-28 12:15:00,54.17,42.653999999999996,27.315,29.17 +2020-06-28 12:30:00,51.79,41.54,27.315,29.17 +2020-06-28 12:45:00,52.87,42.261,27.315,29.17 +2020-06-28 13:00:00,48.04,42.218999999999994,23.894000000000002,29.17 +2020-06-28 13:15:00,48.71,42.547,23.894000000000002,29.17 +2020-06-28 13:30:00,47.9,40.772,23.894000000000002,29.17 +2020-06-28 13:45:00,51.51,39.719,23.894000000000002,29.17 +2020-06-28 14:00:00,51.52,42.372,21.148000000000003,29.17 +2020-06-28 14:15:00,49.54,40.695,21.148000000000003,29.17 +2020-06-28 14:30:00,49.02,39.174,21.148000000000003,29.17 +2020-06-28 14:45:00,52.42,38.562,21.148000000000003,29.17 +2020-06-28 15:00:00,52.14,41.068000000000005,21.229,29.17 +2020-06-28 15:15:00,52.45,38.25,21.229,29.17 +2020-06-28 15:30:00,53.38,35.873000000000005,21.229,29.17 +2020-06-28 15:45:00,50.9,33.839,21.229,29.17 +2020-06-28 16:00:00,52.91,36.479,25.037,29.17 +2020-06-28 16:15:00,55.27,35.875,25.037,29.17 +2020-06-28 16:30:00,56.98,36.352,25.037,29.17 +2020-06-28 16:45:00,61.07,31.888,25.037,29.17 +2020-06-28 17:00:00,63.55,36.014,37.11,29.17 +2020-06-28 17:15:00,65.49,35.226,37.11,29.17 +2020-06-28 17:30:00,66.42,35.125,37.11,29.17 +2020-06-28 17:45:00,67.75,34.19,37.11,29.17 +2020-06-28 18:00:00,72.02,38.864000000000004,42.215,29.17 +2020-06-28 18:15:00,71.79,39.239000000000004,42.215,29.17 +2020-06-28 18:30:00,71.81,37.871,42.215,29.17 +2020-06-28 18:45:00,73.23,38.946,42.215,29.17 +2020-06-28 19:00:00,75.51,41.174,44.383,29.17 +2020-06-28 19:15:00,72.91,38.681,44.383,29.17 +2020-06-28 19:30:00,72.05,38.239000000000004,44.383,29.17 +2020-06-28 19:45:00,71.62,38.571999999999996,44.383,29.17 +2020-06-28 20:00:00,72.23,37.048,43.426,29.17 +2020-06-28 20:15:00,72.93,37.198,43.426,29.17 +2020-06-28 20:30:00,75.71,37.187,43.426,29.17 +2020-06-28 20:45:00,76.24,37.47,43.426,29.17 +2020-06-28 21:00:00,79.17,35.599000000000004,42.265,29.17 +2020-06-28 21:15:00,79.66,38.616,42.265,29.17 +2020-06-28 21:30:00,77.53,38.846,42.265,29.17 +2020-06-28 21:45:00,76.34,40.054,42.265,29.17 +2020-06-28 22:00:00,71.7,39.599000000000004,42.26,29.17 +2020-06-28 22:15:00,73.09,40.06,42.26,29.17 +2020-06-28 22:30:00,70.56,39.058,42.26,29.17 +2020-06-28 22:45:00,71.0,35.898,42.26,29.17 +2020-06-28 23:00:00,64.44,32.248000000000005,36.609,29.17 +2020-06-28 23:15:00,64.62,30.998,36.609,29.17 +2020-06-28 23:30:00,61.55,29.895,36.609,29.17 +2020-06-28 23:45:00,63.88,29.316,36.609,29.17 +2020-06-29 00:00:00,66.83,25.676,34.611,29.28 +2020-06-29 00:15:00,67.56,25.444000000000003,34.611,29.28 +2020-06-29 00:30:00,66.99,23.704,34.611,29.28 +2020-06-29 00:45:00,65.2,22.566,34.611,29.28 +2020-06-29 01:00:00,61.33,23.096,33.552,29.28 +2020-06-29 01:15:00,61.25,22.009,33.552,29.28 +2020-06-29 01:30:00,68.73,20.438,33.552,29.28 +2020-06-29 01:45:00,69.26,20.480999999999998,33.552,29.28 +2020-06-29 02:00:00,67.73,20.535999999999998,32.351,29.28 +2020-06-29 02:15:00,64.1,17.863,32.351,29.28 +2020-06-29 02:30:00,65.25,20.449,32.351,29.28 +2020-06-29 02:45:00,62.46,20.95,32.351,29.28 +2020-06-29 03:00:00,63.93,23.139,30.793000000000003,29.28 +2020-06-29 03:15:00,64.66,21.044,30.793000000000003,29.28 +2020-06-29 03:30:00,64.42,20.402,30.793000000000003,29.28 +2020-06-29 03:45:00,65.17,21.073,30.793000000000003,29.28 +2020-06-29 04:00:00,69.29,28.322,31.274,29.28 +2020-06-29 04:15:00,69.39,35.769,31.274,29.28 +2020-06-29 04:30:00,73.21,33.074,31.274,29.28 +2020-06-29 04:45:00,76.37,32.777,31.274,29.28 +2020-06-29 05:00:00,84.05,45.946999999999996,37.75,29.28 +2020-06-29 05:15:00,89.26,54.832,37.75,29.28 +2020-06-29 05:30:00,91.77,47.041000000000004,37.75,29.28 +2020-06-29 05:45:00,100.7,45.004,37.75,29.28 +2020-06-29 06:00:00,106.18,46.221000000000004,55.36,29.28 +2020-06-29 06:15:00,104.78,46.18899999999999,55.36,29.28 +2020-06-29 06:30:00,101.12,44.909,55.36,29.28 +2020-06-29 06:45:00,106.27,47.068999999999996,55.36,29.28 +2020-06-29 07:00:00,110.35,47.54600000000001,65.87,29.28 +2020-06-29 07:15:00,106.69,47.847,65.87,29.28 +2020-06-29 07:30:00,102.74,45.05,65.87,29.28 +2020-06-29 07:45:00,98.31,45.833999999999996,65.87,29.28 +2020-06-29 08:00:00,101.86,43.085,55.695,29.28 +2020-06-29 08:15:00,104.63,46.422,55.695,29.28 +2020-06-29 08:30:00,103.4,46.312,55.695,29.28 +2020-06-29 08:45:00,98.22,49.619,55.695,29.28 +2020-06-29 09:00:00,97.83,44.388000000000005,50.881,29.28 +2020-06-29 09:15:00,98.5,43.49100000000001,50.881,29.28 +2020-06-29 09:30:00,97.26,46.31399999999999,50.881,29.28 +2020-06-29 09:45:00,99.53,47.886,50.881,29.28 +2020-06-29 10:00:00,105.48,44.043,49.138000000000005,29.28 +2020-06-29 10:15:00,107.62,46.105,49.138000000000005,29.28 +2020-06-29 10:30:00,106.69,46.236999999999995,49.138000000000005,29.28 +2020-06-29 10:45:00,101.86,46.96,49.138000000000005,29.28 +2020-06-29 11:00:00,108.46,43.041000000000004,49.178000000000004,29.28 +2020-06-29 11:15:00,104.0,44.08,49.178000000000004,29.28 +2020-06-29 11:30:00,102.52,46.606,49.178000000000004,29.28 +2020-06-29 11:45:00,97.39,48.825,49.178000000000004,29.28 +2020-06-29 12:00:00,95.56,45.77,47.698,29.28 +2020-06-29 12:15:00,98.26,45.39,47.698,29.28 +2020-06-29 12:30:00,92.89,43.213,47.698,29.28 +2020-06-29 12:45:00,91.17,44.008,47.698,29.28 +2020-06-29 13:00:00,90.56,44.903,48.104,29.28 +2020-06-29 13:15:00,91.19,44.283,48.104,29.28 +2020-06-29 13:30:00,93.09,42.658,48.104,29.28 +2020-06-29 13:45:00,90.72,42.498000000000005,48.104,29.28 +2020-06-29 14:00:00,92.67,44.217,48.53,29.28 +2020-06-29 14:15:00,90.61,43.095,48.53,29.28 +2020-06-29 14:30:00,89.74,41.367,48.53,29.28 +2020-06-29 14:45:00,89.22,42.806999999999995,48.53,29.28 +2020-06-29 15:00:00,92.79,45.053999999999995,49.351000000000006,29.28 +2020-06-29 15:15:00,89.48,41.575,49.351000000000006,29.28 +2020-06-29 15:30:00,89.59,39.916,49.351000000000006,29.28 +2020-06-29 15:45:00,91.17,37.37,49.351000000000006,29.28 +2020-06-29 16:00:00,92.45,41.121,51.44,29.28 +2020-06-29 16:15:00,93.63,40.543,51.44,29.28 +2020-06-29 16:30:00,98.96,40.327,51.44,29.28 +2020-06-29 16:45:00,97.02,35.815,51.44,29.28 +2020-06-29 17:00:00,99.29,38.823,56.868,29.28 +2020-06-29 17:15:00,99.23,38.361,56.868,29.28 +2020-06-29 17:30:00,99.19,37.845,56.868,29.28 +2020-06-29 17:45:00,102.14,36.434,56.868,29.28 +2020-06-29 18:00:00,102.78,40.075,57.229,29.28 +2020-06-29 18:15:00,101.5,38.505,57.229,29.28 +2020-06-29 18:30:00,103.73,36.42,57.229,29.28 +2020-06-29 18:45:00,102.37,40.659,57.229,29.28 +2020-06-29 19:00:00,99.56,42.528,57.744,29.28 +2020-06-29 19:15:00,95.89,41.278,57.744,29.28 +2020-06-29 19:30:00,96.63,40.488,57.744,29.28 +2020-06-29 19:45:00,92.86,40.168,57.744,29.28 +2020-06-29 20:00:00,91.91,37.258,66.05199999999999,29.28 +2020-06-29 20:15:00,90.4,38.671,66.05199999999999,29.28 +2020-06-29 20:30:00,93.06,39.088,66.05199999999999,29.28 +2020-06-29 20:45:00,90.78,39.683,66.05199999999999,29.28 +2020-06-29 21:00:00,92.65,37.217,59.396,29.28 +2020-06-29 21:15:00,88.69,40.618,59.396,29.28 +2020-06-29 21:30:00,86.02,41.196000000000005,59.396,29.28 +2020-06-29 21:45:00,84.23,42.163000000000004,59.396,29.28 +2020-06-29 22:00:00,80.64,39.546,53.06,29.28 +2020-06-29 22:15:00,79.82,41.918,53.06,29.28 +2020-06-29 22:30:00,78.02,36.138000000000005,53.06,29.28 +2020-06-29 22:45:00,80.24,32.711,53.06,29.28 +2020-06-29 23:00:00,73.65,29.131,46.148,29.28 +2020-06-29 23:15:00,74.02,26.403000000000002,46.148,29.28 +2020-06-29 23:30:00,72.94,25.406999999999996,46.148,29.28 +2020-06-29 23:45:00,71.27,24.415,46.148,29.28 +2020-06-30 00:00:00,72.03,23.34,44.625,29.28 +2020-06-30 00:15:00,69.91,24.04,44.625,29.28 +2020-06-30 00:30:00,68.65,22.963,44.625,29.28 +2020-06-30 00:45:00,71.12,22.569000000000003,44.625,29.28 +2020-06-30 01:00:00,68.89,22.561,41.733000000000004,29.28 +2020-06-30 01:15:00,68.91,21.552,41.733000000000004,29.28 +2020-06-30 01:30:00,69.21,19.86,41.733000000000004,29.28 +2020-06-30 01:45:00,72.69,19.401,41.733000000000004,29.28 +2020-06-30 02:00:00,69.55,18.98,39.872,29.28 +2020-06-30 02:15:00,70.67,17.457,39.872,29.28 +2020-06-30 02:30:00,77.04,19.615,39.872,29.28 +2020-06-30 02:45:00,77.77,20.448,39.872,29.28 +2020-06-30 03:00:00,73.56,22.02,38.711,29.28 +2020-06-30 03:15:00,71.83,20.819000000000003,38.711,29.28 +2020-06-30 03:30:00,74.59,20.177,38.711,29.28 +2020-06-30 03:45:00,72.48,19.767,38.711,29.28 +2020-06-30 04:00:00,73.67,25.706,39.823,29.28 +2020-06-30 04:15:00,76.58,33.113,39.823,29.28 +2020-06-30 04:30:00,78.87,30.273000000000003,39.823,29.28 +2020-06-30 04:45:00,84.64,30.521,39.823,29.28 +2020-06-30 05:00:00,90.01,45.441,43.228,29.28 +2020-06-30 05:15:00,94.67,54.836000000000006,43.228,29.28 +2020-06-30 05:30:00,100.35,47.21,43.228,29.28 +2020-06-30 05:45:00,104.12,44.481,43.228,29.28 +2020-06-30 06:00:00,104.34,46.69,54.316,29.28 +2020-06-30 06:15:00,106.09,46.928999999999995,54.316,29.28 +2020-06-30 06:30:00,104.25,45.317,54.316,29.28 +2020-06-30 06:45:00,111.24,46.6,54.316,29.28 +2020-06-30 07:00:00,112.68,47.207,65.758,29.28 +2020-06-30 07:15:00,112.93,47.266000000000005,65.758,29.28 +2020-06-30 07:30:00,106.91,44.458999999999996,65.758,29.28 +2020-06-30 07:45:00,109.59,44.313,65.758,29.28 +2020-06-30 08:00:00,108.14,41.508,57.983000000000004,29.28 +2020-06-30 08:15:00,105.14,44.367,57.983000000000004,29.28 +2020-06-30 08:30:00,102.56,44.407,57.983000000000004,29.28 +2020-06-30 08:45:00,109.58,46.778999999999996,57.983000000000004,29.28 +2020-06-30 09:00:00,108.83,41.842,52.653,29.28 +2020-06-30 09:15:00,107.0,40.89,52.653,29.28 +2020-06-30 09:30:00,102.96,44.428000000000004,52.653,29.28 +2020-06-30 09:45:00,100.07,47.33,52.653,29.28 +2020-06-30 10:00:00,106.46,42.102,51.408,29.28 +2020-06-30 10:15:00,108.26,43.974,51.408,29.28 +2020-06-30 10:30:00,109.08,44.14,51.408,29.28 +2020-06-30 10:45:00,106.82,45.883,51.408,29.28 +2020-06-30 11:00:00,100.17,42.043,51.913000000000004,29.28 +2020-06-30 11:15:00,103.91,43.474,51.913000000000004,29.28 +2020-06-30 11:30:00,106.58,44.784,51.913000000000004,29.28 +2020-06-30 11:45:00,101.37,46.586000000000006,51.913000000000004,29.28 +2020-06-30 12:00:00,98.37,43.301,49.508,29.28 +2020-06-30 12:15:00,96.43,43.236999999999995,49.508,29.28 +2020-06-30 12:30:00,100.54,41.938,49.508,29.28 +2020-06-30 12:45:00,100.76,43.412,49.508,29.28 +2020-06-30 13:00:00,99.12,43.91,50.007,29.28 +2020-06-30 13:15:00,93.41,45.073,50.007,29.28 +2020-06-30 13:30:00,100.0,43.479,50.007,29.28 +2020-06-30 13:45:00,100.4,42.37,50.007,29.28 +2020-06-30 14:00:00,101.59,44.562,49.778999999999996,29.28 +2020-06-30 14:15:00,93.92,43.248000000000005,49.778999999999996,29.28 +2020-06-30 14:30:00,98.79,41.861000000000004,49.778999999999996,29.28 +2020-06-30 14:45:00,99.07,42.513000000000005,49.778999999999996,29.28 +2020-06-30 15:00:00,98.76,44.603,51.559,29.28 +2020-06-30 15:15:00,96.36,42.058,51.559,29.28 +2020-06-30 15:30:00,97.72,40.195,51.559,29.28 +2020-06-30 15:45:00,100.74,37.971,51.559,29.28 +2020-06-30 16:00:00,101.72,41.047,53.531000000000006,29.28 +2020-06-30 16:15:00,97.36,40.577,53.531000000000006,29.28 +2020-06-30 16:30:00,100.31,40.037,53.531000000000006,29.28 +2020-06-30 16:45:00,100.27,36.243,53.531000000000006,29.28 +2020-06-30 17:00:00,108.34,39.504,59.497,29.28 +2020-06-30 17:15:00,109.28,39.463,59.497,29.28 +2020-06-30 17:30:00,107.79,38.535,59.497,29.28 +2020-06-30 17:45:00,105.96,36.778,59.497,29.28 +2020-06-30 18:00:00,107.94,39.473,59.861999999999995,29.28 +2020-06-30 18:15:00,109.77,39.347,59.861999999999995,29.28 +2020-06-30 18:30:00,112.0,36.985,59.861999999999995,29.28 +2020-06-30 18:45:00,107.95,41.065,59.861999999999995,29.28 +2020-06-30 19:00:00,102.45,41.773,60.989,29.28 +2020-06-30 19:15:00,102.48,40.681999999999995,60.989,29.28 +2020-06-30 19:30:00,105.3,39.64,60.989,29.28 +2020-06-30 19:45:00,101.39,39.65,60.989,29.28 +2020-06-30 20:00:00,93.96,37.129,68.35600000000001,29.28 +2020-06-30 20:15:00,92.47,37.068000000000005,68.35600000000001,29.28 +2020-06-30 20:30:00,92.77,37.573,68.35600000000001,29.28 +2020-06-30 20:45:00,95.45,38.578,68.35600000000001,29.28 +2020-06-30 21:00:00,91.73,36.955,59.251000000000005,29.28 +2020-06-30 21:15:00,91.24,38.936,59.251000000000005,29.28 +2020-06-30 21:30:00,89.56,39.635,59.251000000000005,29.28 +2020-06-30 21:45:00,87.06,40.806,59.251000000000005,29.28 +2020-06-30 22:00:00,82.92,38.446999999999996,54.736999999999995,29.28 +2020-06-30 22:15:00,83.13,40.474000000000004,54.736999999999995,29.28 +2020-06-30 22:30:00,79.39,34.979,54.736999999999995,29.28 +2020-06-30 22:45:00,78.5,31.551,54.736999999999995,29.28 +2020-06-30 23:00:00,75.71,27.199,46.806999999999995,29.28 +2020-06-30 23:15:00,75.56,26.018,46.806999999999995,29.28 +2020-06-30 23:30:00,74.43,25.016,46.806999999999995,29.28 +2020-06-30 23:45:00,73.31,24.177,46.806999999999995,29.28 +2020-07-01 00:00:00,70.43,18.788,42.195,29.509 +2020-07-01 00:15:00,71.7,19.662,42.195,29.509 +2020-07-01 00:30:00,70.95,18.385,42.195,29.509 +2020-07-01 00:45:00,71.27,18.276,42.195,29.509 +2020-07-01 01:00:00,69.68,18.217,38.82,29.509 +2020-07-01 01:15:00,70.95,17.452,38.82,29.509 +2020-07-01 01:30:00,71.25,15.89,38.82,29.509 +2020-07-01 01:45:00,71.45,15.93,38.82,29.509 +2020-07-01 02:00:00,70.66,15.644,37.023,29.509 +2020-07-01 02:15:00,70.4,14.513,37.023,29.509 +2020-07-01 02:30:00,71.19,16.352999999999998,37.023,29.509 +2020-07-01 02:45:00,71.81,17.062,37.023,29.509 +2020-07-01 03:00:00,72.87,18.293,36.818000000000005,29.509 +2020-07-01 03:15:00,71.24,17.483,36.818000000000005,29.509 +2020-07-01 03:30:00,72.47,16.722,36.818000000000005,29.509 +2020-07-01 03:45:00,73.77,15.866,36.818000000000005,29.509 +2020-07-01 04:00:00,79.37,20.849,37.495,29.509 +2020-07-01 04:15:00,83.66,27.185,37.495,29.509 +2020-07-01 04:30:00,85.87,24.576,37.495,29.509 +2020-07-01 04:45:00,82.08,24.599,37.495,29.509 +2020-07-01 05:00:00,87.68,37.652,39.858000000000004,29.509 +2020-07-01 05:15:00,91.0,45.316,39.858000000000004,29.509 +2020-07-01 05:30:00,94.48,39.075,39.858000000000004,29.509 +2020-07-01 05:45:00,96.89,36.391999999999996,39.858000000000004,29.509 +2020-07-01 06:00:00,102.46,37.272,52.867,29.509 +2020-07-01 06:15:00,102.72,36.881,52.867,29.509 +2020-07-01 06:30:00,102.82,36.024,52.867,29.509 +2020-07-01 06:45:00,103.56,37.891999999999996,52.867,29.509 +2020-07-01 07:00:00,106.17,38.054,66.061,29.509 +2020-07-01 07:15:00,107.32,38.36,66.061,29.509 +2020-07-01 07:30:00,103.25,36.042,66.061,29.509 +2020-07-01 07:45:00,105.44,36.064,66.061,29.509 +2020-07-01 08:00:00,102.77,31.921,58.532,29.509 +2020-07-01 08:15:00,106.3,34.949,58.532,29.509 +2020-07-01 08:30:00,109.35,36.171,58.532,29.509 +2020-07-01 08:45:00,110.17,38.823,58.532,29.509 +2020-07-01 09:00:00,107.3,32.952,56.047,29.509 +2020-07-01 09:15:00,102.83,32.253,56.047,29.509 +2020-07-01 09:30:00,105.13,36.139,56.047,29.509 +2020-07-01 09:45:00,108.99,39.617,56.047,29.509 +2020-07-01 10:00:00,108.91,36.763000000000005,53.823,29.509 +2020-07-01 10:15:00,111.42,38.422,53.823,29.509 +2020-07-01 10:30:00,107.94,38.482,53.823,29.509 +2020-07-01 10:45:00,106.69,39.787,53.823,29.509 +2020-07-01 11:00:00,108.14,37.555,54.184,29.509 +2020-07-01 11:15:00,107.34,38.873000000000005,54.184,29.509 +2020-07-01 11:30:00,103.63,40.246,54.184,29.509 +2020-07-01 11:45:00,99.79,41.605,54.184,29.509 +2020-07-01 12:00:00,101.32,36.647,52.628,29.509 +2020-07-01 12:15:00,105.42,36.146,52.628,29.509 +2020-07-01 12:30:00,103.81,35.018,52.628,29.509 +2020-07-01 12:45:00,100.08,36.165,52.628,29.509 +2020-07-01 13:00:00,97.35,36.506,52.31,29.509 +2020-07-01 13:15:00,99.57,37.897,52.31,29.509 +2020-07-01 13:30:00,102.34,36.091,52.31,29.509 +2020-07-01 13:45:00,101.61,35.67,52.31,29.509 +2020-07-01 14:00:00,97.36,37.439,52.278999999999996,29.509 +2020-07-01 14:15:00,95.86,36.361,52.278999999999996,29.509 +2020-07-01 14:30:00,94.71,35.399,52.278999999999996,29.509 +2020-07-01 14:45:00,102.25,35.926,52.278999999999996,29.509 +2020-07-01 15:00:00,101.58,37.861999999999995,53.306999999999995,29.509 +2020-07-01 15:15:00,106.6,35.504,53.306999999999995,29.509 +2020-07-01 15:30:00,109.06,33.898,53.306999999999995,29.509 +2020-07-01 15:45:00,110.7,32.225,53.306999999999995,29.509 +2020-07-01 16:00:00,104.2,34.999,55.358999999999995,29.509 +2020-07-01 16:15:00,100.24,34.702,55.358999999999995,29.509 +2020-07-01 16:30:00,107.63,33.689,55.358999999999995,29.509 +2020-07-01 16:45:00,110.17,30.451999999999998,55.358999999999995,29.509 +2020-07-01 17:00:00,110.35,34.147,59.211999999999996,29.509 +2020-07-01 17:15:00,104.03,34.079,59.211999999999996,29.509 +2020-07-01 17:30:00,107.88,32.936,59.211999999999996,29.509 +2020-07-01 17:45:00,108.5,31.721,59.211999999999996,29.509 +2020-07-01 18:00:00,113.12,34.732,60.403999999999996,29.509 +2020-07-01 18:15:00,112.56,34.763000000000005,60.403999999999996,29.509 +2020-07-01 18:30:00,109.0,32.714,60.403999999999996,29.509 +2020-07-01 18:45:00,106.69,35.778,60.403999999999996,29.509 +2020-07-01 19:00:00,103.85,36.946999999999996,60.993,29.509 +2020-07-01 19:15:00,104.5,36.014,60.993,29.509 +2020-07-01 19:30:00,99.49,34.96,60.993,29.509 +2020-07-01 19:45:00,102.64,34.247,60.993,29.509 +2020-07-01 20:00:00,93.35,31.489,66.6,29.509 +2020-07-01 20:15:00,92.62,31.057,66.6,29.509 +2020-07-01 20:30:00,94.73,31.609,66.6,29.509 +2020-07-01 20:45:00,92.77,32.135999999999996,66.6,29.509 +2020-07-01 21:00:00,93.61,30.487,59.855,29.509 +2020-07-01 21:15:00,91.43,32.09,59.855,29.509 +2020-07-01 21:30:00,88.22,33.063,59.855,29.509 +2020-07-01 21:45:00,86.19,34.155,59.855,29.509 +2020-07-01 22:00:00,82.68,31.27,54.942,29.509 +2020-07-01 22:15:00,82.47,33.906,54.942,29.509 +2020-07-01 22:30:00,79.33,29.747,54.942,29.509 +2020-07-01 22:45:00,79.58,26.566999999999997,54.942,29.509 +2020-07-01 23:00:00,76.55,23.329,46.056000000000004,29.509 +2020-07-01 23:15:00,75.74,21.73,46.056000000000004,29.509 +2020-07-01 23:30:00,76.51,20.483,46.056000000000004,29.509 +2020-07-01 23:45:00,75.37,19.579,46.056000000000004,29.509 +2020-07-02 00:00:00,71.27,18.766,40.859,29.509 +2020-07-02 00:15:00,75.23,19.639,40.859,29.509 +2020-07-02 00:30:00,72.87,18.366,40.859,29.509 +2020-07-02 00:45:00,72.99,18.262,40.859,29.509 +2020-07-02 01:00:00,73.16,18.214000000000002,39.06,29.509 +2020-07-02 01:15:00,71.58,17.437,39.06,29.509 +2020-07-02 01:30:00,70.03,15.875,39.06,29.509 +2020-07-02 01:45:00,71.6,15.907,39.06,29.509 +2020-07-02 02:00:00,71.98,15.627,37.592,29.509 +2020-07-02 02:15:00,72.45,14.513,37.592,29.509 +2020-07-02 02:30:00,71.95,16.332,37.592,29.509 +2020-07-02 02:45:00,73.87,17.044,37.592,29.509 +2020-07-02 03:00:00,73.17,18.271,37.416,29.509 +2020-07-02 03:15:00,73.55,17.469,37.416,29.509 +2020-07-02 03:30:00,74.45,16.714000000000002,37.416,29.509 +2020-07-02 03:45:00,76.07,15.876,37.416,29.509 +2020-07-02 04:00:00,85.78,20.818,38.176,29.509 +2020-07-02 04:15:00,84.85,27.118000000000002,38.176,29.509 +2020-07-02 04:30:00,81.2,24.5,38.176,29.509 +2020-07-02 04:45:00,82.99,24.521,38.176,29.509 +2020-07-02 05:00:00,91.83,37.501999999999995,41.203,29.509 +2020-07-02 05:15:00,92.7,45.07,41.203,29.509 +2020-07-02 05:30:00,93.71,38.889,41.203,29.509 +2020-07-02 05:45:00,100.65,36.238,41.203,29.509 +2020-07-02 06:00:00,111.15,37.12,51.09,29.509 +2020-07-02 06:15:00,112.6,36.724000000000004,51.09,29.509 +2020-07-02 06:30:00,107.72,35.889,51.09,29.509 +2020-07-02 06:45:00,109.61,37.786,51.09,29.509 +2020-07-02 07:00:00,106.92,37.939,63.541000000000004,29.509 +2020-07-02 07:15:00,111.87,38.266,63.541000000000004,29.509 +2020-07-02 07:30:00,113.84,35.949,63.541000000000004,29.509 +2020-07-02 07:45:00,113.44,36.0,63.541000000000004,29.509 +2020-07-02 08:00:00,107.91,31.868000000000002,55.65,29.509 +2020-07-02 08:15:00,107.55,34.914,55.65,29.509 +2020-07-02 08:30:00,114.18,36.126,55.65,29.509 +2020-07-02 08:45:00,115.94,38.775,55.65,29.509 +2020-07-02 09:00:00,110.22,32.898,51.833999999999996,29.509 +2020-07-02 09:15:00,109.29,32.201,51.833999999999996,29.509 +2020-07-02 09:30:00,107.38,36.082,51.833999999999996,29.509 +2020-07-02 09:45:00,111.58,39.568000000000005,51.833999999999996,29.509 +2020-07-02 10:00:00,112.97,36.721,49.70399999999999,29.509 +2020-07-02 10:15:00,113.92,38.382,49.70399999999999,29.509 +2020-07-02 10:30:00,108.83,38.439,49.70399999999999,29.509 +2020-07-02 10:45:00,113.08,39.747,49.70399999999999,29.509 +2020-07-02 11:00:00,112.65,37.512,48.593999999999994,29.509 +2020-07-02 11:15:00,108.39,38.832,48.593999999999994,29.509 +2020-07-02 11:30:00,105.46,40.194,48.593999999999994,29.509 +2020-07-02 11:45:00,103.74,41.549,48.593999999999994,29.509 +2020-07-02 12:00:00,102.28,36.616,46.275,29.509 +2020-07-02 12:15:00,112.02,36.114000000000004,46.275,29.509 +2020-07-02 12:30:00,110.12,34.974000000000004,46.275,29.509 +2020-07-02 12:45:00,111.95,36.118,46.275,29.509 +2020-07-02 13:00:00,108.93,36.444,45.803000000000004,29.509 +2020-07-02 13:15:00,104.36,37.830999999999996,45.803000000000004,29.509 +2020-07-02 13:30:00,111.63,36.033,45.803000000000004,29.509 +2020-07-02 13:45:00,108.98,35.619,45.803000000000004,29.509 +2020-07-02 14:00:00,114.1,37.393,46.251999999999995,29.509 +2020-07-02 14:15:00,102.95,36.316,46.251999999999995,29.509 +2020-07-02 14:30:00,101.66,35.339,46.251999999999995,29.509 +2020-07-02 14:45:00,108.11,35.873000000000005,46.251999999999995,29.509 +2020-07-02 15:00:00,117.54,37.826,48.309,29.509 +2020-07-02 15:15:00,115.68,35.461,48.309,29.509 +2020-07-02 15:30:00,115.1,33.853,48.309,29.509 +2020-07-02 15:45:00,108.35,32.172,48.309,29.509 +2020-07-02 16:00:00,106.86,34.964,49.681999999999995,29.509 +2020-07-02 16:15:00,116.47,34.668,49.681999999999995,29.509 +2020-07-02 16:30:00,115.12,33.673,49.681999999999995,29.509 +2020-07-02 16:45:00,115.58,30.432,49.681999999999995,29.509 +2020-07-02 17:00:00,113.83,34.135999999999996,53.086000000000006,29.509 +2020-07-02 17:15:00,108.49,34.075,53.086000000000006,29.509 +2020-07-02 17:30:00,108.27,32.933,53.086000000000006,29.509 +2020-07-02 17:45:00,110.25,31.714000000000002,53.086000000000006,29.509 +2020-07-02 18:00:00,117.44,34.733000000000004,54.038999999999994,29.509 +2020-07-02 18:15:00,113.72,34.743,54.038999999999994,29.509 +2020-07-02 18:30:00,110.69,32.695,54.038999999999994,29.509 +2020-07-02 18:45:00,103.51,35.757,54.038999999999994,29.509 +2020-07-02 19:00:00,99.92,36.928000000000004,53.408,29.509 +2020-07-02 19:15:00,95.01,35.986999999999995,53.408,29.509 +2020-07-02 19:30:00,95.4,34.926,53.408,29.509 +2020-07-02 19:45:00,91.38,34.207,53.408,29.509 +2020-07-02 20:00:00,91.86,31.435,55.309,29.509 +2020-07-02 20:15:00,89.76,31.002,55.309,29.509 +2020-07-02 20:30:00,90.27,31.554000000000002,55.309,29.509 +2020-07-02 20:45:00,93.88,32.098,55.309,29.509 +2020-07-02 21:00:00,88.63,30.451999999999998,51.585,29.509 +2020-07-02 21:15:00,87.77,32.058,51.585,29.509 +2020-07-02 21:30:00,87.98,33.01,51.585,29.509 +2020-07-02 21:45:00,85.55,34.099000000000004,51.585,29.509 +2020-07-02 22:00:00,81.55,31.225,48.006,29.509 +2020-07-02 22:15:00,79.87,33.864000000000004,48.006,29.509 +2020-07-02 22:30:00,78.85,29.693,48.006,29.509 +2020-07-02 22:45:00,80.14,26.506,48.006,29.509 +2020-07-02 23:00:00,74.23,23.264,42.309,29.509 +2020-07-02 23:15:00,74.85,21.691999999999997,42.309,29.509 +2020-07-02 23:30:00,74.97,20.459,42.309,29.509 +2020-07-02 23:45:00,73.89,19.547,42.309,29.509 +2020-07-03 00:00:00,70.93,16.918,39.649,29.509 +2020-07-03 00:15:00,74.24,18.007,39.649,29.509 +2020-07-03 00:30:00,70.69,17.027,39.649,29.509 +2020-07-03 00:45:00,71.66,17.366,39.649,29.509 +2020-07-03 01:00:00,70.83,16.942,37.744,29.509 +2020-07-03 01:15:00,71.09,15.489,37.744,29.509 +2020-07-03 01:30:00,73.13,14.683,37.744,29.509 +2020-07-03 01:45:00,70.01,14.513,37.744,29.509 +2020-07-03 02:00:00,69.99,15.138,36.965,29.509 +2020-07-03 02:15:00,72.95,14.513,36.965,29.509 +2020-07-03 02:30:00,70.26,16.631,36.965,29.509 +2020-07-03 02:45:00,72.71,16.631,36.965,29.509 +2020-07-03 03:00:00,71.77,18.701,37.678000000000004,29.509 +2020-07-03 03:15:00,71.51,16.611,37.678000000000004,29.509 +2020-07-03 03:30:00,76.38,15.615,37.678000000000004,29.509 +2020-07-03 03:45:00,80.75,15.698,37.678000000000004,29.509 +2020-07-03 04:00:00,85.8,20.732,38.591,29.509 +2020-07-03 04:15:00,79.97,25.381,38.591,29.509 +2020-07-03 04:30:00,79.88,23.738000000000003,38.591,29.509 +2020-07-03 04:45:00,87.51,23.125999999999998,38.591,29.509 +2020-07-03 05:00:00,88.68,35.584,40.666,29.509 +2020-07-03 05:15:00,99.92,44.004,40.666,29.509 +2020-07-03 05:30:00,102.65,38.053000000000004,40.666,29.509 +2020-07-03 05:45:00,105.28,34.976,40.666,29.509 +2020-07-03 06:00:00,112.07,36.077,51.784,29.509 +2020-07-03 06:15:00,109.3,35.889,51.784,29.509 +2020-07-03 06:30:00,114.85,35.047,51.784,29.509 +2020-07-03 06:45:00,117.65,36.794000000000004,51.784,29.509 +2020-07-03 07:00:00,118.78,37.632,61.383,29.509 +2020-07-03 07:15:00,114.49,38.888000000000005,61.383,29.509 +2020-07-03 07:30:00,122.05,34.546,61.383,29.509 +2020-07-03 07:45:00,121.0,34.44,61.383,29.509 +2020-07-03 08:00:00,120.65,31.176,55.272,29.509 +2020-07-03 08:15:00,115.76,34.957,55.272,29.509 +2020-07-03 08:30:00,118.63,36.018,55.272,29.509 +2020-07-03 08:45:00,124.55,38.578,55.272,29.509 +2020-07-03 09:00:00,124.35,30.26,53.506,29.509 +2020-07-03 09:15:00,123.23,31.552,53.506,29.509 +2020-07-03 09:30:00,117.79,34.724000000000004,53.506,29.509 +2020-07-03 09:45:00,124.21,38.628,53.506,29.509 +2020-07-03 10:00:00,124.18,35.675,51.363,29.509 +2020-07-03 10:15:00,125.33,37.067,51.363,29.509 +2020-07-03 10:30:00,113.6,37.708,51.363,29.509 +2020-07-03 10:45:00,112.47,38.94,51.363,29.509 +2020-07-03 11:00:00,117.84,36.973,51.043,29.509 +2020-07-03 11:15:00,119.54,37.192,51.043,29.509 +2020-07-03 11:30:00,114.56,38.071,51.043,29.509 +2020-07-03 11:45:00,111.63,38.407,51.043,29.509 +2020-07-03 12:00:00,106.04,33.891999999999996,47.52,29.509 +2020-07-03 12:15:00,110.61,32.827,47.52,29.509 +2020-07-03 12:30:00,107.32,31.789,47.52,29.509 +2020-07-03 12:45:00,103.64,32.069,47.52,29.509 +2020-07-03 13:00:00,101.08,32.958,45.494,29.509 +2020-07-03 13:15:00,99.8,34.49,45.494,29.509 +2020-07-03 13:30:00,95.66,33.538000000000004,45.494,29.509 +2020-07-03 13:45:00,101.57,33.458,45.494,29.509 +2020-07-03 14:00:00,98.0,34.446999999999996,43.883,29.509 +2020-07-03 14:15:00,99.34,33.841,43.883,29.509 +2020-07-03 14:30:00,102.34,34.419000000000004,43.883,29.509 +2020-07-03 14:45:00,107.73,34.214,43.883,29.509 +2020-07-03 15:00:00,109.4,36.125,45.714,29.509 +2020-07-03 15:15:00,106.77,33.525,45.714,29.509 +2020-07-03 15:30:00,104.22,31.396,45.714,29.509 +2020-07-03 15:45:00,105.17,30.504,45.714,29.509 +2020-07-03 16:00:00,101.31,32.468,48.222,29.509 +2020-07-03 16:15:00,98.01,32.68,48.222,29.509 +2020-07-03 16:30:00,102.98,31.509,48.222,29.509 +2020-07-03 16:45:00,107.56,27.44,48.222,29.509 +2020-07-03 17:00:00,110.19,32.964,52.619,29.509 +2020-07-03 17:15:00,103.65,32.747,52.619,29.509 +2020-07-03 17:30:00,108.81,31.796,52.619,29.509 +2020-07-03 17:45:00,110.01,30.412,52.619,29.509 +2020-07-03 18:00:00,107.36,33.431,52.99,29.509 +2020-07-03 18:15:00,103.91,32.449,52.99,29.509 +2020-07-03 18:30:00,105.74,30.276,52.99,29.509 +2020-07-03 18:45:00,106.53,33.777,52.99,29.509 +2020-07-03 19:00:00,100.94,35.813,51.923,29.509 +2020-07-03 19:15:00,91.66,35.426,51.923,29.509 +2020-07-03 19:30:00,95.59,34.438,51.923,29.509 +2020-07-03 19:45:00,96.63,32.675,51.923,29.509 +2020-07-03 20:00:00,95.47,29.728,56.238,29.509 +2020-07-03 20:15:00,91.08,30.12,56.238,29.509 +2020-07-03 20:30:00,87.72,30.166,56.238,29.509 +2020-07-03 20:45:00,93.25,29.855,56.238,29.509 +2020-07-03 21:00:00,93.94,29.558000000000003,52.426,29.509 +2020-07-03 21:15:00,91.58,32.889,52.426,29.509 +2020-07-03 21:30:00,84.87,33.661,52.426,29.509 +2020-07-03 21:45:00,81.0,34.939,52.426,29.509 +2020-07-03 22:00:00,81.22,31.889,48.196000000000005,29.509 +2020-07-03 22:15:00,83.92,34.275999999999996,48.196000000000005,29.509 +2020-07-03 22:30:00,81.87,34.796,48.196000000000005,29.509 +2020-07-03 22:45:00,78.52,32.36,48.196000000000005,29.509 +2020-07-03 23:00:00,69.31,30.891,41.71,29.509 +2020-07-03 23:15:00,70.51,27.789,41.71,29.509 +2020-07-03 23:30:00,75.91,24.73,41.71,29.509 +2020-07-03 23:45:00,74.86,23.709,41.71,29.509 +2020-07-04 00:00:00,71.28,18.308,41.105,29.398000000000003 +2020-07-04 00:15:00,66.58,18.862000000000002,41.105,29.398000000000003 +2020-07-04 00:30:00,68.4,17.344,41.105,29.398000000000003 +2020-07-04 00:45:00,72.06,16.899,41.105,29.398000000000003 +2020-07-04 01:00:00,70.88,16.746,36.934,29.398000000000003 +2020-07-04 01:15:00,68.8,15.936,36.934,29.398000000000003 +2020-07-04 01:30:00,62.1,14.513,36.934,29.398000000000003 +2020-07-04 01:45:00,61.89,15.368,36.934,29.398000000000003 +2020-07-04 02:00:00,61.38,15.015999999999998,34.782,29.398000000000003 +2020-07-04 02:15:00,61.18,14.513,34.782,29.398000000000003 +2020-07-04 02:30:00,67.97,14.918,34.782,29.398000000000003 +2020-07-04 02:45:00,68.39,15.735999999999999,34.782,29.398000000000003 +2020-07-04 03:00:00,67.7,16.349,34.489000000000004,29.398000000000003 +2020-07-04 03:15:00,65.75,14.513,34.489000000000004,29.398000000000003 +2020-07-04 03:30:00,69.36,14.513,34.489000000000004,29.398000000000003 +2020-07-04 03:45:00,67.53,14.645,34.489000000000004,29.398000000000003 +2020-07-04 04:00:00,64.42,17.846,34.111,29.398000000000003 +2020-07-04 04:15:00,60.03,21.635,34.111,29.398000000000003 +2020-07-04 04:30:00,62.56,18.337,34.111,29.398000000000003 +2020-07-04 04:45:00,68.6,18.028,34.111,29.398000000000003 +2020-07-04 05:00:00,69.39,22.414,33.283,29.398000000000003 +2020-07-04 05:15:00,67.18,20.141,33.283,29.398000000000003 +2020-07-04 05:30:00,62.58,15.806,33.283,29.398000000000003 +2020-07-04 05:45:00,66.37,17.168,33.283,29.398000000000003 +2020-07-04 06:00:00,67.09,29.13,33.653,29.398000000000003 +2020-07-04 06:15:00,70.36,36.363,33.653,29.398000000000003 +2020-07-04 06:30:00,75.13,32.815,33.653,29.398000000000003 +2020-07-04 06:45:00,76.55,31.62,33.653,29.398000000000003 +2020-07-04 07:00:00,74.19,31.340999999999998,36.732,29.398000000000003 +2020-07-04 07:15:00,69.0,31.328000000000003,36.732,29.398000000000003 +2020-07-04 07:30:00,75.39,28.43,36.732,29.398000000000003 +2020-07-04 07:45:00,78.46,29.125999999999998,36.732,29.398000000000003 +2020-07-04 08:00:00,78.82,26.471,41.318999999999996,29.398000000000003 +2020-07-04 08:15:00,75.62,29.854,41.318999999999996,29.398000000000003 +2020-07-04 08:30:00,72.05,30.805,41.318999999999996,29.398000000000003 +2020-07-04 08:45:00,72.92,34.181999999999995,41.318999999999996,29.398000000000003 +2020-07-04 09:00:00,75.51,29.045,43.195,29.398000000000003 +2020-07-04 09:15:00,75.93,30.805,43.195,29.398000000000003 +2020-07-04 09:30:00,76.19,34.44,43.195,29.398000000000003 +2020-07-04 09:45:00,69.16,37.868,43.195,29.398000000000003 +2020-07-04 10:00:00,68.23,35.586999999999996,41.843999999999994,29.398000000000003 +2020-07-04 10:15:00,68.67,37.345,41.843999999999994,29.398000000000003 +2020-07-04 10:30:00,72.15,37.611999999999995,41.843999999999994,29.398000000000003 +2020-07-04 10:45:00,69.35,38.399,41.843999999999994,29.398000000000003 +2020-07-04 11:00:00,67.52,36.268,39.035,29.398000000000003 +2020-07-04 11:15:00,67.46,37.444,39.035,29.398000000000003 +2020-07-04 11:30:00,65.74,38.674,39.035,29.398000000000003 +2020-07-04 11:45:00,64.54,39.766999999999996,39.035,29.398000000000003 +2020-07-04 12:00:00,62.25,35.786,38.001,29.398000000000003 +2020-07-04 12:15:00,60.77,35.577,38.001,29.398000000000003 +2020-07-04 12:30:00,60.3,34.369,38.001,29.398000000000003 +2020-07-04 12:45:00,59.23,35.464,38.001,29.398000000000003 +2020-07-04 13:00:00,57.7,35.444,34.747,29.398000000000003 +2020-07-04 13:15:00,57.19,36.525999999999996,34.747,29.398000000000003 +2020-07-04 13:30:00,57.53,35.806,34.747,29.398000000000003 +2020-07-04 13:45:00,57.64,34.283,34.747,29.398000000000003 +2020-07-04 14:00:00,57.47,35.258,33.434,29.398000000000003 +2020-07-04 14:15:00,57.5,33.368,33.434,29.398000000000003 +2020-07-04 14:30:00,57.46,33.594,33.434,29.398000000000003 +2020-07-04 14:45:00,58.13,33.887,33.434,29.398000000000003 +2020-07-04 15:00:00,59.46,36.232,35.921,29.398000000000003 +2020-07-04 15:15:00,59.91,34.321,35.921,29.398000000000003 +2020-07-04 15:30:00,60.67,32.314,35.921,29.398000000000003 +2020-07-04 15:45:00,61.72,30.444000000000003,35.921,29.398000000000003 +2020-07-04 16:00:00,63.63,34.691,39.427,29.398000000000003 +2020-07-04 16:15:00,64.21,33.919000000000004,39.427,29.398000000000003 +2020-07-04 16:30:00,69.13,33.007,39.427,29.398000000000003 +2020-07-04 16:45:00,69.2,28.857,39.427,29.398000000000003 +2020-07-04 17:00:00,69.91,33.164,44.096000000000004,29.398000000000003 +2020-07-04 17:15:00,72.05,30.614,44.096000000000004,29.398000000000003 +2020-07-04 17:30:00,73.58,29.529,44.096000000000004,29.398000000000003 +2020-07-04 17:45:00,75.0,28.683000000000003,44.096000000000004,29.398000000000003 +2020-07-04 18:00:00,76.84,33.131,43.931000000000004,29.398000000000003 +2020-07-04 18:15:00,76.16,33.784,43.931000000000004,29.398000000000003 +2020-07-04 18:30:00,76.26,32.959,43.931000000000004,29.398000000000003 +2020-07-04 18:45:00,77.12,33.004,43.931000000000004,29.398000000000003 +2020-07-04 19:00:00,75.57,33.28,42.187,29.398000000000003 +2020-07-04 19:15:00,72.31,31.864,42.187,29.398000000000003 +2020-07-04 19:30:00,71.21,31.631999999999998,42.187,29.398000000000003 +2020-07-04 19:45:00,70.88,31.693,42.187,29.398000000000003 +2020-07-04 20:00:00,70.25,29.52,38.315,29.398000000000003 +2020-07-04 20:15:00,69.83,29.163,38.315,29.398000000000003 +2020-07-04 20:30:00,69.99,28.314,38.315,29.398000000000003 +2020-07-04 20:45:00,70.95,29.953000000000003,38.315,29.398000000000003 +2020-07-04 21:00:00,71.53,28.039,36.843,29.398000000000003 +2020-07-04 21:15:00,71.29,30.995,36.843,29.398000000000003 +2020-07-04 21:30:00,70.14,31.895,36.843,29.398000000000003 +2020-07-04 21:45:00,68.61,32.623000000000005,36.843,29.398000000000003 +2020-07-04 22:00:00,66.07,29.506999999999998,37.260999999999996,29.398000000000003 +2020-07-04 22:15:00,66.0,32.06,37.260999999999996,29.398000000000003 +2020-07-04 22:30:00,62.91,31.948,37.260999999999996,29.398000000000003 +2020-07-04 22:45:00,62.98,29.82,37.260999999999996,29.398000000000003 +2020-07-04 23:00:00,59.32,27.59,32.148,29.398000000000003 +2020-07-04 23:15:00,58.95,25.004,32.148,29.398000000000003 +2020-07-04 23:30:00,58.24,24.543000000000003,32.148,29.398000000000003 +2020-07-04 23:45:00,57.81,23.965999999999998,32.148,29.398000000000003 +2020-07-05 00:00:00,55.67,19.707,28.905,29.398000000000003 +2020-07-05 00:15:00,55.37,19.081,28.905,29.398000000000003 +2020-07-05 00:30:00,54.85,17.43,28.905,29.398000000000003 +2020-07-05 00:45:00,54.72,16.848,28.905,29.398000000000003 +2020-07-05 01:00:00,53.6,16.983,26.906999999999996,29.398000000000003 +2020-07-05 01:15:00,53.84,15.994000000000002,26.906999999999996,29.398000000000003 +2020-07-05 01:30:00,54.02,14.513,26.906999999999996,29.398000000000003 +2020-07-05 01:45:00,54.04,14.877,26.906999999999996,29.398000000000003 +2020-07-05 02:00:00,53.25,14.642000000000001,25.938000000000002,29.398000000000003 +2020-07-05 02:15:00,53.53,14.513,25.938000000000002,29.398000000000003 +2020-07-05 02:30:00,53.44,15.464,25.938000000000002,29.398000000000003 +2020-07-05 02:45:00,52.89,15.967,25.938000000000002,29.398000000000003 +2020-07-05 03:00:00,52.29,17.232,24.693,29.398000000000003 +2020-07-05 03:15:00,53.06,14.776,24.693,29.398000000000003 +2020-07-05 03:30:00,52.75,14.513,24.693,29.398000000000003 +2020-07-05 03:45:00,51.46,14.513,24.693,29.398000000000003 +2020-07-05 04:00:00,51.22,17.359,25.683000000000003,29.398000000000003 +2020-07-05 04:15:00,51.09,20.709,25.683000000000003,29.398000000000003 +2020-07-05 04:30:00,50.47,18.782,25.683000000000003,29.398000000000003 +2020-07-05 04:45:00,51.05,17.98,25.683000000000003,29.398000000000003 +2020-07-05 05:00:00,50.86,22.613000000000003,26.023000000000003,29.398000000000003 +2020-07-05 05:15:00,50.47,19.552,26.023000000000003,29.398000000000003 +2020-07-05 05:30:00,48.1,14.87,26.023000000000003,29.398000000000003 +2020-07-05 05:45:00,51.33,15.972000000000001,26.023000000000003,29.398000000000003 +2020-07-05 06:00:00,51.68,25.519000000000002,25.834,29.398000000000003 +2020-07-05 06:15:00,51.55,33.667,25.834,29.398000000000003 +2020-07-05 06:30:00,52.79,29.468000000000004,25.834,29.398000000000003 +2020-07-05 06:45:00,53.9,27.323,25.834,29.398000000000003 +2020-07-05 07:00:00,53.77,27.261999999999997,27.765,29.398000000000003 +2020-07-05 07:15:00,53.67,25.614,27.765,29.398000000000003 +2020-07-05 07:30:00,52.11,24.101,27.765,29.398000000000003 +2020-07-05 07:45:00,54.99,24.828000000000003,27.765,29.398000000000003 +2020-07-05 08:00:00,55.61,22.898000000000003,31.357,29.398000000000003 +2020-07-05 08:15:00,55.39,27.529,31.357,29.398000000000003 +2020-07-05 08:30:00,56.12,29.285999999999998,31.357,29.398000000000003 +2020-07-05 08:45:00,56.36,32.464,31.357,29.398000000000003 +2020-07-05 09:00:00,57.17,27.238000000000003,33.238,29.398000000000003 +2020-07-05 09:15:00,57.85,28.448,33.238,29.398000000000003 +2020-07-05 09:30:00,60.75,32.537,33.238,29.398000000000003 +2020-07-05 09:45:00,58.0,37.027,33.238,29.398000000000003 +2020-07-05 10:00:00,61.54,35.167,34.22,29.398000000000003 +2020-07-05 10:15:00,62.53,37.032,34.22,29.398000000000003 +2020-07-05 10:30:00,60.64,37.488,34.22,29.398000000000003 +2020-07-05 10:45:00,64.41,39.474000000000004,34.22,29.398000000000003 +2020-07-05 11:00:00,63.97,36.913000000000004,36.298,29.398000000000003 +2020-07-05 11:15:00,62.55,37.61,36.298,29.398000000000003 +2020-07-05 11:30:00,61.59,39.439,36.298,29.398000000000003 +2020-07-05 11:45:00,61.53,40.755,36.298,29.398000000000003 +2020-07-05 12:00:00,58.57,37.967,33.52,29.398000000000003 +2020-07-05 12:15:00,59.34,36.952,33.52,29.398000000000003 +2020-07-05 12:30:00,56.89,36.068000000000005,33.52,29.398000000000003 +2020-07-05 12:45:00,56.14,36.559,33.52,29.398000000000003 +2020-07-05 13:00:00,56.09,36.25,30.12,29.398000000000003 +2020-07-05 13:15:00,58.54,36.499,30.12,29.398000000000003 +2020-07-05 13:30:00,53.91,34.625,30.12,29.398000000000003 +2020-07-05 13:45:00,54.48,34.336999999999996,30.12,29.398000000000003 +2020-07-05 14:00:00,53.1,36.564,27.233,29.398000000000003 +2020-07-05 14:15:00,52.56,35.042,27.233,29.398000000000003 +2020-07-05 14:30:00,53.24,33.861999999999995,27.233,29.398000000000003 +2020-07-05 14:45:00,53.16,33.063,27.233,29.398000000000003 +2020-07-05 15:00:00,52.17,35.689,27.468000000000004,29.398000000000003 +2020-07-05 15:15:00,53.08,32.861999999999995,27.468000000000004,29.398000000000003 +2020-07-05 15:30:00,52.72,30.621,27.468000000000004,29.398000000000003 +2020-07-05 15:45:00,52.87,28.991999999999997,27.468000000000004,29.398000000000003 +2020-07-05 16:00:00,58.7,31.435,30.8,29.398000000000003 +2020-07-05 16:15:00,59.41,30.967,30.8,29.398000000000003 +2020-07-05 16:30:00,58.69,31.108,30.8,29.398000000000003 +2020-07-05 16:45:00,63.11,27.015,30.8,29.398000000000003 +2020-07-05 17:00:00,67.05,31.717,37.806,29.398000000000003 +2020-07-05 17:15:00,64.23,30.783,37.806,29.398000000000003 +2020-07-05 17:30:00,65.25,30.528000000000002,37.806,29.398000000000003 +2020-07-05 17:45:00,68.4,29.974,37.806,29.398000000000003 +2020-07-05 18:00:00,72.02,35.104,40.766,29.398000000000003 +2020-07-05 18:15:00,71.05,35.239000000000004,40.766,29.398000000000003 +2020-07-05 18:30:00,71.03,34.275999999999996,40.766,29.398000000000003 +2020-07-05 18:45:00,71.01,34.335,40.766,29.398000000000003 +2020-07-05 19:00:00,71.74,36.906,41.163000000000004,29.398000000000003 +2020-07-05 19:15:00,70.17,34.306999999999995,41.163000000000004,29.398000000000003 +2020-07-05 19:30:00,70.19,33.819,41.163000000000004,29.398000000000003 +2020-07-05 19:45:00,70.61,33.332,41.163000000000004,29.398000000000003 +2020-07-05 20:00:00,70.45,31.316,39.885999999999996,29.398000000000003 +2020-07-05 20:15:00,71.14,30.765,39.885999999999996,29.398000000000003 +2020-07-05 20:30:00,75.63,30.69,39.885999999999996,29.398000000000003 +2020-07-05 20:45:00,75.49,30.666,39.885999999999996,29.398000000000003 +2020-07-05 21:00:00,75.9,28.771,38.900999999999996,29.398000000000003 +2020-07-05 21:15:00,76.48,31.448,38.900999999999996,29.398000000000003 +2020-07-05 21:30:00,74.17,31.642,38.900999999999996,29.398000000000003 +2020-07-05 21:45:00,73.12,32.73,38.900999999999996,29.398000000000003 +2020-07-05 22:00:00,70.17,31.783,39.806999999999995,29.398000000000003 +2020-07-05 22:15:00,70.35,32.641999999999996,39.806999999999995,29.398000000000003 +2020-07-05 22:30:00,68.44,32.145,39.806999999999995,29.398000000000003 +2020-07-05 22:45:00,68.89,28.736,39.806999999999995,29.398000000000003 +2020-07-05 23:00:00,66.69,26.328000000000003,35.564,29.398000000000003 +2020-07-05 23:15:00,65.66,24.987,35.564,29.398000000000003 +2020-07-05 23:30:00,64.82,23.948,35.564,29.398000000000003 +2020-07-05 23:45:00,63.72,23.485,35.564,29.398000000000003 +2020-07-06 00:00:00,62.93,21.053,36.578,29.509 +2020-07-06 00:15:00,62.05,21.09,36.578,29.509 +2020-07-06 00:30:00,61.97,19.039,36.578,29.509 +2020-07-06 00:45:00,61.79,18.081,36.578,29.509 +2020-07-06 01:00:00,61.64,18.625,35.292,29.509 +2020-07-06 01:15:00,60.91,17.667,35.292,29.509 +2020-07-06 01:30:00,60.67,16.254,35.292,29.509 +2020-07-06 01:45:00,59.95,16.808,35.292,29.509 +2020-07-06 02:00:00,61.3,17.049,34.319,29.509 +2020-07-06 02:15:00,61.83,14.513,34.319,29.509 +2020-07-06 02:30:00,62.07,16.898,34.319,29.509 +2020-07-06 02:45:00,62.17,17.285,34.319,29.509 +2020-07-06 03:00:00,65.43,19.002,33.13,29.509 +2020-07-06 03:15:00,64.03,17.215,33.13,29.509 +2020-07-06 03:30:00,65.09,16.549,33.13,29.509 +2020-07-06 03:45:00,65.84,16.95,33.13,29.509 +2020-07-06 04:00:00,67.57,23.045,33.851,29.509 +2020-07-06 04:15:00,68.87,29.180999999999997,33.851,29.509 +2020-07-06 04:30:00,71.48,26.636999999999997,33.851,29.509 +2020-07-06 04:45:00,75.03,26.21,33.851,29.509 +2020-07-06 05:00:00,80.19,37.529,38.718,29.509 +2020-07-06 05:15:00,84.78,44.1,38.718,29.509 +2020-07-06 05:30:00,88.76,37.885,38.718,29.509 +2020-07-06 05:45:00,91.27,36.103,38.718,29.509 +2020-07-06 06:00:00,94.97,35.835,51.648999999999994,29.509 +2020-07-06 06:15:00,100.81,35.295,51.648999999999994,29.509 +2020-07-06 06:30:00,107.42,34.845,51.648999999999994,29.509 +2020-07-06 06:45:00,109.2,37.764,51.648999999999994,29.509 +2020-07-06 07:00:00,100.07,37.709,60.159,29.509 +2020-07-06 07:15:00,99.42,38.38,60.159,29.509 +2020-07-06 07:30:00,109.47,36.021,60.159,29.509 +2020-07-06 07:45:00,108.74,37.223,60.159,29.509 +2020-07-06 08:00:00,103.48,33.216,53.8,29.509 +2020-07-06 08:15:00,101.9,36.735,53.8,29.509 +2020-07-06 08:30:00,102.67,37.72,53.8,29.509 +2020-07-06 08:45:00,102.51,41.309,53.8,29.509 +2020-07-06 09:00:00,108.05,34.997,50.583,29.509 +2020-07-06 09:15:00,108.16,34.559,50.583,29.509 +2020-07-06 09:30:00,103.79,37.734,50.583,29.509 +2020-07-06 09:45:00,100.1,39.804,50.583,29.509 +2020-07-06 10:00:00,100.26,38.406,49.11600000000001,29.509 +2020-07-06 10:15:00,98.97,40.135999999999996,49.11600000000001,29.509 +2020-07-06 10:30:00,101.45,40.166,49.11600000000001,29.509 +2020-07-06 10:45:00,105.24,40.429,49.11600000000001,29.509 +2020-07-06 11:00:00,115.14,38.292,49.056000000000004,29.509 +2020-07-06 11:15:00,114.12,39.161,49.056000000000004,29.509 +2020-07-06 11:30:00,109.51,41.618,49.056000000000004,29.509 +2020-07-06 11:45:00,107.16,43.486999999999995,49.056000000000004,29.509 +2020-07-06 12:00:00,98.95,38.736,47.227,29.509 +2020-07-06 12:15:00,97.34,37.839,47.227,29.509 +2020-07-06 12:30:00,94.65,35.788000000000004,47.227,29.509 +2020-07-06 12:45:00,94.35,36.159,47.227,29.509 +2020-07-06 13:00:00,95.56,36.779,47.006,29.509 +2020-07-06 13:15:00,94.55,36.189,47.006,29.509 +2020-07-06 13:30:00,93.02,34.541,47.006,29.509 +2020-07-06 13:45:00,98.7,35.205999999999996,47.006,29.509 +2020-07-06 14:00:00,95.14,36.535,47.19,29.509 +2020-07-06 14:15:00,91.16,35.689,47.19,29.509 +2020-07-06 14:30:00,89.41,34.361999999999995,47.19,29.509 +2020-07-06 14:45:00,88.76,35.762,47.19,29.509 +2020-07-06 15:00:00,87.58,37.905,47.846000000000004,29.509 +2020-07-06 15:15:00,88.49,34.553000000000004,47.846000000000004,29.509 +2020-07-06 15:30:00,88.04,33.184,47.846000000000004,29.509 +2020-07-06 15:45:00,88.3,31.066999999999997,47.846000000000004,29.509 +2020-07-06 16:00:00,88.8,34.711999999999996,49.641000000000005,29.509 +2020-07-06 16:15:00,90.56,34.364000000000004,49.641000000000005,29.509 +2020-07-06 16:30:00,93.03,33.887,49.641000000000005,29.509 +2020-07-06 16:45:00,95.66,29.892,49.641000000000005,29.509 +2020-07-06 17:00:00,98.75,33.438,54.133,29.509 +2020-07-06 17:15:00,99.58,32.971,54.133,29.509 +2020-07-06 17:30:00,99.52,32.346,54.133,29.509 +2020-07-06 17:45:00,99.38,31.47,54.133,29.509 +2020-07-06 18:00:00,99.13,35.501999999999995,53.761,29.509 +2020-07-06 18:15:00,101.41,33.836999999999996,53.761,29.509 +2020-07-06 18:30:00,101.33,32.049,53.761,29.509 +2020-07-06 18:45:00,99.47,35.366,53.761,29.509 +2020-07-06 19:00:00,96.1,37.764,53.923,29.509 +2020-07-06 19:15:00,92.71,36.589,53.923,29.509 +2020-07-06 19:30:00,92.02,35.689,53.923,29.509 +2020-07-06 19:45:00,92.82,34.603,53.923,29.509 +2020-07-06 20:00:00,91.58,31.381,58.786,29.509 +2020-07-06 20:15:00,89.83,32.415,58.786,29.509 +2020-07-06 20:30:00,90.8,32.982,58.786,29.509 +2020-07-06 20:45:00,90.9,33.107,58.786,29.509 +2020-07-06 21:00:00,88.74,30.525,54.591,29.509 +2020-07-06 21:15:00,87.51,33.721,54.591,29.509 +2020-07-06 21:30:00,85.2,34.366,54.591,29.509 +2020-07-06 21:45:00,83.29,35.247,54.591,29.509 +2020-07-06 22:00:00,80.54,32.321999999999996,51.551,29.509 +2020-07-06 22:15:00,79.21,35.305,51.551,29.509 +2020-07-06 22:30:00,78.44,30.779,51.551,29.509 +2020-07-06 22:45:00,76.79,27.581999999999997,51.551,29.509 +2020-07-06 23:00:00,75.39,25.141,44.716,29.509 +2020-07-06 23:15:00,74.53,21.991,44.716,29.509 +2020-07-06 23:30:00,74.57,20.794,44.716,29.509 +2020-07-06 23:45:00,72.99,19.643,44.716,29.509 +2020-07-07 00:00:00,71.53,18.721,43.01,29.509 +2020-07-07 00:15:00,70.84,19.582,43.01,29.509 +2020-07-07 00:30:00,71.39,18.327,43.01,29.509 +2020-07-07 00:45:00,70.56,18.253,43.01,29.509 +2020-07-07 01:00:00,71.2,18.25,40.687,29.509 +2020-07-07 01:15:00,70.9,17.422,40.687,29.509 +2020-07-07 01:30:00,70.33,15.867,40.687,29.509 +2020-07-07 01:45:00,70.39,15.862,40.687,29.509 +2020-07-07 02:00:00,70.97,15.605,39.554,29.509 +2020-07-07 02:15:00,70.84,14.513,39.554,29.509 +2020-07-07 02:30:00,77.92,16.289,39.554,29.509 +2020-07-07 02:45:00,78.65,17.02,39.554,29.509 +2020-07-07 03:00:00,75.67,18.214000000000002,38.958,29.509 +2020-07-07 03:15:00,70.77,17.46,38.958,29.509 +2020-07-07 03:30:00,74.81,16.742,38.958,29.509 +2020-07-07 03:45:00,74.03,15.989,38.958,29.509 +2020-07-07 04:00:00,77.7,20.735,39.783,29.509 +2020-07-07 04:15:00,82.93,26.872,39.783,29.509 +2020-07-07 04:30:00,86.8,24.212,39.783,29.509 +2020-07-07 04:45:00,87.2,24.226999999999997,39.783,29.509 +2020-07-07 05:00:00,87.99,36.885999999999996,42.281000000000006,29.509 +2020-07-07 05:15:00,95.27,44.023999999999994,42.281000000000006,29.509 +2020-07-07 05:30:00,96.03,38.126999999999995,42.281000000000006,29.509 +2020-07-07 05:45:00,100.96,35.614000000000004,42.281000000000006,29.509 +2020-07-07 06:00:00,111.3,36.497,50.801,29.509 +2020-07-07 06:15:00,114.63,36.082,50.801,29.509 +2020-07-07 06:30:00,116.56,35.349000000000004,50.801,29.509 +2020-07-07 06:45:00,116.13,37.391,50.801,29.509 +2020-07-07 07:00:00,116.59,37.498000000000005,60.202,29.509 +2020-07-07 07:15:00,121.92,37.93,60.202,29.509 +2020-07-07 07:30:00,124.36,35.623000000000005,60.202,29.509 +2020-07-07 07:45:00,123.71,35.817,60.202,29.509 +2020-07-07 08:00:00,119.38,31.735,54.461000000000006,29.509 +2020-07-07 08:15:00,124.37,34.859,54.461000000000006,29.509 +2020-07-07 08:30:00,128.67,36.022,54.461000000000006,29.509 +2020-07-07 08:45:00,130.1,38.647,54.461000000000006,29.509 +2020-07-07 09:00:00,129.85,32.748000000000005,50.753,29.509 +2020-07-07 09:15:00,128.0,32.054,50.753,29.509 +2020-07-07 09:30:00,130.61,35.906,50.753,29.509 +2020-07-07 09:45:00,129.99,39.417,50.753,29.509 +2020-07-07 10:00:00,122.45,36.611999999999995,49.703,29.509 +2020-07-07 10:15:00,120.63,38.272,49.703,29.509 +2020-07-07 10:30:00,122.78,38.313,49.703,29.509 +2020-07-07 10:45:00,123.45,39.629,49.703,29.509 +2020-07-07 11:00:00,128.68,37.385,49.42100000000001,29.509 +2020-07-07 11:15:00,125.65,38.714,49.42100000000001,29.509 +2020-07-07 11:30:00,122.76,40.018,49.42100000000001,29.509 +2020-07-07 11:45:00,121.95,41.35,49.42100000000001,29.509 +2020-07-07 12:00:00,118.98,36.525999999999996,47.155,29.509 +2020-07-07 12:15:00,125.09,36.022,47.155,29.509 +2020-07-07 12:30:00,122.87,34.824,47.155,29.509 +2020-07-07 12:45:00,119.92,35.959,47.155,29.509 +2020-07-07 13:00:00,121.51,36.201,47.515,29.509 +2020-07-07 13:15:00,124.02,37.568000000000005,47.515,29.509 +2020-07-07 13:30:00,121.3,35.8,47.515,29.509 +2020-07-07 13:45:00,120.45,35.42,47.515,29.509 +2020-07-07 14:00:00,111.95,37.211,47.575,29.509 +2020-07-07 14:15:00,112.15,36.14,47.575,29.509 +2020-07-07 14:30:00,118.73,35.104,47.575,29.509 +2020-07-07 14:45:00,117.88,35.671,47.575,29.509 +2020-07-07 15:00:00,112.52,37.69,48.903,29.509 +2020-07-07 15:15:00,106.84,35.296,48.903,29.509 +2020-07-07 15:30:00,107.4,33.681999999999995,48.903,29.509 +2020-07-07 15:45:00,99.77,31.961,48.903,29.509 +2020-07-07 16:00:00,96.93,34.83,50.218999999999994,29.509 +2020-07-07 16:15:00,106.09,34.543,50.218999999999994,29.509 +2020-07-07 16:30:00,106.41,33.641,50.218999999999994,29.509 +2020-07-07 16:45:00,104.59,30.386999999999997,50.218999999999994,29.509 +2020-07-07 17:00:00,99.96,34.125,55.396,29.509 +2020-07-07 17:15:00,103.8,34.11,55.396,29.509 +2020-07-07 17:30:00,100.99,32.974000000000004,55.396,29.509 +2020-07-07 17:45:00,102.8,31.754,55.396,29.509 +2020-07-07 18:00:00,108.74,34.804,55.583999999999996,29.509 +2020-07-07 18:15:00,108.95,34.722,55.583999999999996,29.509 +2020-07-07 18:30:00,107.59,32.679,55.583999999999996,29.509 +2020-07-07 18:45:00,103.26,35.739000000000004,55.583999999999996,29.509 +2020-07-07 19:00:00,99.07,36.917,56.071000000000005,29.509 +2020-07-07 19:15:00,94.57,35.938,56.071000000000005,29.509 +2020-07-07 19:30:00,92.99,34.843,56.071000000000005,29.509 +2020-07-07 19:45:00,92.6,34.101,56.071000000000005,29.509 +2020-07-07 20:00:00,91.59,31.27,61.55,29.509 +2020-07-07 20:15:00,90.41,30.826999999999998,61.55,29.509 +2020-07-07 20:30:00,90.06,31.373,61.55,29.509 +2020-07-07 20:45:00,90.65,31.985,61.55,29.509 +2020-07-07 21:00:00,90.29,30.355999999999998,55.94,29.509 +2020-07-07 21:15:00,88.11,31.968000000000004,55.94,29.509 +2020-07-07 21:30:00,85.72,32.819,55.94,29.509 +2020-07-07 21:45:00,84.05,33.887,55.94,29.509 +2020-07-07 22:00:00,82.01,31.055,52.857,29.509 +2020-07-07 22:15:00,79.35,33.702,52.857,29.509 +2020-07-07 22:30:00,77.27,29.464000000000002,52.857,29.509 +2020-07-07 22:45:00,75.9,26.239,52.857,29.509 +2020-07-07 23:00:00,73.88,23.0,46.04,29.509 +2020-07-07 23:15:00,72.06,21.55,46.04,29.509 +2020-07-07 23:30:00,71.73,20.384,46.04,29.509 +2020-07-07 23:45:00,70.42,19.437,46.04,29.509 +2020-07-08 00:00:00,68.87,18.723,42.195,29.509 +2020-07-08 00:15:00,68.28,19.582,42.195,29.509 +2020-07-08 00:30:00,67.17,18.331,42.195,29.509 +2020-07-08 00:45:00,67.5,18.262,42.195,29.509 +2020-07-08 01:00:00,68.65,18.267,38.82,29.509 +2020-07-08 01:15:00,68.23,17.430999999999997,38.82,29.509 +2020-07-08 01:30:00,67.33,15.877,38.82,29.509 +2020-07-08 01:45:00,67.32,15.865,38.82,29.509 +2020-07-08 02:00:00,67.95,15.613,37.023,29.509 +2020-07-08 02:15:00,67.93,14.513,37.023,29.509 +2020-07-08 02:30:00,75.12,16.292,37.023,29.509 +2020-07-08 02:45:00,73.61,17.028,37.023,29.509 +2020-07-08 03:00:00,77.07,18.215,36.818000000000005,29.509 +2020-07-08 03:15:00,72.12,17.471,36.818000000000005,29.509 +2020-07-08 03:30:00,73.91,16.761,36.818000000000005,29.509 +2020-07-08 03:45:00,71.79,16.022000000000002,36.818000000000005,29.509 +2020-07-08 04:00:00,77.39,20.733,37.495,29.509 +2020-07-08 04:15:00,82.0,26.840999999999998,37.495,29.509 +2020-07-08 04:30:00,84.87,24.173000000000002,37.495,29.509 +2020-07-08 04:45:00,83.56,24.188000000000002,37.495,29.509 +2020-07-08 05:00:00,86.92,36.789,39.858000000000004,29.509 +2020-07-08 05:15:00,93.46,43.852,39.858000000000004,29.509 +2020-07-08 05:30:00,93.59,38.008,39.858000000000004,29.509 +2020-07-08 05:45:00,96.52,35.519,39.858000000000004,29.509 +2020-07-08 06:00:00,99.68,36.399,52.867,29.509 +2020-07-08 06:15:00,102.4,35.982,52.867,29.509 +2020-07-08 06:30:00,108.54,35.269,52.867,29.509 +2020-07-08 06:45:00,113.47,37.338,52.867,29.509 +2020-07-08 07:00:00,108.0,37.436,66.061,29.509 +2020-07-08 07:15:00,102.54,37.89,66.061,29.509 +2020-07-08 07:30:00,100.01,35.586,66.061,29.509 +2020-07-08 07:45:00,101.07,35.809,66.061,29.509 +2020-07-08 08:00:00,108.39,31.736,58.532,29.509 +2020-07-08 08:15:00,116.1,34.872,58.532,29.509 +2020-07-08 08:30:00,117.34,36.025,58.532,29.509 +2020-07-08 08:45:00,115.72,38.645,58.532,29.509 +2020-07-08 09:00:00,111.76,32.743,56.047,29.509 +2020-07-08 09:15:00,108.9,32.047,56.047,29.509 +2020-07-08 09:30:00,108.08,35.893,56.047,29.509 +2020-07-08 09:45:00,116.56,39.406,56.047,29.509 +2020-07-08 10:00:00,120.29,36.609,53.823,29.509 +2020-07-08 10:15:00,118.95,38.266999999999996,53.823,29.509 +2020-07-08 10:30:00,112.11,38.305,53.823,29.509 +2020-07-08 10:45:00,109.74,39.623000000000005,53.823,29.509 +2020-07-08 11:00:00,109.06,37.376999999999995,54.184,29.509 +2020-07-08 11:15:00,108.4,38.705999999999996,54.184,29.509 +2020-07-08 11:30:00,105.6,40.0,54.184,29.509 +2020-07-08 11:45:00,107.2,41.326,54.184,29.509 +2020-07-08 12:00:00,114.53,36.522,52.628,29.509 +2020-07-08 12:15:00,112.75,36.016,52.628,29.509 +2020-07-08 12:30:00,101.99,34.808,52.628,29.509 +2020-07-08 12:45:00,99.23,35.94,52.628,29.509 +2020-07-08 13:00:00,100.2,36.167,52.31,29.509 +2020-07-08 13:15:00,102.73,37.527,52.31,29.509 +2020-07-08 13:30:00,102.61,35.765,52.31,29.509 +2020-07-08 13:45:00,102.47,35.391999999999996,52.31,29.509 +2020-07-08 14:00:00,116.5,37.185,52.278999999999996,29.509 +2020-07-08 14:15:00,112.73,36.115,52.278999999999996,29.509 +2020-07-08 14:30:00,108.85,35.069,52.278999999999996,29.509 +2020-07-08 14:45:00,109.45,35.644,52.278999999999996,29.509 +2020-07-08 15:00:00,101.37,37.671,53.306999999999995,29.509 +2020-07-08 15:15:00,100.94,35.272,53.306999999999995,29.509 +2020-07-08 15:30:00,99.62,33.659,53.306999999999995,29.509 +2020-07-08 15:45:00,97.35,31.93,53.306999999999995,29.509 +2020-07-08 16:00:00,93.45,34.812,55.358999999999995,29.509 +2020-07-08 16:15:00,94.89,34.528,55.358999999999995,29.509 +2020-07-08 16:30:00,98.99,33.643,55.358999999999995,29.509 +2020-07-08 16:45:00,99.62,30.389,55.358999999999995,29.509 +2020-07-08 17:00:00,98.93,34.132,59.211999999999996,29.509 +2020-07-08 17:15:00,98.18,34.126999999999995,59.211999999999996,29.509 +2020-07-08 17:30:00,98.72,32.994,59.211999999999996,29.509 +2020-07-08 17:45:00,100.38,31.776999999999997,59.211999999999996,29.509 +2020-07-08 18:00:00,99.58,34.832,60.403999999999996,29.509 +2020-07-08 18:15:00,99.0,34.734,60.403999999999996,29.509 +2020-07-08 18:30:00,100.18,32.691,60.403999999999996,29.509 +2020-07-08 18:45:00,98.99,35.751999999999995,60.403999999999996,29.509 +2020-07-08 19:00:00,96.37,36.931999999999995,60.993,29.509 +2020-07-08 19:15:00,92.43,35.945,60.993,29.509 +2020-07-08 19:30:00,91.2,34.844,60.993,29.509 +2020-07-08 19:45:00,90.88,34.098,60.993,29.509 +2020-07-08 20:00:00,90.29,31.255,66.6,29.509 +2020-07-08 20:15:00,88.95,30.811,66.6,29.509 +2020-07-08 20:30:00,89.13,31.355999999999998,66.6,29.509 +2020-07-08 20:45:00,91.03,31.976999999999997,66.6,29.509 +2020-07-08 21:00:00,89.33,30.351999999999997,59.855,29.509 +2020-07-08 21:15:00,87.63,31.965999999999998,59.855,29.509 +2020-07-08 21:30:00,85.13,32.796,59.855,29.509 +2020-07-08 21:45:00,83.76,33.858000000000004,59.855,29.509 +2020-07-08 22:00:00,81.36,31.031999999999996,54.942,29.509 +2020-07-08 22:15:00,79.43,33.68,54.942,29.509 +2020-07-08 22:30:00,76.01,29.427,54.942,29.509 +2020-07-08 22:45:00,74.2,26.193,54.942,29.509 +2020-07-08 23:00:00,74.17,22.959,46.056000000000004,29.509 +2020-07-08 23:15:00,72.84,21.531999999999996,46.056000000000004,29.509 +2020-07-08 23:30:00,72.63,20.379,46.056000000000004,29.509 +2020-07-08 23:45:00,72.27,19.425,46.056000000000004,29.509 +2020-07-09 00:00:00,70.77,18.729,40.859,29.509 +2020-07-09 00:15:00,69.68,19.587,40.859,29.509 +2020-07-09 00:30:00,68.62,18.339000000000002,40.859,29.509 +2020-07-09 00:45:00,68.26,18.276,40.859,29.509 +2020-07-09 01:00:00,68.62,18.287,39.06,29.509 +2020-07-09 01:15:00,67.79,17.444000000000003,39.06,29.509 +2020-07-09 01:30:00,66.52,15.892000000000001,39.06,29.509 +2020-07-09 01:45:00,67.76,15.872,39.06,29.509 +2020-07-09 02:00:00,68.72,15.626,37.592,29.509 +2020-07-09 02:15:00,67.64,14.513,37.592,29.509 +2020-07-09 02:30:00,67.33,16.301,37.592,29.509 +2020-07-09 02:45:00,67.89,17.039,37.592,29.509 +2020-07-09 03:00:00,69.13,18.219,37.416,29.509 +2020-07-09 03:15:00,70.24,17.485,37.416,29.509 +2020-07-09 03:30:00,71.26,16.783,37.416,29.509 +2020-07-09 03:45:00,72.08,16.06,37.416,29.509 +2020-07-09 04:00:00,76.24,20.737,38.176,29.509 +2020-07-09 04:15:00,75.02,26.815,38.176,29.509 +2020-07-09 04:30:00,77.62,24.14,38.176,29.509 +2020-07-09 04:45:00,80.21,24.154,38.176,29.509 +2020-07-09 05:00:00,85.53,36.703,41.203,29.509 +2020-07-09 05:15:00,89.78,43.691,41.203,29.509 +2020-07-09 05:30:00,93.38,37.9,41.203,29.509 +2020-07-09 05:45:00,101.99,35.434,41.203,29.509 +2020-07-09 06:00:00,106.23,36.31,51.09,29.509 +2020-07-09 06:15:00,109.0,35.891999999999996,51.09,29.509 +2020-07-09 06:30:00,106.78,35.196999999999996,51.09,29.509 +2020-07-09 06:45:00,104.81,37.294000000000004,51.09,29.509 +2020-07-09 07:00:00,106.36,37.384,63.541000000000004,29.509 +2020-07-09 07:15:00,110.69,37.859,63.541000000000004,29.509 +2020-07-09 07:30:00,112.06,35.56,63.541000000000004,29.509 +2020-07-09 07:45:00,118.5,35.809,63.541000000000004,29.509 +2020-07-09 08:00:00,121.66,31.746,55.65,29.509 +2020-07-09 08:15:00,122.22,34.893,55.65,29.509 +2020-07-09 08:30:00,118.81,36.037,55.65,29.509 +2020-07-09 08:45:00,116.9,38.649,55.65,29.509 +2020-07-09 09:00:00,117.8,32.743,51.833999999999996,29.509 +2020-07-09 09:15:00,120.31,32.048,51.833999999999996,29.509 +2020-07-09 09:30:00,120.69,35.887,51.833999999999996,29.509 +2020-07-09 09:45:00,124.35,39.402,51.833999999999996,29.509 +2020-07-09 10:00:00,130.54,36.613,49.70399999999999,29.509 +2020-07-09 10:15:00,131.59,38.268,49.70399999999999,29.509 +2020-07-09 10:30:00,127.98,38.302,49.70399999999999,29.509 +2020-07-09 10:45:00,122.86,39.621,49.70399999999999,29.509 +2020-07-09 11:00:00,122.53,37.374,48.593999999999994,29.509 +2020-07-09 11:15:00,121.63,38.705,48.593999999999994,29.509 +2020-07-09 11:30:00,121.85,39.986,48.593999999999994,29.509 +2020-07-09 11:45:00,120.39,41.306999999999995,48.593999999999994,29.509 +2020-07-09 12:00:00,120.74,36.523,46.275,29.509 +2020-07-09 12:15:00,120.27,36.015,46.275,29.509 +2020-07-09 12:30:00,117.63,34.798,46.275,29.509 +2020-07-09 12:45:00,114.26,35.927,46.275,29.509 +2020-07-09 13:00:00,109.73,36.135999999999996,45.803000000000004,29.509 +2020-07-09 13:15:00,108.7,37.491,45.803000000000004,29.509 +2020-07-09 13:30:00,113.78,35.734,45.803000000000004,29.509 +2020-07-09 13:45:00,117.0,35.368,45.803000000000004,29.509 +2020-07-09 14:00:00,110.72,37.163000000000004,46.251999999999995,29.509 +2020-07-09 14:15:00,111.27,36.095,46.251999999999995,29.509 +2020-07-09 14:30:00,112.08,35.039,46.251999999999995,29.509 +2020-07-09 14:45:00,109.67,35.62,46.251999999999995,29.509 +2020-07-09 15:00:00,109.08,37.655,48.309,29.509 +2020-07-09 15:15:00,104.89,35.251,48.309,29.509 +2020-07-09 15:30:00,100.7,33.638000000000005,48.309,29.509 +2020-07-09 15:45:00,104.1,31.903000000000002,48.309,29.509 +2020-07-09 16:00:00,103.6,34.797,49.681999999999995,29.509 +2020-07-09 16:15:00,105.47,34.515,49.681999999999995,29.509 +2020-07-09 16:30:00,100.69,33.648,49.681999999999995,29.509 +2020-07-09 16:45:00,101.97,30.396,49.681999999999995,29.509 +2020-07-09 17:00:00,103.65,34.143,53.086000000000006,29.509 +2020-07-09 17:15:00,103.74,34.149,53.086000000000006,29.509 +2020-07-09 17:30:00,105.78,33.018,53.086000000000006,29.509 +2020-07-09 17:45:00,108.82,31.804000000000002,53.086000000000006,29.509 +2020-07-09 18:00:00,108.39,34.865,54.038999999999994,29.509 +2020-07-09 18:15:00,108.03,34.75,54.038999999999994,29.509 +2020-07-09 18:30:00,106.59,32.711,54.038999999999994,29.509 +2020-07-09 18:45:00,103.0,35.77,54.038999999999994,29.509 +2020-07-09 19:00:00,100.88,36.952,53.408,29.509 +2020-07-09 19:15:00,97.96,35.959,53.408,29.509 +2020-07-09 19:30:00,94.18,34.851,53.408,29.509 +2020-07-09 19:45:00,92.67,34.101,53.408,29.509 +2020-07-09 20:00:00,90.99,31.249000000000002,55.309,29.509 +2020-07-09 20:15:00,90.23,30.802,55.309,29.509 +2020-07-09 20:30:00,90.8,31.345,55.309,29.509 +2020-07-09 20:45:00,92.1,31.975,55.309,29.509 +2020-07-09 21:00:00,89.34,30.354,51.585,29.509 +2020-07-09 21:15:00,87.34,31.968000000000004,51.585,29.509 +2020-07-09 21:30:00,84.56,32.778,51.585,29.509 +2020-07-09 21:45:00,83.79,33.833,51.585,29.509 +2020-07-09 22:00:00,80.79,31.013,48.006,29.509 +2020-07-09 22:15:00,78.88,33.661,48.006,29.509 +2020-07-09 22:30:00,76.76,29.392,48.006,29.509 +2020-07-09 22:45:00,75.3,26.151999999999997,48.006,29.509 +2020-07-09 23:00:00,73.05,22.921,42.309,29.509 +2020-07-09 23:15:00,72.92,21.516,42.309,29.509 +2020-07-09 23:30:00,71.81,20.377,42.309,29.509 +2020-07-09 23:45:00,70.78,19.418,42.309,29.509 +2020-07-10 00:00:00,75.87,16.908,39.649,29.509 +2020-07-10 00:15:00,78.17,17.980999999999998,39.649,29.509 +2020-07-10 00:30:00,75.85,17.027,39.649,29.509 +2020-07-10 00:45:00,71.62,17.408,39.649,29.509 +2020-07-10 01:00:00,69.81,17.038,37.744,29.509 +2020-07-10 01:15:00,68.97,15.522,37.744,29.509 +2020-07-10 01:30:00,67.92,14.729000000000001,37.744,29.509 +2020-07-10 01:45:00,69.22,14.513,37.744,29.509 +2020-07-10 02:00:00,68.97,15.165999999999999,36.965,29.509 +2020-07-10 02:15:00,71.25,14.513,36.965,29.509 +2020-07-10 02:30:00,75.07,16.63,36.965,29.509 +2020-07-10 02:45:00,74.85,16.655,36.965,29.509 +2020-07-10 03:00:00,75.76,18.677,37.678000000000004,29.509 +2020-07-10 03:15:00,71.23,16.657,37.678000000000004,29.509 +2020-07-10 03:30:00,77.96,15.714,37.678000000000004,29.509 +2020-07-10 03:45:00,73.99,15.908,37.678000000000004,29.509 +2020-07-10 04:00:00,73.54,20.688000000000002,38.591,29.509 +2020-07-10 04:15:00,74.61,25.119,38.591,29.509 +2020-07-10 04:30:00,77.28,23.421,38.591,29.509 +2020-07-10 04:45:00,79.7,22.803,38.591,29.509 +2020-07-10 05:00:00,86.23,34.849000000000004,40.666,29.509 +2020-07-10 05:15:00,91.4,42.71,40.666,29.509 +2020-07-10 05:30:00,95.84,37.144,40.666,29.509 +2020-07-10 05:45:00,103.23,34.24,40.666,29.509 +2020-07-10 06:00:00,106.94,35.33,51.784,29.509 +2020-07-10 06:15:00,106.98,35.125,51.784,29.509 +2020-07-10 06:30:00,107.3,34.42,51.784,29.509 +2020-07-10 06:45:00,110.82,36.363,51.784,29.509 +2020-07-10 07:00:00,103.93,37.14,61.383,29.509 +2020-07-10 07:15:00,103.93,38.543,61.383,29.509 +2020-07-10 07:30:00,100.62,34.223,61.383,29.509 +2020-07-10 07:45:00,107.12,34.312,61.383,29.509 +2020-07-10 08:00:00,114.53,31.116999999999997,55.272,29.509 +2020-07-10 08:15:00,110.12,34.992,55.272,29.509 +2020-07-10 08:30:00,106.93,35.983000000000004,55.272,29.509 +2020-07-10 08:45:00,109.04,38.505,55.272,29.509 +2020-07-10 09:00:00,114.76,30.16,53.506,29.509 +2020-07-10 09:15:00,113.51,31.453000000000003,53.506,29.509 +2020-07-10 09:30:00,110.89,34.580999999999996,53.506,29.509 +2020-07-10 09:45:00,107.02,38.509,53.506,29.509 +2020-07-10 10:00:00,113.21,35.611,51.363,29.509 +2020-07-10 10:15:00,111.84,36.994,51.363,29.509 +2020-07-10 10:30:00,112.42,37.61,51.363,29.509 +2020-07-10 10:45:00,110.71,38.852,51.363,29.509 +2020-07-10 11:00:00,112.67,36.874,51.043,29.509 +2020-07-10 11:15:00,113.11,37.103,51.043,29.509 +2020-07-10 11:30:00,107.88,37.903,51.043,29.509 +2020-07-10 11:45:00,109.91,38.202,51.043,29.509 +2020-07-10 12:00:00,111.54,33.830999999999996,47.52,29.509 +2020-07-10 12:15:00,112.77,32.756,47.52,29.509 +2020-07-10 12:30:00,109.24,31.646,47.52,29.509 +2020-07-10 12:45:00,105.69,31.91,47.52,29.509 +2020-07-10 13:00:00,106.61,32.681,45.494,29.509 +2020-07-10 13:15:00,112.86,34.179,45.494,29.509 +2020-07-10 13:30:00,115.59,33.268,45.494,29.509 +2020-07-10 13:45:00,109.93,33.236999999999995,45.494,29.509 +2020-07-10 14:00:00,105.89,34.242,43.883,29.509 +2020-07-10 14:15:00,101.49,33.646,43.883,29.509 +2020-07-10 14:30:00,95.86,34.148,43.883,29.509 +2020-07-10 14:45:00,97.16,33.991,43.883,29.509 +2020-07-10 15:00:00,94.55,35.974000000000004,45.714,29.509 +2020-07-10 15:15:00,89.18,33.336999999999996,45.714,29.509 +2020-07-10 15:30:00,91.05,31.205,45.714,29.509 +2020-07-10 15:45:00,100.25,30.261999999999997,45.714,29.509 +2020-07-10 16:00:00,97.71,32.321999999999996,48.222,29.509 +2020-07-10 16:15:00,101.07,32.548,48.222,29.509 +2020-07-10 16:30:00,98.92,31.503,48.222,29.509 +2020-07-10 16:45:00,98.38,27.433000000000003,48.222,29.509 +2020-07-10 17:00:00,103.96,32.991,52.619,29.509 +2020-07-10 17:15:00,105.3,32.848,52.619,29.509 +2020-07-10 17:30:00,104.89,31.909000000000002,52.619,29.509 +2020-07-10 17:45:00,101.36,30.537,52.619,29.509 +2020-07-10 18:00:00,102.68,33.596,52.99,29.509 +2020-07-10 18:15:00,105.55,32.494,52.99,29.509 +2020-07-10 18:30:00,106.3,30.329,52.99,29.509 +2020-07-10 18:45:00,103.31,33.828,52.99,29.509 +2020-07-10 19:00:00,97.76,35.876,51.923,29.509 +2020-07-10 19:15:00,92.42,35.438,51.923,29.509 +2020-07-10 19:30:00,92.95,34.405,51.923,29.509 +2020-07-10 19:45:00,97.4,32.611,51.923,29.509 +2020-07-10 20:00:00,95.69,29.585,56.238,29.509 +2020-07-10 20:15:00,92.52,29.965999999999998,56.238,29.509 +2020-07-10 20:30:00,86.88,30.0,56.238,29.509 +2020-07-10 20:45:00,87.29,29.768,56.238,29.509 +2020-07-10 21:00:00,83.47,29.494,52.426,29.509 +2020-07-10 21:15:00,83.93,32.832,52.426,29.509 +2020-07-10 21:30:00,80.36,33.465,52.426,29.509 +2020-07-10 21:45:00,84.98,34.704,52.426,29.509 +2020-07-10 22:00:00,82.87,31.701999999999998,48.196000000000005,29.509 +2020-07-10 22:15:00,79.75,34.096,48.196000000000005,29.509 +2020-07-10 22:30:00,73.88,34.513000000000005,48.196000000000005,29.509 +2020-07-10 22:45:00,70.67,32.027,48.196000000000005,29.509 +2020-07-10 23:00:00,71.98,30.574,41.71,29.509 +2020-07-10 23:15:00,75.31,27.635,41.71,29.509 +2020-07-10 23:30:00,73.88,24.671999999999997,41.71,29.509 +2020-07-10 23:45:00,69.5,23.603,41.71,29.509 +2020-07-11 00:00:00,66.78,18.323,41.105,29.398000000000003 +2020-07-11 00:15:00,65.61,18.863,41.105,29.398000000000003 +2020-07-11 00:30:00,68.91,17.372,41.105,29.398000000000003 +2020-07-11 00:45:00,71.3,16.97,41.105,29.398000000000003 +2020-07-11 01:00:00,71.31,16.866,36.934,29.398000000000003 +2020-07-11 01:15:00,66.13,15.995999999999999,36.934,29.398000000000003 +2020-07-11 01:30:00,62.39,14.513,36.934,29.398000000000003 +2020-07-11 01:45:00,65.34,15.394,36.934,29.398000000000003 +2020-07-11 02:00:00,69.2,15.074000000000002,34.782,29.398000000000003 +2020-07-11 02:15:00,69.08,14.513,34.782,29.398000000000003 +2020-07-11 02:30:00,66.46,14.948,34.782,29.398000000000003 +2020-07-11 02:45:00,61.07,15.79,34.782,29.398000000000003 +2020-07-11 03:00:00,61.65,16.352,34.489000000000004,29.398000000000003 +2020-07-11 03:15:00,67.74,14.513,34.489000000000004,29.398000000000003 +2020-07-11 03:30:00,68.92,14.513,34.489000000000004,29.398000000000003 +2020-07-11 03:45:00,67.2,14.882,34.489000000000004,29.398000000000003 +2020-07-11 04:00:00,61.39,17.837,34.111,29.398000000000003 +2020-07-11 04:15:00,62.51,21.416,34.111,29.398000000000003 +2020-07-11 04:30:00,67.0,18.064,34.111,29.398000000000003 +2020-07-11 04:45:00,68.68,17.75,34.111,29.398000000000003 +2020-07-11 05:00:00,67.76,21.743000000000002,33.283,29.398000000000003 +2020-07-11 05:15:00,65.79,18.933,33.283,29.398000000000003 +2020-07-11 05:30:00,71.55,14.975999999999999,33.283,29.398000000000003 +2020-07-11 05:45:00,73.19,16.502,33.283,29.398000000000003 +2020-07-11 06:00:00,72.98,28.447,33.653,29.398000000000003 +2020-07-11 06:15:00,70.87,35.666,33.653,29.398000000000003 +2020-07-11 06:30:00,69.27,32.253,33.653,29.398000000000003 +2020-07-11 06:45:00,72.03,31.250999999999998,33.653,29.398000000000003 +2020-07-11 07:00:00,78.34,30.91,36.732,29.398000000000003 +2020-07-11 07:15:00,79.62,31.045,36.732,29.398000000000003 +2020-07-11 07:30:00,79.34,28.175,36.732,29.398000000000003 +2020-07-11 07:45:00,76.51,29.061,36.732,29.398000000000003 +2020-07-11 08:00:00,81.35,26.475,41.318999999999996,29.398000000000003 +2020-07-11 08:15:00,81.1,29.944000000000003,41.318999999999996,29.398000000000003 +2020-07-11 08:30:00,79.87,30.825,41.318999999999996,29.398000000000003 +2020-07-11 08:45:00,74.11,34.164,41.318999999999996,29.398000000000003 +2020-07-11 09:00:00,76.56,29.0,43.195,29.398000000000003 +2020-07-11 09:15:00,73.1,30.758000000000003,43.195,29.398000000000003 +2020-07-11 09:30:00,79.0,34.347,43.195,29.398000000000003 +2020-07-11 09:45:00,80.51,37.795,43.195,29.398000000000003 +2020-07-11 10:00:00,81.2,35.568000000000005,41.843999999999994,29.398000000000003 +2020-07-11 10:15:00,76.6,37.312,41.843999999999994,29.398000000000003 +2020-07-11 10:30:00,80.08,37.554,41.843999999999994,29.398000000000003 +2020-07-11 10:45:00,79.57,38.348,41.843999999999994,29.398000000000003 +2020-07-11 11:00:00,72.28,36.21,39.035,29.398000000000003 +2020-07-11 11:15:00,72.4,37.393,39.035,29.398000000000003 +2020-07-11 11:30:00,66.56,38.545,39.035,29.398000000000003 +2020-07-11 11:45:00,64.38,39.599000000000004,39.035,29.398000000000003 +2020-07-11 12:00:00,63.06,35.757,38.001,29.398000000000003 +2020-07-11 12:15:00,61.1,35.538000000000004,38.001,29.398000000000003 +2020-07-11 12:30:00,60.22,34.263000000000005,38.001,29.398000000000003 +2020-07-11 12:45:00,59.31,35.336999999999996,38.001,29.398000000000003 +2020-07-11 13:00:00,58.37,35.196999999999996,34.747,29.398000000000003 +2020-07-11 13:15:00,58.07,36.245,34.747,29.398000000000003 +2020-07-11 13:30:00,57.74,35.563,34.747,29.398000000000003 +2020-07-11 13:45:00,57.83,34.092,34.747,29.398000000000003 +2020-07-11 14:00:00,58.61,35.078,33.434,29.398000000000003 +2020-07-11 14:15:00,58.61,33.199,33.434,29.398000000000003 +2020-07-11 14:30:00,59.59,33.353,33.434,29.398000000000003 +2020-07-11 14:45:00,60.3,33.694,33.434,29.398000000000003 +2020-07-11 15:00:00,61.37,36.101,35.921,29.398000000000003 +2020-07-11 15:15:00,61.08,34.154,35.921,29.398000000000003 +2020-07-11 15:30:00,61.78,32.147,35.921,29.398000000000003 +2020-07-11 15:45:00,62.97,30.228,35.921,29.398000000000003 +2020-07-11 16:00:00,64.97,34.564,39.427,29.398000000000003 +2020-07-11 16:15:00,65.55,33.809,39.427,29.398000000000003 +2020-07-11 16:30:00,67.43,33.021,39.427,29.398000000000003 +2020-07-11 16:45:00,69.59,28.877,39.427,29.398000000000003 +2020-07-11 17:00:00,71.58,33.213,44.096000000000004,29.398000000000003 +2020-07-11 17:15:00,72.95,30.74,44.096000000000004,29.398000000000003 +2020-07-11 17:30:00,74.4,29.671,44.096000000000004,29.398000000000003 +2020-07-11 17:45:00,76.23,28.840999999999998,44.096000000000004,29.398000000000003 +2020-07-11 18:00:00,76.65,33.328,43.931000000000004,29.398000000000003 +2020-07-11 18:15:00,76.76,33.864000000000004,43.931000000000004,29.398000000000003 +2020-07-11 18:30:00,77.06,33.051,43.931000000000004,29.398000000000003 +2020-07-11 18:45:00,77.28,33.094,43.931000000000004,29.398000000000003 +2020-07-11 19:00:00,75.61,33.381,42.187,29.398000000000003 +2020-07-11 19:15:00,73.06,31.915,42.187,29.398000000000003 +2020-07-11 19:30:00,72.09,31.64,42.187,29.398000000000003 +2020-07-11 19:45:00,72.06,31.671,42.187,29.398000000000003 +2020-07-11 20:00:00,71.02,29.423000000000002,38.315,29.398000000000003 +2020-07-11 20:15:00,70.88,29.055,38.315,29.398000000000003 +2020-07-11 20:30:00,71.67,28.191999999999997,38.315,29.398000000000003 +2020-07-11 20:45:00,73.05,29.901999999999997,38.315,29.398000000000003 +2020-07-11 21:00:00,72.16,28.011999999999997,36.843,29.398000000000003 +2020-07-11 21:15:00,70.77,30.971999999999998,36.843,29.398000000000003 +2020-07-11 21:30:00,69.19,31.735,36.843,29.398000000000003 +2020-07-11 21:45:00,68.12,32.418,36.843,29.398000000000003 +2020-07-11 22:00:00,65.88,29.346,37.260999999999996,29.398000000000003 +2020-07-11 22:15:00,64.83,31.903000000000002,37.260999999999996,29.398000000000003 +2020-07-11 22:30:00,63.5,31.685,37.260999999999996,29.398000000000003 +2020-07-11 22:45:00,61.86,29.505,37.260999999999996,29.398000000000003 +2020-07-11 23:00:00,60.37,27.302,32.148,29.398000000000003 +2020-07-11 23:15:00,59.02,24.873,32.148,29.398000000000003 +2020-07-11 23:30:00,58.04,24.506999999999998,32.148,29.398000000000003 +2020-07-11 23:45:00,57.38,23.886,32.148,29.398000000000003 +2020-07-12 00:00:00,56.58,19.749000000000002,28.905,29.398000000000003 +2020-07-12 00:15:00,55.15,19.109,28.905,29.398000000000003 +2020-07-12 00:30:00,55.08,17.485,28.905,29.398000000000003 +2020-07-12 00:45:00,55.02,16.945,28.905,29.398000000000003 +2020-07-12 01:00:00,54.63,17.125999999999998,26.906999999999996,29.398000000000003 +2020-07-12 01:15:00,54.08,16.081,26.906999999999996,29.398000000000003 +2020-07-12 01:30:00,54.07,14.513,26.906999999999996,29.398000000000003 +2020-07-12 01:45:00,53.22,14.932,26.906999999999996,29.398000000000003 +2020-07-12 02:00:00,53.3,14.73,25.938000000000002,29.398000000000003 +2020-07-12 02:15:00,52.94,14.513,25.938000000000002,29.398000000000003 +2020-07-12 02:30:00,53.05,15.523,25.938000000000002,29.398000000000003 +2020-07-12 02:45:00,52.79,16.05,25.938000000000002,29.398000000000003 +2020-07-12 03:00:00,53.08,17.264,24.693,29.398000000000003 +2020-07-12 03:15:00,52.25,14.882,24.693,29.398000000000003 +2020-07-12 03:30:00,52.6,14.513,24.693,29.398000000000003 +2020-07-12 03:45:00,52.24,14.513,24.693,29.398000000000003 +2020-07-12 04:00:00,51.42,17.385,25.683000000000003,29.398000000000003 +2020-07-12 04:15:00,51.0,20.531,25.683000000000003,29.398000000000003 +2020-07-12 04:30:00,50.5,18.554000000000002,25.683000000000003,29.398000000000003 +2020-07-12 04:45:00,50.97,17.746,25.683000000000003,29.398000000000003 +2020-07-12 05:00:00,51.24,22.005,26.023000000000003,29.398000000000003 +2020-07-12 05:15:00,51.43,18.430999999999997,26.023000000000003,29.398000000000003 +2020-07-12 05:30:00,51.69,14.513,26.023000000000003,29.398000000000003 +2020-07-12 05:45:00,52.25,15.375,26.023000000000003,29.398000000000003 +2020-07-12 06:00:00,52.24,24.9,25.834,29.398000000000003 +2020-07-12 06:15:00,52.9,33.04,25.834,29.398000000000003 +2020-07-12 06:30:00,53.7,28.971,25.834,29.398000000000003 +2020-07-12 06:45:00,55.15,27.016,25.834,29.398000000000003 +2020-07-12 07:00:00,54.82,26.895,27.765,29.398000000000003 +2020-07-12 07:15:00,55.23,25.395,27.765,29.398000000000003 +2020-07-12 07:30:00,56.51,23.913,27.765,29.398000000000003 +2020-07-12 07:45:00,57.17,24.826999999999998,27.765,29.398000000000003 +2020-07-12 08:00:00,55.26,22.965,31.357,29.398000000000003 +2020-07-12 08:15:00,55.02,27.676,31.357,29.398000000000003 +2020-07-12 08:30:00,54.22,29.362,31.357,29.398000000000003 +2020-07-12 08:45:00,55.5,32.498000000000005,31.357,29.398000000000003 +2020-07-12 09:00:00,56.55,27.249000000000002,33.238,29.398000000000003 +2020-07-12 09:15:00,54.44,28.454,33.238,29.398000000000003 +2020-07-12 09:30:00,53.99,32.496,33.238,29.398000000000003 +2020-07-12 09:45:00,54.89,37.0,33.238,29.398000000000003 +2020-07-12 10:00:00,55.54,35.193000000000005,34.22,29.398000000000003 +2020-07-12 10:15:00,57.68,37.039,34.22,29.398000000000003 +2020-07-12 10:30:00,59.4,37.47,34.22,29.398000000000003 +2020-07-12 10:45:00,58.87,39.461,34.22,29.398000000000003 +2020-07-12 11:00:00,56.65,36.895,36.298,29.398000000000003 +2020-07-12 11:15:00,55.61,37.598,36.298,29.398000000000003 +2020-07-12 11:30:00,53.69,39.349000000000004,36.298,29.398000000000003 +2020-07-12 11:45:00,51.82,40.623000000000005,36.298,29.398000000000003 +2020-07-12 12:00:00,49.79,37.971,33.52,29.398000000000003 +2020-07-12 12:15:00,48.34,36.944,33.52,29.398000000000003 +2020-07-12 12:30:00,47.06,35.995,33.52,29.398000000000003 +2020-07-12 12:45:00,46.84,36.466,33.52,29.398000000000003 +2020-07-12 13:00:00,45.47,36.035,30.12,29.398000000000003 +2020-07-12 13:15:00,44.9,36.248000000000005,30.12,29.398000000000003 +2020-07-12 13:30:00,46.01,34.412,30.12,29.398000000000003 +2020-07-12 13:45:00,48.63,34.175,30.12,29.398000000000003 +2020-07-12 14:00:00,47.77,36.409,27.233,29.398000000000003 +2020-07-12 14:15:00,50.67,34.899,27.233,29.398000000000003 +2020-07-12 14:30:00,49.51,33.652,27.233,29.398000000000003 +2020-07-12 14:45:00,50.09,32.899,27.233,29.398000000000003 +2020-07-12 15:00:00,49.11,35.577,27.468000000000004,29.398000000000003 +2020-07-12 15:15:00,51.61,32.717,27.468000000000004,29.398000000000003 +2020-07-12 15:30:00,52.28,30.478,27.468000000000004,29.398000000000003 +2020-07-12 15:45:00,54.69,28.804000000000002,27.468000000000004,29.398000000000003 +2020-07-12 16:00:00,56.69,31.328000000000003,30.8,29.398000000000003 +2020-07-12 16:15:00,57.79,30.877,30.8,29.398000000000003 +2020-07-12 16:30:00,60.36,31.142,30.8,29.398000000000003 +2020-07-12 16:45:00,62.51,27.061999999999998,30.8,29.398000000000003 +2020-07-12 17:00:00,64.81,31.787,37.806,29.398000000000003 +2020-07-12 17:15:00,65.45,30.935,37.806,29.398000000000003 +2020-07-12 17:30:00,67.71,30.695999999999998,37.806,29.398000000000003 +2020-07-12 17:45:00,68.61,30.166999999999998,37.806,29.398000000000003 +2020-07-12 18:00:00,71.24,35.333,40.766,29.398000000000003 +2020-07-12 18:15:00,71.8,35.356,40.766,29.398000000000003 +2020-07-12 18:30:00,72.57,34.407,40.766,29.398000000000003 +2020-07-12 18:45:00,72.89,34.463,40.766,29.398000000000003 +2020-07-12 19:00:00,72.83,37.047,41.163000000000004,29.398000000000003 +2020-07-12 19:15:00,71.76,34.399,41.163000000000004,29.398000000000003 +2020-07-12 19:30:00,70.93,33.869,41.163000000000004,29.398000000000003 +2020-07-12 19:45:00,71.3,33.354,41.163000000000004,29.398000000000003 +2020-07-12 20:00:00,68.86,31.265,39.885999999999996,29.398000000000003 +2020-07-12 20:15:00,69.89,30.703000000000003,39.885999999999996,29.398000000000003 +2020-07-12 20:30:00,71.35,30.611,39.885999999999996,29.398000000000003 +2020-07-12 20:45:00,74.04,30.651,39.885999999999996,29.398000000000003 +2020-07-12 21:00:00,76.76,28.779,38.900999999999996,29.398000000000003 +2020-07-12 21:15:00,76.45,31.459,38.900999999999996,29.398000000000003 +2020-07-12 21:30:00,75.21,31.52,38.900999999999996,29.398000000000003 +2020-07-12 21:45:00,73.92,32.556,38.900999999999996,29.398000000000003 +2020-07-12 22:00:00,72.32,31.649,39.806999999999995,29.398000000000003 +2020-07-12 22:15:00,71.26,32.507,39.806999999999995,29.398000000000003 +2020-07-12 22:30:00,70.12,31.899,39.806999999999995,29.398000000000003 +2020-07-12 22:45:00,69.17,28.441999999999997,39.806999999999995,29.398000000000003 +2020-07-12 23:00:00,66.25,26.066999999999997,35.564,29.398000000000003 +2020-07-12 23:15:00,66.98,24.875999999999998,35.564,29.398000000000003 +2020-07-12 23:30:00,67.33,23.935,35.564,29.398000000000003 +2020-07-12 23:45:00,65.65,23.429000000000002,35.564,29.398000000000003 +2020-07-13 00:00:00,63.25,21.121,36.578,29.509 +2020-07-13 00:15:00,63.73,21.145,36.578,29.509 +2020-07-13 00:30:00,63.24,19.121,36.578,29.509 +2020-07-13 00:45:00,64.24,18.207,36.578,29.509 +2020-07-13 01:00:00,62.98,18.792,35.292,29.509 +2020-07-13 01:15:00,64.06,17.78,35.292,29.509 +2020-07-13 01:30:00,62.35,16.386,35.292,29.509 +2020-07-13 01:45:00,62.1,16.892,35.292,29.509 +2020-07-13 02:00:00,62.13,17.167,34.319,29.509 +2020-07-13 02:15:00,63.93,14.513,34.319,29.509 +2020-07-13 02:30:00,62.7,16.987000000000002,34.319,29.509 +2020-07-13 02:45:00,68.66,17.396,34.319,29.509 +2020-07-13 03:00:00,71.71,19.061,33.13,29.509 +2020-07-13 03:15:00,70.72,17.35,33.13,29.509 +2020-07-13 03:30:00,68.96,16.737000000000002,33.13,29.509 +2020-07-13 03:45:00,68.2,17.241,33.13,29.509 +2020-07-13 04:00:00,71.65,23.105999999999998,33.851,29.509 +2020-07-13 04:15:00,75.48,29.046,33.851,29.509 +2020-07-13 04:30:00,81.43,26.454,33.851,29.509 +2020-07-13 04:45:00,88.04,26.021,33.851,29.509 +2020-07-13 05:00:00,87.82,36.985,38.718,29.509 +2020-07-13 05:15:00,92.38,43.06399999999999,38.718,29.509 +2020-07-13 05:30:00,92.03,37.214,38.718,29.509 +2020-07-13 05:45:00,94.27,35.575,38.718,29.509 +2020-07-13 06:00:00,105.23,35.279,51.648999999999994,29.509 +2020-07-13 06:15:00,111.06,34.735,51.648999999999994,29.509 +2020-07-13 06:30:00,115.27,34.412,51.648999999999994,29.509 +2020-07-13 06:45:00,111.43,37.519,51.648999999999994,29.509 +2020-07-13 07:00:00,115.92,37.404,60.159,29.509 +2020-07-13 07:15:00,114.27,38.225,60.159,29.509 +2020-07-13 07:30:00,121.23,35.899,60.159,29.509 +2020-07-13 07:45:00,121.9,37.286,60.159,29.509 +2020-07-13 08:00:00,121.83,33.344,53.8,29.509 +2020-07-13 08:15:00,117.34,36.936,53.8,29.509 +2020-07-13 08:30:00,119.03,37.851,53.8,29.509 +2020-07-13 08:45:00,125.58,41.396,53.8,29.509 +2020-07-13 09:00:00,127.01,35.063,50.583,29.509 +2020-07-13 09:15:00,124.63,34.619,50.583,29.509 +2020-07-13 09:30:00,120.25,37.743,50.583,29.509 +2020-07-13 09:45:00,127.67,39.821999999999996,50.583,29.509 +2020-07-13 10:00:00,129.63,38.476,49.11600000000001,29.509 +2020-07-13 10:15:00,127.89,40.185,49.11600000000001,29.509 +2020-07-13 10:30:00,123.11,40.189,49.11600000000001,29.509 +2020-07-13 10:45:00,122.39,40.455,49.11600000000001,29.509 +2020-07-13 11:00:00,118.84,38.315,49.056000000000004,29.509 +2020-07-13 11:15:00,121.61,39.187,49.056000000000004,29.509 +2020-07-13 11:30:00,114.5,41.566,49.056000000000004,29.509 +2020-07-13 11:45:00,110.96,43.393,49.056000000000004,29.509 +2020-07-13 12:00:00,107.31,38.772,47.227,29.509 +2020-07-13 12:15:00,105.66,37.86,47.227,29.509 +2020-07-13 12:30:00,102.31,35.75,47.227,29.509 +2020-07-13 12:45:00,93.18,36.098,47.227,29.509 +2020-07-13 13:00:00,92.65,36.595,47.006,29.509 +2020-07-13 13:15:00,92.56,35.967,47.006,29.509 +2020-07-13 13:30:00,91.16,34.355,47.006,29.509 +2020-07-13 13:45:00,91.85,35.071999999999996,47.006,29.509 +2020-07-13 14:00:00,90.82,36.405,47.19,29.509 +2020-07-13 14:15:00,91.72,35.571999999999996,47.19,29.509 +2020-07-13 14:30:00,90.69,34.181999999999995,47.19,29.509 +2020-07-13 14:45:00,90.03,35.629,47.19,29.509 +2020-07-13 15:00:00,93.94,37.814,47.846000000000004,29.509 +2020-07-13 15:15:00,99.23,34.43,47.846000000000004,29.509 +2020-07-13 15:30:00,95.18,33.066,47.846000000000004,29.509 +2020-07-13 15:45:00,96.44,30.903000000000002,47.846000000000004,29.509 +2020-07-13 16:00:00,96.49,34.625,49.641000000000005,29.509 +2020-07-13 16:15:00,95.72,34.295,49.641000000000005,29.509 +2020-07-13 16:30:00,96.85,33.941,49.641000000000005,29.509 +2020-07-13 16:45:00,94.4,29.968000000000004,49.641000000000005,29.509 +2020-07-13 17:00:00,98.43,33.529,54.133,29.509 +2020-07-13 17:15:00,98.75,33.147,54.133,29.509 +2020-07-13 17:30:00,99.74,32.542,54.133,29.509 +2020-07-13 17:45:00,98.7,31.698,54.133,29.509 +2020-07-13 18:00:00,102.1,35.762,53.761,29.509 +2020-07-13 18:15:00,99.07,33.991,53.761,29.509 +2020-07-13 18:30:00,100.45,32.218,53.761,29.509 +2020-07-13 18:45:00,100.43,35.531,53.761,29.509 +2020-07-13 19:00:00,97.31,37.945,53.923,29.509 +2020-07-13 19:15:00,93.81,36.722,53.923,29.509 +2020-07-13 19:30:00,93.3,35.78,53.923,29.509 +2020-07-13 19:45:00,91.72,34.668,53.923,29.509 +2020-07-13 20:00:00,90.68,31.375,58.786,29.509 +2020-07-13 20:15:00,90.77,32.399,58.786,29.509 +2020-07-13 20:30:00,90.87,32.946,58.786,29.509 +2020-07-13 20:45:00,91.51,33.126999999999995,58.786,29.509 +2020-07-13 21:00:00,89.62,30.568,54.591,29.509 +2020-07-13 21:15:00,88.41,33.764,54.591,29.509 +2020-07-13 21:30:00,85.66,34.279,54.591,29.509 +2020-07-13 21:45:00,85.22,35.104,54.591,29.509 +2020-07-13 22:00:00,79.21,32.213,51.551,29.509 +2020-07-13 22:15:00,80.28,35.194,51.551,29.509 +2020-07-13 22:30:00,79.16,30.554000000000002,51.551,29.509 +2020-07-13 22:45:00,78.47,27.307,51.551,29.509 +2020-07-13 23:00:00,72.96,24.906999999999996,44.716,29.509 +2020-07-13 23:15:00,73.07,21.904,44.716,29.509 +2020-07-13 23:30:00,69.81,20.805,44.716,29.509 +2020-07-13 23:45:00,72.76,19.611,44.716,29.509 +2020-07-14 00:00:00,67.54,18.816,43.01,29.509 +2020-07-14 00:15:00,69.07,19.663,43.01,29.509 +2020-07-14 00:30:00,66.45,18.436,43.01,29.509 +2020-07-14 00:45:00,70.02,18.406,43.01,29.509 +2020-07-14 01:00:00,69.42,18.439,40.687,29.509 +2020-07-14 01:15:00,69.69,17.563,40.687,29.509 +2020-07-14 01:30:00,67.81,16.028,40.687,29.509 +2020-07-14 01:45:00,69.25,15.975999999999999,40.687,29.509 +2020-07-14 02:00:00,68.49,15.753,39.554,29.509 +2020-07-14 02:15:00,69.32,14.513,39.554,29.509 +2020-07-14 02:30:00,69.78,16.408,39.554,29.509 +2020-07-14 02:45:00,69.55,17.16,39.554,29.509 +2020-07-14 03:00:00,70.53,18.301,38.958,29.509 +2020-07-14 03:15:00,71.95,17.624000000000002,38.958,29.509 +2020-07-14 03:30:00,71.88,16.959,38.958,29.509 +2020-07-14 03:45:00,75.76,16.305,38.958,29.509 +2020-07-14 04:00:00,74.52,20.831999999999997,39.783,29.509 +2020-07-14 04:15:00,75.95,26.779,39.783,29.509 +2020-07-14 04:30:00,79.03,24.072,39.783,29.509 +2020-07-14 04:45:00,81.77,24.083000000000002,39.783,29.509 +2020-07-14 05:00:00,88.06,36.406,42.281000000000006,29.509 +2020-07-14 05:15:00,90.34,43.077,42.281000000000006,29.509 +2020-07-14 05:30:00,92.69,37.535,42.281000000000006,29.509 +2020-07-14 05:45:00,98.51,35.157,42.281000000000006,29.509 +2020-07-14 06:00:00,109.3,36.005,50.801,29.509 +2020-07-14 06:15:00,108.62,35.589,50.801,29.509 +2020-07-14 06:30:00,105.84,34.981,50.801,29.509 +2020-07-14 06:45:00,105.08,37.208,50.801,29.509 +2020-07-14 07:00:00,116.07,37.256,60.202,29.509 +2020-07-14 07:15:00,115.56,37.838,60.202,29.509 +2020-07-14 07:30:00,111.32,35.568000000000005,60.202,29.509 +2020-07-14 07:45:00,103.14,35.945,60.202,29.509 +2020-07-14 08:00:00,106.06,31.928,54.461000000000006,29.509 +2020-07-14 08:15:00,103.57,35.115,54.461000000000006,29.509 +2020-07-14 08:30:00,103.42,36.209,54.461000000000006,29.509 +2020-07-14 08:45:00,106.65,38.788000000000004,54.461000000000006,29.509 +2020-07-14 09:00:00,110.63,32.869,50.753,29.509 +2020-07-14 09:15:00,110.97,32.167,50.753,29.509 +2020-07-14 09:30:00,104.6,35.965,50.753,29.509 +2020-07-14 09:45:00,103.21,39.481,50.753,29.509 +2020-07-14 10:00:00,108.94,36.727,49.703,29.509 +2020-07-14 10:15:00,110.62,38.361,49.703,29.509 +2020-07-14 10:30:00,109.15,38.375,49.703,29.509 +2020-07-14 10:45:00,102.77,39.693000000000005,49.703,29.509 +2020-07-14 11:00:00,100.68,37.448,49.42100000000001,29.509 +2020-07-14 11:15:00,104.47,38.778,49.42100000000001,29.509 +2020-07-14 11:30:00,103.85,40.007,49.42100000000001,29.509 +2020-07-14 11:45:00,104.68,41.293,49.42100000000001,29.509 +2020-07-14 12:00:00,101.13,36.594,47.155,29.509 +2020-07-14 12:15:00,100.12,36.073,47.155,29.509 +2020-07-14 12:30:00,97.38,34.82,47.155,29.509 +2020-07-14 12:45:00,103.21,35.93,47.155,29.509 +2020-07-14 13:00:00,104.0,36.05,47.515,29.509 +2020-07-14 13:15:00,102.08,37.375,47.515,29.509 +2020-07-14 13:30:00,96.92,35.641,47.515,29.509 +2020-07-14 13:45:00,97.9,35.316,47.515,29.509 +2020-07-14 14:00:00,96.33,37.105,47.575,29.509 +2020-07-14 14:15:00,101.9,36.049,47.575,29.509 +2020-07-14 14:30:00,99.58,34.953,47.575,29.509 +2020-07-14 14:45:00,98.18,35.567,47.575,29.509 +2020-07-14 15:00:00,93.03,37.619,48.903,29.509 +2020-07-14 15:15:00,90.27,35.195,48.903,29.509 +2020-07-14 15:30:00,93.61,33.589,48.903,29.509 +2020-07-14 15:45:00,99.29,31.824,48.903,29.509 +2020-07-14 16:00:00,102.48,34.764,50.218999999999994,29.509 +2020-07-14 16:15:00,101.61,34.497,50.218999999999994,29.509 +2020-07-14 16:30:00,97.93,33.715,50.218999999999994,29.509 +2020-07-14 16:45:00,99.0,30.489,50.218999999999994,29.509 +2020-07-14 17:00:00,100.95,34.236999999999995,55.396,29.509 +2020-07-14 17:15:00,101.47,34.311,55.396,29.509 +2020-07-14 17:30:00,101.51,33.196999999999996,55.396,29.509 +2020-07-14 17:45:00,104.76,32.015,55.396,29.509 +2020-07-14 18:00:00,107.85,35.096,55.583999999999996,29.509 +2020-07-14 18:15:00,108.01,34.913000000000004,55.583999999999996,29.509 +2020-07-14 18:30:00,105.94,32.885999999999996,55.583999999999996,29.509 +2020-07-14 18:45:00,101.24,35.943000000000005,55.583999999999996,29.509 +2020-07-14 19:00:00,102.79,37.137,56.071000000000005,29.509 +2020-07-14 19:15:00,102.8,36.111,56.071000000000005,29.509 +2020-07-14 19:30:00,101.74,34.975,56.071000000000005,29.509 +2020-07-14 19:45:00,95.47,34.209,56.071000000000005,29.509 +2020-07-14 20:00:00,91.86,31.309,61.55,29.509 +2020-07-14 20:15:00,91.07,30.857,61.55,29.509 +2020-07-14 20:30:00,91.99,31.381,61.55,29.509 +2020-07-14 20:45:00,93.25,32.04,61.55,29.509 +2020-07-14 21:00:00,90.18,30.436,55.94,29.509 +2020-07-14 21:15:00,89.36,32.047,55.94,29.509 +2020-07-14 21:30:00,86.61,32.768,55.94,29.509 +2020-07-14 21:45:00,85.12,33.775,55.94,29.509 +2020-07-14 22:00:00,80.32,30.973000000000003,52.857,29.509 +2020-07-14 22:15:00,79.79,33.615,52.857,29.509 +2020-07-14 22:30:00,78.37,29.256999999999998,52.857,29.509 +2020-07-14 22:45:00,77.97,25.984,52.857,29.509 +2020-07-14 23:00:00,72.49,22.794,46.04,29.509 +2020-07-14 23:15:00,73.85,21.485,46.04,29.509 +2020-07-14 23:30:00,70.65,20.417,46.04,29.509 +2020-07-14 23:45:00,72.46,19.43,46.04,29.509 +2020-07-15 00:00:00,70.34,18.844,42.195,29.509 +2020-07-15 00:15:00,71.25,19.69,42.195,29.509 +2020-07-15 00:30:00,70.44,18.468,42.195,29.509 +2020-07-15 00:45:00,70.41,18.444000000000003,42.195,29.509 +2020-07-15 01:00:00,67.46,18.48,38.82,29.509 +2020-07-15 01:15:00,68.86,17.599,38.82,29.509 +2020-07-15 01:30:00,67.2,16.067999999999998,38.82,29.509 +2020-07-15 01:45:00,67.83,16.009,38.82,29.509 +2020-07-15 02:00:00,66.75,15.790999999999999,37.023,29.509 +2020-07-15 02:15:00,67.72,14.513,37.023,29.509 +2020-07-15 02:30:00,67.76,16.441,37.023,29.509 +2020-07-15 02:45:00,67.84,17.197,37.023,29.509 +2020-07-15 03:00:00,66.87,18.329,36.818000000000005,29.509 +2020-07-15 03:15:00,71.03,17.664,36.818000000000005,29.509 +2020-07-15 03:30:00,71.12,17.007,36.818000000000005,29.509 +2020-07-15 03:45:00,72.97,16.365,36.818000000000005,29.509 +2020-07-15 04:00:00,73.68,20.866,37.495,29.509 +2020-07-15 04:15:00,76.29,26.789,37.495,29.509 +2020-07-15 04:30:00,80.11,24.078000000000003,37.495,29.509 +2020-07-15 04:45:00,84.96,24.088,37.495,29.509 +2020-07-15 05:00:00,87.78,36.374,39.858000000000004,29.509 +2020-07-15 05:15:00,87.33,42.99100000000001,39.858000000000004,29.509 +2020-07-15 05:30:00,94.23,37.495,39.858000000000004,29.509 +2020-07-15 05:45:00,94.63,35.131,39.858000000000004,29.509 +2020-07-15 06:00:00,100.78,35.971,52.867,29.509 +2020-07-15 06:15:00,108.9,35.558,52.867,29.509 +2020-07-15 06:30:00,110.63,34.966,52.867,29.509 +2020-07-15 06:45:00,111.18,37.216,52.867,29.509 +2020-07-15 07:00:00,105.52,37.258,66.061,29.509 +2020-07-15 07:15:00,103.46,37.86,66.061,29.509 +2020-07-15 07:30:00,103.47,35.599000000000004,66.061,29.509 +2020-07-15 07:45:00,102.77,35.999,66.061,29.509 +2020-07-15 08:00:00,103.23,31.991,58.532,29.509 +2020-07-15 08:15:00,108.72,35.184,58.532,29.509 +2020-07-15 08:30:00,110.2,36.266999999999996,58.532,29.509 +2020-07-15 08:45:00,110.41,38.838,58.532,29.509 +2020-07-15 09:00:00,110.93,32.918,56.047,29.509 +2020-07-15 09:15:00,114.49,32.213,56.047,29.509 +2020-07-15 09:30:00,113.05,36.003,56.047,29.509 +2020-07-15 09:45:00,111.8,39.516999999999996,56.047,29.509 +2020-07-15 10:00:00,110.53,36.769,53.823,29.509 +2020-07-15 10:15:00,113.38,38.397,53.823,29.509 +2020-07-15 10:30:00,118.08,38.407,53.823,29.509 +2020-07-15 10:45:00,119.14,39.724000000000004,53.823,29.509 +2020-07-15 11:00:00,114.85,37.48,54.184,29.509 +2020-07-15 11:15:00,105.35,38.809,54.184,29.509 +2020-07-15 11:30:00,107.75,40.027,54.184,29.509 +2020-07-15 11:45:00,104.65,41.305,54.184,29.509 +2020-07-15 12:00:00,101.57,36.622,52.628,29.509 +2020-07-15 12:15:00,102.98,36.098,52.628,29.509 +2020-07-15 12:30:00,101.37,34.839,52.628,29.509 +2020-07-15 12:45:00,100.1,35.945,52.628,29.509 +2020-07-15 13:00:00,95.13,36.046,52.31,29.509 +2020-07-15 13:15:00,97.79,37.365,52.31,29.509 +2020-07-15 13:30:00,92.09,35.635,52.31,29.509 +2020-07-15 13:45:00,93.99,35.318000000000005,52.31,29.509 +2020-07-15 14:00:00,91.96,37.105,52.278999999999996,29.509 +2020-07-15 14:15:00,93.66,36.051,52.278999999999996,29.509 +2020-07-15 14:30:00,93.43,34.949,52.278999999999996,29.509 +2020-07-15 14:45:00,94.43,35.57,52.278999999999996,29.509 +2020-07-15 15:00:00,90.17,37.62,53.306999999999995,29.509 +2020-07-15 15:15:00,89.69,35.193000000000005,53.306999999999995,29.509 +2020-07-15 15:30:00,94.34,33.59,53.306999999999995,29.509 +2020-07-15 15:45:00,93.63,31.82,53.306999999999995,29.509 +2020-07-15 16:00:00,94.82,34.765,55.358999999999995,29.509 +2020-07-15 16:15:00,96.19,34.501999999999995,55.358999999999995,29.509 +2020-07-15 16:30:00,95.74,33.736999999999995,55.358999999999995,29.509 +2020-07-15 16:45:00,96.06,30.52,55.358999999999995,29.509 +2020-07-15 17:00:00,98.6,34.265,59.211999999999996,29.509 +2020-07-15 17:15:00,98.46,34.354,59.211999999999996,29.509 +2020-07-15 17:30:00,98.19,33.245,59.211999999999996,29.509 +2020-07-15 17:45:00,101.1,32.071999999999996,59.211999999999996,29.509 +2020-07-15 18:00:00,101.97,35.156,60.403999999999996,29.509 +2020-07-15 18:15:00,100.51,34.96,60.403999999999996,29.509 +2020-07-15 18:30:00,99.62,32.937,60.403999999999996,29.509 +2020-07-15 18:45:00,99.61,35.994,60.403999999999996,29.509 +2020-07-15 19:00:00,96.67,37.191,60.993,29.509 +2020-07-15 19:15:00,92.57,36.158,60.993,29.509 +2020-07-15 19:30:00,91.19,35.016999999999996,60.993,29.509 +2020-07-15 19:45:00,90.05,34.249,60.993,29.509 +2020-07-15 20:00:00,89.39,31.340999999999998,66.6,29.509 +2020-07-15 20:15:00,89.79,30.886999999999997,66.6,29.509 +2020-07-15 20:30:00,90.48,31.406999999999996,66.6,29.509 +2020-07-15 20:45:00,94.33,32.068000000000005,66.6,29.509 +2020-07-15 21:00:00,89.0,30.467,59.855,29.509 +2020-07-15 21:15:00,90.24,32.078,59.855,29.509 +2020-07-15 21:30:00,86.46,32.781,59.855,29.509 +2020-07-15 21:45:00,84.69,33.777,59.855,29.509 +2020-07-15 22:00:00,80.58,30.976999999999997,54.942,29.509 +2020-07-15 22:15:00,79.78,33.615,54.942,29.509 +2020-07-15 22:30:00,77.4,29.239,54.942,29.509 +2020-07-15 22:45:00,76.55,25.959,54.942,29.509 +2020-07-15 23:00:00,70.55,22.781,46.056000000000004,29.509 +2020-07-15 23:15:00,73.75,21.489,46.056000000000004,29.509 +2020-07-15 23:30:00,72.68,20.434,46.056000000000004,29.509 +2020-07-15 23:45:00,71.83,19.444000000000003,46.056000000000004,29.509 +2020-07-16 00:00:00,68.4,17.695999999999998,40.859,29.509 +2020-07-16 00:15:00,69.33,18.609,40.859,29.509 +2020-07-16 00:30:00,69.21,17.279,40.859,29.509 +2020-07-16 00:45:00,69.57,17.358,40.859,29.509 +2020-07-16 01:00:00,66.81,17.385,39.06,29.509 +2020-07-16 01:15:00,69.01,16.565,39.06,29.509 +2020-07-16 01:30:00,67.69,15.054,39.06,29.509 +2020-07-16 01:45:00,68.28,15.171,39.06,29.509 +2020-07-16 02:00:00,68.0,15.002,37.592,29.509 +2020-07-16 02:15:00,67.65,14.513,37.592,29.509 +2020-07-16 02:30:00,67.91,15.694,37.592,29.509 +2020-07-16 02:45:00,69.21,16.421,37.592,29.509 +2020-07-16 03:00:00,68.8,17.433,37.416,29.509 +2020-07-16 03:15:00,69.64,16.917,37.416,29.509 +2020-07-16 03:30:00,71.41,16.209,37.416,29.509 +2020-07-16 03:45:00,74.92,15.402999999999999,37.416,29.509 +2020-07-16 04:00:00,75.15,19.62,38.176,29.509 +2020-07-16 04:15:00,76.88,25.261999999999997,38.176,29.509 +2020-07-16 04:30:00,76.66,22.57,38.176,29.509 +2020-07-16 04:45:00,80.18,22.496,38.176,29.509 +2020-07-16 05:00:00,87.87,34.303000000000004,41.203,29.509 +2020-07-16 05:15:00,89.57,40.372,41.203,29.509 +2020-07-16 05:30:00,96.26,35.325,41.203,29.509 +2020-07-16 05:45:00,101.5,32.945,41.203,29.509 +2020-07-16 06:00:00,107.07,33.306,51.09,29.509 +2020-07-16 06:15:00,102.95,32.66,51.09,29.509 +2020-07-16 06:30:00,101.95,32.357,51.09,29.509 +2020-07-16 06:45:00,107.07,34.909,51.09,29.509 +2020-07-16 07:00:00,109.85,34.71,63.541000000000004,29.509 +2020-07-16 07:15:00,107.55,35.439,63.541000000000004,29.509 +2020-07-16 07:30:00,105.46,33.343,63.541000000000004,29.509 +2020-07-16 07:45:00,99.9,33.835,63.541000000000004,29.509 +2020-07-16 08:00:00,103.71,29.159000000000002,55.65,29.509 +2020-07-16 08:15:00,106.39,32.507,55.65,29.509 +2020-07-16 08:30:00,107.35,34.055,55.65,29.509 +2020-07-16 08:45:00,103.5,36.774,55.65,29.509 +2020-07-16 09:00:00,99.32,32.482,51.833999999999996,29.509 +2020-07-16 09:15:00,101.09,32.001999999999995,51.833999999999996,29.509 +2020-07-16 09:30:00,106.4,35.815,51.833999999999996,29.509 +2020-07-16 09:45:00,112.1,39.141,51.833999999999996,29.509 +2020-07-16 10:00:00,113.34,36.639,49.70399999999999,29.509 +2020-07-16 10:15:00,103.53,38.18,49.70399999999999,29.509 +2020-07-16 10:30:00,108.61,38.09,49.70399999999999,29.509 +2020-07-16 10:45:00,110.26,39.092,49.70399999999999,29.509 +2020-07-16 11:00:00,107.49,36.751999999999995,48.593999999999994,29.509 +2020-07-16 11:15:00,101.65,38.056999999999995,48.593999999999994,29.509 +2020-07-16 11:30:00,99.95,39.297,48.593999999999994,29.509 +2020-07-16 11:45:00,105.9,40.42,48.593999999999994,29.509 +2020-07-16 12:00:00,104.35,34.999,46.275,29.509 +2020-07-16 12:15:00,106.27,34.306999999999995,46.275,29.509 +2020-07-16 12:30:00,97.52,33.074,46.275,29.509 +2020-07-16 12:45:00,98.65,34.076,46.275,29.509 +2020-07-16 13:00:00,102.47,34.053000000000004,45.803000000000004,29.509 +2020-07-16 13:15:00,104.02,35.477,45.803000000000004,29.509 +2020-07-16 13:30:00,100.98,33.608000000000004,45.803000000000004,29.509 +2020-07-16 13:45:00,94.53,33.578,45.803000000000004,29.509 +2020-07-16 14:00:00,96.85,35.39,46.251999999999995,29.509 +2020-07-16 14:15:00,96.71,34.388000000000005,46.251999999999995,29.509 +2020-07-16 14:30:00,99.91,33.429,46.251999999999995,29.509 +2020-07-16 14:45:00,99.62,34.041,46.251999999999995,29.509 +2020-07-16 15:00:00,96.59,36.379,48.309,29.509 +2020-07-16 15:15:00,93.27,34.016999999999996,48.309,29.509 +2020-07-16 15:30:00,95.88,32.552,48.309,29.509 +2020-07-16 15:45:00,100.36,30.926,48.309,29.509 +2020-07-16 16:00:00,100.07,33.31,49.681999999999995,29.509 +2020-07-16 16:15:00,95.77,33.111,49.681999999999995,29.509 +2020-07-16 16:30:00,98.62,32.172,49.681999999999995,29.509 +2020-07-16 16:45:00,100.86,29.104,49.681999999999995,29.509 +2020-07-16 17:00:00,105.52,33.069,53.086000000000006,29.509 +2020-07-16 17:15:00,107.45,33.188,53.086000000000006,29.509 +2020-07-16 17:30:00,104.16,32.003,53.086000000000006,29.509 +2020-07-16 17:45:00,103.07,31.022,53.086000000000006,29.509 +2020-07-16 18:00:00,101.84,34.315,54.038999999999994,29.509 +2020-07-16 18:15:00,100.31,34.167,54.038999999999994,29.509 +2020-07-16 18:30:00,100.5,32.226,54.038999999999994,29.509 +2020-07-16 18:45:00,106.08,34.979,54.038999999999994,29.509 +2020-07-16 19:00:00,105.9,36.38,53.408,29.509 +2020-07-16 19:15:00,102.77,35.374,53.408,29.509 +2020-07-16 19:30:00,96.1,34.2,53.408,29.509 +2020-07-16 19:45:00,95.11,33.135,53.408,29.509 +2020-07-16 20:00:00,91.57,30.674,55.309,29.509 +2020-07-16 20:15:00,94.42,29.885,55.309,29.509 +2020-07-16 20:30:00,93.02,30.358,55.309,29.509 +2020-07-16 20:45:00,93.42,30.788,55.309,29.509 +2020-07-16 21:00:00,91.2,29.548000000000002,51.585,29.509 +2020-07-16 21:15:00,90.29,30.741999999999997,51.585,29.509 +2020-07-16 21:30:00,87.58,31.38,51.585,29.509 +2020-07-16 21:45:00,86.37,32.32,51.585,29.509 +2020-07-16 22:00:00,81.95,29.609,48.006,29.509 +2020-07-16 22:15:00,81.66,32.455,48.006,29.509 +2020-07-16 22:30:00,79.46,28.267,48.006,29.509 +2020-07-16 22:45:00,80.13,25.146,48.006,29.509 +2020-07-16 23:00:00,75.25,21.895,42.309,29.509 +2020-07-16 23:15:00,75.85,20.484,42.309,29.509 +2020-07-16 23:30:00,74.85,19.387,42.309,29.509 +2020-07-16 23:45:00,73.27,18.362000000000002,42.309,29.509 +2020-07-17 00:00:00,69.67,15.872,39.649,29.509 +2020-07-17 00:15:00,72.11,17.002,39.649,29.509 +2020-07-17 00:30:00,72.27,15.982999999999999,39.649,29.509 +2020-07-17 00:45:00,72.1,16.518,39.649,29.509 +2020-07-17 01:00:00,69.69,16.156,37.744,29.509 +2020-07-17 01:15:00,70.44,14.607000000000001,37.744,29.509 +2020-07-17 01:30:00,70.83,14.513,37.744,29.509 +2020-07-17 01:45:00,73.34,14.513,37.744,29.509 +2020-07-17 02:00:00,70.32,14.565999999999999,36.965,29.509 +2020-07-17 02:15:00,70.93,14.513,36.965,29.509 +2020-07-17 02:30:00,70.09,16.064,36.965,29.509 +2020-07-17 02:45:00,69.59,16.048,36.965,29.509 +2020-07-17 03:00:00,70.93,17.969,37.678000000000004,29.509 +2020-07-17 03:15:00,72.45,16.085,37.678000000000004,29.509 +2020-07-17 03:30:00,74.49,15.127,37.678000000000004,29.509 +2020-07-17 03:45:00,74.49,15.26,37.678000000000004,29.509 +2020-07-17 04:00:00,76.78,19.583,38.591,29.509 +2020-07-17 04:15:00,83.05,23.531999999999996,38.591,29.509 +2020-07-17 04:30:00,83.79,21.848000000000003,38.591,29.509 +2020-07-17 04:45:00,82.32,21.16,38.591,29.509 +2020-07-17 05:00:00,92.53,32.51,40.666,29.509 +2020-07-17 05:15:00,93.78,39.453,40.666,29.509 +2020-07-17 05:30:00,99.45,34.591,40.666,29.509 +2020-07-17 05:45:00,102.94,31.750999999999998,40.666,29.509 +2020-07-17 06:00:00,108.29,32.311,51.784,29.509 +2020-07-17 06:15:00,101.27,31.945999999999998,51.784,29.509 +2020-07-17 06:30:00,101.61,31.660999999999998,51.784,29.509 +2020-07-17 06:45:00,108.21,33.99,51.784,29.509 +2020-07-17 07:00:00,112.7,34.54,61.383,29.509 +2020-07-17 07:15:00,110.87,36.194,61.383,29.509 +2020-07-17 07:30:00,107.13,31.997,61.383,29.509 +2020-07-17 07:45:00,105.72,32.355,61.383,29.509 +2020-07-17 08:00:00,109.76,28.636,55.272,29.509 +2020-07-17 08:15:00,109.09,32.758,55.272,29.509 +2020-07-17 08:30:00,106.95,34.098,55.272,29.509 +2020-07-17 08:45:00,103.14,36.793,55.272,29.509 +2020-07-17 09:00:00,105.78,29.919,53.506,29.509 +2020-07-17 09:15:00,106.52,31.503,53.506,29.509 +2020-07-17 09:30:00,107.91,34.588,53.506,29.509 +2020-07-17 09:45:00,105.7,38.348,53.506,29.509 +2020-07-17 10:00:00,103.69,35.79,51.363,29.509 +2020-07-17 10:15:00,108.86,37.004,51.363,29.509 +2020-07-17 10:30:00,108.56,37.53,51.363,29.509 +2020-07-17 10:45:00,105.22,38.471,51.363,29.509 +2020-07-17 11:00:00,99.4,36.409,51.043,29.509 +2020-07-17 11:15:00,104.69,36.599000000000004,51.043,29.509 +2020-07-17 11:30:00,105.83,37.249,51.043,29.509 +2020-07-17 11:45:00,105.9,37.297,51.043,29.509 +2020-07-17 12:00:00,97.56,32.258,47.52,29.509 +2020-07-17 12:15:00,96.68,31.066,47.52,29.509 +2020-07-17 12:30:00,91.93,29.941999999999997,47.52,29.509 +2020-07-17 12:45:00,92.95,30.011999999999997,47.52,29.509 +2020-07-17 13:00:00,96.42,30.528000000000002,45.494,29.509 +2020-07-17 13:15:00,97.53,32.061,45.494,29.509 +2020-07-17 13:30:00,94.43,31.075,45.494,29.509 +2020-07-17 13:45:00,91.76,31.401999999999997,45.494,29.509 +2020-07-17 14:00:00,92.13,32.44,43.883,29.509 +2020-07-17 14:15:00,90.45,31.94,43.883,29.509 +2020-07-17 14:30:00,91.4,32.594,43.883,29.509 +2020-07-17 14:45:00,93.5,32.418,43.883,29.509 +2020-07-17 15:00:00,94.71,34.715,45.714,29.509 +2020-07-17 15:15:00,95.27,32.129,45.714,29.509 +2020-07-17 15:30:00,88.29,30.189,45.714,29.509 +2020-07-17 15:45:00,92.07,29.386,45.714,29.509 +2020-07-17 16:00:00,91.83,30.943,48.222,29.509 +2020-07-17 16:15:00,100.32,31.264,48.222,29.509 +2020-07-17 16:30:00,98.96,30.133000000000003,48.222,29.509 +2020-07-17 16:45:00,102.8,26.223000000000003,48.222,29.509 +2020-07-17 17:00:00,96.41,32.058,52.619,29.509 +2020-07-17 17:15:00,97.39,32.042,52.619,29.509 +2020-07-17 17:30:00,96.03,31.074,52.619,29.509 +2020-07-17 17:45:00,101.63,29.944000000000003,52.619,29.509 +2020-07-17 18:00:00,104.59,33.204,52.99,29.509 +2020-07-17 18:15:00,104.12,32.05,52.99,29.509 +2020-07-17 18:30:00,99.36,29.965999999999998,52.99,29.509 +2020-07-17 18:45:00,95.42,33.177,52.99,29.509 +2020-07-17 19:00:00,99.76,35.439,51.923,29.509 +2020-07-17 19:15:00,99.09,34.96,51.923,29.509 +2020-07-17 19:30:00,93.72,33.879,51.923,29.509 +2020-07-17 19:45:00,89.16,31.75,51.923,29.509 +2020-07-17 20:00:00,86.65,29.105999999999998,56.238,29.509 +2020-07-17 20:15:00,91.65,29.175,56.238,29.509 +2020-07-17 20:30:00,91.6,29.116999999999997,56.238,29.509 +2020-07-17 20:45:00,89.51,28.627,56.238,29.509 +2020-07-17 21:00:00,81.75,28.764,52.426,29.509 +2020-07-17 21:15:00,84.05,31.724,52.426,29.509 +2020-07-17 21:30:00,85.59,32.179,52.426,29.509 +2020-07-17 21:45:00,85.67,33.286,52.426,29.509 +2020-07-17 22:00:00,79.08,30.348000000000003,48.196000000000005,29.509 +2020-07-17 22:15:00,73.88,32.935,48.196000000000005,29.509 +2020-07-17 22:30:00,71.96,33.379,48.196000000000005,29.509 +2020-07-17 22:45:00,70.94,30.925,48.196000000000005,29.509 +2020-07-17 23:00:00,69.13,29.535999999999998,41.71,29.509 +2020-07-17 23:15:00,72.96,26.595,41.71,29.509 +2020-07-17 23:30:00,74.34,23.662,41.71,29.509 +2020-07-17 23:45:00,73.55,22.548000000000002,41.71,29.509 +2020-07-18 00:00:00,64.97,17.482,41.105,29.398000000000003 +2020-07-18 00:15:00,64.83,18.22,41.105,29.398000000000003 +2020-07-18 00:30:00,69.52,16.590999999999998,41.105,29.398000000000003 +2020-07-18 00:45:00,69.35,16.285,41.105,29.398000000000003 +2020-07-18 01:00:00,67.98,16.169,36.934,29.398000000000003 +2020-07-18 01:15:00,63.54,15.332,36.934,29.398000000000003 +2020-07-18 01:30:00,65.47,14.513,36.934,29.398000000000003 +2020-07-18 01:45:00,67.32,14.98,36.934,29.398000000000003 +2020-07-18 02:00:00,66.36,14.703,34.782,29.398000000000003 +2020-07-18 02:15:00,62.18,14.513,34.782,29.398000000000003 +2020-07-18 02:30:00,60.75,14.605,34.782,29.398000000000003 +2020-07-18 02:45:00,66.16,15.431,34.782,29.398000000000003 +2020-07-18 03:00:00,65.84,15.811,34.489000000000004,29.398000000000003 +2020-07-18 03:15:00,67.15,14.513,34.489000000000004,29.398000000000003 +2020-07-18 03:30:00,60.47,14.513,34.489000000000004,29.398000000000003 +2020-07-18 03:45:00,66.51,14.547,34.489000000000004,29.398000000000003 +2020-07-18 04:00:00,65.6,17.13,34.111,29.398000000000003 +2020-07-18 04:15:00,60.82,20.293,34.111,29.398000000000003 +2020-07-18 04:30:00,65.33,16.974,34.111,29.398000000000003 +2020-07-18 04:45:00,61.82,16.62,34.111,29.398000000000003 +2020-07-18 05:00:00,61.13,20.213,33.283,29.398000000000003 +2020-07-18 05:15:00,60.73,16.794,33.283,29.398000000000003 +2020-07-18 05:30:00,62.16,14.513,33.283,29.398000000000003 +2020-07-18 05:45:00,66.61,15.129000000000001,33.283,29.398000000000003 +2020-07-18 06:00:00,72.23,26.281,33.653,29.398000000000003 +2020-07-18 06:15:00,71.83,33.03,33.653,29.398000000000003 +2020-07-18 06:30:00,69.3,30.125,33.653,29.398000000000003 +2020-07-18 06:45:00,68.38,29.725,33.653,29.398000000000003 +2020-07-18 07:00:00,73.72,29.413,36.732,29.398000000000003 +2020-07-18 07:15:00,77.89,29.795,36.732,29.398000000000003 +2020-07-18 07:30:00,79.76,26.998,36.732,29.398000000000003 +2020-07-18 07:45:00,78.08,28.014,36.732,29.398000000000003 +2020-07-18 08:00:00,79.55,24.814,41.318999999999996,29.398000000000003 +2020-07-18 08:15:00,85.3,28.331999999999997,41.318999999999996,29.398000000000003 +2020-07-18 08:30:00,84.34,29.479,41.318999999999996,29.398000000000003 +2020-07-18 08:45:00,82.38,32.879,41.318999999999996,29.398000000000003 +2020-07-18 09:00:00,79.5,29.301,43.195,29.398000000000003 +2020-07-18 09:15:00,79.99,31.334,43.195,29.398000000000003 +2020-07-18 09:30:00,88.35,34.856,43.195,29.398000000000003 +2020-07-18 09:45:00,91.53,38.098,43.195,29.398000000000003 +2020-07-18 10:00:00,87.81,36.235,41.843999999999994,29.398000000000003 +2020-07-18 10:15:00,83.2,37.816,41.843999999999994,29.398000000000003 +2020-07-18 10:30:00,84.65,37.939,41.843999999999994,29.398000000000003 +2020-07-18 10:45:00,92.01,38.341,41.843999999999994,29.398000000000003 +2020-07-18 11:00:00,88.51,36.077,39.035,29.398000000000003 +2020-07-18 11:15:00,83.11,37.3,39.035,29.398000000000003 +2020-07-18 11:30:00,78.1,38.375,39.035,29.398000000000003 +2020-07-18 11:45:00,76.27,39.258,39.035,29.398000000000003 +2020-07-18 12:00:00,74.23,34.78,38.001,29.398000000000003 +2020-07-18 12:15:00,71.26,34.455,38.001,29.398000000000003 +2020-07-18 12:30:00,65.03,33.147,38.001,29.398000000000003 +2020-07-18 12:45:00,61.3,34.099000000000004,38.001,29.398000000000003 +2020-07-18 13:00:00,60.24,33.71,34.747,29.398000000000003 +2020-07-18 13:15:00,62.03,34.865,34.747,29.398000000000003 +2020-07-18 13:30:00,65.18,34.141,34.747,29.398000000000003 +2020-07-18 13:45:00,70.0,32.931999999999995,34.747,29.398000000000003 +2020-07-18 14:00:00,72.75,33.854,33.434,29.398000000000003 +2020-07-18 14:15:00,74.53,32.037,33.434,29.398000000000003 +2020-07-18 14:30:00,77.56,32.413000000000004,33.434,29.398000000000003 +2020-07-18 14:45:00,79.82,32.749,33.434,29.398000000000003 +2020-07-18 15:00:00,79.73,35.416,35.921,29.398000000000003 +2020-07-18 15:15:00,78.71,33.518,35.921,29.398000000000003 +2020-07-18 15:30:00,78.58,31.645,35.921,29.398000000000003 +2020-07-18 15:45:00,78.43,29.824,35.921,29.398000000000003 +2020-07-18 16:00:00,77.97,33.81,39.427,29.398000000000003 +2020-07-18 16:15:00,75.31,33.069,39.427,29.398000000000003 +2020-07-18 16:30:00,75.91,32.207,39.427,29.398000000000003 +2020-07-18 16:45:00,77.36,28.189,39.427,29.398000000000003 +2020-07-18 17:00:00,78.24,32.734,44.096000000000004,29.398000000000003 +2020-07-18 17:15:00,78.03,30.230999999999998,44.096000000000004,29.398000000000003 +2020-07-18 17:30:00,78.44,29.135,44.096000000000004,29.398000000000003 +2020-07-18 17:45:00,80.13,28.595,44.096000000000004,29.398000000000003 +2020-07-18 18:00:00,79.23,33.352,43.931000000000004,29.398000000000003 +2020-07-18 18:15:00,78.26,33.839,43.931000000000004,29.398000000000003 +2020-07-18 18:30:00,77.24,33.111,43.931000000000004,29.398000000000003 +2020-07-18 18:45:00,76.69,32.857,43.931000000000004,29.398000000000003 +2020-07-18 19:00:00,73.66,33.239000000000004,42.187,29.398000000000003 +2020-07-18 19:15:00,71.27,31.711,42.187,29.398000000000003 +2020-07-18 19:30:00,70.94,31.392,42.187,29.398000000000003 +2020-07-18 19:45:00,71.36,31.168000000000003,42.187,29.398000000000003 +2020-07-18 20:00:00,71.01,29.235,38.315,29.398000000000003 +2020-07-18 20:15:00,72.39,28.453000000000003,38.315,29.398000000000003 +2020-07-18 20:30:00,72.9,27.475,38.315,29.398000000000003 +2020-07-18 20:45:00,72.58,29.005,38.315,29.398000000000003 +2020-07-18 21:00:00,71.06,27.381,36.843,29.398000000000003 +2020-07-18 21:15:00,70.83,29.934,36.843,29.398000000000003 +2020-07-18 21:30:00,68.87,30.485,36.843,29.398000000000003 +2020-07-18 21:45:00,68.49,31.026,36.843,29.398000000000003 +2020-07-18 22:00:00,65.43,27.968000000000004,37.260999999999996,29.398000000000003 +2020-07-18 22:15:00,65.56,30.64,37.260999999999996,29.398000000000003 +2020-07-18 22:30:00,63.24,30.226,37.260999999999996,29.398000000000003 +2020-07-18 22:45:00,62.82,28.031999999999996,37.260999999999996,29.398000000000003 +2020-07-18 23:00:00,57.26,25.816999999999997,32.148,29.398000000000003 +2020-07-18 23:15:00,59.35,23.447,32.148,29.398000000000003 +2020-07-18 23:30:00,58.36,23.248,32.148,29.398000000000003 +2020-07-18 23:45:00,57.61,22.671,32.148,29.398000000000003 +2020-07-19 00:00:00,55.61,18.977,28.905,29.398000000000003 +2020-07-19 00:15:00,55.79,18.499000000000002,28.905,29.398000000000003 +2020-07-19 00:30:00,54.68,16.748,28.905,29.398000000000003 +2020-07-19 00:45:00,54.38,16.272000000000002,28.905,29.398000000000003 +2020-07-19 01:00:00,51.87,16.452,26.906999999999996,29.398000000000003 +2020-07-19 01:15:00,53.39,15.395999999999999,26.906999999999996,29.398000000000003 +2020-07-19 01:30:00,53.69,14.513,26.906999999999996,29.398000000000003 +2020-07-19 01:45:00,53.75,14.513,26.906999999999996,29.398000000000003 +2020-07-19 02:00:00,52.09,14.513,25.938000000000002,29.398000000000003 +2020-07-19 02:15:00,52.77,14.513,25.938000000000002,29.398000000000003 +2020-07-19 02:30:00,52.15,15.205,25.938000000000002,29.398000000000003 +2020-07-19 02:45:00,52.3,15.685,25.938000000000002,29.398000000000003 +2020-07-19 03:00:00,51.95,16.727999999999998,24.693,29.398000000000003 +2020-07-19 03:15:00,52.28,14.535,24.693,29.398000000000003 +2020-07-19 03:30:00,53.32,14.513,24.693,29.398000000000003 +2020-07-19 03:45:00,53.05,14.513,24.693,29.398000000000003 +2020-07-19 04:00:00,52.24,16.62,25.683000000000003,29.398000000000003 +2020-07-19 04:15:00,51.65,19.378,25.683000000000003,29.398000000000003 +2020-07-19 04:30:00,51.6,17.485,25.683000000000003,29.398000000000003 +2020-07-19 04:45:00,52.23,16.61,25.683000000000003,29.398000000000003 +2020-07-19 05:00:00,51.43,20.625,26.023000000000003,29.398000000000003 +2020-07-19 05:15:00,51.17,16.522000000000002,26.023000000000003,29.398000000000003 +2020-07-19 05:30:00,51.75,14.513,26.023000000000003,29.398000000000003 +2020-07-19 05:45:00,52.6,14.513,26.023000000000003,29.398000000000003 +2020-07-19 06:00:00,54.27,22.829,25.834,29.398000000000003 +2020-07-19 06:15:00,54.5,30.6,25.834,29.398000000000003 +2020-07-19 06:30:00,55.07,27.053,25.834,29.398000000000003 +2020-07-19 06:45:00,57.47,25.699,25.834,29.398000000000003 +2020-07-19 07:00:00,59.13,25.54,27.765,29.398000000000003 +2020-07-19 07:15:00,59.65,24.248,27.765,29.398000000000003 +2020-07-19 07:30:00,60.2,22.959,27.765,29.398000000000003 +2020-07-19 07:45:00,60.84,24.031999999999996,27.765,29.398000000000003 +2020-07-19 08:00:00,61.14,21.519000000000002,31.357,29.398000000000003 +2020-07-19 08:15:00,58.21,26.334,31.357,29.398000000000003 +2020-07-19 08:30:00,57.28,28.247,31.357,29.398000000000003 +2020-07-19 08:45:00,58.82,31.338,31.357,29.398000000000003 +2020-07-19 09:00:00,59.11,27.697,33.238,29.398000000000003 +2020-07-19 09:15:00,60.1,29.121,33.238,29.398000000000003 +2020-07-19 09:30:00,59.77,33.126,33.238,29.398000000000003 +2020-07-19 09:45:00,62.12,37.48,33.238,29.398000000000003 +2020-07-19 10:00:00,62.62,35.931,34.22,29.398000000000003 +2020-07-19 10:15:00,63.28,37.588,34.22,29.398000000000003 +2020-07-19 10:30:00,63.94,37.878,34.22,29.398000000000003 +2020-07-19 10:45:00,63.69,39.635,34.22,29.398000000000003 +2020-07-19 11:00:00,63.64,36.87,36.298,29.398000000000003 +2020-07-19 11:15:00,65.93,37.582,36.298,29.398000000000003 +2020-07-19 11:30:00,62.62,39.328,36.298,29.398000000000003 +2020-07-19 11:45:00,61.61,40.412,36.298,29.398000000000003 +2020-07-19 12:00:00,60.44,37.195,33.52,29.398000000000003 +2020-07-19 12:15:00,58.9,35.936,33.52,29.398000000000003 +2020-07-19 12:30:00,58.57,35.041,33.52,29.398000000000003 +2020-07-19 12:45:00,56.89,35.4,33.52,29.398000000000003 +2020-07-19 13:00:00,54.98,34.747,30.12,29.398000000000003 +2020-07-19 13:15:00,53.24,34.882,30.12,29.398000000000003 +2020-07-19 13:30:00,51.7,32.954,30.12,29.398000000000003 +2020-07-19 13:45:00,54.52,33.073,30.12,29.398000000000003 +2020-07-19 14:00:00,54.96,35.285,27.233,29.398000000000003 +2020-07-19 14:15:00,52.39,33.801,27.233,29.398000000000003 +2020-07-19 14:30:00,50.08,32.659,27.233,29.398000000000003 +2020-07-19 14:45:00,49.54,31.871,27.233,29.398000000000003 +2020-07-19 15:00:00,51.32,34.882,27.468000000000004,29.398000000000003 +2020-07-19 15:15:00,51.02,32.001,27.468000000000004,29.398000000000003 +2020-07-19 15:30:00,52.26,29.862,27.468000000000004,29.398000000000003 +2020-07-19 15:45:00,52.6,28.27,27.468000000000004,29.398000000000003 +2020-07-19 16:00:00,56.74,30.274,30.8,29.398000000000003 +2020-07-19 16:15:00,57.44,29.891,30.8,29.398000000000003 +2020-07-19 16:30:00,60.95,30.116999999999997,30.8,29.398000000000003 +2020-07-19 16:45:00,61.94,26.168000000000003,30.8,29.398000000000003 +2020-07-19 17:00:00,67.22,31.125999999999998,37.806,29.398000000000003 +2020-07-19 17:15:00,71.64,30.329,37.806,29.398000000000003 +2020-07-19 17:30:00,73.51,30.09,37.806,29.398000000000003 +2020-07-19 17:45:00,74.82,29.78,37.806,29.398000000000003 +2020-07-19 18:00:00,75.5,35.266,40.766,29.398000000000003 +2020-07-19 18:15:00,73.22,35.173,40.766,29.398000000000003 +2020-07-19 18:30:00,72.29,34.387,40.766,29.398000000000003 +2020-07-19 18:45:00,72.76,34.076,40.766,29.398000000000003 +2020-07-19 19:00:00,74.69,36.863,41.163000000000004,29.398000000000003 +2020-07-19 19:15:00,73.14,34.086,41.163000000000004,29.398000000000003 +2020-07-19 19:30:00,71.41,33.510999999999996,41.163000000000004,29.398000000000003 +2020-07-19 19:45:00,72.67,32.666,41.163000000000004,29.398000000000003 +2020-07-19 20:00:00,73.53,30.916,39.885999999999996,29.398000000000003 +2020-07-19 20:15:00,75.2,29.899,39.885999999999996,29.398000000000003 +2020-07-19 20:30:00,76.95,29.673000000000002,39.885999999999996,29.398000000000003 +2020-07-19 20:45:00,76.76,29.506,39.885999999999996,29.398000000000003 +2020-07-19 21:00:00,74.32,28.004,38.900999999999996,29.398000000000003 +2020-07-19 21:15:00,74.99,30.284000000000002,38.900999999999996,29.398000000000003 +2020-07-19 21:30:00,73.19,30.101999999999997,38.900999999999996,29.398000000000003 +2020-07-19 21:45:00,71.17,30.999000000000002,38.900999999999996,29.398000000000003 +2020-07-19 22:00:00,66.49,30.218000000000004,39.806999999999995,29.398000000000003 +2020-07-19 22:15:00,68.96,31.16,39.806999999999995,29.398000000000003 +2020-07-19 22:30:00,67.63,30.438000000000002,39.806999999999995,29.398000000000003 +2020-07-19 22:45:00,67.17,26.953000000000003,39.806999999999995,29.398000000000003 +2020-07-19 23:00:00,62.05,24.655,35.564,29.398000000000003 +2020-07-19 23:15:00,63.9,23.502,35.564,29.398000000000003 +2020-07-19 23:30:00,62.49,22.683000000000003,35.564,29.398000000000003 +2020-07-19 23:45:00,62.2,22.201,35.564,29.398000000000003 +2020-07-20 00:00:00,60.09,20.273,36.578,29.509 +2020-07-20 00:15:00,60.71,20.372,36.578,29.509 +2020-07-20 00:30:00,59.91,18.199,36.578,29.509 +2020-07-20 00:45:00,60.28,17.354,36.578,29.509 +2020-07-20 01:00:00,58.64,17.95,35.292,29.509 +2020-07-20 01:15:00,58.78,16.954,35.292,29.509 +2020-07-20 01:30:00,59.4,15.62,35.292,29.509 +2020-07-20 01:45:00,58.95,16.297,35.292,29.509 +2020-07-20 02:00:00,58.17,16.67,34.319,29.509 +2020-07-20 02:15:00,59.43,14.513,34.319,29.509 +2020-07-20 02:30:00,65.62,16.453,34.319,29.509 +2020-07-20 02:45:00,68.61,16.833,34.319,29.509 +2020-07-20 03:00:00,68.25,18.299,33.13,29.509 +2020-07-20 03:15:00,63.19,16.746,33.13,29.509 +2020-07-20 03:30:00,64.3,16.148,33.13,29.509 +2020-07-20 03:45:00,71.14,16.613,33.13,29.509 +2020-07-20 04:00:00,74.7,22.09,33.851,29.509 +2020-07-20 04:15:00,76.07,27.603,33.851,29.509 +2020-07-20 04:30:00,72.95,24.997,33.851,29.509 +2020-07-20 04:45:00,78.22,24.505,33.851,29.509 +2020-07-20 05:00:00,83.67,34.926,38.718,29.509 +2020-07-20 05:15:00,86.56,40.138000000000005,38.718,29.509 +2020-07-20 05:30:00,86.91,34.939,38.718,29.509 +2020-07-20 05:45:00,96.34,33.38,38.718,29.509 +2020-07-20 06:00:00,101.08,32.46,51.648999999999994,29.509 +2020-07-20 06:15:00,103.96,31.761999999999997,51.648999999999994,29.509 +2020-07-20 06:30:00,100.97,31.808000000000003,51.648999999999994,29.509 +2020-07-20 06:45:00,101.61,35.384,51.648999999999994,29.509 +2020-07-20 07:00:00,101.25,34.973,60.159,29.509 +2020-07-20 07:15:00,101.47,36.051,60.159,29.509 +2020-07-20 07:30:00,105.36,33.915,60.159,29.509 +2020-07-20 07:45:00,106.42,35.589,60.159,29.509 +2020-07-20 08:00:00,107.88,31.041,53.8,29.509 +2020-07-20 08:15:00,105.6,34.771,53.8,29.509 +2020-07-20 08:30:00,101.05,36.065,53.8,29.509 +2020-07-20 08:45:00,107.78,39.739000000000004,53.8,29.509 +2020-07-20 09:00:00,106.48,34.959,50.583,29.509 +2020-07-20 09:15:00,103.76,34.821,50.583,29.509 +2020-07-20 09:30:00,100.88,37.909,50.583,29.509 +2020-07-20 09:45:00,97.4,39.689,50.583,29.509 +2020-07-20 10:00:00,103.26,38.675,49.11600000000001,29.509 +2020-07-20 10:15:00,107.19,40.2,49.11600000000001,29.509 +2020-07-20 10:30:00,105.63,40.084,49.11600000000001,29.509 +2020-07-20 10:45:00,97.62,39.985,49.11600000000001,29.509 +2020-07-20 11:00:00,97.57,37.84,49.056000000000004,29.509 +2020-07-20 11:15:00,103.01,38.643,49.056000000000004,29.509 +2020-07-20 11:30:00,102.55,40.983000000000004,49.056000000000004,29.509 +2020-07-20 11:45:00,105.27,42.663999999999994,49.056000000000004,29.509 +2020-07-20 12:00:00,98.29,37.333,47.227,29.509 +2020-07-20 12:15:00,99.49,36.194,47.227,29.509 +2020-07-20 12:30:00,97.27,34.074,47.227,29.509 +2020-07-20 12:45:00,96.77,34.235,47.227,29.509 +2020-07-20 13:00:00,95.98,34.484,47.006,29.509 +2020-07-20 13:15:00,91.51,33.8,47.006,29.509 +2020-07-20 13:30:00,91.54,32.129,47.006,29.509 +2020-07-20 13:45:00,91.45,33.25,47.006,29.509 +2020-07-20 14:00:00,91.7,34.586,47.19,29.509 +2020-07-20 14:15:00,91.78,33.838,47.19,29.509 +2020-07-20 14:30:00,88.92,32.577,47.19,29.509 +2020-07-20 14:45:00,90.77,34.091,47.19,29.509 +2020-07-20 15:00:00,87.71,36.536,47.846000000000004,29.509 +2020-07-20 15:15:00,88.74,33.169000000000004,47.846000000000004,29.509 +2020-07-20 15:30:00,89.17,31.98,47.846000000000004,29.509 +2020-07-20 15:45:00,88.84,29.899,47.846000000000004,29.509 +2020-07-20 16:00:00,89.26,33.132,49.641000000000005,29.509 +2020-07-20 16:15:00,89.55,32.911,49.641000000000005,29.509 +2020-07-20 16:30:00,91.94,32.528,49.641000000000005,29.509 +2020-07-20 16:45:00,94.53,28.75,49.641000000000005,29.509 +2020-07-20 17:00:00,95.39,32.519,54.133,29.509 +2020-07-20 17:15:00,94.5,32.255,54.133,29.509 +2020-07-20 17:30:00,96.79,31.66,54.133,29.509 +2020-07-20 17:45:00,98.25,31.088,54.133,29.509 +2020-07-20 18:00:00,98.64,35.414,53.761,29.509 +2020-07-20 18:15:00,97.32,33.545,53.761,29.509 +2020-07-20 18:30:00,97.45,31.878,53.761,29.509 +2020-07-20 18:45:00,96.82,34.93,53.761,29.509 +2020-07-20 19:00:00,93.5,37.611,53.923,29.509 +2020-07-20 19:15:00,90.15,36.361,53.923,29.509 +2020-07-20 19:30:00,89.51,35.339,53.923,29.509 +2020-07-20 19:45:00,89.22,33.908,53.923,29.509 +2020-07-20 20:00:00,86.74,31.003,58.786,29.509 +2020-07-20 20:15:00,87.84,31.72,58.786,29.509 +2020-07-20 20:30:00,88.62,32.219,58.786,29.509 +2020-07-20 20:45:00,88.28,32.134,58.786,29.509 +2020-07-20 21:00:00,86.92,29.896,54.591,29.509 +2020-07-20 21:15:00,85.22,32.747,54.591,29.509 +2020-07-20 21:30:00,80.97,33.064,54.591,29.509 +2020-07-20 21:45:00,80.99,33.756,54.591,29.509 +2020-07-20 22:00:00,74.55,31.004,51.551,29.509 +2020-07-20 22:15:00,75.32,34.176,51.551,29.509 +2020-07-20 22:30:00,74.29,29.589000000000002,51.551,29.509 +2020-07-20 22:45:00,72.88,26.478,51.551,29.509 +2020-07-20 23:00:00,68.65,24.109,44.716,29.509 +2020-07-20 23:15:00,69.81,20.999000000000002,44.716,29.509 +2020-07-20 23:30:00,68.67,19.935,44.716,29.509 +2020-07-20 23:45:00,67.93,18.665,44.716,29.509 +2020-07-21 00:00:00,63.82,17.929000000000002,43.01,29.509 +2020-07-21 00:15:00,65.39,18.83,43.01,29.509 +2020-07-21 00:30:00,64.02,17.522000000000002,43.01,29.509 +2020-07-21 00:45:00,64.2,17.633,43.01,29.509 +2020-07-21 01:00:00,64.16,17.659000000000002,40.687,29.509 +2020-07-21 01:15:00,65.18,16.822,40.687,29.509 +2020-07-21 01:30:00,64.43,15.337,40.687,29.509 +2020-07-21 01:45:00,65.07,15.425,40.687,29.509 +2020-07-21 02:00:00,63.66,15.28,39.554,29.509 +2020-07-21 02:15:00,65.32,14.513,39.554,29.509 +2020-07-21 02:30:00,72.4,15.956,39.554,29.509 +2020-07-21 02:45:00,73.53,16.692,39.554,29.509 +2020-07-21 03:00:00,68.84,17.657,38.958,29.509 +2020-07-21 03:15:00,66.73,17.21,38.958,29.509 +2020-07-21 03:30:00,70.8,16.541,38.958,29.509 +2020-07-21 03:45:00,75.24,15.792,38.958,29.509 +2020-07-21 04:00:00,77.83,19.896,39.783,29.509 +2020-07-21 04:15:00,79.77,25.436,39.783,29.509 +2020-07-21 04:30:00,76.2,22.725,39.783,29.509 +2020-07-21 04:45:00,78.11,22.65,39.783,29.509 +2020-07-21 05:00:00,86.55,34.32,42.281000000000006,29.509 +2020-07-21 05:15:00,88.52,40.176,42.281000000000006,29.509 +2020-07-21 05:30:00,90.48,35.345,42.281000000000006,29.509 +2020-07-21 05:45:00,99.68,33.008,42.281000000000006,29.509 +2020-07-21 06:00:00,105.81,33.314,50.801,29.509 +2020-07-21 06:15:00,105.55,32.696,50.801,29.509 +2020-07-21 06:30:00,99.73,32.465,50.801,29.509 +2020-07-21 06:45:00,103.62,35.137,50.801,29.509 +2020-07-21 07:00:00,102.74,34.907,60.202,29.509 +2020-07-21 07:15:00,100.9,35.742,60.202,29.509 +2020-07-21 07:30:00,99.85,33.693000000000005,60.202,29.509 +2020-07-21 07:45:00,99.38,34.294000000000004,60.202,29.509 +2020-07-21 08:00:00,99.42,29.659000000000002,54.461000000000006,29.509 +2020-07-21 08:15:00,99.3,33.006,54.461000000000006,29.509 +2020-07-21 08:30:00,105.13,34.493,54.461000000000006,29.509 +2020-07-21 08:45:00,106.32,37.162,54.461000000000006,29.509 +2020-07-21 09:00:00,105.96,32.863,50.753,29.509 +2020-07-21 09:15:00,101.04,32.369,50.753,29.509 +2020-07-21 09:30:00,99.71,36.133,50.753,29.509 +2020-07-21 09:45:00,98.06,39.431999999999995,50.753,29.509 +2020-07-21 10:00:00,97.74,36.961999999999996,49.703,29.509 +2020-07-21 10:15:00,100.93,38.46,49.703,29.509 +2020-07-21 10:30:00,104.39,38.344,49.703,29.509 +2020-07-21 10:45:00,104.51,39.336999999999996,49.703,29.509 +2020-07-21 11:00:00,102.28,37.006,49.42100000000001,29.509 +2020-07-21 11:15:00,99.28,38.304,49.42100000000001,29.509 +2020-07-21 11:30:00,95.02,39.493,49.42100000000001,29.509 +2020-07-21 11:45:00,101.89,40.571,49.42100000000001,29.509 +2020-07-21 12:00:00,99.86,35.217,47.155,29.509 +2020-07-21 12:15:00,95.66,34.506,47.155,29.509 +2020-07-21 12:30:00,93.76,33.255,47.155,29.509 +2020-07-21 12:45:00,93.59,34.227,47.155,29.509 +2020-07-21 13:00:00,92.33,34.104,47.515,29.509 +2020-07-21 13:15:00,91.18,35.485,47.515,29.509 +2020-07-21 13:30:00,89.86,33.629,47.515,29.509 +2020-07-21 13:45:00,90.0,33.645,47.515,29.509 +2020-07-21 14:00:00,90.09,35.437,47.575,29.509 +2020-07-21 14:15:00,92.68,34.449,47.575,29.509 +2020-07-21 14:30:00,90.15,33.472,47.575,29.509 +2020-07-21 14:45:00,88.28,34.118,47.575,29.509 +2020-07-21 15:00:00,88.62,36.428000000000004,48.903,29.509 +2020-07-21 15:15:00,88.99,34.052,48.903,29.509 +2020-07-21 15:30:00,89.61,32.603,48.903,29.509 +2020-07-21 15:45:00,91.36,30.955,48.903,29.509 +2020-07-21 16:00:00,93.5,33.351,50.218999999999994,29.509 +2020-07-21 16:15:00,91.9,33.176,50.218999999999994,29.509 +2020-07-21 16:30:00,94.2,32.317,50.218999999999994,29.509 +2020-07-21 16:45:00,95.32,29.316,50.218999999999994,29.509 +2020-07-21 17:00:00,95.98,33.25,55.396,29.509 +2020-07-21 17:15:00,97.01,33.464,55.396,29.509 +2020-07-21 17:30:00,98.47,32.316,55.396,29.509 +2020-07-21 17:45:00,99.18,31.405,55.396,29.509 +2020-07-21 18:00:00,98.99,34.709,55.583999999999996,29.509 +2020-07-21 18:15:00,99.15,34.52,55.583999999999996,29.509 +2020-07-21 18:30:00,101.85,32.603,55.583999999999996,29.509 +2020-07-21 18:45:00,98.64,35.358000000000004,55.583999999999996,29.509 +2020-07-21 19:00:00,97.79,36.768,56.071000000000005,29.509 +2020-07-21 19:15:00,91.91,35.734,56.071000000000005,29.509 +2020-07-21 19:30:00,90.5,34.538000000000004,56.071000000000005,29.509 +2020-07-21 19:45:00,89.8,33.467,56.071000000000005,29.509 +2020-07-21 20:00:00,87.79,30.967,61.55,29.509 +2020-07-21 20:15:00,91.21,30.176,61.55,29.509 +2020-07-21 20:30:00,90.1,30.614,61.55,29.509 +2020-07-21 20:45:00,90.93,31.037,61.55,29.509 +2020-07-21 21:00:00,86.27,29.811999999999998,55.94,29.509 +2020-07-21 21:15:00,86.42,30.99,55.94,29.509 +2020-07-21 21:30:00,83.72,31.548000000000002,55.94,29.509 +2020-07-21 21:45:00,82.77,32.415,55.94,29.509 +2020-07-21 22:00:00,76.63,29.698,52.857,29.509 +2020-07-21 22:15:00,77.71,32.525999999999996,52.857,29.509 +2020-07-21 22:30:00,75.72,28.224,52.857,29.509 +2020-07-21 22:45:00,76.32,25.078000000000003,52.857,29.509 +2020-07-21 23:00:00,71.02,21.905,46.04,29.509 +2020-07-21 23:15:00,71.7,20.565,46.04,29.509 +2020-07-21 23:30:00,70.36,19.544,46.04,29.509 +2020-07-21 23:45:00,70.53,18.503,46.04,29.509 +2020-07-22 00:00:00,67.59,17.987000000000002,42.195,29.509 +2020-07-22 00:15:00,68.74,18.885,42.195,29.509 +2020-07-22 00:30:00,66.81,17.581,42.195,29.509 +2020-07-22 00:45:00,67.64,17.7,42.195,29.509 +2020-07-22 01:00:00,66.47,17.722,38.82,29.509 +2020-07-22 01:15:00,67.91,16.884,38.82,29.509 +2020-07-22 01:30:00,65.56,15.405999999999999,38.82,29.509 +2020-07-22 01:45:00,66.27,15.485999999999999,38.82,29.509 +2020-07-22 02:00:00,64.97,15.347999999999999,37.023,29.509 +2020-07-22 02:15:00,72.4,14.513,37.023,29.509 +2020-07-22 02:30:00,74.12,16.021,37.023,29.509 +2020-07-22 02:45:00,73.22,16.758,37.023,29.509 +2020-07-22 03:00:00,66.46,17.713,36.818000000000005,29.509 +2020-07-22 03:15:00,75.15,17.28,36.818000000000005,29.509 +2020-07-22 03:30:00,76.71,16.618,36.818000000000005,29.509 +2020-07-22 03:45:00,78.64,15.88,36.818000000000005,29.509 +2020-07-22 04:00:00,77.87,19.965,37.495,29.509 +2020-07-22 04:15:00,72.77,25.488000000000003,37.495,29.509 +2020-07-22 04:30:00,76.27,22.774,37.495,29.509 +2020-07-22 04:45:00,81.51,22.7,37.495,29.509 +2020-07-22 05:00:00,85.93,34.351,39.858000000000004,29.509 +2020-07-22 05:15:00,89.79,40.176,39.858000000000004,29.509 +2020-07-22 05:30:00,98.41,35.385,39.858000000000004,29.509 +2020-07-22 05:45:00,102.38,33.051,39.858000000000004,29.509 +2020-07-22 06:00:00,107.43,33.343,52.867,29.509 +2020-07-22 06:15:00,101.45,32.733000000000004,52.867,29.509 +2020-07-22 06:30:00,105.73,32.514,52.867,29.509 +2020-07-22 06:45:00,102.48,35.209,52.867,29.509 +2020-07-22 07:00:00,105.43,34.973,66.061,29.509 +2020-07-22 07:15:00,109.7,35.829,66.061,29.509 +2020-07-22 07:30:00,109.95,33.79,66.061,29.509 +2020-07-22 07:45:00,108.67,34.412,66.061,29.509 +2020-07-22 08:00:00,103.21,29.785,58.532,29.509 +2020-07-22 08:15:00,103.06,33.128,58.532,29.509 +2020-07-22 08:30:00,103.46,34.603,58.532,29.509 +2020-07-22 08:45:00,102.16,37.262,58.532,29.509 +2020-07-22 09:00:00,103.55,32.961999999999996,56.047,29.509 +2020-07-22 09:15:00,112.12,32.464,56.047,29.509 +2020-07-22 09:30:00,113.73,36.217,56.047,29.509 +2020-07-22 09:45:00,114.83,39.509,56.047,29.509 +2020-07-22 10:00:00,106.75,37.044000000000004,53.823,29.509 +2020-07-22 10:15:00,110.2,38.532,53.823,29.509 +2020-07-22 10:30:00,111.64,38.41,53.823,29.509 +2020-07-22 10:45:00,111.39,39.402,53.823,29.509 +2020-07-22 11:00:00,107.68,37.074,54.184,29.509 +2020-07-22 11:15:00,106.1,38.369,54.184,29.509 +2020-07-22 11:30:00,109.13,39.548,54.184,29.509 +2020-07-22 11:45:00,107.03,40.616,54.184,29.509 +2020-07-22 12:00:00,105.01,35.274,52.628,29.509 +2020-07-22 12:15:00,102.07,34.558,52.628,29.509 +2020-07-22 12:30:00,96.47,33.306,52.628,29.509 +2020-07-22 12:45:00,97.11,34.27,52.628,29.509 +2020-07-22 13:00:00,97.9,34.126999999999995,52.31,29.509 +2020-07-22 13:15:00,101.75,35.499,52.31,29.509 +2020-07-22 13:30:00,97.86,33.644,52.31,29.509 +2020-07-22 13:45:00,95.02,33.67,52.31,29.509 +2020-07-22 14:00:00,94.83,35.455999999999996,52.278999999999996,29.509 +2020-07-22 14:15:00,98.49,34.472,52.278999999999996,29.509 +2020-07-22 14:30:00,95.78,33.493,52.278999999999996,29.509 +2020-07-22 14:45:00,94.64,34.146,52.278999999999996,29.509 +2020-07-22 15:00:00,92.12,36.445,53.306999999999995,29.509 +2020-07-22 15:15:00,93.44,34.067,53.306999999999995,29.509 +2020-07-22 15:30:00,92.66,32.624,53.306999999999995,29.509 +2020-07-22 15:45:00,95.61,30.971999999999998,53.306999999999995,29.509 +2020-07-22 16:00:00,95.06,33.367,55.358999999999995,29.509 +2020-07-22 16:15:00,96.05,33.196,55.358999999999995,29.509 +2020-07-22 16:30:00,96.79,32.354,55.358999999999995,29.509 +2020-07-22 16:45:00,97.88,29.37,55.358999999999995,29.509 +2020-07-22 17:00:00,99.85,33.293,59.211999999999996,29.509 +2020-07-22 17:15:00,99.72,33.529,59.211999999999996,29.509 +2020-07-22 17:30:00,100.86,32.388000000000005,59.211999999999996,29.509 +2020-07-22 17:45:00,101.07,31.495,59.211999999999996,29.509 +2020-07-22 18:00:00,101.72,34.8,60.403999999999996,29.509 +2020-07-22 18:15:00,102.59,34.605,60.403999999999996,29.509 +2020-07-22 18:30:00,100.5,32.693000000000005,60.403999999999996,29.509 +2020-07-22 18:45:00,100.9,35.45,60.403999999999996,29.509 +2020-07-22 19:00:00,97.19,36.861,60.993,29.509 +2020-07-22 19:15:00,97.17,35.821999999999996,60.993,29.509 +2020-07-22 19:30:00,92.14,34.623000000000005,60.993,29.509 +2020-07-22 19:45:00,91.77,33.551,60.993,29.509 +2020-07-22 20:00:00,90.35,31.044,66.6,29.509 +2020-07-22 20:15:00,91.78,30.253,66.6,29.509 +2020-07-22 20:30:00,95.13,30.684,66.6,29.509 +2020-07-22 20:45:00,92.78,31.101999999999997,66.6,29.509 +2020-07-22 21:00:00,90.14,29.88,59.855,29.509 +2020-07-22 21:15:00,88.39,31.054000000000002,59.855,29.509 +2020-07-22 21:30:00,85.36,31.596,59.855,29.509 +2020-07-22 21:45:00,84.32,32.446,59.855,29.509 +2020-07-22 22:00:00,79.02,29.726,54.942,29.509 +2020-07-22 22:15:00,78.99,32.55,54.942,29.509 +2020-07-22 22:30:00,76.96,28.224,54.942,29.509 +2020-07-22 22:45:00,79.38,25.072,54.942,29.509 +2020-07-22 23:00:00,71.81,21.918000000000003,46.056000000000004,29.509 +2020-07-22 23:15:00,73.46,20.590999999999998,46.056000000000004,29.509 +2020-07-22 23:30:00,72.52,19.585,46.056000000000004,29.509 +2020-07-22 23:45:00,71.69,18.542,46.056000000000004,29.509 +2020-07-23 00:00:00,68.3,18.047,40.859,29.509 +2020-07-23 00:15:00,70.02,18.944000000000003,40.859,29.509 +2020-07-23 00:30:00,68.78,17.644000000000002,40.859,29.509 +2020-07-23 00:45:00,69.1,17.771,40.859,29.509 +2020-07-23 01:00:00,68.25,17.789,39.06,29.509 +2020-07-23 01:15:00,68.94,16.949,39.06,29.509 +2020-07-23 01:30:00,67.5,15.479000000000001,39.06,29.509 +2020-07-23 01:45:00,67.58,15.552999999999999,39.06,29.509 +2020-07-23 02:00:00,66.85,15.42,37.592,29.509 +2020-07-23 02:15:00,71.03,14.513,37.592,29.509 +2020-07-23 02:30:00,76.38,16.089000000000002,37.592,29.509 +2020-07-23 02:45:00,76.3,16.827,37.592,29.509 +2020-07-23 03:00:00,71.84,17.772000000000002,37.416,29.509 +2020-07-23 03:15:00,72.28,17.354,37.416,29.509 +2020-07-23 03:30:00,79.96,16.701,37.416,29.509 +2020-07-23 03:45:00,81.25,15.972000000000001,37.416,29.509 +2020-07-23 04:00:00,80.97,20.04,38.176,29.509 +2020-07-23 04:15:00,74.82,25.546,38.176,29.509 +2020-07-23 04:30:00,77.22,22.83,38.176,29.509 +2020-07-23 04:45:00,84.81,22.756999999999998,38.176,29.509 +2020-07-23 05:00:00,87.82,34.391,41.203,29.509 +2020-07-23 05:15:00,95.47,40.189,41.203,29.509 +2020-07-23 05:30:00,100.21,35.435,41.203,29.509 +2020-07-23 05:45:00,104.43,33.103,41.203,29.509 +2020-07-23 06:00:00,105.17,33.383,51.09,29.509 +2020-07-23 06:15:00,102.39,32.779,51.09,29.509 +2020-07-23 06:30:00,107.01,32.573,51.09,29.509 +2020-07-23 06:45:00,109.32,35.289,51.09,29.509 +2020-07-23 07:00:00,113.51,35.048,63.541000000000004,29.509 +2020-07-23 07:15:00,107.64,35.925,63.541000000000004,29.509 +2020-07-23 07:30:00,106.21,33.898,63.541000000000004,29.509 +2020-07-23 07:45:00,107.98,34.539,63.541000000000004,29.509 +2020-07-23 08:00:00,109.58,29.919,55.65,29.509 +2020-07-23 08:15:00,109.46,33.257,55.65,29.509 +2020-07-23 08:30:00,109.2,34.72,55.65,29.509 +2020-07-23 08:45:00,111.06,37.368,55.65,29.509 +2020-07-23 09:00:00,111.53,33.069,51.833999999999996,29.509 +2020-07-23 09:15:00,109.8,32.566,51.833999999999996,29.509 +2020-07-23 09:30:00,107.6,36.308,51.833999999999996,29.509 +2020-07-23 09:45:00,106.21,39.592,51.833999999999996,29.509 +2020-07-23 10:00:00,111.27,37.133,49.70399999999999,29.509 +2020-07-23 10:15:00,114.48,38.61,49.70399999999999,29.509 +2020-07-23 10:30:00,116.26,38.482,49.70399999999999,29.509 +2020-07-23 10:45:00,112.02,39.472,49.70399999999999,29.509 +2020-07-23 11:00:00,112.26,37.147,48.593999999999994,29.509 +2020-07-23 11:15:00,110.11,38.439,48.593999999999994,29.509 +2020-07-23 11:30:00,107.88,39.608000000000004,48.593999999999994,29.509 +2020-07-23 11:45:00,103.41,40.667,48.593999999999994,29.509 +2020-07-23 12:00:00,100.19,35.335,46.275,29.509 +2020-07-23 12:15:00,101.53,34.614000000000004,46.275,29.509 +2020-07-23 12:30:00,98.55,33.36,46.275,29.509 +2020-07-23 12:45:00,97.18,34.318000000000005,46.275,29.509 +2020-07-23 13:00:00,95.62,34.154,45.803000000000004,29.509 +2020-07-23 13:15:00,94.73,35.516,45.803000000000004,29.509 +2020-07-23 13:30:00,96.45,33.664,45.803000000000004,29.509 +2020-07-23 13:45:00,95.72,33.7,45.803000000000004,29.509 +2020-07-23 14:00:00,95.01,35.48,46.251999999999995,29.509 +2020-07-23 14:15:00,93.53,34.498000000000005,46.251999999999995,29.509 +2020-07-23 14:30:00,92.34,33.518,46.251999999999995,29.509 +2020-07-23 14:45:00,91.59,34.177,46.251999999999995,29.509 +2020-07-23 15:00:00,90.99,36.466,48.309,29.509 +2020-07-23 15:15:00,91.91,34.086,48.309,29.509 +2020-07-23 15:30:00,92.29,32.647,48.309,29.509 +2020-07-23 15:45:00,98.7,30.991999999999997,48.309,29.509 +2020-07-23 16:00:00,94.54,33.385,49.681999999999995,29.509 +2020-07-23 16:15:00,95.86,33.22,49.681999999999995,29.509 +2020-07-23 16:30:00,97.35,32.391999999999996,49.681999999999995,29.509 +2020-07-23 16:45:00,99.16,29.426,49.681999999999995,29.509 +2020-07-23 17:00:00,99.71,33.339,53.086000000000006,29.509 +2020-07-23 17:15:00,100.72,33.598,53.086000000000006,29.509 +2020-07-23 17:30:00,104.23,32.466,53.086000000000006,29.509 +2020-07-23 17:45:00,102.39,31.589000000000002,53.086000000000006,29.509 +2020-07-23 18:00:00,103.82,34.895,54.038999999999994,29.509 +2020-07-23 18:15:00,102.29,34.695,54.038999999999994,29.509 +2020-07-23 18:30:00,102.15,32.79,54.038999999999994,29.509 +2020-07-23 18:45:00,101.8,35.545,54.038999999999994,29.509 +2020-07-23 19:00:00,98.33,36.96,53.408,29.509 +2020-07-23 19:15:00,94.82,35.916,53.408,29.509 +2020-07-23 19:30:00,93.96,34.714,53.408,29.509 +2020-07-23 19:45:00,92.17,33.641,53.408,29.509 +2020-07-23 20:00:00,91.92,31.128,55.309,29.509 +2020-07-23 20:15:00,92.98,30.337,55.309,29.509 +2020-07-23 20:30:00,94.08,30.759,55.309,29.509 +2020-07-23 20:45:00,96.13,31.17,55.309,29.509 +2020-07-23 21:00:00,89.56,29.951999999999998,51.585,29.509 +2020-07-23 21:15:00,88.93,31.123,51.585,29.509 +2020-07-23 21:30:00,84.47,31.65,51.585,29.509 +2020-07-23 21:45:00,83.82,32.483000000000004,51.585,29.509 +2020-07-23 22:00:00,79.54,29.758000000000003,48.006,29.509 +2020-07-23 22:15:00,79.02,32.576,48.006,29.509 +2020-07-23 22:30:00,77.0,28.225,48.006,29.509 +2020-07-23 22:45:00,76.14,25.069000000000003,48.006,29.509 +2020-07-23 23:00:00,71.58,21.935,42.309,29.509 +2020-07-23 23:15:00,73.24,20.619,42.309,29.509 +2020-07-23 23:30:00,70.94,19.628,42.309,29.509 +2020-07-23 23:45:00,70.55,18.584,42.309,29.509 +2020-07-24 00:00:00,68.03,16.248,39.649,29.509 +2020-07-24 00:15:00,69.75,17.363,39.649,29.509 +2020-07-24 00:30:00,68.38,16.374000000000002,39.649,29.509 +2020-07-24 00:45:00,68.41,16.957,39.649,29.509 +2020-07-24 01:00:00,66.25,16.582,37.744,29.509 +2020-07-24 01:15:00,67.66,15.015,37.744,29.509 +2020-07-24 01:30:00,66.78,14.513,37.744,29.509 +2020-07-24 01:45:00,67.15,14.513,37.744,29.509 +2020-07-24 02:00:00,66.84,15.012,36.965,29.509 +2020-07-24 02:15:00,67.65,14.513,36.965,29.509 +2020-07-24 02:30:00,73.02,16.486,36.965,29.509 +2020-07-24 02:45:00,75.8,16.48,36.965,29.509 +2020-07-24 03:00:00,71.52,18.334,37.678000000000004,29.509 +2020-07-24 03:15:00,70.7,16.549,37.678000000000004,29.509 +2020-07-24 03:30:00,77.5,15.645999999999999,37.678000000000004,29.509 +2020-07-24 03:45:00,79.57,15.852,37.678000000000004,29.509 +2020-07-24 04:00:00,80.0,20.035999999999998,38.591,29.509 +2020-07-24 04:15:00,75.24,23.857,38.591,29.509 +2020-07-24 04:30:00,76.43,22.151999999999997,38.591,29.509 +2020-07-24 04:45:00,79.48,21.465,38.591,29.509 +2020-07-24 05:00:00,87.6,32.664,40.666,29.509 +2020-07-24 05:15:00,90.22,39.36,40.666,29.509 +2020-07-24 05:30:00,93.14,34.783,40.666,29.509 +2020-07-24 05:45:00,91.92,31.98,40.666,29.509 +2020-07-24 06:00:00,106.2,32.453,51.784,29.509 +2020-07-24 06:15:00,107.22,32.135,51.784,29.509 +2020-07-24 06:30:00,110.0,31.943,51.784,29.509 +2020-07-24 06:45:00,107.88,34.431999999999995,51.784,29.509 +2020-07-24 07:00:00,114.31,34.941,61.383,29.509 +2020-07-24 07:15:00,115.72,36.741,61.383,29.509 +2020-07-24 07:30:00,112.69,32.617,61.383,29.509 +2020-07-24 07:45:00,107.73,33.121,61.383,29.509 +2020-07-24 08:00:00,109.58,29.456,55.272,29.509 +2020-07-24 08:15:00,115.15,33.56,55.272,29.509 +2020-07-24 08:30:00,122.28,34.815,55.272,29.509 +2020-07-24 08:45:00,123.43,37.437,55.272,29.509 +2020-07-24 09:00:00,124.99,30.558000000000003,53.506,29.509 +2020-07-24 09:15:00,124.06,32.117,53.506,29.509 +2020-07-24 09:30:00,113.17,35.128,53.506,29.509 +2020-07-24 09:45:00,102.68,38.842,53.506,29.509 +2020-07-24 10:00:00,108.12,36.326,51.363,29.509 +2020-07-24 10:15:00,109.83,37.472,51.363,29.509 +2020-07-24 10:30:00,109.0,37.959,51.363,29.509 +2020-07-24 10:45:00,104.36,38.887,51.363,29.509 +2020-07-24 11:00:00,106.38,36.842,51.043,29.509 +2020-07-24 11:15:00,106.17,37.016999999999996,51.043,29.509 +2020-07-24 11:30:00,105.41,37.599000000000004,51.043,29.509 +2020-07-24 11:45:00,102.94,37.579,51.043,29.509 +2020-07-24 12:00:00,101.2,32.623000000000005,47.52,29.509 +2020-07-24 12:15:00,101.62,31.401999999999997,47.52,29.509 +2020-07-24 12:30:00,104.8,30.261,47.52,29.509 +2020-07-24 12:45:00,107.68,30.285,47.52,29.509 +2020-07-24 13:00:00,109.01,30.66,45.494,29.509 +2020-07-24 13:15:00,111.33,32.129,45.494,29.509 +2020-07-24 13:30:00,109.44,31.159000000000002,45.494,29.509 +2020-07-24 13:45:00,103.01,31.551,45.494,29.509 +2020-07-24 14:00:00,93.71,32.553000000000004,43.883,29.509 +2020-07-24 14:15:00,95.41,32.075,43.883,29.509 +2020-07-24 14:30:00,104.39,32.713,43.883,29.509 +2020-07-24 14:45:00,101.16,32.585,43.883,29.509 +2020-07-24 15:00:00,96.2,34.821,45.714,29.509 +2020-07-24 15:15:00,94.94,32.219,45.714,29.509 +2020-07-24 15:30:00,91.29,30.305999999999997,45.714,29.509 +2020-07-24 15:45:00,89.73,29.476,45.714,29.509 +2020-07-24 16:00:00,90.1,31.035999999999998,48.222,29.509 +2020-07-24 16:15:00,94.51,31.392,48.222,29.509 +2020-07-24 16:30:00,101.04,30.37,48.222,29.509 +2020-07-24 16:45:00,100.07,26.570999999999998,48.222,29.509 +2020-07-24 17:00:00,103.96,32.347,52.619,29.509 +2020-07-24 17:15:00,101.89,32.475,52.619,29.509 +2020-07-24 17:30:00,100.32,31.561,52.619,29.509 +2020-07-24 17:45:00,99.63,30.541999999999998,52.619,29.509 +2020-07-24 18:00:00,100.58,33.814,52.99,29.509 +2020-07-24 18:15:00,97.61,32.611999999999995,52.99,29.509 +2020-07-24 18:30:00,96.16,30.566,52.99,29.509 +2020-07-24 18:45:00,95.07,33.78,52.99,29.509 +2020-07-24 19:00:00,91.05,36.055,51.923,29.509 +2020-07-24 19:15:00,89.09,35.54,51.923,29.509 +2020-07-24 19:30:00,87.63,34.434,51.923,29.509 +2020-07-24 19:45:00,86.2,32.297,51.923,29.509 +2020-07-24 20:00:00,85.56,29.603,56.238,29.509 +2020-07-24 20:15:00,85.67,29.671,56.238,29.509 +2020-07-24 20:30:00,86.67,29.561,56.238,29.509 +2020-07-24 20:45:00,85.85,29.044,56.238,29.509 +2020-07-24 21:00:00,80.66,29.201999999999998,52.426,29.509 +2020-07-24 21:15:00,79.89,32.137,52.426,29.509 +2020-07-24 21:30:00,76.38,32.484,52.426,29.509 +2020-07-24 21:45:00,77.28,33.479,52.426,29.509 +2020-07-24 22:00:00,72.52,30.523000000000003,48.196000000000005,29.509 +2020-07-24 22:15:00,73.32,33.078,48.196000000000005,29.509 +2020-07-24 22:30:00,71.32,33.355,48.196000000000005,29.509 +2020-07-24 22:45:00,70.83,30.868000000000002,48.196000000000005,29.509 +2020-07-24 23:00:00,64.51,29.603,41.71,29.509 +2020-07-24 23:15:00,66.35,26.752,41.71,29.509 +2020-07-24 23:30:00,63.12,23.924,41.71,29.509 +2020-07-24 23:45:00,64.62,22.791,41.71,29.509 +2020-07-25 00:00:00,60.52,17.883,41.105,29.398000000000003 +2020-07-25 00:15:00,63.43,18.605999999999998,41.105,29.398000000000003 +2020-07-25 00:30:00,61.73,17.007,41.105,29.398000000000003 +2020-07-25 00:45:00,60.92,16.749000000000002,41.105,29.398000000000003 +2020-07-25 01:00:00,58.7,16.616,36.934,29.398000000000003 +2020-07-25 01:15:00,60.36,15.764000000000001,36.934,29.398000000000003 +2020-07-25 01:30:00,59.34,14.513,36.934,29.398000000000003 +2020-07-25 01:45:00,59.81,15.419,36.934,29.398000000000003 +2020-07-25 02:00:00,63.49,15.175999999999998,34.782,29.398000000000003 +2020-07-25 02:15:00,67.28,14.513,34.782,29.398000000000003 +2020-07-25 02:30:00,64.51,15.055,34.782,29.398000000000003 +2020-07-25 02:45:00,58.8,15.890999999999998,34.782,29.398000000000003 +2020-07-25 03:00:00,58.48,16.201,34.489000000000004,29.398000000000003 +2020-07-25 03:15:00,60.49,14.513,34.489000000000004,29.398000000000003 +2020-07-25 03:30:00,60.97,14.513,34.489000000000004,29.398000000000003 +2020-07-25 03:45:00,67.24,15.163,34.489000000000004,29.398000000000003 +2020-07-25 04:00:00,67.49,17.617,34.111,29.398000000000003 +2020-07-25 04:15:00,64.17,20.659000000000002,34.111,29.398000000000003 +2020-07-25 04:30:00,56.16,17.320999999999998,34.111,29.398000000000003 +2020-07-25 04:45:00,60.64,16.969,34.111,29.398000000000003 +2020-07-25 05:00:00,64.22,20.432000000000002,33.283,29.398000000000003 +2020-07-25 05:15:00,62.07,16.791,33.283,29.398000000000003 +2020-07-25 05:30:00,66.1,14.513,33.283,29.398000000000003 +2020-07-25 05:45:00,71.54,15.429,33.283,29.398000000000003 +2020-07-25 06:00:00,72.53,26.487,33.653,29.398000000000003 +2020-07-25 06:15:00,71.91,33.288000000000004,33.653,29.398000000000003 +2020-07-25 06:30:00,69.01,30.473000000000003,33.653,29.398000000000003 +2020-07-25 06:45:00,69.56,30.228,33.653,29.398000000000003 +2020-07-25 07:00:00,73.11,29.875999999999998,36.732,29.398000000000003 +2020-07-25 07:15:00,76.36,30.405,36.732,29.398000000000003 +2020-07-25 07:30:00,78.06,27.684,36.732,29.398000000000003 +2020-07-25 07:45:00,80.04,28.840999999999998,36.732,29.398000000000003 +2020-07-25 08:00:00,77.09,25.694000000000003,41.318999999999996,29.398000000000003 +2020-07-25 08:15:00,79.78,29.186,41.318999999999996,29.398000000000003 +2020-07-25 08:30:00,80.24,30.247,41.318999999999996,29.398000000000003 +2020-07-25 08:45:00,80.25,33.574,41.318999999999996,29.398000000000003 +2020-07-25 09:00:00,76.95,29.991999999999997,43.195,29.398000000000003 +2020-07-25 09:15:00,80.1,31.998,43.195,29.398000000000003 +2020-07-25 09:30:00,78.95,35.445,43.195,29.398000000000003 +2020-07-25 09:45:00,77.75,38.635999999999996,43.195,29.398000000000003 +2020-07-25 10:00:00,76.41,36.813,41.843999999999994,29.398000000000003 +2020-07-25 10:15:00,81.4,38.321999999999996,41.843999999999994,29.398000000000003 +2020-07-25 10:30:00,76.14,38.407,41.843999999999994,29.398000000000003 +2020-07-25 10:45:00,74.84,38.793,41.843999999999994,29.398000000000003 +2020-07-25 11:00:00,74.35,36.548,39.035,29.398000000000003 +2020-07-25 11:15:00,74.37,37.754,39.035,29.398000000000003 +2020-07-25 11:30:00,82.18,38.760999999999996,39.035,29.398000000000003 +2020-07-25 11:45:00,79.36,39.576,39.035,29.398000000000003 +2020-07-25 12:00:00,73.47,35.176,38.001,29.398000000000003 +2020-07-25 12:15:00,71.39,34.818000000000005,38.001,29.398000000000003 +2020-07-25 12:30:00,73.92,33.497,38.001,29.398000000000003 +2020-07-25 12:45:00,73.51,34.403,38.001,29.398000000000003 +2020-07-25 13:00:00,62.75,33.873000000000005,34.747,29.398000000000003 +2020-07-25 13:15:00,60.52,34.961,34.747,29.398000000000003 +2020-07-25 13:30:00,64.66,34.251,34.747,29.398000000000003 +2020-07-25 13:45:00,71.77,33.11,34.747,29.398000000000003 +2020-07-25 14:00:00,74.39,33.992,33.434,29.398000000000003 +2020-07-25 14:15:00,80.98,32.196,33.434,29.398000000000003 +2020-07-25 14:30:00,77.88,32.56,33.434,29.398000000000003 +2020-07-25 14:45:00,74.01,32.943000000000005,33.434,29.398000000000003 +2020-07-25 15:00:00,67.72,35.54,35.921,29.398000000000003 +2020-07-25 15:15:00,63.96,33.629,35.921,29.398000000000003 +2020-07-25 15:30:00,60.29,31.785,35.921,29.398000000000003 +2020-07-25 15:45:00,70.07,29.939,35.921,29.398000000000003 +2020-07-25 16:00:00,80.46,33.92,39.427,29.398000000000003 +2020-07-25 16:15:00,81.09,33.215,39.427,29.398000000000003 +2020-07-25 16:30:00,81.48,32.46,39.427,29.398000000000003 +2020-07-25 16:45:00,81.44,28.561,39.427,29.398000000000003 +2020-07-25 17:00:00,83.25,33.041,44.096000000000004,29.398000000000003 +2020-07-25 17:15:00,81.38,30.686,44.096000000000004,29.398000000000003 +2020-07-25 17:30:00,81.62,29.645,44.096000000000004,29.398000000000003 +2020-07-25 17:45:00,80.18,29.224,44.096000000000004,29.398000000000003 +2020-07-25 18:00:00,82.87,33.99,43.931000000000004,29.398000000000003 +2020-07-25 18:15:00,82.76,34.434,43.931000000000004,29.398000000000003 +2020-07-25 18:30:00,83.27,33.746,43.931000000000004,29.398000000000003 +2020-07-25 18:45:00,81.38,33.495,43.931000000000004,29.398000000000003 +2020-07-25 19:00:00,79.1,33.891999999999996,42.187,29.398000000000003 +2020-07-25 19:15:00,73.98,32.328,42.187,29.398000000000003 +2020-07-25 19:30:00,74.69,31.985,42.187,29.398000000000003 +2020-07-25 19:45:00,73.47,31.756999999999998,42.187,29.398000000000003 +2020-07-25 20:00:00,74.38,29.776,38.315,29.398000000000003 +2020-07-25 20:15:00,74.87,28.994,38.315,29.398000000000003 +2020-07-25 20:30:00,74.53,27.961,38.315,29.398000000000003 +2020-07-25 20:45:00,73.21,29.456,38.315,29.398000000000003 +2020-07-25 21:00:00,69.25,27.854,36.843,29.398000000000003 +2020-07-25 21:15:00,69.72,30.38,36.843,29.398000000000003 +2020-07-25 21:30:00,66.85,30.825,36.843,29.398000000000003 +2020-07-25 21:45:00,66.91,31.249000000000002,36.843,29.398000000000003 +2020-07-25 22:00:00,63.87,28.166999999999998,37.260999999999996,29.398000000000003 +2020-07-25 22:15:00,65.21,30.805999999999997,37.260999999999996,29.398000000000003 +2020-07-25 22:30:00,60.75,30.22,37.260999999999996,29.398000000000003 +2020-07-25 22:45:00,62.66,27.993000000000002,37.260999999999996,29.398000000000003 +2020-07-25 23:00:00,57.37,25.910999999999998,32.148,29.398000000000003 +2020-07-25 23:15:00,58.97,23.625,32.148,29.398000000000003 +2020-07-25 23:30:00,57.29,23.531,32.148,29.398000000000003 +2020-07-25 23:45:00,57.1,22.938000000000002,32.148,29.398000000000003 +2020-07-26 00:00:00,52.63,19.403,28.905,29.398000000000003 +2020-07-26 00:15:00,55.31,18.910999999999998,28.905,29.398000000000003 +2020-07-26 00:30:00,54.02,17.189,28.905,29.398000000000003 +2020-07-26 00:45:00,54.11,16.762999999999998,28.905,29.398000000000003 +2020-07-26 01:00:00,51.5,16.92,26.906999999999996,29.398000000000003 +2020-07-26 01:15:00,52.88,15.854000000000001,26.906999999999996,29.398000000000003 +2020-07-26 01:30:00,53.34,14.513,26.906999999999996,29.398000000000003 +2020-07-26 01:45:00,53.47,14.937999999999999,26.906999999999996,29.398000000000003 +2020-07-26 02:00:00,55.77,14.847000000000001,25.938000000000002,29.398000000000003 +2020-07-26 02:15:00,52.74,14.513,25.938000000000002,29.398000000000003 +2020-07-26 02:30:00,51.94,15.683,25.938000000000002,29.398000000000003 +2020-07-26 02:45:00,52.62,16.17,25.938000000000002,29.398000000000003 +2020-07-26 03:00:00,52.69,17.144000000000002,24.693,29.398000000000003 +2020-07-26 03:15:00,52.83,15.054,24.693,29.398000000000003 +2020-07-26 03:30:00,53.82,14.513,24.693,29.398000000000003 +2020-07-26 03:45:00,53.8,14.713,24.693,29.398000000000003 +2020-07-26 04:00:00,53.27,17.141,25.683000000000003,29.398000000000003 +2020-07-26 04:15:00,52.43,19.784000000000002,25.683000000000003,29.398000000000003 +2020-07-26 04:30:00,51.66,17.875999999999998,25.683000000000003,29.398000000000003 +2020-07-26 04:45:00,52.2,17.003,25.683000000000003,29.398000000000003 +2020-07-26 05:00:00,51.2,20.91,26.023000000000003,29.398000000000003 +2020-07-26 05:15:00,51.8,16.61,26.023000000000003,29.398000000000003 +2020-07-26 05:30:00,52.03,14.513,26.023000000000003,29.398000000000003 +2020-07-26 05:45:00,52.83,14.56,26.023000000000003,29.398000000000003 +2020-07-26 06:00:00,54.33,23.101,25.834,29.398000000000003 +2020-07-26 06:15:00,54.38,30.927,25.834,29.398000000000003 +2020-07-26 06:30:00,54.62,27.465999999999998,25.834,29.398000000000003 +2020-07-26 06:45:00,55.22,26.263,25.834,29.398000000000003 +2020-07-26 07:00:00,57.6,26.066,27.765,29.398000000000003 +2020-07-26 07:15:00,56.19,24.92,27.765,29.398000000000003 +2020-07-26 07:30:00,57.26,23.711,27.765,29.398000000000003 +2020-07-26 07:45:00,57.26,24.921,27.765,29.398000000000003 +2020-07-26 08:00:00,55.54,22.46,31.357,29.398000000000003 +2020-07-26 08:15:00,55.35,27.239,31.357,29.398000000000003 +2020-07-26 08:30:00,54.77,29.068,31.357,29.398000000000003 +2020-07-26 08:45:00,57.54,32.083,31.357,29.398000000000003 +2020-07-26 09:00:00,55.42,28.44,33.238,29.398000000000003 +2020-07-26 09:15:00,54.51,29.836,33.238,29.398000000000003 +2020-07-26 09:30:00,54.13,33.762,33.238,29.398000000000003 +2020-07-26 09:45:00,56.02,38.06,33.238,29.398000000000003 +2020-07-26 10:00:00,57.58,36.551,34.22,29.398000000000003 +2020-07-26 10:15:00,58.34,38.132,34.22,29.398000000000003 +2020-07-26 10:30:00,58.17,38.382,34.22,29.398000000000003 +2020-07-26 10:45:00,57.3,40.123000000000005,34.22,29.398000000000003 +2020-07-26 11:00:00,52.42,37.379,36.298,29.398000000000003 +2020-07-26 11:15:00,55.26,38.073,36.298,29.398000000000003 +2020-07-26 11:30:00,54.51,39.75,36.298,29.398000000000003 +2020-07-26 11:45:00,58.89,40.765,36.298,29.398000000000003 +2020-07-26 12:00:00,51.91,37.62,33.52,29.398000000000003 +2020-07-26 12:15:00,53.07,36.328,33.52,29.398000000000003 +2020-07-26 12:30:00,55.05,35.425,33.52,29.398000000000003 +2020-07-26 12:45:00,57.13,35.735,33.52,29.398000000000003 +2020-07-26 13:00:00,54.79,34.94,30.12,29.398000000000003 +2020-07-26 13:15:00,57.11,35.007,30.12,29.398000000000003 +2020-07-26 13:30:00,57.12,33.091,30.12,29.398000000000003 +2020-07-26 13:45:00,53.35,33.279,30.12,29.398000000000003 +2020-07-26 14:00:00,53.46,35.445,27.233,29.398000000000003 +2020-07-26 14:15:00,50.5,33.985,27.233,29.398000000000003 +2020-07-26 14:30:00,48.85,32.835,27.233,29.398000000000003 +2020-07-26 14:45:00,48.31,32.092,27.233,29.398000000000003 +2020-07-26 15:00:00,50.34,35.024,27.468000000000004,29.398000000000003 +2020-07-26 15:15:00,51.44,32.132,27.468000000000004,29.398000000000003 +2020-07-26 15:30:00,51.5,30.025,27.468000000000004,29.398000000000003 +2020-07-26 15:45:00,56.42,28.409000000000002,27.468000000000004,29.398000000000003 +2020-07-26 16:00:00,57.3,30.403000000000002,30.8,29.398000000000003 +2020-07-26 16:15:00,58.79,30.057,30.8,29.398000000000003 +2020-07-26 16:30:00,61.59,30.386999999999997,30.8,29.398000000000003 +2020-07-26 16:45:00,60.88,26.564,30.8,29.398000000000003 +2020-07-26 17:00:00,65.37,31.451,37.806,29.398000000000003 +2020-07-26 17:15:00,66.63,30.805999999999997,37.806,29.398000000000003 +2020-07-26 17:30:00,68.97,30.625999999999998,37.806,29.398000000000003 +2020-07-26 17:45:00,71.33,30.439,37.806,29.398000000000003 +2020-07-26 18:00:00,72.0,35.931999999999995,40.766,29.398000000000003 +2020-07-26 18:15:00,74.61,35.803000000000004,40.766,29.398000000000003 +2020-07-26 18:30:00,73.46,35.058,40.766,29.398000000000003 +2020-07-26 18:45:00,72.82,34.749,40.766,29.398000000000003 +2020-07-26 19:00:00,75.82,37.553000000000004,41.163000000000004,29.398000000000003 +2020-07-26 19:15:00,72.02,34.741,41.163000000000004,29.398000000000003 +2020-07-26 19:30:00,72.59,34.144,41.163000000000004,29.398000000000003 +2020-07-26 19:45:00,73.1,33.296,41.163000000000004,29.398000000000003 +2020-07-26 20:00:00,77.43,31.500999999999998,39.885999999999996,29.398000000000003 +2020-07-26 20:15:00,75.87,30.485,39.885999999999996,29.398000000000003 +2020-07-26 20:30:00,76.9,30.201999999999998,39.885999999999996,29.398000000000003 +2020-07-26 20:45:00,76.0,29.991,39.885999999999996,29.398000000000003 +2020-07-26 21:00:00,74.18,28.511,38.900999999999996,29.398000000000003 +2020-07-26 21:15:00,74.35,30.761999999999997,38.900999999999996,29.398000000000003 +2020-07-26 21:30:00,73.05,30.476999999999997,38.900999999999996,29.398000000000003 +2020-07-26 21:45:00,74.93,31.252,38.900999999999996,29.398000000000003 +2020-07-26 22:00:00,69.15,30.444000000000003,39.806999999999995,29.398000000000003 +2020-07-26 22:15:00,68.91,31.346999999999998,39.806999999999995,29.398000000000003 +2020-07-26 22:30:00,67.21,30.45,39.806999999999995,29.398000000000003 +2020-07-26 22:45:00,66.92,26.933000000000003,39.806999999999995,29.398000000000003 +2020-07-26 23:00:00,62.44,24.776,35.564,29.398000000000003 +2020-07-26 23:15:00,64.2,23.701,35.564,29.398000000000003 +2020-07-26 23:30:00,63.75,22.988000000000003,35.564,29.398000000000003 +2020-07-26 23:45:00,63.65,22.491,35.564,29.398000000000003 +2020-07-27 00:00:00,58.08,20.721999999999998,36.578,29.509 +2020-07-27 00:15:00,59.82,20.807,36.578,29.509 +2020-07-27 00:30:00,59.44,18.666,36.578,29.509 +2020-07-27 00:45:00,59.65,17.87,36.578,29.509 +2020-07-27 01:00:00,58.51,18.438,35.292,29.509 +2020-07-27 01:15:00,59.94,17.435,35.292,29.509 +2020-07-27 01:30:00,58.16,16.151,35.292,29.509 +2020-07-27 01:45:00,59.68,16.791,35.292,29.509 +2020-07-27 02:00:00,59.36,17.198,34.319,29.509 +2020-07-27 02:15:00,60.33,14.605,34.319,29.509 +2020-07-27 02:30:00,60.15,16.959,34.319,29.509 +2020-07-27 02:45:00,68.55,17.345,34.319,29.509 +2020-07-27 03:00:00,69.23,18.741,33.13,29.509 +2020-07-27 03:15:00,68.09,17.291,33.13,29.509 +2020-07-27 03:30:00,64.78,16.747,33.13,29.509 +2020-07-27 03:45:00,67.17,17.275,33.13,29.509 +2020-07-27 04:00:00,69.51,22.643,33.851,29.509 +2020-07-27 04:15:00,71.44,28.05,33.851,29.509 +2020-07-27 04:30:00,75.51,25.430999999999997,33.851,29.509 +2020-07-27 04:45:00,75.8,24.941,33.851,29.509 +2020-07-27 05:00:00,83.51,35.275999999999996,38.718,29.509 +2020-07-27 05:15:00,93.98,40.316,38.718,29.509 +2020-07-27 05:30:00,96.18,35.374,38.718,29.509 +2020-07-27 05:45:00,97.7,33.821,38.718,29.509 +2020-07-27 06:00:00,98.3,32.796,51.648999999999994,29.509 +2020-07-27 06:15:00,101.88,32.159,51.648999999999994,29.509 +2020-07-27 06:30:00,101.45,32.286,51.648999999999994,29.509 +2020-07-27 06:45:00,102.71,36.009,51.648999999999994,29.509 +2020-07-27 07:00:00,109.88,35.56,60.159,29.509 +2020-07-27 07:15:00,110.12,36.783,60.159,29.509 +2020-07-27 07:30:00,108.64,34.733000000000004,60.159,29.509 +2020-07-27 07:45:00,103.69,36.539,60.159,29.509 +2020-07-27 08:00:00,102.39,32.04,53.8,29.509 +2020-07-27 08:15:00,101.23,35.729,53.8,29.509 +2020-07-27 08:30:00,102.32,36.937,53.8,29.509 +2020-07-27 08:45:00,101.63,40.533,53.8,29.509 +2020-07-27 09:00:00,100.95,35.754,50.583,29.509 +2020-07-27 09:15:00,100.48,35.586,50.583,29.509 +2020-07-27 09:30:00,99.92,38.594,50.583,29.509 +2020-07-27 09:45:00,102.25,40.314,50.583,29.509 +2020-07-27 10:00:00,100.85,39.336,49.11600000000001,29.509 +2020-07-27 10:15:00,105.36,40.782,49.11600000000001,29.509 +2020-07-27 10:30:00,110.58,40.625,49.11600000000001,29.509 +2020-07-27 10:45:00,104.99,40.507,49.11600000000001,29.509 +2020-07-27 11:00:00,104.14,38.387,49.056000000000004,29.509 +2020-07-27 11:15:00,99.79,39.169000000000004,49.056000000000004,29.509 +2020-07-27 11:30:00,97.15,41.443999999999996,49.056000000000004,29.509 +2020-07-27 11:45:00,97.01,43.053000000000004,49.056000000000004,29.509 +2020-07-27 12:00:00,94.59,37.788000000000004,47.227,29.509 +2020-07-27 12:15:00,96.64,36.614000000000004,47.227,29.509 +2020-07-27 12:30:00,94.46,34.49,47.227,29.509 +2020-07-27 12:45:00,96.43,34.6,47.227,29.509 +2020-07-27 13:00:00,94.41,34.707,47.006,29.509 +2020-07-27 13:15:00,98.63,33.953,47.006,29.509 +2020-07-27 13:30:00,92.15,32.293,47.006,29.509 +2020-07-27 13:45:00,92.79,33.483000000000004,47.006,29.509 +2020-07-27 14:00:00,96.24,34.769,47.19,29.509 +2020-07-27 14:15:00,94.96,34.046,47.19,29.509 +2020-07-27 14:30:00,97.53,32.781,47.19,29.509 +2020-07-27 14:45:00,94.66,34.342,47.19,29.509 +2020-07-27 15:00:00,96.06,36.696,47.846000000000004,29.509 +2020-07-27 15:15:00,96.69,33.32,47.846000000000004,29.509 +2020-07-27 15:30:00,94.08,32.165,47.846000000000004,29.509 +2020-07-27 15:45:00,95.96,30.063000000000002,47.846000000000004,29.509 +2020-07-27 16:00:00,97.15,33.278,49.641000000000005,29.509 +2020-07-27 16:15:00,97.99,33.095,49.641000000000005,29.509 +2020-07-27 16:30:00,102.46,32.815,49.641000000000005,29.509 +2020-07-27 16:45:00,101.93,29.17,49.641000000000005,29.509 +2020-07-27 17:00:00,100.45,32.861999999999995,54.133,29.509 +2020-07-27 17:15:00,98.37,32.753,54.133,29.509 +2020-07-27 17:30:00,98.03,32.219,54.133,29.509 +2020-07-27 17:45:00,97.96,31.778000000000002,54.133,29.509 +2020-07-27 18:00:00,100.96,36.108000000000004,53.761,29.509 +2020-07-27 18:15:00,102.23,34.207,53.761,29.509 +2020-07-27 18:30:00,99.94,32.584,53.761,29.509 +2020-07-27 18:45:00,102.7,35.639,53.761,29.509 +2020-07-27 19:00:00,97.52,38.338,53.923,29.509 +2020-07-27 19:15:00,93.44,37.054,53.923,29.509 +2020-07-27 19:30:00,92.1,36.010999999999996,53.923,29.509 +2020-07-27 19:45:00,92.41,34.579,53.923,29.509 +2020-07-27 20:00:00,90.77,31.631999999999998,58.786,29.509 +2020-07-27 20:15:00,91.86,32.35,58.786,29.509 +2020-07-27 20:30:00,92.73,32.789,58.786,29.509 +2020-07-27 20:45:00,92.17,32.653,58.786,29.509 +2020-07-27 21:00:00,88.4,30.436,54.591,29.509 +2020-07-27 21:15:00,86.32,33.257,54.591,29.509 +2020-07-27 21:30:00,81.87,33.473,54.591,29.509 +2020-07-27 21:45:00,81.44,34.038000000000004,54.591,29.509 +2020-07-27 22:00:00,75.29,31.253,51.551,29.509 +2020-07-27 22:15:00,76.35,34.385999999999996,51.551,29.509 +2020-07-27 22:30:00,74.74,29.62,51.551,29.509 +2020-07-27 22:45:00,78.59,26.478,51.551,29.509 +2020-07-27 23:00:00,69.51,24.256,44.716,29.509 +2020-07-27 23:15:00,70.81,21.219,44.716,29.509 +2020-07-27 23:30:00,70.12,20.26,44.716,29.509 +2020-07-27 23:45:00,69.6,18.979,44.716,29.509 +2020-07-28 00:00:00,66.06,18.403,43.01,29.509 +2020-07-28 00:15:00,67.08,19.291,43.01,29.509 +2020-07-28 00:30:00,66.41,18.014,43.01,29.509 +2020-07-28 00:45:00,67.0,18.176,43.01,29.509 +2020-07-28 01:00:00,64.96,18.167,40.687,29.509 +2020-07-28 01:15:00,66.69,17.328,40.687,29.509 +2020-07-28 01:30:00,66.57,15.895,40.687,29.509 +2020-07-28 01:45:00,67.11,15.945,40.687,29.509 +2020-07-28 02:00:00,65.29,15.835,39.554,29.509 +2020-07-28 02:15:00,66.22,14.524000000000001,39.554,29.509 +2020-07-28 02:30:00,67.12,16.489,39.554,29.509 +2020-07-28 02:45:00,74.51,17.230999999999998,39.554,29.509 +2020-07-28 03:00:00,75.51,18.124000000000002,38.958,29.509 +2020-07-28 03:15:00,71.6,17.781,38.958,29.509 +2020-07-28 03:30:00,69.58,17.167,38.958,29.509 +2020-07-28 03:45:00,71.85,16.477999999999998,38.958,29.509 +2020-07-28 04:00:00,75.9,20.482,39.783,29.509 +2020-07-28 04:15:00,74.67,25.921999999999997,39.783,29.509 +2020-07-28 04:30:00,75.33,23.201999999999998,39.783,29.509 +2020-07-28 04:45:00,79.19,23.13,39.783,29.509 +2020-07-28 05:00:00,91.86,34.734,42.281000000000006,29.509 +2020-07-28 05:15:00,95.84,40.444,42.281000000000006,29.509 +2020-07-28 05:30:00,96.24,35.861,42.281000000000006,29.509 +2020-07-28 05:45:00,94.7,33.519,42.281000000000006,29.509 +2020-07-28 06:00:00,104.47,33.715,50.801,29.509 +2020-07-28 06:15:00,108.31,33.161,50.801,29.509 +2020-07-28 06:30:00,109.99,33.008,50.801,29.509 +2020-07-28 06:45:00,108.51,35.823,50.801,29.509 +2020-07-28 07:00:00,107.45,35.556,60.202,29.509 +2020-07-28 07:15:00,105.46,36.536,60.202,29.509 +2020-07-28 07:30:00,107.66,34.574,60.202,29.509 +2020-07-28 07:45:00,111.91,35.306,60.202,29.509 +2020-07-28 08:00:00,111.65,30.717,54.461000000000006,29.509 +2020-07-28 08:15:00,111.03,34.015,54.461000000000006,29.509 +2020-07-28 08:30:00,104.82,35.417,54.461000000000006,29.509 +2020-07-28 08:45:00,105.65,38.006,54.461000000000006,29.509 +2020-07-28 09:00:00,105.34,33.71,50.753,29.509 +2020-07-28 09:15:00,104.97,33.184,50.753,29.509 +2020-07-28 09:30:00,111.63,36.865,50.753,29.509 +2020-07-28 09:45:00,111.1,40.098,50.753,29.509 +2020-07-28 10:00:00,103.92,37.664,49.703,29.509 +2020-07-28 10:15:00,108.58,39.079,49.703,29.509 +2020-07-28 10:30:00,104.47,38.921,49.703,29.509 +2020-07-28 10:45:00,102.44,39.895,49.703,29.509 +2020-07-28 11:00:00,99.91,37.591,49.42100000000001,29.509 +2020-07-28 11:15:00,101.99,38.866,49.42100000000001,29.509 +2020-07-28 11:30:00,102.94,39.99,49.42100000000001,29.509 +2020-07-28 11:45:00,104.11,40.995,49.42100000000001,29.509 +2020-07-28 12:00:00,103.58,35.702,47.155,29.509 +2020-07-28 12:15:00,108.58,34.954,47.155,29.509 +2020-07-28 12:30:00,112.24,33.703,47.155,29.509 +2020-07-28 12:45:00,113.51,34.622,47.155,29.509 +2020-07-28 13:00:00,110.53,34.356,47.515,29.509 +2020-07-28 13:15:00,113.4,35.666,47.515,29.509 +2020-07-28 13:30:00,111.67,33.818000000000005,47.515,29.509 +2020-07-28 13:45:00,108.55,33.906,47.515,29.509 +2020-07-28 14:00:00,98.05,35.644,47.575,29.509 +2020-07-28 14:15:00,94.38,34.681,47.575,29.509 +2020-07-28 14:30:00,93.99,33.705,47.575,29.509 +2020-07-28 14:45:00,93.91,34.396,47.575,29.509 +2020-07-28 15:00:00,99.21,36.606,48.903,29.509 +2020-07-28 15:15:00,101.07,34.223,48.903,29.509 +2020-07-28 15:30:00,100.87,32.81,48.903,29.509 +2020-07-28 15:45:00,100.92,31.144000000000002,48.903,29.509 +2020-07-28 16:00:00,97.59,33.515,50.218999999999994,29.509 +2020-07-28 16:15:00,98.84,33.376999999999995,50.218999999999994,29.509 +2020-07-28 16:30:00,99.43,32.621,50.218999999999994,29.509 +2020-07-28 16:45:00,99.6,29.761,50.218999999999994,29.509 +2020-07-28 17:00:00,102.62,33.61,55.396,29.509 +2020-07-28 17:15:00,101.28,33.985,55.396,29.509 +2020-07-28 17:30:00,100.85,32.898,55.396,29.509 +2020-07-28 17:45:00,102.53,32.125,55.396,29.509 +2020-07-28 18:00:00,102.54,35.43,55.583999999999996,29.509 +2020-07-28 18:15:00,100.74,35.217,55.583999999999996,29.509 +2020-07-28 18:30:00,102.02,33.344,55.583999999999996,29.509 +2020-07-28 18:45:00,102.2,36.103,55.583999999999996,29.509 +2020-07-28 19:00:00,97.76,37.53,56.071000000000005,29.509 +2020-07-28 19:15:00,95.49,36.464,56.071000000000005,29.509 +2020-07-28 19:30:00,94.9,35.249,56.071000000000005,29.509 +2020-07-28 19:45:00,93.33,34.178000000000004,56.071000000000005,29.509 +2020-07-28 20:00:00,91.91,31.64,61.55,29.509 +2020-07-28 20:15:00,93.19,30.85,61.55,29.509 +2020-07-28 20:30:00,94.82,31.225,61.55,29.509 +2020-07-28 20:45:00,93.22,31.589000000000002,61.55,29.509 +2020-07-28 21:00:00,89.1,30.386999999999997,55.94,29.509 +2020-07-28 21:15:00,87.92,31.531999999999996,55.94,29.509 +2020-07-28 21:30:00,84.69,31.991999999999997,55.94,29.509 +2020-07-28 21:45:00,84.68,32.727,55.94,29.509 +2020-07-28 22:00:00,78.94,29.971,52.857,29.509 +2020-07-28 22:15:00,85.09,32.757,52.857,29.509 +2020-07-28 22:30:00,81.12,28.272,52.857,29.509 +2020-07-28 22:45:00,81.25,25.096999999999998,52.857,29.509 +2020-07-28 23:00:00,77.11,22.079,46.04,29.509 +2020-07-28 23:15:00,78.13,20.805999999999997,46.04,29.509 +2020-07-28 23:30:00,77.41,19.891,46.04,29.509 +2020-07-28 23:45:00,77.45,18.84,46.04,29.509 +2020-07-29 00:00:00,72.28,18.484,42.195,29.509 +2020-07-29 00:15:00,73.33,19.371,42.195,29.509 +2020-07-29 00:30:00,73.27,18.098,42.195,29.509 +2020-07-29 00:45:00,72.85,18.268,42.195,29.509 +2020-07-29 01:00:00,71.33,18.252,38.82,29.509 +2020-07-29 01:15:00,72.86,17.414,38.82,29.509 +2020-07-29 01:30:00,73.45,15.99,38.82,29.509 +2020-07-29 01:45:00,73.58,16.035,38.82,29.509 +2020-07-29 02:00:00,71.67,15.93,37.023,29.509 +2020-07-29 02:15:00,72.3,14.637,37.023,29.509 +2020-07-29 02:30:00,69.76,16.581,37.023,29.509 +2020-07-29 02:45:00,79.06,17.323,37.023,29.509 +2020-07-29 03:00:00,79.64,18.204,36.818000000000005,29.509 +2020-07-29 03:15:00,77.13,17.878,36.818000000000005,29.509 +2020-07-29 03:30:00,73.32,17.271,36.818000000000005,29.509 +2020-07-29 03:45:00,77.35,16.589000000000002,36.818000000000005,29.509 +2020-07-29 04:00:00,79.17,20.584,37.495,29.509 +2020-07-29 04:15:00,78.45,26.015,37.495,29.509 +2020-07-29 04:30:00,78.67,23.294,37.495,29.509 +2020-07-29 04:45:00,81.9,23.224,37.495,29.509 +2020-07-29 05:00:00,97.1,34.83,39.858000000000004,29.509 +2020-07-29 05:15:00,99.54,40.534,39.858000000000004,29.509 +2020-07-29 05:30:00,103.49,35.981,39.858000000000004,29.509 +2020-07-29 05:45:00,98.13,33.632,39.858000000000004,29.509 +2020-07-29 06:00:00,105.66,33.81,52.867,29.509 +2020-07-29 06:15:00,110.73,33.266999999999996,52.867,29.509 +2020-07-29 06:30:00,111.3,33.122,52.867,29.509 +2020-07-29 06:45:00,112.66,35.955,52.867,29.509 +2020-07-29 07:00:00,108.85,35.685,66.061,29.509 +2020-07-29 07:15:00,105.98,36.684,66.061,29.509 +2020-07-29 07:30:00,105.83,34.736999999999995,66.061,29.509 +2020-07-29 07:45:00,104.0,35.484,66.061,29.509 +2020-07-29 08:00:00,106.11,30.903000000000002,58.532,29.509 +2020-07-29 08:15:00,104.13,34.188,58.532,29.509 +2020-07-29 08:30:00,106.01,35.577,58.532,29.509 +2020-07-29 08:45:00,106.01,38.155,58.532,29.509 +2020-07-29 09:00:00,106.42,33.86,56.047,29.509 +2020-07-29 09:15:00,105.79,33.329,56.047,29.509 +2020-07-29 09:30:00,105.45,36.997,56.047,29.509 +2020-07-29 09:45:00,103.21,40.218,56.047,29.509 +2020-07-29 10:00:00,102.4,37.788000000000004,53.823,29.509 +2020-07-29 10:15:00,106.01,39.188,53.823,29.509 +2020-07-29 10:30:00,104.96,39.025,53.823,29.509 +2020-07-29 10:45:00,104.1,39.994,53.823,29.509 +2020-07-29 11:00:00,108.14,37.696,54.184,29.509 +2020-07-29 11:15:00,108.27,38.968,54.184,29.509 +2020-07-29 11:30:00,108.1,40.082,54.184,29.509 +2020-07-29 11:45:00,102.46,41.075,54.184,29.509 +2020-07-29 12:00:00,102.99,35.788000000000004,52.628,29.509 +2020-07-29 12:15:00,100.87,35.034,52.628,29.509 +2020-07-29 12:30:00,100.15,33.786,52.628,29.509 +2020-07-29 12:45:00,98.46,34.696,52.628,29.509 +2020-07-29 13:00:00,98.77,34.41,52.31,29.509 +2020-07-29 13:15:00,97.85,35.708,52.31,29.509 +2020-07-29 13:30:00,97.02,33.861,52.31,29.509 +2020-07-29 13:45:00,97.78,33.959,52.31,29.509 +2020-07-29 14:00:00,96.14,35.687,52.278999999999996,29.509 +2020-07-29 14:15:00,95.9,34.727,52.278999999999996,29.509 +2020-07-29 14:30:00,96.19,33.756,52.278999999999996,29.509 +2020-07-29 14:45:00,96.56,34.452,52.278999999999996,29.509 +2020-07-29 15:00:00,95.81,36.641999999999996,53.306999999999995,29.509 +2020-07-29 15:15:00,92.1,34.259,53.306999999999995,29.509 +2020-07-29 15:30:00,94.45,32.853,53.306999999999995,29.509 +2020-07-29 15:45:00,96.56,31.185,53.306999999999995,29.509 +2020-07-29 16:00:00,97.51,33.548,55.358999999999995,29.509 +2020-07-29 16:15:00,98.55,33.417,55.358999999999995,29.509 +2020-07-29 16:30:00,98.44,32.673,55.358999999999995,29.509 +2020-07-29 16:45:00,98.51,29.837,55.358999999999995,29.509 +2020-07-29 17:00:00,100.98,33.671,59.211999999999996,29.509 +2020-07-29 17:15:00,101.93,34.071,59.211999999999996,29.509 +2020-07-29 17:30:00,102.63,32.995,59.211999999999996,29.509 +2020-07-29 17:45:00,103.93,32.245,59.211999999999996,29.509 +2020-07-29 18:00:00,104.7,35.549,60.403999999999996,29.509 +2020-07-29 18:15:00,103.25,35.335,60.403999999999996,29.509 +2020-07-29 18:30:00,103.93,33.47,60.403999999999996,29.509 +2020-07-29 18:45:00,103.57,36.229,60.403999999999996,29.509 +2020-07-29 19:00:00,99.94,37.659,60.993,29.509 +2020-07-29 19:15:00,95.58,36.588,60.993,29.509 +2020-07-29 19:30:00,95.02,35.373000000000005,60.993,29.509 +2020-07-29 19:45:00,95.21,34.303000000000004,60.993,29.509 +2020-07-29 20:00:00,93.06,31.76,66.6,29.509 +2020-07-29 20:15:00,95.46,30.971999999999998,66.6,29.509 +2020-07-29 20:30:00,95.47,31.337,66.6,29.509 +2020-07-29 20:45:00,94.87,31.686999999999998,66.6,29.509 +2020-07-29 21:00:00,91.12,30.487,59.855,29.509 +2020-07-29 21:15:00,90.81,31.628,59.855,29.509 +2020-07-29 21:30:00,87.11,32.074,59.855,29.509 +2020-07-29 21:45:00,85.59,32.789,59.855,29.509 +2020-07-29 22:00:00,80.94,30.025,54.942,29.509 +2020-07-29 22:15:00,79.82,32.803000000000004,54.942,29.509 +2020-07-29 22:30:00,78.83,28.29,54.942,29.509 +2020-07-29 22:45:00,78.28,25.111,54.942,29.509 +2020-07-29 23:00:00,73.71,22.119,46.056000000000004,29.509 +2020-07-29 23:15:00,75.13,20.851999999999997,46.056000000000004,29.509 +2020-07-29 23:30:00,74.23,19.952,46.056000000000004,29.509 +2020-07-29 23:45:00,72.75,18.901,46.056000000000004,29.509 +2020-07-30 00:00:00,66.91,18.569000000000003,40.859,29.509 +2020-07-30 00:15:00,70.85,19.454,40.859,29.509 +2020-07-30 00:30:00,68.48,18.186,40.859,29.509 +2020-07-30 00:45:00,71.01,18.364,40.859,29.509 +2020-07-30 01:00:00,68.53,18.339000000000002,39.06,29.509 +2020-07-30 01:15:00,69.68,17.503,39.06,29.509 +2020-07-30 01:30:00,69.53,16.087,39.06,29.509 +2020-07-30 01:45:00,69.27,16.129,39.06,29.509 +2020-07-30 02:00:00,69.25,16.028,37.592,29.509 +2020-07-30 02:15:00,69.73,14.753,37.592,29.509 +2020-07-30 02:30:00,69.73,16.677,37.592,29.509 +2020-07-30 02:45:00,70.05,17.419,37.592,29.509 +2020-07-30 03:00:00,70.14,18.289,37.416,29.509 +2020-07-30 03:15:00,67.95,17.977999999999998,37.416,29.509 +2020-07-30 03:30:00,71.64,17.379,37.416,29.509 +2020-07-30 03:45:00,74.13,16.704,37.416,29.509 +2020-07-30 04:00:00,78.12,20.691,38.176,29.509 +2020-07-30 04:15:00,77.94,26.114,38.176,29.509 +2020-07-30 04:30:00,82.51,23.393,38.176,29.509 +2020-07-30 04:45:00,88.96,23.324,38.176,29.509 +2020-07-30 05:00:00,95.56,34.935,41.203,29.509 +2020-07-30 05:15:00,96.07,40.637,41.203,29.509 +2020-07-30 05:30:00,94.6,36.111999999999995,41.203,29.509 +2020-07-30 05:45:00,97.84,33.755,41.203,29.509 +2020-07-30 06:00:00,103.74,33.912,51.09,29.509 +2020-07-30 06:15:00,106.27,33.382,51.09,29.509 +2020-07-30 06:30:00,107.84,33.246,51.09,29.509 +2020-07-30 06:45:00,108.65,36.096,51.09,29.509 +2020-07-30 07:00:00,115.44,35.821,63.541000000000004,29.509 +2020-07-30 07:15:00,115.82,36.841,63.541000000000004,29.509 +2020-07-30 07:30:00,119.1,34.909,63.541000000000004,29.509 +2020-07-30 07:45:00,116.84,35.671,63.541000000000004,29.509 +2020-07-30 08:00:00,114.55,31.096,55.65,29.509 +2020-07-30 08:15:00,110.16,34.368,55.65,29.509 +2020-07-30 08:30:00,112.03,35.745,55.65,29.509 +2020-07-30 08:45:00,108.34,38.311,55.65,29.509 +2020-07-30 09:00:00,110.22,34.018,51.833999999999996,29.509 +2020-07-30 09:15:00,117.14,33.48,51.833999999999996,29.509 +2020-07-30 09:30:00,114.83,37.135,51.833999999999996,29.509 +2020-07-30 09:45:00,112.31,40.343,51.833999999999996,29.509 +2020-07-30 10:00:00,105.58,37.918,49.70399999999999,29.509 +2020-07-30 10:15:00,106.54,39.303000000000004,49.70399999999999,29.509 +2020-07-30 10:30:00,105.24,39.134,49.70399999999999,29.509 +2020-07-30 10:45:00,108.78,40.099000000000004,49.70399999999999,29.509 +2020-07-30 11:00:00,108.28,37.806,48.593999999999994,29.509 +2020-07-30 11:15:00,108.61,39.073,48.593999999999994,29.509 +2020-07-30 11:30:00,108.06,40.179,48.593999999999994,29.509 +2020-07-30 11:45:00,105.5,41.161,48.593999999999994,29.509 +2020-07-30 12:00:00,101.8,35.878,46.275,29.509 +2020-07-30 12:15:00,100.72,35.118,46.275,29.509 +2020-07-30 12:30:00,98.24,33.873000000000005,46.275,29.509 +2020-07-30 12:45:00,98.08,34.773,46.275,29.509 +2020-07-30 13:00:00,96.54,34.467,45.803000000000004,29.509 +2020-07-30 13:15:00,96.92,35.754,45.803000000000004,29.509 +2020-07-30 13:30:00,96.23,33.906,45.803000000000004,29.509 +2020-07-30 13:45:00,97.99,34.016,45.803000000000004,29.509 +2020-07-30 14:00:00,96.53,35.733000000000004,46.251999999999995,29.509 +2020-07-30 14:15:00,96.4,34.777,46.251999999999995,29.509 +2020-07-30 14:30:00,96.01,33.809,46.251999999999995,29.509 +2020-07-30 14:45:00,97.0,34.510999999999996,46.251999999999995,29.509 +2020-07-30 15:00:00,95.45,36.68,48.309,29.509 +2020-07-30 15:15:00,95.82,34.297,48.309,29.509 +2020-07-30 15:30:00,95.51,32.898,48.309,29.509 +2020-07-30 15:45:00,96.03,31.229,48.309,29.509 +2020-07-30 16:00:00,97.23,33.583,49.681999999999995,29.509 +2020-07-30 16:15:00,98.16,33.459,49.681999999999995,29.509 +2020-07-30 16:30:00,98.84,32.728,49.681999999999995,29.509 +2020-07-30 16:45:00,101.61,29.916999999999998,49.681999999999995,29.509 +2020-07-30 17:00:00,101.98,33.736,53.086000000000006,29.509 +2020-07-30 17:15:00,101.92,34.16,53.086000000000006,29.509 +2020-07-30 17:30:00,102.4,33.095,53.086000000000006,29.509 +2020-07-30 17:45:00,104.67,32.369,53.086000000000006,29.509 +2020-07-30 18:00:00,104.01,35.671,54.038999999999994,29.509 +2020-07-30 18:15:00,102.55,35.457,54.038999999999994,29.509 +2020-07-30 18:30:00,101.69,33.601,54.038999999999994,29.509 +2020-07-30 18:45:00,101.62,36.359,54.038999999999994,29.509 +2020-07-30 19:00:00,97.79,37.793,53.408,29.509 +2020-07-30 19:15:00,95.25,36.719,53.408,29.509 +2020-07-30 19:30:00,94.88,35.501999999999995,53.408,29.509 +2020-07-30 19:45:00,94.26,34.434,53.408,29.509 +2020-07-30 20:00:00,94.38,31.886,55.309,29.509 +2020-07-30 20:15:00,95.53,31.099,55.309,29.509 +2020-07-30 20:30:00,95.23,31.453000000000003,55.309,29.509 +2020-07-30 20:45:00,94.49,31.789,55.309,29.509 +2020-07-30 21:00:00,90.29,30.593000000000004,51.585,29.509 +2020-07-30 21:15:00,90.19,31.728,51.585,29.509 +2020-07-30 21:30:00,85.83,32.162,51.585,29.509 +2020-07-30 21:45:00,84.2,32.855,51.585,29.509 +2020-07-30 22:00:00,79.37,30.081999999999997,48.006,29.509 +2020-07-30 22:15:00,79.95,32.852,48.006,29.509 +2020-07-30 22:30:00,77.7,28.309,48.006,29.509 +2020-07-30 22:45:00,78.53,25.127,48.006,29.509 +2020-07-30 23:00:00,73.03,22.162,42.309,29.509 +2020-07-30 23:15:00,73.29,20.901999999999997,42.309,29.509 +2020-07-30 23:30:00,72.79,20.016,42.309,29.509 +2020-07-30 23:45:00,72.36,18.965999999999998,42.309,29.509 +2020-07-31 00:00:00,67.66,16.794,39.649,29.509 +2020-07-31 00:15:00,67.46,17.897000000000002,39.649,29.509 +2020-07-31 00:30:00,69.3,16.939,39.649,29.509 +2020-07-31 00:45:00,69.18,17.575,39.649,29.509 +2020-07-31 01:00:00,68.54,17.151,37.744,29.509 +2020-07-31 01:15:00,69.64,15.592,37.744,29.509 +2020-07-31 01:30:00,68.26,14.98,37.744,29.509 +2020-07-31 01:45:00,68.52,14.735999999999999,37.744,29.509 +2020-07-31 02:00:00,66.7,15.647,36.965,29.509 +2020-07-31 02:15:00,69.12,14.513,36.965,29.509 +2020-07-31 02:30:00,68.98,17.101,36.965,29.509 +2020-07-31 02:45:00,68.73,17.097,36.965,29.509 +2020-07-31 03:00:00,69.73,18.875999999999998,37.678000000000004,29.509 +2020-07-31 03:15:00,70.06,17.2,37.678000000000004,29.509 +2020-07-31 03:30:00,71.02,16.35,37.678000000000004,29.509 +2020-07-31 03:45:00,73.59,16.607,37.678000000000004,29.509 +2020-07-31 04:00:00,77.51,20.721,38.591,29.509 +2020-07-31 04:15:00,84.16,24.464000000000002,38.591,29.509 +2020-07-31 04:30:00,85.18,22.758000000000003,38.591,29.509 +2020-07-31 04:45:00,83.87,22.075,38.591,29.509 +2020-07-31 05:00:00,88.03,33.272,40.666,29.509 +2020-07-31 05:15:00,93.14,39.897,40.666,29.509 +2020-07-31 05:30:00,96.11,35.541,40.666,29.509 +2020-07-31 05:45:00,103.78,32.701,40.666,29.509 +2020-07-31 06:00:00,109.56,33.047,51.784,29.509 +2020-07-31 06:15:00,108.31,32.806,51.784,29.509 +2020-07-31 06:30:00,104.13,32.679,51.784,29.509 +2020-07-31 06:45:00,103.29,35.299,51.784,29.509 +2020-07-31 07:00:00,105.64,35.775,61.383,29.509 +2020-07-31 07:15:00,107.2,37.719,61.383,29.509 +2020-07-31 07:30:00,106.76,33.693000000000005,61.383,29.509 +2020-07-31 07:45:00,108.81,34.313,61.383,29.509 +2020-07-31 08:00:00,110.94,30.691,55.272,29.509 +2020-07-31 08:15:00,111.98,34.72,55.272,29.509 +2020-07-31 08:30:00,111.09,35.89,55.272,29.509 +2020-07-31 08:45:00,105.5,38.428000000000004,55.272,29.509 +2020-07-31 09:00:00,112.21,31.557,53.506,29.509 +2020-07-31 09:15:00,112.71,33.08,53.506,29.509 +2020-07-31 09:30:00,117.26,36.003,53.506,29.509 +2020-07-31 09:45:00,117.46,39.635,53.506,29.509 +2020-07-31 10:00:00,118.86,37.153,51.363,29.509 +2020-07-31 10:15:00,109.46,38.202,51.363,29.509 +2020-07-31 10:30:00,106.07,38.647,51.363,29.509 +2020-07-31 10:45:00,104.7,39.549,51.363,29.509 +2020-07-31 11:00:00,102.28,37.538000000000004,51.043,29.509 +2020-07-31 11:15:00,99.07,37.687,51.043,29.509 +2020-07-31 11:30:00,98.78,38.205999999999996,51.043,29.509 +2020-07-31 11:45:00,102.16,38.109,51.043,29.509 +2020-07-31 12:00:00,98.03,33.196,47.52,29.509 +2020-07-31 12:15:00,98.7,31.933000000000003,47.52,29.509 +2020-07-31 12:30:00,97.7,30.805999999999997,47.52,29.509 +2020-07-31 12:45:00,101.31,30.771,47.52,29.509 +2020-07-31 13:00:00,97.21,31.002,45.494,29.509 +2020-07-31 13:15:00,94.94,32.394,45.494,29.509 +2020-07-31 13:30:00,95.09,31.428,45.494,29.509 +2020-07-31 13:45:00,99.62,31.894000000000002,45.494,29.509 +2020-07-31 14:00:00,99.4,32.83,43.883,29.509 +2020-07-31 14:15:00,102.69,32.379,43.883,29.509 +2020-07-31 14:30:00,104.82,33.031,43.883,29.509 +2020-07-31 14:45:00,108.76,32.946,43.883,29.509 +2020-07-31 15:00:00,107.19,35.053000000000004,45.714,29.509 +2020-07-31 15:15:00,107.16,32.45,45.714,29.509 +2020-07-31 15:30:00,102.02,30.58,45.714,29.509 +2020-07-31 15:45:00,98.73,29.737,45.714,29.509 +2020-07-31 16:00:00,97.24,31.250999999999998,48.222,29.509 +2020-07-31 16:15:00,96.65,31.649,48.222,29.509 +2020-07-31 16:30:00,95.0,30.721999999999998,48.222,29.509 +2020-07-31 16:45:00,98.3,27.085,48.222,29.509 +2020-07-31 17:00:00,100.32,32.76,52.619,29.509 +2020-07-31 17:15:00,100.27,33.058,52.619,29.509 +2020-07-31 17:30:00,99.43,32.214,52.619,29.509 +2020-07-31 17:45:00,99.98,31.351999999999997,52.619,29.509 +2020-07-31 18:00:00,100.2,34.617,52.99,29.509 +2020-07-31 18:15:00,101.05,33.407,52.99,29.509 +2020-07-31 18:30:00,100.24,31.410999999999998,52.99,29.509 +2020-07-31 18:45:00,97.28,34.628,52.99,29.509 +2020-07-31 19:00:00,94.41,36.923,51.923,29.509 +2020-07-31 19:15:00,93.63,36.38,51.923,29.509 +2020-07-31 19:30:00,90.61,35.259,51.923,29.509 +2020-07-31 19:45:00,91.18,33.129,51.923,29.509 +2020-07-31 20:00:00,91.95,30.405,56.238,29.509 +2020-07-31 20:15:00,91.38,30.476999999999997,56.238,29.509 +2020-07-31 20:30:00,90.78,30.296999999999997,56.238,29.509 +2020-07-31 20:45:00,90.26,29.695,56.238,29.509 +2020-07-31 21:00:00,85.04,29.877,52.426,29.509 +2020-07-31 21:15:00,83.4,32.773,52.426,29.509 +2020-07-31 21:30:00,80.13,33.031,52.426,29.509 +2020-07-31 21:45:00,79.5,33.88,52.426,29.509 +2020-07-31 22:00:00,75.94,30.871,48.196000000000005,29.509 +2020-07-31 22:15:00,75.61,33.374,48.196000000000005,29.509 +2020-07-31 22:30:00,74.04,33.457,48.196000000000005,29.509 +2020-07-31 22:45:00,74.51,30.945,48.196000000000005,29.509 +2020-07-31 23:00:00,69.29,29.855999999999998,41.71,29.509 +2020-07-31 23:15:00,67.98,27.054000000000002,41.71,29.509 +2020-07-31 23:30:00,67.78,24.331999999999997,41.71,29.509 +2020-07-31 23:45:00,68.07,23.197,41.71,29.509 +2020-08-01 00:00:00,64.84,16.840999999999998,40.227,29.423000000000002 +2020-08-01 00:15:00,65.7,17.429000000000002,40.227,29.423000000000002 +2020-08-01 00:30:00,61.29,16.066,40.227,29.423000000000002 +2020-08-01 00:45:00,63.86,15.87,40.227,29.423000000000002 +2020-08-01 01:00:00,61.89,15.71,36.303000000000004,29.423000000000002 +2020-08-01 01:15:00,63.24,14.94,36.303000000000004,29.423000000000002 +2020-08-01 01:30:00,61.9,13.640999999999998,36.303000000000004,29.423000000000002 +2020-08-01 01:45:00,62.53,14.588,36.303000000000004,29.423000000000002 +2020-08-01 02:00:00,60.34,14.395999999999999,33.849000000000004,29.423000000000002 +2020-08-01 02:15:00,60.96,13.040999999999999,33.849000000000004,29.423000000000002 +2020-08-01 02:30:00,66.99,14.253,33.849000000000004,29.423000000000002 +2020-08-01 02:45:00,68.32,15.014000000000001,33.849000000000004,29.423000000000002 +2020-08-01 03:00:00,65.65,15.258,33.149,29.423000000000002 +2020-08-01 03:15:00,61.57,13.152999999999999,33.149,29.423000000000002 +2020-08-01 03:30:00,61.95,13.040999999999999,33.149,29.423000000000002 +2020-08-01 03:45:00,64.13,14.512,33.149,29.423000000000002 +2020-08-01 04:00:00,63.58,16.715,32.501,29.423000000000002 +2020-08-01 04:15:00,62.36,19.454,32.501,29.423000000000002 +2020-08-01 04:30:00,64.17,16.437,32.501,29.423000000000002 +2020-08-01 04:45:00,69.28,16.136,32.501,29.423000000000002 +2020-08-01 05:00:00,70.79,19.305,31.648000000000003,29.423000000000002 +2020-08-01 05:15:00,66.78,16.058,31.648000000000003,29.423000000000002 +2020-08-01 05:30:00,64.36,13.495999999999999,31.648000000000003,29.423000000000002 +2020-08-01 05:45:00,65.34,14.887,31.648000000000003,29.423000000000002 +2020-08-01 06:00:00,68.08,24.894000000000002,32.552,29.423000000000002 +2020-08-01 06:15:00,71.47,31.201,32.552,29.423000000000002 +2020-08-01 06:30:00,70.7,28.622,32.552,29.423000000000002 +2020-08-01 06:45:00,73.6,28.4,32.552,29.423000000000002 +2020-08-01 07:00:00,80.26,28.034000000000002,35.181999999999995,29.423000000000002 +2020-08-01 07:15:00,81.15,28.61,35.181999999999995,29.423000000000002 +2020-08-01 07:30:00,83.99,26.254,35.181999999999995,29.423000000000002 +2020-08-01 07:45:00,78.02,27.406,35.181999999999995,29.423000000000002 +2020-08-01 08:00:00,78.36,24.79,40.35,29.423000000000002 +2020-08-01 08:15:00,78.58,27.854,40.35,29.423000000000002 +2020-08-01 08:30:00,84.13,28.653000000000002,40.35,29.423000000000002 +2020-08-01 08:45:00,88.25,31.538,40.35,29.423000000000002 +2020-08-01 09:00:00,83.81,27.921,42.292,29.423000000000002 +2020-08-01 09:15:00,77.51,29.616,42.292,29.423000000000002 +2020-08-01 09:30:00,77.4,32.631,42.292,29.423000000000002 +2020-08-01 09:45:00,81.46,35.472,42.292,29.423000000000002 +2020-08-01 10:00:00,88.03,33.78,40.084,29.423000000000002 +2020-08-01 10:15:00,87.71,35.077,40.084,29.423000000000002 +2020-08-01 10:30:00,88.54,35.137,40.084,29.423000000000002 +2020-08-01 10:45:00,81.12,35.558,40.084,29.423000000000002 +2020-08-01 11:00:00,75.49,33.609,36.966,29.423000000000002 +2020-08-01 11:15:00,77.04,34.655,36.966,29.423000000000002 +2020-08-01 11:30:00,73.65,35.507,36.966,29.423000000000002 +2020-08-01 11:45:00,73.41,36.196999999999996,36.966,29.423000000000002 +2020-08-01 12:00:00,72.34,32.466,35.19,29.423000000000002 +2020-08-01 12:15:00,72.7,32.124,35.19,29.423000000000002 +2020-08-01 12:30:00,68.3,30.954,35.19,29.423000000000002 +2020-08-01 12:45:00,72.12,31.738000000000003,35.19,29.423000000000002 +2020-08-01 13:00:00,71.85,31.165,32.277,29.423000000000002 +2020-08-01 13:15:00,73.17,32.038000000000004,32.277,29.423000000000002 +2020-08-01 13:30:00,75.14,31.421,32.277,29.423000000000002 +2020-08-01 13:45:00,78.23,30.410999999999998,32.277,29.423000000000002 +2020-08-01 14:00:00,75.78,31.076999999999998,31.436999999999998,29.423000000000002 +2020-08-01 14:15:00,76.7,29.488000000000003,31.436999999999998,29.423000000000002 +2020-08-01 14:30:00,76.4,29.755,31.436999999999998,29.423000000000002 +2020-08-01 14:45:00,71.54,30.14,31.436999999999998,29.423000000000002 +2020-08-01 15:00:00,72.15,32.229,33.493,29.423000000000002 +2020-08-01 15:15:00,79.53,30.479,33.493,29.423000000000002 +2020-08-01 15:30:00,82.37,28.81,33.493,29.423000000000002 +2020-08-01 15:45:00,82.54,27.113000000000003,33.493,29.423000000000002 +2020-08-01 16:00:00,80.64,30.905,36.593,29.423000000000002 +2020-08-01 16:15:00,78.96,30.311,36.593,29.423000000000002 +2020-08-01 16:30:00,81.42,29.761,36.593,29.423000000000002 +2020-08-01 16:45:00,77.27,26.39,36.593,29.423000000000002 +2020-08-01 17:00:00,78.24,30.271,42.049,29.423000000000002 +2020-08-01 17:15:00,80.69,28.34,42.049,29.423000000000002 +2020-08-01 17:30:00,83.53,27.478,42.049,29.423000000000002 +2020-08-01 17:45:00,81.6,27.182,42.049,29.423000000000002 +2020-08-01 18:00:00,83.2,31.39,43.755,29.423000000000002 +2020-08-01 18:15:00,82.56,31.776,43.755,29.423000000000002 +2020-08-01 18:30:00,82.19,31.188000000000002,43.755,29.423000000000002 +2020-08-01 18:45:00,81.88,31.037,43.755,29.423000000000002 +2020-08-01 19:00:00,79.58,31.397,44.492,29.423000000000002 +2020-08-01 19:15:00,76.37,29.973000000000003,44.492,29.423000000000002 +2020-08-01 19:30:00,75.74,29.653000000000002,44.492,29.423000000000002 +2020-08-01 19:45:00,76.03,29.514,44.492,29.423000000000002 +2020-08-01 20:00:00,78.67,27.618000000000002,40.896,29.423000000000002 +2020-08-01 20:15:00,78.26,27.023000000000003,40.896,29.423000000000002 +2020-08-01 20:30:00,77.78,26.051,40.896,29.423000000000002 +2020-08-01 20:45:00,79.77,27.362,40.896,29.423000000000002 +2020-08-01 21:00:00,74.49,25.883000000000003,39.056,29.423000000000002 +2020-08-01 21:15:00,73.64,28.221,39.056,29.423000000000002 +2020-08-01 21:30:00,71.31,28.568,39.056,29.423000000000002 +2020-08-01 21:45:00,70.86,28.83,39.056,29.423000000000002 +2020-08-01 22:00:00,68.57,26.011,38.478,29.423000000000002 +2020-08-01 22:15:00,67.73,28.296999999999997,38.478,29.423000000000002 +2020-08-01 22:30:00,65.12,27.611,38.478,29.423000000000002 +2020-08-01 22:45:00,65.56,25.581999999999997,38.478,29.423000000000002 +2020-08-01 23:00:00,61.72,23.868000000000002,32.953,29.423000000000002 +2020-08-01 23:15:00,60.35,21.84,32.953,29.423000000000002 +2020-08-01 23:30:00,58.32,21.82,32.953,29.423000000000002 +2020-08-01 23:45:00,57.81,21.265,32.953,29.423000000000002 +2020-08-02 00:00:00,56.36,18.218,28.584,29.423000000000002 +2020-08-02 00:15:00,57.66,17.723,28.584,29.423000000000002 +2020-08-02 00:30:00,57.1,16.248,28.584,29.423000000000002 +2020-08-02 00:45:00,57.12,15.909,28.584,29.423000000000002 +2020-08-02 01:00:00,55.38,16.003,26.419,29.423000000000002 +2020-08-02 01:15:00,55.55,15.054,26.419,29.423000000000002 +2020-08-02 01:30:00,55.72,13.6,26.419,29.423000000000002 +2020-08-02 01:45:00,55.6,14.199000000000002,26.419,29.423000000000002 +2020-08-02 02:00:00,55.11,14.134,25.335,29.423000000000002 +2020-08-02 02:15:00,55.47,13.040999999999999,25.335,29.423000000000002 +2020-08-02 02:30:00,54.77,14.844000000000001,25.335,29.423000000000002 +2020-08-02 02:45:00,55.36,15.297,25.335,29.423000000000002 +2020-08-02 03:00:00,54.8,16.134,24.805,29.423000000000002 +2020-08-02 03:15:00,55.54,14.325,24.805,29.423000000000002 +2020-08-02 03:30:00,55.72,13.217,24.805,29.423000000000002 +2020-08-02 03:45:00,55.21,14.154000000000002,24.805,29.423000000000002 +2020-08-02 04:00:00,58.15,16.34,25.772,29.423000000000002 +2020-08-02 04:15:00,56.39,18.723,25.772,29.423000000000002 +2020-08-02 04:30:00,54.95,16.98,25.772,29.423000000000002 +2020-08-02 04:45:00,55.36,16.218,25.772,29.423000000000002 +2020-08-02 05:00:00,55.17,19.77,25.971999999999998,29.423000000000002 +2020-08-02 05:15:00,54.51,15.937000000000001,25.971999999999998,29.423000000000002 +2020-08-02 05:30:00,54.25,13.040999999999999,25.971999999999998,29.423000000000002 +2020-08-02 05:45:00,55.07,14.138,25.971999999999998,29.423000000000002 +2020-08-02 06:00:00,55.41,21.9,26.026,29.423000000000002 +2020-08-02 06:15:00,55.73,29.107,26.026,29.423000000000002 +2020-08-02 06:30:00,57.85,25.941,26.026,29.423000000000002 +2020-08-02 06:45:00,59.03,24.854,26.026,29.423000000000002 +2020-08-02 07:00:00,59.2,24.644000000000002,27.396,29.423000000000002 +2020-08-02 07:15:00,60.74,23.725,27.396,29.423000000000002 +2020-08-02 07:30:00,63.57,22.7,27.396,29.423000000000002 +2020-08-02 07:45:00,64.87,23.888,27.396,29.423000000000002 +2020-08-02 08:00:00,63.57,21.895,30.791999999999998,29.423000000000002 +2020-08-02 08:15:00,66.35,26.094,30.791999999999998,29.423000000000002 +2020-08-02 08:30:00,66.99,27.593000000000004,30.791999999999998,29.423000000000002 +2020-08-02 08:45:00,68.25,30.224,30.791999999999998,29.423000000000002 +2020-08-02 09:00:00,68.6,26.549,32.482,29.423000000000002 +2020-08-02 09:15:00,67.03,27.708000000000002,32.482,29.423000000000002 +2020-08-02 09:30:00,66.69,31.144000000000002,32.482,29.423000000000002 +2020-08-02 09:45:00,67.87,34.96,32.482,29.423000000000002 +2020-08-02 10:00:00,69.2,33.574,31.951,29.423000000000002 +2020-08-02 10:15:00,67.97,34.938,31.951,29.423000000000002 +2020-08-02 10:30:00,69.5,35.152,31.951,29.423000000000002 +2020-08-02 10:45:00,73.44,36.748000000000005,31.951,29.423000000000002 +2020-08-02 11:00:00,71.52,34.372,33.619,29.423000000000002 +2020-08-02 11:15:00,64.82,34.964,33.619,29.423000000000002 +2020-08-02 11:30:00,60.86,36.402,33.619,29.423000000000002 +2020-08-02 11:45:00,66.18,37.275,33.619,29.423000000000002 +2020-08-02 12:00:00,63.81,34.646,30.975,29.423000000000002 +2020-08-02 12:15:00,60.26,33.494,30.975,29.423000000000002 +2020-08-02 12:30:00,56.84,32.681,30.975,29.423000000000002 +2020-08-02 12:45:00,55.4,32.926,30.975,29.423000000000002 +2020-08-02 13:00:00,53.87,32.108000000000004,27.956999999999997,29.423000000000002 +2020-08-02 13:15:00,58.19,32.109,27.956999999999997,29.423000000000002 +2020-08-02 13:30:00,55.86,30.419,27.956999999999997,29.423000000000002 +2020-08-02 13:45:00,57.45,30.579,27.956999999999997,29.423000000000002 +2020-08-02 14:00:00,56.54,32.385999999999996,25.555999999999997,29.423000000000002 +2020-08-02 14:15:00,55.69,31.107,25.555999999999997,29.423000000000002 +2020-08-02 14:30:00,56.82,30.05,25.555999999999997,29.423000000000002 +2020-08-02 14:45:00,56.34,29.430999999999997,25.555999999999997,29.423000000000002 +2020-08-02 15:00:00,56.78,31.79,26.271,29.423000000000002 +2020-08-02 15:15:00,54.95,29.179000000000002,26.271,29.423000000000002 +2020-08-02 15:30:00,54.84,27.285,26.271,29.423000000000002 +2020-08-02 15:45:00,56.41,25.801,26.271,29.423000000000002 +2020-08-02 16:00:00,59.72,27.843000000000004,30.369,29.423000000000002 +2020-08-02 16:15:00,59.6,27.56,30.369,29.423000000000002 +2020-08-02 16:30:00,61.23,27.973000000000003,30.369,29.423000000000002 +2020-08-02 16:45:00,63.53,24.678,30.369,29.423000000000002 +2020-08-02 17:00:00,67.12,28.910999999999998,38.787,29.423000000000002 +2020-08-02 17:15:00,68.98,28.498,38.787,29.423000000000002 +2020-08-02 17:30:00,70.71,28.406,38.787,29.423000000000002 +2020-08-02 17:45:00,74.99,28.346999999999998,38.787,29.423000000000002 +2020-08-02 18:00:00,74.67,33.192,41.886,29.423000000000002 +2020-08-02 18:15:00,73.6,33.085,41.886,29.423000000000002 +2020-08-02 18:30:00,76.34,32.428000000000004,41.886,29.423000000000002 +2020-08-02 18:45:00,74.12,32.243,41.886,29.423000000000002 +2020-08-02 19:00:00,75.09,34.739000000000004,42.91,29.423000000000002 +2020-08-02 19:15:00,72.97,32.211999999999996,42.91,29.423000000000002 +2020-08-02 19:30:00,73.09,31.666,42.91,29.423000000000002 +2020-08-02 19:45:00,73.57,30.991,42.91,29.423000000000002 +2020-08-02 20:00:00,78.39,29.259,42.148999999999994,29.423000000000002 +2020-08-02 20:15:00,77.61,28.465,42.148999999999994,29.423000000000002 +2020-08-02 20:30:00,77.86,28.169,42.148999999999994,29.423000000000002 +2020-08-02 20:45:00,80.93,27.945999999999998,42.148999999999994,29.423000000000002 +2020-08-02 21:00:00,76.12,26.549,40.955999999999996,29.423000000000002 +2020-08-02 21:15:00,75.92,28.636999999999997,40.955999999999996,29.423000000000002 +2020-08-02 21:30:00,72.29,28.338,40.955999999999996,29.423000000000002 +2020-08-02 21:45:00,71.28,28.91,40.955999999999996,29.423000000000002 +2020-08-02 22:00:00,67.02,28.098000000000003,39.873000000000005,29.423000000000002 +2020-08-02 22:15:00,68.04,28.831,39.873000000000005,29.423000000000002 +2020-08-02 22:30:00,65.78,27.840999999999998,39.873000000000005,29.423000000000002 +2020-08-02 22:45:00,65.72,24.656999999999996,39.873000000000005,29.423000000000002 +2020-08-02 23:00:00,60.72,22.861,35.510999999999996,29.423000000000002 +2020-08-02 23:15:00,63.05,21.92,35.510999999999996,29.423000000000002 +2020-08-02 23:30:00,62.6,21.354,35.510999999999996,29.423000000000002 +2020-08-02 23:45:00,62.86,20.894000000000002,35.510999999999996,29.423000000000002 +2020-08-03 00:00:00,64.33,19.451,33.475,29.535 +2020-08-03 00:15:00,67.22,19.498,33.475,29.535 +2020-08-03 00:30:00,66.41,17.651,33.475,29.535 +2020-08-03 00:45:00,63.17,16.98,33.475,29.535 +2020-08-03 01:00:00,63.68,17.434,33.111,29.535 +2020-08-03 01:15:00,60.61,16.54,33.111,29.535 +2020-08-03 01:30:00,60.33,15.436,33.111,29.535 +2020-08-03 01:45:00,61.13,15.931,33.111,29.535 +2020-08-03 02:00:00,59.92,16.308,32.358000000000004,29.535 +2020-08-03 02:15:00,61.4,14.12,32.358000000000004,29.535 +2020-08-03 02:30:00,61.07,16.077,32.358000000000004,29.535 +2020-08-03 02:45:00,61.46,16.435,32.358000000000004,29.535 +2020-08-03 03:00:00,60.32,17.656,30.779,29.535 +2020-08-03 03:15:00,63.48,16.432000000000002,30.779,29.535 +2020-08-03 03:30:00,63.96,16.000999999999998,30.779,29.535 +2020-08-03 03:45:00,66.5,16.54,30.779,29.535 +2020-08-03 04:00:00,74.97,21.386999999999997,31.416,29.535 +2020-08-03 04:15:00,76.04,26.27,31.416,29.535 +2020-08-03 04:30:00,74.98,23.919,31.416,29.535 +2020-08-03 04:45:00,83.07,23.5,31.416,29.535 +2020-08-03 05:00:00,90.67,32.928000000000004,37.221,29.535 +2020-08-03 05:15:00,89.8,37.603,37.221,29.535 +2020-08-03 05:30:00,91.37,33.244,37.221,29.535 +2020-08-03 05:45:00,93.43,31.784000000000002,37.221,29.535 +2020-08-03 06:00:00,105.8,30.877,51.891000000000005,29.535 +2020-08-03 06:15:00,107.93,30.43,51.891000000000005,29.535 +2020-08-03 06:30:00,104.29,30.522,51.891000000000005,29.535 +2020-08-03 06:45:00,105.53,33.891,51.891000000000005,29.535 +2020-08-03 07:00:00,111.0,33.523,62.282,29.535 +2020-08-03 07:15:00,108.3,34.719,62.282,29.535 +2020-08-03 07:30:00,108.02,32.942,62.282,29.535 +2020-08-03 07:45:00,103.2,34.629,62.282,29.535 +2020-08-03 08:00:00,104.16,30.793000000000003,54.102,29.535 +2020-08-03 08:15:00,105.8,33.993,54.102,29.535 +2020-08-03 08:30:00,107.65,34.896,54.102,29.535 +2020-08-03 08:45:00,105.3,38.001999999999995,54.102,29.535 +2020-08-03 09:00:00,103.61,33.321999999999996,50.917,29.535 +2020-08-03 09:15:00,107.08,33.051,50.917,29.535 +2020-08-03 09:30:00,106.27,35.66,50.917,29.535 +2020-08-03 09:45:00,106.17,37.192,50.917,29.535 +2020-08-03 10:00:00,102.23,36.264,49.718999999999994,29.535 +2020-08-03 10:15:00,106.93,37.5,49.718999999999994,29.535 +2020-08-03 10:30:00,107.96,37.342,49.718999999999994,29.535 +2020-08-03 10:45:00,107.77,37.3,49.718999999999994,29.535 +2020-08-03 11:00:00,100.64,35.437,49.833999999999996,29.535 +2020-08-03 11:15:00,97.07,36.126,49.833999999999996,29.535 +2020-08-03 11:30:00,95.48,38.11,49.833999999999996,29.535 +2020-08-03 11:45:00,95.33,39.504,49.833999999999996,29.535 +2020-08-03 12:00:00,98.79,35.001,47.832,29.535 +2020-08-03 12:15:00,96.36,33.953,47.832,29.535 +2020-08-03 12:30:00,97.53,32.065,47.832,29.535 +2020-08-03 12:45:00,95.9,32.147,47.832,29.535 +2020-08-03 13:00:00,100.77,32.144,48.03,29.535 +2020-08-03 13:15:00,99.22,31.401,48.03,29.535 +2020-08-03 13:30:00,102.37,29.932,48.03,29.535 +2020-08-03 13:45:00,97.29,30.98,48.03,29.535 +2020-08-03 14:00:00,94.86,31.984,48.157,29.535 +2020-08-03 14:15:00,93.05,31.354,48.157,29.535 +2020-08-03 14:30:00,92.91,30.191999999999997,48.157,29.535 +2020-08-03 14:45:00,92.65,31.615,48.157,29.535 +2020-08-03 15:00:00,92.04,33.464,48.897,29.535 +2020-08-03 15:15:00,91.52,30.410999999999998,48.897,29.535 +2020-08-03 15:30:00,90.89,29.355999999999998,48.897,29.535 +2020-08-03 15:45:00,92.82,27.436999999999998,48.897,29.535 +2020-08-03 16:00:00,94.14,30.561,51.446000000000005,29.535 +2020-08-03 16:15:00,94.64,30.415,51.446000000000005,29.535 +2020-08-03 16:30:00,96.56,30.275,51.446000000000005,29.535 +2020-08-03 16:45:00,98.01,27.131999999999998,51.446000000000005,29.535 +2020-08-03 17:00:00,98.65,30.291,57.507,29.535 +2020-08-03 17:15:00,100.17,30.348000000000003,57.507,29.535 +2020-08-03 17:30:00,101.49,29.936999999999998,57.507,29.535 +2020-08-03 17:45:00,102.04,29.642,57.507,29.535 +2020-08-03 18:00:00,103.4,33.454,57.896,29.535 +2020-08-03 18:15:00,101.83,31.758000000000003,57.896,29.535 +2020-08-03 18:30:00,102.94,30.328000000000003,57.896,29.535 +2020-08-03 18:45:00,102.54,33.139,57.896,29.535 +2020-08-03 19:00:00,99.56,35.525,57.891999999999996,29.535 +2020-08-03 19:15:00,96.15,34.346,57.891999999999996,29.535 +2020-08-03 19:30:00,94.72,33.41,57.891999999999996,29.535 +2020-08-03 19:45:00,96.09,32.209,57.891999999999996,29.535 +2020-08-03 20:00:00,96.55,29.432,64.57300000000001,29.535 +2020-08-03 20:15:00,103.85,30.159000000000002,64.57300000000001,29.535 +2020-08-03 20:30:00,102.7,30.487,64.57300000000001,29.535 +2020-08-03 20:45:00,96.84,30.337,64.57300000000001,29.535 +2020-08-03 21:00:00,90.83,28.291999999999998,59.431999999999995,29.535 +2020-08-03 21:15:00,90.21,30.875999999999998,59.431999999999995,29.535 +2020-08-03 21:30:00,85.03,31.019000000000002,59.431999999999995,29.535 +2020-08-03 21:45:00,84.99,31.394000000000002,59.431999999999995,29.535 +2020-08-03 22:00:00,79.53,28.799,51.519,29.535 +2020-08-03 22:15:00,80.49,31.503,51.519,29.535 +2020-08-03 22:30:00,79.92,26.988000000000003,51.519,29.535 +2020-08-03 22:45:00,78.23,24.1,51.519,29.535 +2020-08-03 23:00:00,73.74,22.268,44.501000000000005,29.535 +2020-08-03 23:15:00,72.71,19.594,44.501000000000005,29.535 +2020-08-03 23:30:00,74.86,18.83,44.501000000000005,29.535 +2020-08-03 23:45:00,80.0,17.692,44.501000000000005,29.535 +2020-08-04 00:00:00,77.12,17.405,44.522,29.535 +2020-08-04 00:15:00,74.58,18.18,44.522,29.535 +2020-08-04 00:30:00,69.85,17.092,44.522,29.535 +2020-08-04 00:45:00,70.87,17.262999999999998,44.522,29.535 +2020-08-04 01:00:00,70.62,17.199,41.441,29.535 +2020-08-04 01:15:00,70.7,16.449,41.441,29.535 +2020-08-04 01:30:00,73.19,15.217,41.441,29.535 +2020-08-04 01:45:00,78.29,15.190999999999999,41.441,29.535 +2020-08-04 02:00:00,77.5,15.107999999999999,40.203,29.535 +2020-08-04 02:15:00,71.54,14.061,40.203,29.535 +2020-08-04 02:30:00,71.46,15.665999999999999,40.203,29.535 +2020-08-04 02:45:00,76.65,16.338,40.203,29.535 +2020-08-04 03:00:00,78.03,17.101,39.536,29.535 +2020-08-04 03:15:00,78.15,16.855,39.536,29.535 +2020-08-04 03:30:00,71.82,16.365,39.536,29.535 +2020-08-04 03:45:00,79.75,15.821,39.536,29.535 +2020-08-04 04:00:00,84.05,19.462,40.759,29.535 +2020-08-04 04:15:00,86.03,24.38,40.759,29.535 +2020-08-04 04:30:00,85.13,21.936,40.759,29.535 +2020-08-04 04:45:00,89.94,21.901999999999997,40.759,29.535 +2020-08-04 05:00:00,95.49,32.521,43.623999999999995,29.535 +2020-08-04 05:15:00,96.01,37.814,43.623999999999995,29.535 +2020-08-04 05:30:00,98.12,33.751,43.623999999999995,29.535 +2020-08-04 05:45:00,99.46,31.581,43.623999999999995,29.535 +2020-08-04 06:00:00,100.86,31.744,52.684,29.535 +2020-08-04 06:15:00,104.1,31.389,52.684,29.535 +2020-08-04 06:30:00,107.85,31.221999999999998,52.684,29.535 +2020-08-04 06:45:00,112.25,33.775999999999996,52.684,29.535 +2020-08-04 07:00:00,112.11,33.569,62.676,29.535 +2020-08-04 07:15:00,113.72,34.546,62.676,29.535 +2020-08-04 07:30:00,109.77,32.847,62.676,29.535 +2020-08-04 07:45:00,107.83,33.577,62.676,29.535 +2020-08-04 08:00:00,112.52,29.664,56.161,29.535 +2020-08-04 08:15:00,112.46,32.497,56.161,29.535 +2020-08-04 08:30:00,112.9,33.57,56.161,29.535 +2020-08-04 08:45:00,111.06,35.781,56.161,29.535 +2020-08-04 09:00:00,115.35,31.52,52.132,29.535 +2020-08-04 09:15:00,114.68,30.95,52.132,29.535 +2020-08-04 09:30:00,112.07,34.161,52.132,29.535 +2020-08-04 09:45:00,109.95,37.027,52.132,29.535 +2020-08-04 10:00:00,113.26,34.8,51.032,29.535 +2020-08-04 10:15:00,111.01,35.992,51.032,29.535 +2020-08-04 10:30:00,108.81,35.836,51.032,29.535 +2020-08-04 10:45:00,112.99,36.762,51.032,29.535 +2020-08-04 11:00:00,106.02,34.756,51.085,29.535 +2020-08-04 11:15:00,107.07,35.876999999999995,51.085,29.535 +2020-08-04 11:30:00,108.91,36.829,51.085,29.535 +2020-08-04 11:45:00,110.11,37.693000000000005,51.085,29.535 +2020-08-04 12:00:00,105.38,33.145,49.049,29.535 +2020-08-04 12:15:00,101.69,32.468,49.049,29.535 +2020-08-04 12:30:00,100.66,31.366999999999997,49.049,29.535 +2020-08-04 12:45:00,99.77,32.162,49.049,29.535 +2020-08-04 13:00:00,98.37,31.823,49.722,29.535 +2020-08-04 13:15:00,98.25,32.902,49.722,29.535 +2020-08-04 13:30:00,97.22,31.279,49.722,29.535 +2020-08-04 13:45:00,97.65,31.354,49.722,29.535 +2020-08-04 14:00:00,98.37,32.76,49.565,29.535 +2020-08-04 14:15:00,100.11,31.919,49.565,29.535 +2020-08-04 14:30:00,100.18,31.025,49.565,29.535 +2020-08-04 14:45:00,99.55,31.674,49.565,29.535 +2020-08-04 15:00:00,100.99,33.382,51.108999999999995,29.535 +2020-08-04 15:15:00,98.14,31.217,51.108999999999995,29.535 +2020-08-04 15:30:00,97.4,29.936999999999998,51.108999999999995,29.535 +2020-08-04 15:45:00,98.43,28.403000000000002,51.108999999999995,29.535 +2020-08-04 16:00:00,102.05,30.775,52.725,29.535 +2020-08-04 16:15:00,104.62,30.676,52.725,29.535 +2020-08-04 16:30:00,102.53,30.116999999999997,52.725,29.535 +2020-08-04 16:45:00,103.42,27.68,52.725,29.535 +2020-08-04 17:00:00,104.82,30.979,58.031000000000006,29.535 +2020-08-04 17:15:00,106.88,31.469,58.031000000000006,29.535 +2020-08-04 17:30:00,106.52,30.576,58.031000000000006,29.535 +2020-08-04 17:45:00,106.89,29.989,58.031000000000006,29.535 +2020-08-04 18:00:00,107.37,32.888000000000005,58.338,29.535 +2020-08-04 18:15:00,105.89,32.689,58.338,29.535 +2020-08-04 18:30:00,105.26,31.037,58.338,29.535 +2020-08-04 18:45:00,104.69,33.591,58.338,29.535 +2020-08-04 19:00:00,102.74,34.849000000000004,58.464,29.535 +2020-08-04 19:15:00,99.6,33.861,58.464,29.535 +2020-08-04 19:30:00,98.69,32.769,58.464,29.535 +2020-08-04 19:45:00,99.54,31.89,58.464,29.535 +2020-08-04 20:00:00,98.28,29.48,63.708,29.535 +2020-08-04 20:15:00,104.76,28.862,63.708,29.535 +2020-08-04 20:30:00,104.72,29.14,63.708,29.535 +2020-08-04 20:45:00,102.08,29.421999999999997,63.708,29.535 +2020-08-04 21:00:00,93.21,28.273000000000003,57.06399999999999,29.535 +2020-08-04 21:15:00,91.37,29.372,57.06399999999999,29.535 +2020-08-04 21:30:00,88.94,29.728,57.06399999999999,29.535 +2020-08-04 21:45:00,87.22,30.253,57.06399999999999,29.535 +2020-08-04 22:00:00,81.39,27.693,52.831,29.535 +2020-08-04 22:15:00,90.48,30.084,52.831,29.535 +2020-08-04 22:30:00,86.19,25.816999999999997,52.831,29.535 +2020-08-04 22:45:00,84.07,22.903000000000002,52.831,29.535 +2020-08-04 23:00:00,76.58,20.366,44.717,29.535 +2020-08-04 23:15:00,82.38,19.252,44.717,29.535 +2020-08-04 23:30:00,81.93,18.522000000000002,44.717,29.535 +2020-08-04 23:45:00,82.08,17.589000000000002,44.717,29.535 +2020-08-05 00:00:00,74.43,17.499000000000002,41.263000000000005,29.535 +2020-08-05 00:15:00,72.81,18.273,41.263000000000005,29.535 +2020-08-05 00:30:00,73.78,17.189,41.263000000000005,29.535 +2020-08-05 00:45:00,79.92,17.368,41.263000000000005,29.535 +2020-08-05 01:00:00,79.4,17.293,38.448,29.535 +2020-08-05 01:15:00,79.23,16.547,38.448,29.535 +2020-08-05 01:30:00,73.16,15.324000000000002,38.448,29.535 +2020-08-05 01:45:00,74.56,15.296,38.448,29.535 +2020-08-05 02:00:00,71.2,15.217,36.471,29.535 +2020-08-05 02:15:00,72.53,14.187999999999999,36.471,29.535 +2020-08-05 02:30:00,76.7,15.772,36.471,29.535 +2020-08-05 02:45:00,78.9,16.442999999999998,36.471,29.535 +2020-08-05 03:00:00,76.32,17.195,36.042,29.535 +2020-08-05 03:15:00,71.19,16.965,36.042,29.535 +2020-08-05 03:30:00,73.06,16.482,36.042,29.535 +2020-08-05 03:45:00,74.45,15.94,36.042,29.535 +2020-08-05 04:00:00,76.22,19.583,36.705,29.535 +2020-08-05 04:15:00,79.11,24.499000000000002,36.705,29.535 +2020-08-05 04:30:00,80.91,22.057,36.705,29.535 +2020-08-05 04:45:00,83.59,22.023000000000003,36.705,29.535 +2020-08-05 05:00:00,89.3,32.665,39.716,29.535 +2020-08-05 05:15:00,93.87,37.975,39.716,29.535 +2020-08-05 05:30:00,98.13,33.93,39.716,29.535 +2020-08-05 05:45:00,102.24,31.743000000000002,39.716,29.535 +2020-08-05 06:00:00,110.74,31.885,52.756,29.535 +2020-08-05 06:15:00,113.42,31.543000000000003,52.756,29.535 +2020-08-05 06:30:00,114.26,31.381999999999998,52.756,29.535 +2020-08-05 06:45:00,111.89,33.946999999999996,52.756,29.535 +2020-08-05 07:00:00,112.05,33.738,65.977,29.535 +2020-08-05 07:15:00,111.49,34.732,65.977,29.535 +2020-08-05 07:30:00,110.79,33.048,65.977,29.535 +2020-08-05 07:45:00,115.87,33.79,65.977,29.535 +2020-08-05 08:00:00,116.83,29.88,57.927,29.535 +2020-08-05 08:15:00,115.09,32.696999999999996,57.927,29.535 +2020-08-05 08:30:00,115.44,33.76,57.927,29.535 +2020-08-05 08:45:00,116.68,35.958,57.927,29.535 +2020-08-05 09:00:00,119.16,31.701,54.86,29.535 +2020-08-05 09:15:00,115.56,31.124000000000002,54.86,29.535 +2020-08-05 09:30:00,112.66,34.321999999999996,54.86,29.535 +2020-08-05 09:45:00,127.72,37.173,54.86,29.535 +2020-08-05 10:00:00,125.46,34.949,52.818000000000005,29.535 +2020-08-05 10:15:00,122.4,36.124,52.818000000000005,29.535 +2020-08-05 10:30:00,115.0,35.961999999999996,52.818000000000005,29.535 +2020-08-05 10:45:00,114.33,36.883,52.818000000000005,29.535 +2020-08-05 11:00:00,111.89,34.884,52.937,29.535 +2020-08-05 11:15:00,111.04,36.0,52.937,29.535 +2020-08-05 11:30:00,107.75,36.945,52.937,29.535 +2020-08-05 11:45:00,105.24,37.798,52.937,29.535 +2020-08-05 12:00:00,104.45,33.249,50.826,29.535 +2020-08-05 12:15:00,103.52,32.566,50.826,29.535 +2020-08-05 12:30:00,103.67,31.47,50.826,29.535 +2020-08-05 12:45:00,102.21,32.255,50.826,29.535 +2020-08-05 13:00:00,100.82,31.898000000000003,50.556000000000004,29.535 +2020-08-05 13:15:00,100.96,32.968,50.556000000000004,29.535 +2020-08-05 13:30:00,105.71,31.343000000000004,50.556000000000004,29.535 +2020-08-05 13:45:00,102.53,31.427,50.556000000000004,29.535 +2020-08-05 14:00:00,104.32,32.821,51.188,29.535 +2020-08-05 14:15:00,112.39,31.984,51.188,29.535 +2020-08-05 14:30:00,116.08,31.096999999999998,51.188,29.535 +2020-08-05 14:45:00,116.4,31.75,51.188,29.535 +2020-08-05 15:00:00,114.44,33.433,52.976000000000006,29.535 +2020-08-05 15:15:00,114.65,31.269000000000002,52.976000000000006,29.535 +2020-08-05 15:30:00,115.6,29.997,52.976000000000006,29.535 +2020-08-05 15:45:00,115.27,28.464000000000002,52.976000000000006,29.535 +2020-08-05 16:00:00,113.47,30.823,55.463,29.535 +2020-08-05 16:15:00,110.57,30.73,55.463,29.535 +2020-08-05 16:30:00,109.52,30.18,55.463,29.535 +2020-08-05 16:45:00,111.03,27.772,55.463,29.535 +2020-08-05 17:00:00,109.65,31.051,59.435,29.535 +2020-08-05 17:15:00,106.85,31.566,59.435,29.535 +2020-08-05 17:30:00,106.49,30.684,59.435,29.535 +2020-08-05 17:45:00,109.02,30.124000000000002,59.435,29.535 +2020-08-05 18:00:00,109.21,33.019,61.387,29.535 +2020-08-05 18:15:00,107.26,32.824,61.387,29.535 +2020-08-05 18:30:00,107.93,31.18,61.387,29.535 +2020-08-05 18:45:00,105.44,33.734,61.387,29.535 +2020-08-05 19:00:00,102.48,34.996,63.323,29.535 +2020-08-05 19:15:00,99.33,34.006,63.323,29.535 +2020-08-05 19:30:00,98.06,32.913000000000004,63.323,29.535 +2020-08-05 19:45:00,97.58,32.037,63.323,29.535 +2020-08-05 20:00:00,98.82,29.625999999999998,69.083,29.535 +2020-08-05 20:15:00,99.21,29.009,69.083,29.535 +2020-08-05 20:30:00,100.79,29.276,69.083,29.535 +2020-08-05 20:45:00,97.25,29.539,69.083,29.535 +2020-08-05 21:00:00,94.59,28.393,59.957,29.535 +2020-08-05 21:15:00,93.52,29.486,59.957,29.535 +2020-08-05 21:30:00,89.59,29.833000000000002,59.957,29.535 +2020-08-05 21:45:00,86.86,30.335,59.957,29.535 +2020-08-05 22:00:00,81.98,27.763,53.821000000000005,29.535 +2020-08-05 22:15:00,82.56,30.144000000000002,53.821000000000005,29.535 +2020-08-05 22:30:00,80.52,25.849,53.821000000000005,29.535 +2020-08-05 22:45:00,83.39,22.934,53.821000000000005,29.535 +2020-08-05 23:00:00,76.4,20.426,45.458,29.535 +2020-08-05 23:15:00,76.83,19.312,45.458,29.535 +2020-08-05 23:30:00,76.33,18.596,45.458,29.535 +2020-08-05 23:45:00,75.85,17.664,45.458,29.535 +2020-08-06 00:00:00,71.94,17.595,40.36,29.535 +2020-08-06 00:15:00,72.79,18.368,40.36,29.535 +2020-08-06 00:30:00,71.6,17.289,40.36,29.535 +2020-08-06 00:45:00,72.0,17.474,40.36,29.535 +2020-08-06 01:00:00,70.14,17.387999999999998,38.552,29.535 +2020-08-06 01:15:00,71.15,16.648,38.552,29.535 +2020-08-06 01:30:00,70.1,15.435,38.552,29.535 +2020-08-06 01:45:00,69.93,15.404000000000002,38.552,29.535 +2020-08-06 02:00:00,76.01,15.328,36.895,29.535 +2020-08-06 02:15:00,77.91,14.318,36.895,29.535 +2020-08-06 02:30:00,75.22,15.882,36.895,29.535 +2020-08-06 02:45:00,72.63,16.552,36.895,29.535 +2020-08-06 03:00:00,72.82,17.294,36.565,29.535 +2020-08-06 03:15:00,72.3,17.077,36.565,29.535 +2020-08-06 03:30:00,74.58,16.601,36.565,29.535 +2020-08-06 03:45:00,75.09,16.062,36.565,29.535 +2020-08-06 04:00:00,79.6,19.707,37.263000000000005,29.535 +2020-08-06 04:15:00,86.77,24.622,37.263000000000005,29.535 +2020-08-06 04:30:00,88.17,22.183000000000003,37.263000000000005,29.535 +2020-08-06 04:45:00,90.68,22.151,37.263000000000005,29.535 +2020-08-06 05:00:00,90.92,32.816,40.412,29.535 +2020-08-06 05:15:00,94.47,38.146,40.412,29.535 +2020-08-06 05:30:00,102.91,34.119,40.412,29.535 +2020-08-06 05:45:00,107.32,31.916,40.412,29.535 +2020-08-06 06:00:00,110.3,32.035,49.825,29.535 +2020-08-06 06:15:00,109.16,31.708000000000002,49.825,29.535 +2020-08-06 06:30:00,113.88,31.549,49.825,29.535 +2020-08-06 06:45:00,115.25,34.126,49.825,29.535 +2020-08-06 07:00:00,118.34,33.914,61.082,29.535 +2020-08-06 07:15:00,113.87,34.925,61.082,29.535 +2020-08-06 07:30:00,116.05,33.259,61.082,29.535 +2020-08-06 07:45:00,116.6,34.01,61.082,29.535 +2020-08-06 08:00:00,115.41,30.105,53.961999999999996,29.535 +2020-08-06 08:15:00,108.89,32.903,53.961999999999996,29.535 +2020-08-06 08:30:00,114.46,33.955,53.961999999999996,29.535 +2020-08-06 08:45:00,116.44,36.141999999999996,53.961999999999996,29.535 +2020-08-06 09:00:00,117.7,31.888,50.06100000000001,29.535 +2020-08-06 09:15:00,113.69,31.304000000000002,50.06100000000001,29.535 +2020-08-06 09:30:00,109.27,34.489000000000004,50.06100000000001,29.535 +2020-08-06 09:45:00,108.39,37.323,50.06100000000001,29.535 +2020-08-06 10:00:00,109.59,35.102,47.68,29.535 +2020-08-06 10:15:00,109.22,36.260999999999996,47.68,29.535 +2020-08-06 10:30:00,115.31,36.093,47.68,29.535 +2020-08-06 10:45:00,123.63,37.009,47.68,29.535 +2020-08-06 11:00:00,120.81,35.016999999999996,45.93899999999999,29.535 +2020-08-06 11:15:00,120.53,36.128,45.93899999999999,29.535 +2020-08-06 11:30:00,115.4,37.066,45.93899999999999,29.535 +2020-08-06 11:45:00,114.1,37.907,45.93899999999999,29.535 +2020-08-06 12:00:00,108.48,33.357,43.648999999999994,29.535 +2020-08-06 12:15:00,115.79,32.666,43.648999999999994,29.535 +2020-08-06 12:30:00,114.88,31.578000000000003,43.648999999999994,29.535 +2020-08-06 12:45:00,109.1,32.354,43.648999999999994,29.535 +2020-08-06 13:00:00,105.16,31.976999999999997,42.801,29.535 +2020-08-06 13:15:00,109.12,33.035,42.801,29.535 +2020-08-06 13:30:00,109.71,31.41,42.801,29.535 +2020-08-06 13:45:00,108.45,31.504,42.801,29.535 +2020-08-06 14:00:00,105.05,32.884,43.24,29.535 +2020-08-06 14:15:00,106.1,32.052,43.24,29.535 +2020-08-06 14:30:00,113.49,31.171,43.24,29.535 +2020-08-06 14:45:00,112.04,31.83,43.24,29.535 +2020-08-06 15:00:00,106.97,33.484,45.04600000000001,29.535 +2020-08-06 15:15:00,105.8,31.323,45.04600000000001,29.535 +2020-08-06 15:30:00,104.44,30.059,45.04600000000001,29.535 +2020-08-06 15:45:00,107.22,28.526999999999997,45.04600000000001,29.535 +2020-08-06 16:00:00,109.97,30.872,46.568000000000005,29.535 +2020-08-06 16:15:00,108.43,30.785999999999998,46.568000000000005,29.535 +2020-08-06 16:30:00,109.72,30.246,46.568000000000005,29.535 +2020-08-06 16:45:00,114.1,27.866999999999997,46.568000000000005,29.535 +2020-08-06 17:00:00,114.18,31.127,50.618,29.535 +2020-08-06 17:15:00,107.45,31.666,50.618,29.535 +2020-08-06 17:30:00,110.68,30.795,50.618,29.535 +2020-08-06 17:45:00,113.82,30.261,50.618,29.535 +2020-08-06 18:00:00,115.22,33.153,52.806999999999995,29.535 +2020-08-06 18:15:00,116.03,32.963,52.806999999999995,29.535 +2020-08-06 18:30:00,115.79,31.326999999999998,52.806999999999995,29.535 +2020-08-06 18:45:00,111.98,33.882,52.806999999999995,29.535 +2020-08-06 19:00:00,104.96,35.147,53.464,29.535 +2020-08-06 19:15:00,99.09,34.155,53.464,29.535 +2020-08-06 19:30:00,97.96,33.061,53.464,29.535 +2020-08-06 19:45:00,99.34,32.188,53.464,29.535 +2020-08-06 20:00:00,102.66,29.776999999999997,56.753,29.535 +2020-08-06 20:15:00,99.01,29.160999999999998,56.753,29.535 +2020-08-06 20:30:00,98.36,29.416999999999998,56.753,29.535 +2020-08-06 20:45:00,96.53,29.66,56.753,29.535 +2020-08-06 21:00:00,92.34,28.517,52.506,29.535 +2020-08-06 21:15:00,91.64,29.603,52.506,29.535 +2020-08-06 21:30:00,87.55,29.941999999999997,52.506,29.535 +2020-08-06 21:45:00,86.87,30.42,52.506,29.535 +2020-08-06 22:00:00,82.17,27.836,48.163000000000004,29.535 +2020-08-06 22:15:00,83.32,30.206999999999997,48.163000000000004,29.535 +2020-08-06 22:30:00,79.81,25.884,48.163000000000004,29.535 +2020-08-06 22:45:00,79.96,22.967,48.163000000000004,29.535 +2020-08-06 23:00:00,73.69,20.49,42.379,29.535 +2020-08-06 23:15:00,75.01,19.374000000000002,42.379,29.535 +2020-08-06 23:30:00,75.72,18.672,42.379,29.535 +2020-08-06 23:45:00,76.69,17.742,42.379,29.535 +2020-08-07 00:00:00,71.41,16.029,38.505,29.535 +2020-08-07 00:15:00,70.97,16.997,38.505,29.535 +2020-08-07 00:30:00,66.19,16.194000000000003,38.505,29.535 +2020-08-07 00:45:00,68.12,16.788,38.505,29.535 +2020-08-07 01:00:00,69.02,16.339000000000002,37.004,29.535 +2020-08-07 01:15:00,70.25,14.969000000000001,37.004,29.535 +2020-08-07 01:30:00,70.41,14.469000000000001,37.004,29.535 +2020-08-07 01:45:00,70.59,14.186,37.004,29.535 +2020-08-07 02:00:00,76.79,15.011,36.098,29.535 +2020-08-07 02:15:00,79.54,13.975999999999999,36.098,29.535 +2020-08-07 02:30:00,74.87,16.284000000000002,36.098,29.535 +2020-08-07 02:45:00,70.69,16.291,36.098,29.535 +2020-08-07 03:00:00,70.91,17.83,36.561,29.535 +2020-08-07 03:15:00,70.99,16.409000000000002,36.561,29.535 +2020-08-07 03:30:00,73.56,15.71,36.561,29.535 +2020-08-07 03:45:00,75.0,15.999,36.561,29.535 +2020-08-07 04:00:00,77.55,19.767,37.355,29.535 +2020-08-07 04:15:00,85.83,23.194000000000003,37.355,29.535 +2020-08-07 04:30:00,87.24,21.662,37.355,29.535 +2020-08-07 04:45:00,88.67,21.076,37.355,29.535 +2020-08-07 05:00:00,92.85,31.379,40.285,29.535 +2020-08-07 05:15:00,98.87,37.567,40.285,29.535 +2020-08-07 05:30:00,96.26,33.688,40.285,29.535 +2020-08-07 05:45:00,100.51,31.046999999999997,40.285,29.535 +2020-08-07 06:00:00,106.1,31.334,52.378,29.535 +2020-08-07 06:15:00,108.88,31.254,52.378,29.535 +2020-08-07 06:30:00,111.1,31.092,52.378,29.535 +2020-08-07 06:45:00,113.24,33.475,52.378,29.535 +2020-08-07 07:00:00,114.37,33.924,60.891999999999996,29.535 +2020-08-07 07:15:00,113.51,35.764,60.891999999999996,29.535 +2020-08-07 07:30:00,113.78,32.241,60.891999999999996,29.535 +2020-08-07 07:45:00,114.69,32.854,60.891999999999996,29.535 +2020-08-07 08:00:00,115.11,29.781,53.652,29.535 +2020-08-07 08:15:00,119.6,33.239000000000004,53.652,29.535 +2020-08-07 08:30:00,121.84,34.12,53.652,29.535 +2020-08-07 08:45:00,120.12,36.262,53.652,29.535 +2020-08-07 09:00:00,124.38,29.730999999999998,51.456,29.535 +2020-08-07 09:15:00,127.6,30.976999999999997,51.456,29.535 +2020-08-07 09:30:00,130.39,33.506,51.456,29.535 +2020-08-07 09:45:00,126.25,36.711,51.456,29.535 +2020-08-07 10:00:00,120.83,34.423,49.4,29.535 +2020-08-07 10:15:00,127.56,35.29,49.4,29.535 +2020-08-07 10:30:00,127.41,35.665,49.4,29.535 +2020-08-07 10:45:00,126.26,36.516999999999996,49.4,29.535 +2020-08-07 11:00:00,118.94,34.779,48.773,29.535 +2020-08-07 11:15:00,114.75,34.885,48.773,29.535 +2020-08-07 11:30:00,117.42,35.325,48.773,29.535 +2020-08-07 11:45:00,115.42,35.209,48.773,29.535 +2020-08-07 12:00:00,110.13,30.995,46.033,29.535 +2020-08-07 12:15:00,110.2,29.831999999999997,46.033,29.535 +2020-08-07 12:30:00,109.71,28.854,46.033,29.535 +2020-08-07 12:45:00,110.98,28.804000000000002,46.033,29.535 +2020-08-07 13:00:00,108.18,28.916999999999998,44.38399999999999,29.535 +2020-08-07 13:15:00,108.46,30.076999999999998,44.38399999999999,29.535 +2020-08-07 13:30:00,109.25,29.230999999999998,44.38399999999999,29.535 +2020-08-07 13:45:00,111.29,29.642,44.38399999999999,29.535 +2020-08-07 14:00:00,99.65,30.31,43.162,29.535 +2020-08-07 14:15:00,103.35,29.925,43.162,29.535 +2020-08-07 14:30:00,100.75,30.491,43.162,29.535 +2020-08-07 14:45:00,103.99,30.454,43.162,29.535 +2020-08-07 15:00:00,92.04,32.04,44.91,29.535 +2020-08-07 15:15:00,93.49,29.68,44.91,29.535 +2020-08-07 15:30:00,91.17,27.985,44.91,29.535 +2020-08-07 15:45:00,93.1,27.19,44.91,29.535 +2020-08-07 16:00:00,99.66,28.771,47.489,29.535 +2020-08-07 16:15:00,101.19,29.153000000000002,47.489,29.535 +2020-08-07 16:30:00,102.13,28.436999999999998,47.489,29.535 +2020-08-07 16:45:00,100.1,25.329,47.489,29.535 +2020-08-07 17:00:00,107.59,30.236,52.047,29.535 +2020-08-07 17:15:00,107.4,30.662,52.047,29.535 +2020-08-07 17:30:00,106.27,29.985,52.047,29.535 +2020-08-07 17:45:00,100.88,29.333000000000002,52.047,29.535 +2020-08-07 18:00:00,106.56,32.196,53.306000000000004,29.535 +2020-08-07 18:15:00,103.38,31.123,53.306000000000004,29.535 +2020-08-07 18:30:00,105.2,29.368000000000002,53.306000000000004,29.535 +2020-08-07 18:45:00,105.43,32.33,53.306000000000004,29.535 +2020-08-07 19:00:00,100.82,34.372,53.516000000000005,29.535 +2020-08-07 19:15:00,99.6,33.865,53.516000000000005,29.535 +2020-08-07 19:30:00,101.51,32.855,53.516000000000005,29.535 +2020-08-07 19:45:00,97.37,31.035,53.516000000000005,29.535 +2020-08-07 20:00:00,96.5,28.47,57.88,29.535 +2020-08-07 20:15:00,97.87,28.62,57.88,29.535 +2020-08-07 20:30:00,97.05,28.397,57.88,29.535 +2020-08-07 20:45:00,91.55,27.805,57.88,29.535 +2020-08-07 21:00:00,87.14,27.892,53.32,29.535 +2020-08-07 21:15:00,88.39,30.548000000000002,53.32,29.535 +2020-08-07 21:30:00,86.76,30.733,53.32,29.535 +2020-08-07 21:45:00,86.09,31.351,53.32,29.535 +2020-08-07 22:00:00,78.09,28.561,48.074,29.535 +2020-08-07 22:15:00,76.09,30.691,48.074,29.535 +2020-08-07 22:30:00,77.97,30.533,48.074,29.535 +2020-08-07 22:45:00,79.31,28.241999999999997,48.074,29.535 +2020-08-07 23:00:00,74.19,27.438000000000002,41.306999999999995,29.535 +2020-08-07 23:15:00,68.5,24.93,41.306999999999995,29.535 +2020-08-07 23:30:00,66.0,22.58,41.306999999999995,29.535 +2020-08-07 23:45:00,65.38,21.570999999999998,41.306999999999995,29.535 +2020-08-08 00:00:00,66.74,17.496,40.227,29.423000000000002 +2020-08-08 00:15:00,69.29,18.078,40.227,29.423000000000002 +2020-08-08 00:30:00,68.8,16.746,40.227,29.423000000000002 +2020-08-08 00:45:00,63.95,16.6,40.227,29.423000000000002 +2020-08-08 01:00:00,66.72,16.363,36.303000000000004,29.423000000000002 +2020-08-08 01:15:00,67.67,15.626,36.303000000000004,29.423000000000002 +2020-08-08 01:30:00,68.13,14.392999999999999,36.303000000000004,29.423000000000002 +2020-08-08 01:45:00,65.03,15.319,36.303000000000004,29.423000000000002 +2020-08-08 02:00:00,64.36,15.154000000000002,33.849000000000004,29.423000000000002 +2020-08-08 02:15:00,66.91,13.402000000000001,33.849000000000004,29.423000000000002 +2020-08-08 02:30:00,66.76,14.997,33.849000000000004,29.423000000000002 +2020-08-08 02:45:00,63.89,15.75,33.849000000000004,29.423000000000002 +2020-08-08 03:00:00,58.71,15.923,33.149,29.423000000000002 +2020-08-08 03:15:00,64.73,13.921,33.149,29.423000000000002 +2020-08-08 03:30:00,67.22,13.64,33.149,29.423000000000002 +2020-08-08 03:45:00,67.03,15.345,33.149,29.423000000000002 +2020-08-08 04:00:00,65.26,17.557000000000002,32.501,29.423000000000002 +2020-08-08 04:15:00,60.78,20.284000000000002,32.501,29.423000000000002 +2020-08-08 04:30:00,63.06,17.284000000000002,32.501,29.423000000000002 +2020-08-08 04:45:00,63.74,16.992,32.501,29.423000000000002 +2020-08-08 05:00:00,68.08,20.31,31.648000000000003,29.423000000000002 +2020-08-08 05:15:00,70.17,17.18,31.648000000000003,29.423000000000002 +2020-08-08 05:30:00,69.63,14.745999999999999,31.648000000000003,29.423000000000002 +2020-08-08 05:45:00,69.72,16.029,31.648000000000003,29.423000000000002 +2020-08-08 06:00:00,74.01,25.884,32.552,29.423000000000002 +2020-08-08 06:15:00,76.69,32.289,32.552,29.423000000000002 +2020-08-08 06:30:00,74.92,29.739,32.552,29.423000000000002 +2020-08-08 06:45:00,75.68,29.601,32.552,29.423000000000002 +2020-08-08 07:00:00,78.07,29.214000000000002,35.181999999999995,29.423000000000002 +2020-08-08 07:15:00,77.53,29.912,35.181999999999995,29.423000000000002 +2020-08-08 07:30:00,79.7,27.668000000000003,35.181999999999995,29.423000000000002 +2020-08-08 07:45:00,85.61,28.895,35.181999999999995,29.423000000000002 +2020-08-08 08:00:00,89.39,26.308000000000003,40.35,29.423000000000002 +2020-08-08 08:15:00,89.11,29.252,40.35,29.423000000000002 +2020-08-08 08:30:00,80.69,29.98,40.35,29.423000000000002 +2020-08-08 08:45:00,79.94,32.78,40.35,29.423000000000002 +2020-08-08 09:00:00,81.67,29.186,42.292,29.423000000000002 +2020-08-08 09:15:00,85.15,30.836,42.292,29.423000000000002 +2020-08-08 09:30:00,86.84,33.758,42.292,29.423000000000002 +2020-08-08 09:45:00,85.72,36.493,42.292,29.423000000000002 +2020-08-08 10:00:00,78.03,34.819,40.084,29.423000000000002 +2020-08-08 10:15:00,80.44,36.003,40.084,29.423000000000002 +2020-08-08 10:30:00,87.73,36.022,40.084,29.423000000000002 +2020-08-08 10:45:00,76.01,36.408,40.084,29.423000000000002 +2020-08-08 11:00:00,75.02,34.505,36.966,29.423000000000002 +2020-08-08 11:15:00,71.3,35.515,36.966,29.423000000000002 +2020-08-08 11:30:00,71.44,36.321,36.966,29.423000000000002 +2020-08-08 11:45:00,83.52,36.929,36.966,29.423000000000002 +2020-08-08 12:00:00,78.82,33.196,35.19,29.423000000000002 +2020-08-08 12:15:00,77.58,32.803000000000004,35.19,29.423000000000002 +2020-08-08 12:30:00,68.36,31.677,35.19,29.423000000000002 +2020-08-08 12:45:00,71.92,32.397,35.19,29.423000000000002 +2020-08-08 13:00:00,65.67,31.694000000000003,32.277,29.423000000000002 +2020-08-08 13:15:00,71.96,32.49,32.277,29.423000000000002 +2020-08-08 13:30:00,63.1,31.866,32.277,29.423000000000002 +2020-08-08 13:45:00,64.32,30.926,32.277,29.423000000000002 +2020-08-08 14:00:00,72.95,31.502,31.436999999999998,29.423000000000002 +2020-08-08 14:15:00,65.23,29.944000000000003,31.436999999999998,29.423000000000002 +2020-08-08 14:30:00,63.29,30.256,31.436999999999998,29.423000000000002 +2020-08-08 14:45:00,69.43,30.671999999999997,31.436999999999998,29.423000000000002 +2020-08-08 15:00:00,64.98,32.576,33.493,29.423000000000002 +2020-08-08 15:15:00,62.48,30.843000000000004,33.493,29.423000000000002 +2020-08-08 15:30:00,61.84,29.228,33.493,29.423000000000002 +2020-08-08 15:45:00,64.18,27.535999999999998,33.493,29.423000000000002 +2020-08-08 16:00:00,64.57,31.239,36.593,29.423000000000002 +2020-08-08 16:15:00,66.55,30.691,36.593,29.423000000000002 +2020-08-08 16:30:00,72.26,30.206999999999997,36.593,29.423000000000002 +2020-08-08 16:45:00,72.92,27.033,36.593,29.423000000000002 +2020-08-08 17:00:00,74.06,30.78,42.049,29.423000000000002 +2020-08-08 17:15:00,73.2,29.022,42.049,29.423000000000002 +2020-08-08 17:30:00,75.04,28.235,42.049,29.423000000000002 +2020-08-08 17:45:00,77.03,28.121,42.049,29.423000000000002 +2020-08-08 18:00:00,78.39,32.303000000000004,43.755,29.423000000000002 +2020-08-08 18:15:00,76.69,32.72,43.755,29.423000000000002 +2020-08-08 18:30:00,76.27,32.189,43.755,29.423000000000002 +2020-08-08 18:45:00,76.64,32.038000000000004,43.755,29.423000000000002 +2020-08-08 19:00:00,75.57,32.424,44.492,29.423000000000002 +2020-08-08 19:15:00,72.67,30.984,44.492,29.423000000000002 +2020-08-08 19:30:00,72.39,30.662,44.492,29.423000000000002 +2020-08-08 19:45:00,73.31,30.538,44.492,29.423000000000002 +2020-08-08 20:00:00,75.0,28.638,40.896,29.423000000000002 +2020-08-08 20:15:00,74.88,28.051,40.896,29.423000000000002 +2020-08-08 20:30:00,74.76,27.0,40.896,29.423000000000002 +2020-08-08 20:45:00,74.69,28.18,40.896,29.423000000000002 +2020-08-08 21:00:00,71.99,26.721999999999998,39.056,29.423000000000002 +2020-08-08 21:15:00,71.04,29.015,39.056,29.423000000000002 +2020-08-08 21:30:00,67.87,29.302,39.056,29.423000000000002 +2020-08-08 21:45:00,67.58,29.401,39.056,29.423000000000002 +2020-08-08 22:00:00,63.3,26.502,38.478,29.423000000000002 +2020-08-08 22:15:00,64.97,28.721,38.478,29.423000000000002 +2020-08-08 22:30:00,62.91,27.836,38.478,29.423000000000002 +2020-08-08 22:45:00,62.35,25.795,38.478,29.423000000000002 +2020-08-08 23:00:00,57.64,24.291,32.953,29.423000000000002 +2020-08-08 23:15:00,57.7,22.261999999999997,32.953,29.423000000000002 +2020-08-08 23:30:00,58.28,22.333000000000002,32.953,29.423000000000002 +2020-08-08 23:45:00,58.51,21.79,32.953,29.423000000000002 +2020-08-09 00:00:00,54.08,18.893,28.584,29.423000000000002 +2020-08-09 00:15:00,54.76,18.393,28.584,29.423000000000002 +2020-08-09 00:30:00,54.22,16.949,28.584,29.423000000000002 +2020-08-09 00:45:00,55.07,16.660999999999998,28.584,29.423000000000002 +2020-08-09 01:00:00,51.21,16.673,26.419,29.423000000000002 +2020-08-09 01:15:00,53.23,15.76,26.419,29.423000000000002 +2020-08-09 01:30:00,50.21,14.374,26.419,29.423000000000002 +2020-08-09 01:45:00,52.88,14.954,26.419,29.423000000000002 +2020-08-09 02:00:00,51.33,14.915,25.335,29.423000000000002 +2020-08-09 02:15:00,52.26,13.91,25.335,29.423000000000002 +2020-08-09 02:30:00,51.66,15.61,25.335,29.423000000000002 +2020-08-09 02:45:00,51.22,16.055999999999997,25.335,29.423000000000002 +2020-08-09 03:00:00,51.33,16.819000000000003,24.805,29.423000000000002 +2020-08-09 03:15:00,52.88,15.114,24.805,29.423000000000002 +2020-08-09 03:30:00,52.66,14.052,24.805,29.423000000000002 +2020-08-09 03:45:00,53.13,15.005999999999998,24.805,29.423000000000002 +2020-08-09 04:00:00,53.45,17.209,25.772,29.423000000000002 +2020-08-09 04:15:00,54.38,19.586,25.772,29.423000000000002 +2020-08-09 04:30:00,52.71,17.863,25.772,29.423000000000002 +2020-08-09 04:45:00,52.22,17.111,25.772,29.423000000000002 +2020-08-09 05:00:00,51.09,20.83,25.971999999999998,29.423000000000002 +2020-08-09 05:15:00,51.86,17.137999999999998,25.971999999999998,29.423000000000002 +2020-08-09 05:30:00,51.9,14.347999999999999,25.971999999999998,29.423000000000002 +2020-08-09 05:45:00,52.64,15.338,25.971999999999998,29.423000000000002 +2020-08-09 06:00:00,52.67,22.945,26.026,29.423000000000002 +2020-08-09 06:15:00,53.6,30.255,26.026,29.423000000000002 +2020-08-09 06:30:00,54.27,27.113000000000003,26.026,29.423000000000002 +2020-08-09 06:45:00,55.49,26.105999999999998,26.026,29.423000000000002 +2020-08-09 07:00:00,57.23,25.877,27.396,29.423000000000002 +2020-08-09 07:15:00,57.15,25.078000000000003,27.396,29.423000000000002 +2020-08-09 07:30:00,56.53,24.169,27.396,29.423000000000002 +2020-08-09 07:45:00,56.43,25.428,27.396,29.423000000000002 +2020-08-09 08:00:00,56.12,23.463,30.791999999999998,29.423000000000002 +2020-08-09 08:15:00,57.24,27.535,30.791999999999998,29.423000000000002 +2020-08-09 08:30:00,56.64,28.962,30.791999999999998,29.423000000000002 +2020-08-09 08:45:00,57.8,31.506999999999998,30.791999999999998,29.423000000000002 +2020-08-09 09:00:00,57.9,27.857,32.482,29.423000000000002 +2020-08-09 09:15:00,57.48,28.969,32.482,29.423000000000002 +2020-08-09 09:30:00,53.71,32.31,32.482,29.423000000000002 +2020-08-09 09:45:00,57.49,36.016999999999996,32.482,29.423000000000002 +2020-08-09 10:00:00,58.06,34.648,31.951,29.423000000000002 +2020-08-09 10:15:00,59.54,35.896,31.951,29.423000000000002 +2020-08-09 10:30:00,61.42,36.068000000000005,31.951,29.423000000000002 +2020-08-09 10:45:00,60.84,37.628,31.951,29.423000000000002 +2020-08-09 11:00:00,60.37,35.299,33.619,29.423000000000002 +2020-08-09 11:15:00,61.49,35.853,33.619,29.423000000000002 +2020-08-09 11:30:00,57.13,37.247,33.619,29.423000000000002 +2020-08-09 11:45:00,57.85,38.035,33.619,29.423000000000002 +2020-08-09 12:00:00,53.65,35.400999999999996,30.975,29.423000000000002 +2020-08-09 12:15:00,52.86,34.196999999999996,30.975,29.423000000000002 +2020-08-09 12:30:00,50.62,33.433,30.975,29.423000000000002 +2020-08-09 12:45:00,50.63,33.611999999999995,30.975,29.423000000000002 +2020-08-09 13:00:00,49.19,32.663000000000004,27.956999999999997,29.423000000000002 +2020-08-09 13:15:00,48.43,32.585,27.956999999999997,29.423000000000002 +2020-08-09 13:30:00,48.61,30.886999999999997,27.956999999999997,29.423000000000002 +2020-08-09 13:45:00,49.71,31.119,27.956999999999997,29.423000000000002 +2020-08-09 14:00:00,49.4,32.830999999999996,25.555999999999997,29.423000000000002 +2020-08-09 14:15:00,50.46,31.583000000000002,25.555999999999997,29.423000000000002 +2020-08-09 14:30:00,49.99,30.576,25.555999999999997,29.423000000000002 +2020-08-09 14:45:00,51.47,29.987,25.555999999999997,29.423000000000002 +2020-08-09 15:00:00,52.05,32.154,26.271,29.423000000000002 +2020-08-09 15:15:00,51.23,29.56,26.271,29.423000000000002 +2020-08-09 15:30:00,52.35,27.721999999999998,26.271,29.423000000000002 +2020-08-09 15:45:00,54.8,26.245,26.271,29.423000000000002 +2020-08-09 16:00:00,58.39,28.193,30.369,29.423000000000002 +2020-08-09 16:15:00,59.37,27.954,30.369,29.423000000000002 +2020-08-09 16:30:00,61.94,28.434,30.369,29.423000000000002 +2020-08-09 16:45:00,64.25,25.340999999999998,30.369,29.423000000000002 +2020-08-09 17:00:00,67.55,29.435,38.787,29.423000000000002 +2020-08-09 17:15:00,69.77,29.199,38.787,29.423000000000002 +2020-08-09 17:30:00,74.47,29.183000000000003,38.787,29.423000000000002 +2020-08-09 17:45:00,73.06,29.31,38.787,29.423000000000002 +2020-08-09 18:00:00,76.18,34.126999999999995,41.886,29.423000000000002 +2020-08-09 18:15:00,75.37,34.056,41.886,29.423000000000002 +2020-08-09 18:30:00,78.04,33.457,41.886,29.423000000000002 +2020-08-09 18:45:00,75.37,33.274,41.886,29.423000000000002 +2020-08-09 19:00:00,77.91,35.796,42.91,29.423000000000002 +2020-08-09 19:15:00,76.23,33.253,42.91,29.423000000000002 +2020-08-09 19:30:00,76.04,32.707,42.91,29.423000000000002 +2020-08-09 19:45:00,78.63,32.049,42.91,29.423000000000002 +2020-08-09 20:00:00,79.32,30.315,42.148999999999994,29.423000000000002 +2020-08-09 20:15:00,78.88,29.53,42.148999999999994,29.423000000000002 +2020-08-09 20:30:00,78.51,29.153000000000002,42.148999999999994,29.423000000000002 +2020-08-09 20:45:00,77.97,28.791,42.148999999999994,29.423000000000002 +2020-08-09 21:00:00,76.29,27.416,40.955999999999996,29.423000000000002 +2020-08-09 21:15:00,76.26,29.456999999999997,40.955999999999996,29.423000000000002 +2020-08-09 21:30:00,74.21,29.101,40.955999999999996,29.423000000000002 +2020-08-09 21:45:00,72.91,29.506,40.955999999999996,29.423000000000002 +2020-08-09 22:00:00,68.37,28.61,39.873000000000005,29.423000000000002 +2020-08-09 22:15:00,70.97,29.274,39.873000000000005,29.423000000000002 +2020-08-09 22:30:00,68.0,28.081,39.873000000000005,29.423000000000002 +2020-08-09 22:45:00,68.68,24.886999999999997,39.873000000000005,29.423000000000002 +2020-08-09 23:00:00,64.15,23.305999999999997,35.510999999999996,29.423000000000002 +2020-08-09 23:15:00,65.15,22.361,35.510999999999996,29.423000000000002 +2020-08-09 23:30:00,65.42,21.884,35.510999999999996,29.423000000000002 +2020-08-09 23:45:00,64.78,21.439,35.510999999999996,29.423000000000002 +2020-08-10 00:00:00,64.59,20.146,33.475,29.535 +2020-08-10 00:15:00,67.7,20.188,33.475,29.535 +2020-08-10 00:30:00,67.92,18.373,33.475,29.535 +2020-08-10 00:45:00,66.94,17.753,33.475,29.535 +2020-08-10 01:00:00,60.75,18.12,33.111,29.535 +2020-08-10 01:15:00,62.05,17.266,33.111,29.535 +2020-08-10 01:30:00,61.91,16.230999999999998,33.111,29.535 +2020-08-10 01:45:00,61.67,16.708,33.111,29.535 +2020-08-10 02:00:00,62.03,17.11,32.358000000000004,29.535 +2020-08-10 02:15:00,66.27,15.054,32.358000000000004,29.535 +2020-08-10 02:30:00,69.88,16.866,32.358000000000004,29.535 +2020-08-10 02:45:00,70.55,17.215,32.358000000000004,29.535 +2020-08-10 03:00:00,67.73,18.362000000000002,30.779,29.535 +2020-08-10 03:15:00,64.33,17.243,30.779,29.535 +2020-08-10 03:30:00,68.6,16.858,30.779,29.535 +2020-08-10 03:45:00,68.36,17.41,30.779,29.535 +2020-08-10 04:00:00,72.46,22.284000000000002,31.416,29.535 +2020-08-10 04:15:00,75.4,27.168000000000003,31.416,29.535 +2020-08-10 04:30:00,77.15,24.838,31.416,29.535 +2020-08-10 04:45:00,82.65,24.43,31.416,29.535 +2020-08-10 05:00:00,87.89,34.043,37.221,29.535 +2020-08-10 05:15:00,93.43,38.881,37.221,29.535 +2020-08-10 05:30:00,98.76,34.632,37.221,29.535 +2020-08-10 05:45:00,106.75,33.045,37.221,29.535 +2020-08-10 06:00:00,109.61,31.978,51.891000000000005,29.535 +2020-08-10 06:15:00,110.17,31.636,51.891000000000005,29.535 +2020-08-10 06:30:00,106.16,31.749000000000002,51.891000000000005,29.535 +2020-08-10 06:45:00,105.44,35.194,51.891000000000005,29.535 +2020-08-10 07:00:00,112.69,34.809,62.282,29.535 +2020-08-10 07:15:00,114.13,36.123000000000005,62.282,29.535 +2020-08-10 07:30:00,114.44,34.466,62.282,29.535 +2020-08-10 07:45:00,111.46,36.219,62.282,29.535 +2020-08-10 08:00:00,109.2,32.409,54.102,29.535 +2020-08-10 08:15:00,114.03,35.476,54.102,29.535 +2020-08-10 08:30:00,115.94,36.306999999999995,54.102,29.535 +2020-08-10 08:45:00,113.48,39.326,54.102,29.535 +2020-08-10 09:00:00,109.67,34.673,50.917,29.535 +2020-08-10 09:15:00,113.16,34.354,50.917,29.535 +2020-08-10 09:30:00,114.97,36.866,50.917,29.535 +2020-08-10 09:45:00,114.23,38.284,50.917,29.535 +2020-08-10 10:00:00,110.17,37.372,49.718999999999994,29.535 +2020-08-10 10:15:00,112.96,38.489000000000004,49.718999999999994,29.535 +2020-08-10 10:30:00,113.44,38.289,49.718999999999994,29.535 +2020-08-10 10:45:00,112.68,38.208,49.718999999999994,29.535 +2020-08-10 11:00:00,106.81,36.395,49.833999999999996,29.535 +2020-08-10 11:15:00,101.75,37.046,49.833999999999996,29.535 +2020-08-10 11:30:00,108.76,38.986,49.833999999999996,29.535 +2020-08-10 11:45:00,115.96,40.294000000000004,49.833999999999996,29.535 +2020-08-10 12:00:00,116.04,35.781,47.832,29.535 +2020-08-10 12:15:00,108.93,34.679,47.832,29.535 +2020-08-10 12:30:00,105.17,32.842,47.832,29.535 +2020-08-10 12:45:00,109.13,32.858000000000004,47.832,29.535 +2020-08-10 13:00:00,105.35,32.725,48.03,29.535 +2020-08-10 13:15:00,106.96,31.901999999999997,48.03,29.535 +2020-08-10 13:30:00,112.55,30.421999999999997,48.03,29.535 +2020-08-10 13:45:00,110.89,31.543000000000003,48.03,29.535 +2020-08-10 14:00:00,108.24,32.449,48.157,29.535 +2020-08-10 14:15:00,116.67,31.851,48.157,29.535 +2020-08-10 14:30:00,109.93,30.743000000000002,48.157,29.535 +2020-08-10 14:45:00,100.75,32.195,48.157,29.535 +2020-08-10 15:00:00,102.13,33.843,48.897,29.535 +2020-08-10 15:15:00,101.58,30.81,48.897,29.535 +2020-08-10 15:30:00,103.82,29.811999999999998,48.897,29.535 +2020-08-10 15:45:00,112.32,27.903000000000002,48.897,29.535 +2020-08-10 16:00:00,107.56,30.926,51.446000000000005,29.535 +2020-08-10 16:15:00,110.02,30.826,51.446000000000005,29.535 +2020-08-10 16:30:00,111.09,30.749000000000002,51.446000000000005,29.535 +2020-08-10 16:45:00,111.76,27.815,51.446000000000005,29.535 +2020-08-10 17:00:00,116.67,30.83,57.507,29.535 +2020-08-10 17:15:00,115.62,31.066999999999997,57.507,29.535 +2020-08-10 17:30:00,119.59,30.733,57.507,29.535 +2020-08-10 17:45:00,117.62,30.63,57.507,29.535 +2020-08-10 18:00:00,112.36,34.413000000000004,57.896,29.535 +2020-08-10 18:15:00,111.68,32.756,57.896,29.535 +2020-08-10 18:30:00,111.01,31.386,57.896,29.535 +2020-08-10 18:45:00,118.2,34.199,57.896,29.535 +2020-08-10 19:00:00,115.05,36.61,57.891999999999996,29.535 +2020-08-10 19:15:00,108.14,35.417,57.891999999999996,29.535 +2020-08-10 19:30:00,100.95,34.482,57.891999999999996,29.535 +2020-08-10 19:45:00,107.15,33.301,57.891999999999996,29.535 +2020-08-10 20:00:00,107.38,30.524,64.57300000000001,29.535 +2020-08-10 20:15:00,106.34,31.261,64.57300000000001,29.535 +2020-08-10 20:30:00,101.37,31.505,64.57300000000001,29.535 +2020-08-10 20:45:00,96.58,31.21,64.57300000000001,29.535 +2020-08-10 21:00:00,92.54,29.186,59.431999999999995,29.535 +2020-08-10 21:15:00,98.18,31.721999999999998,59.431999999999995,29.535 +2020-08-10 21:30:00,94.61,31.811,59.431999999999995,29.535 +2020-08-10 21:45:00,89.3,32.016,59.431999999999995,29.535 +2020-08-10 22:00:00,85.65,29.331999999999997,51.519,29.535 +2020-08-10 22:15:00,83.02,31.964000000000002,51.519,29.535 +2020-08-10 22:30:00,84.02,27.245,51.519,29.535 +2020-08-10 22:45:00,86.92,24.348000000000003,51.519,29.535 +2020-08-10 23:00:00,83.27,22.737,44.501000000000005,29.535 +2020-08-10 23:15:00,79.13,20.052,44.501000000000005,29.535 +2020-08-10 23:30:00,77.81,19.378,44.501000000000005,29.535 +2020-08-10 23:45:00,76.11,18.256,44.501000000000005,29.535 +2020-08-11 00:00:00,80.82,18.12,44.522,29.535 +2020-08-11 00:15:00,81.42,18.89,44.522,29.535 +2020-08-11 00:30:00,79.26,17.834,44.522,29.535 +2020-08-11 00:45:00,77.23,18.055999999999997,44.522,29.535 +2020-08-11 01:00:00,77.88,17.902,41.441,29.535 +2020-08-11 01:15:00,81.19,17.195,41.441,29.535 +2020-08-11 01:30:00,80.87,16.033,41.441,29.535 +2020-08-11 01:45:00,77.52,15.99,41.441,29.535 +2020-08-11 02:00:00,76.14,15.933,40.203,29.535 +2020-08-11 02:15:00,80.33,15.019,40.203,29.535 +2020-08-11 02:30:00,80.22,16.476,40.203,29.535 +2020-08-11 02:45:00,76.52,17.139,40.203,29.535 +2020-08-11 03:00:00,76.12,17.828,39.536,29.535 +2020-08-11 03:15:00,81.42,17.687,39.536,29.535 +2020-08-11 03:30:00,82.68,17.243,39.536,29.535 +2020-08-11 03:45:00,81.38,16.709,39.536,29.535 +2020-08-11 04:00:00,84.21,20.386,40.759,29.535 +2020-08-11 04:15:00,83.25,25.311,40.759,29.535 +2020-08-11 04:30:00,87.0,22.891,40.759,29.535 +2020-08-11 04:45:00,96.1,22.866999999999997,40.759,29.535 +2020-08-11 05:00:00,103.35,33.692,43.623999999999995,29.535 +2020-08-11 05:15:00,104.96,39.169000000000004,43.623999999999995,29.535 +2020-08-11 05:30:00,101.93,35.208,43.623999999999995,29.535 +2020-08-11 05:45:00,103.66,32.900999999999996,43.623999999999995,29.535 +2020-08-11 06:00:00,107.81,32.9,52.684,29.535 +2020-08-11 06:15:00,115.41,32.653,52.684,29.535 +2020-08-11 06:30:00,119.45,32.504,52.684,29.535 +2020-08-11 06:45:00,121.63,35.13,52.684,29.535 +2020-08-11 07:00:00,117.33,34.907,62.676,29.535 +2020-08-11 07:15:00,117.63,36.001,62.676,29.535 +2020-08-11 07:30:00,120.47,34.424,62.676,29.535 +2020-08-11 07:45:00,121.97,35.217,62.676,29.535 +2020-08-11 08:00:00,118.67,31.328000000000003,56.161,29.535 +2020-08-11 08:15:00,115.82,34.021,56.161,29.535 +2020-08-11 08:30:00,109.61,35.024,56.161,29.535 +2020-08-11 08:45:00,108.39,37.145,56.161,29.535 +2020-08-11 09:00:00,118.63,32.913000000000004,52.132,29.535 +2020-08-11 09:15:00,124.0,32.294000000000004,52.132,29.535 +2020-08-11 09:30:00,120.89,35.407,52.132,29.535 +2020-08-11 09:45:00,113.74,38.154,52.132,29.535 +2020-08-11 10:00:00,115.04,35.943000000000005,51.032,29.535 +2020-08-11 10:15:00,111.74,37.010999999999996,51.032,29.535 +2020-08-11 10:30:00,117.81,36.813,51.032,29.535 +2020-08-11 10:45:00,120.03,37.7,51.032,29.535 +2020-08-11 11:00:00,116.21,35.745,51.085,29.535 +2020-08-11 11:15:00,109.85,36.827,51.085,29.535 +2020-08-11 11:30:00,112.41,37.735,51.085,29.535 +2020-08-11 11:45:00,117.13,38.514,51.085,29.535 +2020-08-11 12:00:00,121.63,33.95,49.049,29.535 +2020-08-11 12:15:00,122.61,33.218,49.049,29.535 +2020-08-11 12:30:00,116.41,32.172,49.049,29.535 +2020-08-11 12:45:00,106.88,32.898,49.049,29.535 +2020-08-11 13:00:00,103.94,32.428000000000004,49.722,29.535 +2020-08-11 13:15:00,106.12,33.428000000000004,49.722,29.535 +2020-08-11 13:30:00,107.33,31.793000000000003,49.722,29.535 +2020-08-11 13:45:00,105.9,31.94,49.722,29.535 +2020-08-11 14:00:00,104.55,33.245,49.565,29.535 +2020-08-11 14:15:00,111.12,32.436,49.565,29.535 +2020-08-11 14:30:00,106.63,31.599,49.565,29.535 +2020-08-11 14:45:00,112.83,32.278,49.565,29.535 +2020-08-11 15:00:00,106.44,33.777,51.108999999999995,29.535 +2020-08-11 15:15:00,107.53,31.633000000000003,51.108999999999995,29.535 +2020-08-11 15:30:00,111.68,30.412,51.108999999999995,29.535 +2020-08-11 15:45:00,112.97,28.89,51.108999999999995,29.535 +2020-08-11 16:00:00,110.35,31.155,52.725,29.535 +2020-08-11 16:15:00,106.75,31.103,52.725,29.535 +2020-08-11 16:30:00,107.28,30.604,52.725,29.535 +2020-08-11 16:45:00,113.29,28.383000000000003,52.725,29.535 +2020-08-11 17:00:00,120.26,31.531999999999996,58.031000000000006,29.535 +2020-08-11 17:15:00,116.61,32.205,58.031000000000006,29.535 +2020-08-11 17:30:00,113.1,31.391,58.031000000000006,29.535 +2020-08-11 17:45:00,115.09,31.000999999999998,58.031000000000006,29.535 +2020-08-11 18:00:00,111.56,33.868,58.338,29.535 +2020-08-11 18:15:00,114.45,33.714,58.338,29.535 +2020-08-11 18:30:00,115.07,32.123000000000005,58.338,29.535 +2020-08-11 18:45:00,115.29,34.679,58.338,29.535 +2020-08-11 19:00:00,107.15,35.964,58.464,29.535 +2020-08-11 19:15:00,108.56,34.964,58.464,29.535 +2020-08-11 19:30:00,109.56,33.873000000000005,58.464,29.535 +2020-08-11 19:45:00,111.07,33.014,58.464,29.535 +2020-08-11 20:00:00,104.95,30.608,63.708,29.535 +2020-08-11 20:15:00,102.1,30.0,63.708,29.535 +2020-08-11 20:30:00,97.33,30.191999999999997,63.708,29.535 +2020-08-11 20:45:00,97.81,30.324,63.708,29.535 +2020-08-11 21:00:00,95.02,29.195,57.06399999999999,29.535 +2020-08-11 21:15:00,98.79,30.244,57.06399999999999,29.535 +2020-08-11 21:30:00,96.16,30.55,57.06399999999999,29.535 +2020-08-11 21:45:00,94.75,30.9,57.06399999999999,29.535 +2020-08-11 22:00:00,85.73,28.247,52.831,29.535 +2020-08-11 22:15:00,90.12,30.564,52.831,29.535 +2020-08-11 22:30:00,87.77,26.09,52.831,29.535 +2020-08-11 22:45:00,86.92,23.166999999999998,52.831,29.535 +2020-08-11 23:00:00,83.18,20.858,44.717,29.535 +2020-08-11 23:15:00,84.73,19.727,44.717,29.535 +2020-08-11 23:30:00,83.67,19.087,44.717,29.535 +2020-08-11 23:45:00,76.9,18.173,44.717,29.535 +2020-08-12 00:00:00,72.5,18.233,41.263000000000005,29.535 +2020-08-12 00:15:00,78.82,19.003,41.263000000000005,29.535 +2020-08-12 00:30:00,80.76,17.951,41.263000000000005,29.535 +2020-08-12 00:45:00,81.39,18.18,41.263000000000005,29.535 +2020-08-12 01:00:00,78.93,18.011,38.448,29.535 +2020-08-12 01:15:00,81.15,17.312,38.448,29.535 +2020-08-12 01:30:00,80.17,16.162,38.448,29.535 +2020-08-12 01:45:00,77.33,16.117,38.448,29.535 +2020-08-12 02:00:00,73.77,16.063,36.471,29.535 +2020-08-12 02:15:00,73.65,15.169,36.471,29.535 +2020-08-12 02:30:00,80.17,16.605,36.471,29.535 +2020-08-12 02:45:00,80.73,17.266,36.471,29.535 +2020-08-12 03:00:00,81.84,17.944000000000003,36.042,29.535 +2020-08-12 03:15:00,75.9,17.819000000000003,36.042,29.535 +2020-08-12 03:30:00,82.63,17.381,36.042,29.535 +2020-08-12 03:45:00,85.64,16.847,36.042,29.535 +2020-08-12 04:00:00,87.96,20.533,36.705,29.535 +2020-08-12 04:15:00,83.3,25.464000000000002,36.705,29.535 +2020-08-12 04:30:00,94.27,23.048000000000002,36.705,29.535 +2020-08-12 04:45:00,96.6,23.026,36.705,29.535 +2020-08-12 05:00:00,101.67,33.89,39.716,29.535 +2020-08-12 05:15:00,102.94,39.406,39.716,29.535 +2020-08-12 05:30:00,106.43,35.455,39.716,29.535 +2020-08-12 05:45:00,111.7,33.123000000000005,39.716,29.535 +2020-08-12 06:00:00,115.63,33.095,52.756,29.535 +2020-08-12 06:15:00,114.21,32.867,52.756,29.535 +2020-08-12 06:30:00,115.31,32.718,52.756,29.535 +2020-08-12 06:45:00,119.39,35.351,52.756,29.535 +2020-08-12 07:00:00,120.77,35.128,65.977,29.535 +2020-08-12 07:15:00,115.6,36.236999999999995,65.977,29.535 +2020-08-12 07:30:00,114.05,34.679,65.977,29.535 +2020-08-12 07:45:00,117.2,35.479,65.977,29.535 +2020-08-12 08:00:00,118.01,31.593000000000004,57.927,29.535 +2020-08-12 08:15:00,113.31,34.262,57.927,29.535 +2020-08-12 08:30:00,111.75,35.255,57.927,29.535 +2020-08-12 08:45:00,110.43,37.361999999999995,57.927,29.535 +2020-08-12 09:00:00,116.17,33.135,54.86,29.535 +2020-08-12 09:15:00,116.01,32.508,54.86,29.535 +2020-08-12 09:30:00,116.37,35.607,54.86,29.535 +2020-08-12 09:45:00,112.57,38.335,54.86,29.535 +2020-08-12 10:00:00,113.53,36.125,52.818000000000005,29.535 +2020-08-12 10:15:00,118.87,37.175,52.818000000000005,29.535 +2020-08-12 10:30:00,114.46,36.969,52.818000000000005,29.535 +2020-08-12 10:45:00,110.49,37.849000000000004,52.818000000000005,29.535 +2020-08-12 11:00:00,116.41,35.904,52.937,29.535 +2020-08-12 11:15:00,112.99,36.979,52.937,29.535 +2020-08-12 11:30:00,106.78,37.882,52.937,29.535 +2020-08-12 11:45:00,114.37,38.647,52.937,29.535 +2020-08-12 12:00:00,116.44,34.079,50.826,29.535 +2020-08-12 12:15:00,114.58,33.338,50.826,29.535 +2020-08-12 12:30:00,119.4,32.302,50.826,29.535 +2020-08-12 12:45:00,114.81,33.016999999999996,50.826,29.535 +2020-08-12 13:00:00,111.49,32.529,50.556000000000004,29.535 +2020-08-12 13:15:00,104.65,33.516999999999996,50.556000000000004,29.535 +2020-08-12 13:30:00,100.7,31.879,50.556000000000004,29.535 +2020-08-12 13:45:00,102.39,32.037,50.556000000000004,29.535 +2020-08-12 14:00:00,103.75,33.325,51.188,29.535 +2020-08-12 14:15:00,103.76,32.522,51.188,29.535 +2020-08-12 14:30:00,107.42,31.695999999999998,51.188,29.535 +2020-08-12 14:45:00,105.01,32.378,51.188,29.535 +2020-08-12 15:00:00,100.4,33.842,52.976000000000006,29.535 +2020-08-12 15:15:00,103.59,31.701999999999998,52.976000000000006,29.535 +2020-08-12 15:30:00,101.61,30.491,52.976000000000006,29.535 +2020-08-12 15:45:00,103.92,28.971999999999998,52.976000000000006,29.535 +2020-08-12 16:00:00,102.77,31.217,55.463,29.535 +2020-08-12 16:15:00,107.41,31.173000000000002,55.463,29.535 +2020-08-12 16:30:00,108.87,30.682,55.463,29.535 +2020-08-12 16:45:00,109.71,28.494,55.463,29.535 +2020-08-12 17:00:00,108.65,31.62,59.435,29.535 +2020-08-12 17:15:00,108.44,32.32,59.435,29.535 +2020-08-12 17:30:00,108.62,31.518,59.435,29.535 +2020-08-12 17:45:00,109.31,31.159000000000002,59.435,29.535 +2020-08-12 18:00:00,109.78,34.02,61.387,29.535 +2020-08-12 18:15:00,107.43,33.876,61.387,29.535 +2020-08-12 18:30:00,107.41,32.295,61.387,29.535 +2020-08-12 18:45:00,106.93,34.85,61.387,29.535 +2020-08-12 19:00:00,105.24,36.139,63.323,29.535 +2020-08-12 19:15:00,101.29,35.138000000000005,63.323,29.535 +2020-08-12 19:30:00,101.31,34.048,63.323,29.535 +2020-08-12 19:45:00,105.17,33.194,63.323,29.535 +2020-08-12 20:00:00,102.58,30.789,69.083,29.535 +2020-08-12 20:15:00,100.04,30.183000000000003,69.083,29.535 +2020-08-12 20:30:00,99.46,30.362,69.083,29.535 +2020-08-12 20:45:00,97.18,30.468000000000004,69.083,29.535 +2020-08-12 21:00:00,93.2,29.343000000000004,59.957,29.535 +2020-08-12 21:15:00,90.83,30.383000000000003,59.957,29.535 +2020-08-12 21:30:00,86.78,30.683000000000003,59.957,29.535 +2020-08-12 21:45:00,87.17,31.006,59.957,29.535 +2020-08-12 22:00:00,81.89,28.338,53.821000000000005,29.535 +2020-08-12 22:15:00,83.57,30.643,53.821000000000005,29.535 +2020-08-12 22:30:00,80.19,26.136999999999997,53.821000000000005,29.535 +2020-08-12 22:45:00,79.2,23.215,53.821000000000005,29.535 +2020-08-12 23:00:00,76.03,20.941,45.458,29.535 +2020-08-12 23:15:00,75.7,19.805,45.458,29.535 +2020-08-12 23:30:00,76.14,19.178,45.458,29.535 +2020-08-12 23:45:00,76.61,18.267,45.458,29.535 +2020-08-13 00:00:00,72.42,18.349,40.36,29.535 +2020-08-13 00:15:00,73.49,19.119,40.36,29.535 +2020-08-13 00:30:00,73.4,18.072,40.36,29.535 +2020-08-13 00:45:00,73.58,18.308,40.36,29.535 +2020-08-13 01:00:00,73.1,18.123,38.552,29.535 +2020-08-13 01:15:00,73.24,17.432000000000002,38.552,29.535 +2020-08-13 01:30:00,72.48,16.293,38.552,29.535 +2020-08-13 01:45:00,73.22,16.247,38.552,29.535 +2020-08-13 02:00:00,71.87,16.195999999999998,36.895,29.535 +2020-08-13 02:15:00,75.03,15.322000000000001,36.895,29.535 +2020-08-13 02:30:00,78.27,16.736,36.895,29.535 +2020-08-13 02:45:00,80.01,17.394000000000002,36.895,29.535 +2020-08-13 03:00:00,77.38,18.062,36.565,29.535 +2020-08-13 03:15:00,72.6,17.952,36.565,29.535 +2020-08-13 03:30:00,74.33,17.521,36.565,29.535 +2020-08-13 03:45:00,79.52,16.986,36.565,29.535 +2020-08-13 04:00:00,85.0,20.684,37.263000000000005,29.535 +2020-08-13 04:15:00,91.08,25.62,37.263000000000005,29.535 +2020-08-13 04:30:00,94.95,23.21,37.263000000000005,29.535 +2020-08-13 04:45:00,93.59,23.19,37.263000000000005,29.535 +2020-08-13 05:00:00,98.02,34.096,40.412,29.535 +2020-08-13 05:15:00,100.02,39.655,40.412,29.535 +2020-08-13 05:30:00,106.59,35.711,40.412,29.535 +2020-08-13 05:45:00,112.6,33.354,40.412,29.535 +2020-08-13 06:00:00,116.48,33.299,49.825,29.535 +2020-08-13 06:15:00,113.4,33.088,49.825,29.535 +2020-08-13 06:30:00,111.91,32.939,49.825,29.535 +2020-08-13 06:45:00,111.96,35.58,49.825,29.535 +2020-08-13 07:00:00,114.86,35.355,61.082,29.535 +2020-08-13 07:15:00,116.59,36.48,61.082,29.535 +2020-08-13 07:30:00,119.63,34.942,61.082,29.535 +2020-08-13 07:45:00,118.33,35.748000000000005,61.082,29.535 +2020-08-13 08:00:00,111.91,31.864,53.961999999999996,29.535 +2020-08-13 08:15:00,110.29,34.508,53.961999999999996,29.535 +2020-08-13 08:30:00,109.63,35.491,53.961999999999996,29.535 +2020-08-13 08:45:00,114.21,37.586,53.961999999999996,29.535 +2020-08-13 09:00:00,116.43,33.364000000000004,50.06100000000001,29.535 +2020-08-13 09:15:00,117.8,32.728,50.06100000000001,29.535 +2020-08-13 09:30:00,114.9,35.812,50.06100000000001,29.535 +2020-08-13 09:45:00,115.89,38.521,50.06100000000001,29.535 +2020-08-13 10:00:00,115.79,36.312,47.68,29.535 +2020-08-13 10:15:00,109.66,37.342,47.68,29.535 +2020-08-13 10:30:00,115.53,37.13,47.68,29.535 +2020-08-13 10:45:00,114.31,38.004,47.68,29.535 +2020-08-13 11:00:00,112.62,36.067,45.93899999999999,29.535 +2020-08-13 11:15:00,112.44,37.135999999999996,45.93899999999999,29.535 +2020-08-13 11:30:00,112.77,38.033,45.93899999999999,29.535 +2020-08-13 11:45:00,109.6,38.785,45.93899999999999,29.535 +2020-08-13 12:00:00,110.46,34.21,43.648999999999994,29.535 +2020-08-13 12:15:00,110.53,33.461,43.648999999999994,29.535 +2020-08-13 12:30:00,110.63,32.435,43.648999999999994,29.535 +2020-08-13 12:45:00,107.32,33.141,43.648999999999994,29.535 +2020-08-13 13:00:00,111.0,32.635,42.801,29.535 +2020-08-13 13:15:00,110.62,33.609,42.801,29.535 +2020-08-13 13:30:00,110.8,31.969,42.801,29.535 +2020-08-13 13:45:00,106.12,32.137,42.801,29.535 +2020-08-13 14:00:00,106.62,33.408,43.24,29.535 +2020-08-13 14:15:00,110.75,32.61,43.24,29.535 +2020-08-13 14:30:00,108.44,31.795,43.24,29.535 +2020-08-13 14:45:00,108.43,32.482,43.24,29.535 +2020-08-13 15:00:00,100.47,33.91,45.04600000000001,29.535 +2020-08-13 15:15:00,100.61,31.774,45.04600000000001,29.535 +2020-08-13 15:30:00,104.49,30.573,45.04600000000001,29.535 +2020-08-13 15:45:00,105.82,29.055999999999997,45.04600000000001,29.535 +2020-08-13 16:00:00,111.96,31.281999999999996,46.568000000000005,29.535 +2020-08-13 16:15:00,112.43,31.245,46.568000000000005,29.535 +2020-08-13 16:30:00,113.32,30.761,46.568000000000005,29.535 +2020-08-13 16:45:00,111.17,28.608,46.568000000000005,29.535 +2020-08-13 17:00:00,120.16,31.709,50.618,29.535 +2020-08-13 17:15:00,119.4,32.437,50.618,29.535 +2020-08-13 17:30:00,121.21,31.648000000000003,50.618,29.535 +2020-08-13 17:45:00,113.4,31.320999999999998,50.618,29.535 +2020-08-13 18:00:00,115.18,34.176,52.806999999999995,29.535 +2020-08-13 18:15:00,116.91,34.041,52.806999999999995,29.535 +2020-08-13 18:30:00,119.58,32.469,52.806999999999995,29.535 +2020-08-13 18:45:00,114.31,35.025,52.806999999999995,29.535 +2020-08-13 19:00:00,107.96,36.318000000000005,53.464,29.535 +2020-08-13 19:15:00,104.43,35.316,53.464,29.535 +2020-08-13 19:30:00,104.41,34.227,53.464,29.535 +2020-08-13 19:45:00,110.75,33.378,53.464,29.535 +2020-08-13 20:00:00,110.35,30.975,56.753,29.535 +2020-08-13 20:15:00,104.81,30.371,56.753,29.535 +2020-08-13 20:30:00,100.88,30.537,56.753,29.535 +2020-08-13 20:45:00,98.91,30.616,56.753,29.535 +2020-08-13 21:00:00,94.78,29.493000000000002,52.506,29.535 +2020-08-13 21:15:00,93.57,30.526,52.506,29.535 +2020-08-13 21:30:00,89.55,30.82,52.506,29.535 +2020-08-13 21:45:00,89.48,31.116999999999997,52.506,29.535 +2020-08-13 22:00:00,84.47,28.432,48.163000000000004,29.535 +2020-08-13 22:15:00,85.66,30.724,48.163000000000004,29.535 +2020-08-13 22:30:00,83.52,26.188000000000002,48.163000000000004,29.535 +2020-08-13 22:45:00,82.68,23.265,48.163000000000004,29.535 +2020-08-13 23:00:00,78.63,21.028000000000002,42.379,29.535 +2020-08-13 23:15:00,79.67,19.885,42.379,29.535 +2020-08-13 23:30:00,78.45,19.271,42.379,29.535 +2020-08-13 23:45:00,79.55,18.363,42.379,29.535 +2020-08-14 00:00:00,77.38,16.802,38.505,29.535 +2020-08-14 00:15:00,77.2,17.767,38.505,29.535 +2020-08-14 00:30:00,76.42,16.995,38.505,29.535 +2020-08-14 00:45:00,77.01,17.641,38.505,29.535 +2020-08-14 01:00:00,75.04,17.09,37.004,29.535 +2020-08-14 01:15:00,76.59,15.772,37.004,29.535 +2020-08-14 01:30:00,76.72,15.347000000000001,37.004,29.535 +2020-08-14 01:45:00,76.98,15.050999999999998,37.004,29.535 +2020-08-14 02:00:00,75.7,15.899000000000001,36.098,29.535 +2020-08-14 02:15:00,76.98,15.003,36.098,29.535 +2020-08-14 02:30:00,76.36,17.160999999999998,36.098,29.535 +2020-08-14 02:45:00,77.05,17.155,36.098,29.535 +2020-08-14 03:00:00,76.85,18.619,36.561,29.535 +2020-08-14 03:15:00,76.89,17.305,36.561,29.535 +2020-08-14 03:30:00,77.81,16.651,36.561,29.535 +2020-08-14 03:45:00,81.89,16.94,36.561,29.535 +2020-08-14 04:00:00,84.07,20.77,37.355,29.535 +2020-08-14 04:15:00,91.01,24.226,37.355,29.535 +2020-08-14 04:30:00,96.53,22.723000000000003,37.355,29.535 +2020-08-14 04:45:00,98.47,22.149,37.355,29.535 +2020-08-14 05:00:00,97.75,32.713,40.285,29.535 +2020-08-14 05:15:00,106.42,39.152,40.285,29.535 +2020-08-14 05:30:00,111.47,35.349000000000004,40.285,29.535 +2020-08-14 05:45:00,114.34,32.543,40.285,29.535 +2020-08-14 06:00:00,117.13,32.652,52.378,29.535 +2020-08-14 06:15:00,115.21,32.692,52.378,29.535 +2020-08-14 06:30:00,119.17,32.536,52.378,29.535 +2020-08-14 06:45:00,121.31,34.979,52.378,29.535 +2020-08-14 07:00:00,122.16,35.415,60.891999999999996,29.535 +2020-08-14 07:15:00,114.97,37.369,60.891999999999996,29.535 +2020-08-14 07:30:00,119.05,33.976,60.891999999999996,29.535 +2020-08-14 07:45:00,119.76,34.639,60.891999999999996,29.535 +2020-08-14 08:00:00,121.92,31.587,53.652,29.535 +2020-08-14 08:15:00,117.57,34.885,53.652,29.535 +2020-08-14 08:30:00,116.45,35.696,53.652,29.535 +2020-08-14 08:45:00,119.87,37.746,53.652,29.535 +2020-08-14 09:00:00,122.22,31.248,51.456,29.535 +2020-08-14 09:15:00,121.31,32.442,51.456,29.535 +2020-08-14 09:30:00,117.74,34.867,51.456,29.535 +2020-08-14 09:45:00,114.72,37.943000000000005,51.456,29.535 +2020-08-14 10:00:00,116.88,35.665,49.4,29.535 +2020-08-14 10:15:00,122.22,36.4,49.4,29.535 +2020-08-14 10:30:00,123.37,36.731,49.4,29.535 +2020-08-14 10:45:00,118.57,37.541,49.4,29.535 +2020-08-14 11:00:00,116.16,35.859,48.773,29.535 +2020-08-14 11:15:00,114.64,35.923,48.773,29.535 +2020-08-14 11:30:00,114.36,36.323,48.773,29.535 +2020-08-14 11:45:00,109.72,36.116,48.773,29.535 +2020-08-14 12:00:00,109.46,31.871,46.033,29.535 +2020-08-14 12:15:00,106.86,30.65,46.033,29.535 +2020-08-14 12:30:00,104.86,29.738000000000003,46.033,29.535 +2020-08-14 12:45:00,104.8,29.616,46.033,29.535 +2020-08-14 13:00:00,101.33,29.599,44.38399999999999,29.535 +2020-08-14 13:15:00,101.51,30.674,44.38399999999999,29.535 +2020-08-14 13:30:00,101.84,29.811999999999998,44.38399999999999,29.535 +2020-08-14 13:45:00,104.46,30.298000000000002,44.38399999999999,29.535 +2020-08-14 14:00:00,98.79,30.854,43.162,29.535 +2020-08-14 14:15:00,100.52,30.504,43.162,29.535 +2020-08-14 14:30:00,104.56,31.139,43.162,29.535 +2020-08-14 14:45:00,107.41,31.128,43.162,29.535 +2020-08-14 15:00:00,107.75,32.481,44.91,29.535 +2020-08-14 15:15:00,107.7,30.148000000000003,44.91,29.535 +2020-08-14 15:30:00,103.14,28.518,44.91,29.535 +2020-08-14 15:45:00,104.08,27.74,44.91,29.535 +2020-08-14 16:00:00,108.58,29.195,47.489,29.535 +2020-08-14 16:15:00,109.61,29.625999999999998,47.489,29.535 +2020-08-14 16:30:00,105.67,28.965999999999998,47.489,29.535 +2020-08-14 16:45:00,106.48,26.09,47.489,29.535 +2020-08-14 17:00:00,106.27,30.831999999999997,52.047,29.535 +2020-08-14 17:15:00,112.92,31.45,52.047,29.535 +2020-08-14 17:30:00,117.17,30.857,52.047,29.535 +2020-08-14 17:45:00,114.87,30.416999999999998,52.047,29.535 +2020-08-14 18:00:00,113.07,33.241,53.306000000000004,29.535 +2020-08-14 18:15:00,108.05,32.227,53.306000000000004,29.535 +2020-08-14 18:30:00,110.51,30.538,53.306000000000004,29.535 +2020-08-14 18:45:00,114.14,33.5,53.306000000000004,29.535 +2020-08-14 19:00:00,111.52,35.571,53.516000000000005,29.535 +2020-08-14 19:15:00,103.77,35.055,53.516000000000005,29.535 +2020-08-14 19:30:00,100.59,34.052,53.516000000000005,29.535 +2020-08-14 19:45:00,102.62,32.256,53.516000000000005,29.535 +2020-08-14 20:00:00,101.7,29.701999999999998,57.88,29.535 +2020-08-14 20:15:00,100.14,29.865,57.88,29.535 +2020-08-14 20:30:00,104.22,29.55,57.88,29.535 +2020-08-14 20:45:00,102.33,28.785999999999998,57.88,29.535 +2020-08-14 21:00:00,93.47,28.896,53.32,29.535 +2020-08-14 21:15:00,89.73,31.496,53.32,29.535 +2020-08-14 21:30:00,84.2,31.639,53.32,29.535 +2020-08-14 21:45:00,90.61,32.071999999999996,53.32,29.535 +2020-08-14 22:00:00,87.69,29.177,48.074,29.535 +2020-08-14 22:15:00,85.9,31.226999999999997,48.074,29.535 +2020-08-14 22:30:00,80.19,30.853,48.074,29.535 +2020-08-14 22:45:00,79.07,28.558000000000003,48.074,29.535 +2020-08-14 23:00:00,72.75,27.998,41.306999999999995,29.535 +2020-08-14 23:15:00,73.35,25.458000000000002,41.306999999999995,29.535 +2020-08-14 23:30:00,77.1,23.195,41.306999999999995,29.535 +2020-08-14 23:45:00,80.37,22.21,41.306999999999995,29.535 +2020-08-15 00:00:00,78.66,18.289,40.227,29.423000000000002 +2020-08-15 00:15:00,78.01,18.867,40.227,29.423000000000002 +2020-08-15 00:30:00,77.59,17.567,40.227,29.423000000000002 +2020-08-15 00:45:00,78.28,17.474,40.227,29.423000000000002 +2020-08-15 01:00:00,75.62,17.129,36.303000000000004,29.423000000000002 +2020-08-15 01:15:00,73.05,16.448,36.303000000000004,29.423000000000002 +2020-08-15 01:30:00,72.17,15.292,36.303000000000004,29.423000000000002 +2020-08-15 01:45:00,76.06,16.205,36.303000000000004,29.423000000000002 +2020-08-15 02:00:00,74.7,16.063,33.849000000000004,29.423000000000002 +2020-08-15 02:15:00,74.19,14.452,33.849000000000004,29.423000000000002 +2020-08-15 02:30:00,72.6,15.894,33.849000000000004,29.423000000000002 +2020-08-15 02:45:00,74.73,16.635,33.849000000000004,29.423000000000002 +2020-08-15 03:00:00,74.32,16.73,33.149,29.423000000000002 +2020-08-15 03:15:00,71.83,14.837,33.149,29.423000000000002 +2020-08-15 03:30:00,66.95,14.600999999999999,33.149,29.423000000000002 +2020-08-15 03:45:00,67.51,16.303,33.149,29.423000000000002 +2020-08-15 04:00:00,68.28,18.586,32.501,29.423000000000002 +2020-08-15 04:15:00,68.18,21.346999999999998,32.501,29.423000000000002 +2020-08-15 04:30:00,67.99,18.38,32.501,29.423000000000002 +2020-08-15 04:45:00,69.92,18.101,32.501,29.423000000000002 +2020-08-15 05:00:00,68.61,21.697,31.648000000000003,29.423000000000002 +2020-08-15 05:15:00,71.82,18.840999999999998,31.648000000000003,29.423000000000002 +2020-08-15 05:30:00,69.58,16.473,31.648000000000003,29.423000000000002 +2020-08-15 05:45:00,70.22,17.582,31.648000000000003,29.423000000000002 +2020-08-15 06:00:00,73.17,27.255,32.552,29.423000000000002 +2020-08-15 06:15:00,75.29,33.784,32.552,29.423000000000002 +2020-08-15 06:30:00,78.84,31.236,32.552,29.423000000000002 +2020-08-15 06:45:00,86.96,31.153000000000002,32.552,29.423000000000002 +2020-08-15 07:00:00,88.59,30.756,35.181999999999995,29.423000000000002 +2020-08-15 07:15:00,88.61,31.564,35.181999999999995,29.423000000000002 +2020-08-15 07:30:00,88.1,29.455,35.181999999999995,29.423000000000002 +2020-08-15 07:45:00,94.77,30.729,35.181999999999995,29.423000000000002 +2020-08-15 08:00:00,96.75,28.160999999999998,40.35,29.423000000000002 +2020-08-15 08:15:00,93.18,30.936999999999998,40.35,29.423000000000002 +2020-08-15 08:30:00,93.98,31.596,40.35,29.423000000000002 +2020-08-15 08:45:00,95.48,34.302,40.35,29.423000000000002 +2020-08-15 09:00:00,93.38,30.744,42.292,29.423000000000002 +2020-08-15 09:15:00,91.59,32.339,42.292,29.423000000000002 +2020-08-15 09:30:00,92.73,35.157,42.292,29.423000000000002 +2020-08-15 09:45:00,96.95,37.759,42.292,29.423000000000002 +2020-08-15 10:00:00,100.16,36.094,40.084,29.423000000000002 +2020-08-15 10:15:00,101.04,37.143,40.084,29.423000000000002 +2020-08-15 10:30:00,99.67,37.117,40.084,29.423000000000002 +2020-08-15 10:45:00,99.38,37.459,40.084,29.423000000000002 +2020-08-15 11:00:00,95.9,35.616,36.966,29.423000000000002 +2020-08-15 11:15:00,93.51,36.58,36.966,29.423000000000002 +2020-08-15 11:30:00,89.93,37.348,36.966,29.423000000000002 +2020-08-15 11:45:00,86.8,37.865,36.966,29.423000000000002 +2020-08-15 12:00:00,82.81,34.096,35.19,29.423000000000002 +2020-08-15 12:15:00,81.87,33.643,35.19,29.423000000000002 +2020-08-15 12:30:00,80.39,32.586999999999996,35.19,29.423000000000002 +2020-08-15 12:45:00,72.88,33.234,35.19,29.423000000000002 +2020-08-15 13:00:00,68.23,32.400999999999996,32.277,29.423000000000002 +2020-08-15 13:15:00,68.49,33.111999999999995,32.277,29.423000000000002 +2020-08-15 13:30:00,72.94,32.469,32.277,29.423000000000002 +2020-08-15 13:45:00,73.41,31.604,32.277,29.423000000000002 +2020-08-15 14:00:00,67.3,32.065,31.436999999999998,29.423000000000002 +2020-08-15 14:15:00,71.1,30.541999999999998,31.436999999999998,29.423000000000002 +2020-08-15 14:30:00,67.23,30.926,31.436999999999998,29.423000000000002 +2020-08-15 14:45:00,66.15,31.37,31.436999999999998,29.423000000000002 +2020-08-15 15:00:00,64.93,33.033,33.493,29.423000000000002 +2020-08-15 15:15:00,67.48,31.328000000000003,33.493,29.423000000000002 +2020-08-15 15:30:00,66.52,29.779,33.493,29.423000000000002 +2020-08-15 15:45:00,68.11,28.105999999999998,33.493,29.423000000000002 +2020-08-15 16:00:00,71.24,31.679000000000002,36.593,29.423000000000002 +2020-08-15 16:15:00,71.7,31.18,36.593,29.423000000000002 +2020-08-15 16:30:00,73.28,30.749000000000002,36.593,29.423000000000002 +2020-08-15 16:45:00,76.09,27.814,36.593,29.423000000000002 +2020-08-15 17:00:00,77.79,31.39,42.049,29.423000000000002 +2020-08-15 17:15:00,78.28,29.826,42.049,29.423000000000002 +2020-08-15 17:30:00,80.48,29.125999999999998,42.049,29.423000000000002 +2020-08-15 17:45:00,81.56,29.226,42.049,29.423000000000002 +2020-08-15 18:00:00,82.67,33.369,43.755,29.423000000000002 +2020-08-15 18:15:00,81.18,33.85,43.755,29.423000000000002 +2020-08-15 18:30:00,80.1,33.385,43.755,29.423000000000002 +2020-08-15 18:45:00,80.79,33.236,43.755,29.423000000000002 +2020-08-15 19:00:00,78.86,33.652,44.492,29.423000000000002 +2020-08-15 19:15:00,76.55,32.202,44.492,29.423000000000002 +2020-08-15 19:30:00,77.59,31.889,44.492,29.423000000000002 +2020-08-15 19:45:00,80.08,31.791,44.492,29.423000000000002 +2020-08-15 20:00:00,79.74,29.905,40.896,29.423000000000002 +2020-08-15 20:15:00,80.99,29.331,40.896,29.423000000000002 +2020-08-15 20:30:00,76.08,28.186999999999998,40.896,29.423000000000002 +2020-08-15 20:45:00,75.31,29.188000000000002,40.896,29.423000000000002 +2020-08-15 21:00:00,72.65,27.752,39.056,29.423000000000002 +2020-08-15 21:15:00,72.67,29.988000000000003,39.056,29.423000000000002 +2020-08-15 21:30:00,70.17,30.236,39.056,29.423000000000002 +2020-08-15 21:45:00,69.86,30.145,39.056,29.423000000000002 +2020-08-15 22:00:00,66.08,27.138,38.478,29.423000000000002 +2020-08-15 22:15:00,67.02,29.274,38.478,29.423000000000002 +2020-08-15 22:30:00,64.4,28.171999999999997,38.478,29.423000000000002 +2020-08-15 22:45:00,64.41,26.127,38.478,29.423000000000002 +2020-08-15 23:00:00,59.88,24.873,32.953,29.423000000000002 +2020-08-15 23:15:00,61.24,22.807,32.953,29.423000000000002 +2020-08-15 23:30:00,60.61,22.965,32.953,29.423000000000002 +2020-08-15 23:45:00,59.27,22.448,32.953,29.423000000000002 +2020-08-16 00:00:00,56.4,20.963,28.584,29.423000000000002 +2020-08-16 00:15:00,57.15,20.25,28.584,29.423000000000002 +2020-08-16 00:30:00,56.4,19.059,28.584,29.423000000000002 +2020-08-16 00:45:00,56.75,18.803,28.584,29.423000000000002 +2020-08-16 01:00:00,54.08,18.761,26.419,29.423000000000002 +2020-08-16 01:15:00,55.73,17.887999999999998,26.419,29.423000000000002 +2020-08-16 01:30:00,54.82,16.56,26.419,29.423000000000002 +2020-08-16 01:45:00,55.19,16.852,26.419,29.423000000000002 +2020-08-16 02:00:00,53.79,16.772000000000002,25.335,29.423000000000002 +2020-08-16 02:15:00,54.64,15.895999999999999,25.335,29.423000000000002 +2020-08-16 02:30:00,54.21,17.325,25.335,29.423000000000002 +2020-08-16 02:45:00,53.98,17.839000000000002,25.335,29.423000000000002 +2020-08-16 03:00:00,53.46,18.679000000000002,24.805,29.423000000000002 +2020-08-16 03:15:00,54.06,16.922,24.805,29.423000000000002 +2020-08-16 03:30:00,54.48,15.995999999999999,24.805,29.423000000000002 +2020-08-16 03:45:00,55.19,17.086,24.805,29.423000000000002 +2020-08-16 04:00:00,55.99,19.613,25.772,29.423000000000002 +2020-08-16 04:15:00,57.26,22.346,25.772,29.423000000000002 +2020-08-16 04:30:00,54.82,20.494,25.772,29.423000000000002 +2020-08-16 04:45:00,56.06,19.851,25.772,29.423000000000002 +2020-08-16 05:00:00,55.21,23.886,25.971999999999998,29.423000000000002 +2020-08-16 05:15:00,55.74,20.704,25.971999999999998,29.423000000000002 +2020-08-16 05:30:00,55.42,17.433,25.971999999999998,29.423000000000002 +2020-08-16 05:45:00,56.8,18.361,25.971999999999998,29.423000000000002 +2020-08-16 06:00:00,55.15,26.846999999999998,26.026,29.423000000000002 +2020-08-16 06:15:00,58.89,34.753,26.026,29.423000000000002 +2020-08-16 06:30:00,59.5,31.089000000000002,26.026,29.423000000000002 +2020-08-16 06:45:00,60.53,29.549,26.026,29.423000000000002 +2020-08-16 07:00:00,61.9,29.266,27.396,29.423000000000002 +2020-08-16 07:15:00,61.93,28.503,27.396,29.423000000000002 +2020-08-16 07:30:00,61.72,27.575,27.396,29.423000000000002 +2020-08-16 07:45:00,59.55,28.915,27.396,29.423000000000002 +2020-08-16 08:00:00,62.74,27.895,30.791999999999998,29.423000000000002 +2020-08-16 08:15:00,61.33,31.752,30.791999999999998,29.423000000000002 +2020-08-16 08:30:00,60.91,32.825,30.791999999999998,29.423000000000002 +2020-08-16 08:45:00,60.73,35.29,30.791999999999998,29.423000000000002 +2020-08-16 09:00:00,60.52,30.888,32.482,29.423000000000002 +2020-08-16 09:15:00,61.23,31.75,32.482,29.423000000000002 +2020-08-16 09:30:00,61.34,34.828,32.482,29.423000000000002 +2020-08-16 09:45:00,61.67,38.303000000000004,32.482,29.423000000000002 +2020-08-16 10:00:00,59.03,35.681,31.951,29.423000000000002 +2020-08-16 10:15:00,63.77,36.964,31.951,29.423000000000002 +2020-08-16 10:30:00,64.3,37.236,31.951,29.423000000000002 +2020-08-16 10:45:00,64.55,39.031,31.951,29.423000000000002 +2020-08-16 11:00:00,62.19,37.029,33.619,29.423000000000002 +2020-08-16 11:15:00,61.81,37.51,33.619,29.423000000000002 +2020-08-16 11:30:00,60.1,38.786,33.619,29.423000000000002 +2020-08-16 11:45:00,59.33,39.66,33.619,29.423000000000002 +2020-08-16 12:00:00,56.59,37.803000000000004,30.975,29.423000000000002 +2020-08-16 12:15:00,56.52,36.787,30.975,29.423000000000002 +2020-08-16 12:30:00,56.09,36.008,30.975,29.423000000000002 +2020-08-16 12:45:00,54.79,36.227,30.975,29.423000000000002 +2020-08-16 13:00:00,51.28,35.288000000000004,27.956999999999997,29.423000000000002 +2020-08-16 13:15:00,55.09,35.214,27.956999999999997,29.423000000000002 +2020-08-16 13:30:00,52.21,33.672,27.956999999999997,29.423000000000002 +2020-08-16 13:45:00,54.84,33.584,27.956999999999997,29.423000000000002 +2020-08-16 14:00:00,55.31,35.247,25.555999999999997,29.423000000000002 +2020-08-16 14:15:00,56.62,34.028,25.555999999999997,29.423000000000002 +2020-08-16 14:30:00,54.58,32.937,25.555999999999997,29.423000000000002 +2020-08-16 14:45:00,56.86,32.461,25.555999999999997,29.423000000000002 +2020-08-16 15:00:00,58.29,34.168,26.271,29.423000000000002 +2020-08-16 15:15:00,58.87,31.699,26.271,29.423000000000002 +2020-08-16 15:30:00,54.96,29.926,26.271,29.423000000000002 +2020-08-16 15:45:00,59.3,28.335,26.271,29.423000000000002 +2020-08-16 16:00:00,63.28,30.392,30.369,29.423000000000002 +2020-08-16 16:15:00,64.29,30.148000000000003,30.369,29.423000000000002 +2020-08-16 16:30:00,66.51,30.853,30.369,29.423000000000002 +2020-08-16 16:45:00,70.7,27.897,30.369,29.423000000000002 +2020-08-16 17:00:00,74.15,31.488000000000003,38.787,29.423000000000002 +2020-08-16 17:15:00,75.05,31.449,38.787,29.423000000000002 +2020-08-16 17:30:00,76.55,31.503,38.787,29.423000000000002 +2020-08-16 17:45:00,77.73,31.592,38.787,29.423000000000002 +2020-08-16 18:00:00,80.1,35.961,41.886,29.423000000000002 +2020-08-16 18:15:00,79.15,35.951,41.886,29.423000000000002 +2020-08-16 18:30:00,78.57,35.23,41.886,29.423000000000002 +2020-08-16 18:45:00,78.39,35.485,41.886,29.423000000000002 +2020-08-16 19:00:00,77.11,37.847,42.91,29.423000000000002 +2020-08-16 19:15:00,78.29,35.399,42.91,29.423000000000002 +2020-08-16 19:30:00,80.1,34.857,42.91,29.423000000000002 +2020-08-16 19:45:00,82.78,34.594,42.91,29.423000000000002 +2020-08-16 20:00:00,82.35,32.407,42.148999999999994,29.423000000000002 +2020-08-16 20:15:00,81.74,32.199,42.148999999999994,29.423000000000002 +2020-08-16 20:30:00,81.4,31.858,42.148999999999994,29.423000000000002 +2020-08-16 20:45:00,81.03,31.601,42.148999999999994,29.423000000000002 +2020-08-16 21:00:00,79.1,29.82,40.955999999999996,29.423000000000002 +2020-08-16 21:15:00,80.28,32.325,40.955999999999996,29.423000000000002 +2020-08-16 21:30:00,78.42,32.105,40.955999999999996,29.423000000000002 +2020-08-16 21:45:00,77.85,32.394,40.955999999999996,29.423000000000002 +2020-08-16 22:00:00,73.71,31.552,39.873000000000005,29.423000000000002 +2020-08-16 22:15:00,74.13,31.956,39.873000000000005,29.423000000000002 +2020-08-16 22:30:00,70.68,30.46,39.873000000000005,29.423000000000002 +2020-08-16 22:45:00,73.48,27.304000000000002,39.873000000000005,29.423000000000002 +2020-08-16 23:00:00,66.63,25.803,35.510999999999996,29.423000000000002 +2020-08-16 23:15:00,68.8,24.945999999999998,35.510999999999996,29.423000000000002 +2020-08-16 23:30:00,67.36,24.513,35.510999999999996,29.423000000000002 +2020-08-16 23:45:00,68.31,24.014,35.510999999999996,29.423000000000002 +2020-08-17 00:00:00,67.08,22.375,33.475,29.535 +2020-08-17 00:15:00,66.94,22.315,33.475,29.535 +2020-08-17 00:30:00,66.43,20.779,33.475,29.535 +2020-08-17 00:45:00,66.96,20.184,33.475,29.535 +2020-08-17 01:00:00,64.35,20.474,33.111,29.535 +2020-08-17 01:15:00,65.92,19.635,33.111,29.535 +2020-08-17 01:30:00,65.15,18.643,33.111,29.535 +2020-08-17 01:45:00,64.74,18.845,33.111,29.535 +2020-08-17 02:00:00,65.98,19.177,32.358000000000004,29.535 +2020-08-17 02:15:00,72.77,17.374000000000002,32.358000000000004,29.535 +2020-08-17 02:30:00,73.53,18.923,32.358000000000004,29.535 +2020-08-17 02:45:00,69.28,19.314,32.358000000000004,29.535 +2020-08-17 03:00:00,66.89,20.573,30.779,29.535 +2020-08-17 03:15:00,67.8,19.445999999999998,30.779,29.535 +2020-08-17 03:30:00,71.43,19.149,30.779,29.535 +2020-08-17 03:45:00,74.75,19.83,30.779,29.535 +2020-08-17 04:00:00,80.78,25.086,31.416,29.535 +2020-08-17 04:15:00,87.43,30.389,31.416,29.535 +2020-08-17 04:30:00,90.67,28.061,31.416,29.535 +2020-08-17 04:45:00,90.09,27.750999999999998,31.416,29.535 +2020-08-17 05:00:00,93.62,38.103,37.221,29.535 +2020-08-17 05:15:00,96.83,43.934,37.221,29.535 +2020-08-17 05:30:00,105.37,39.284,37.221,29.535 +2020-08-17 05:45:00,108.9,37.507,37.221,29.535 +2020-08-17 06:00:00,113.83,36.976,51.891000000000005,29.535 +2020-08-17 06:15:00,111.93,36.961,51.891000000000005,29.535 +2020-08-17 06:30:00,116.33,36.743,51.891000000000005,29.535 +2020-08-17 06:45:00,116.15,39.816,51.891000000000005,29.535 +2020-08-17 07:00:00,119.7,39.711999999999996,62.282,29.535 +2020-08-17 07:15:00,114.94,40.998000000000005,62.282,29.535 +2020-08-17 07:30:00,113.97,39.330999999999996,62.282,29.535 +2020-08-17 07:45:00,116.01,40.996,62.282,29.535 +2020-08-17 08:00:00,116.0,38.075,54.102,29.535 +2020-08-17 08:15:00,113.19,40.864000000000004,54.102,29.535 +2020-08-17 08:30:00,108.1,41.148,54.102,29.535 +2020-08-17 08:45:00,111.95,43.861000000000004,54.102,29.535 +2020-08-17 09:00:00,116.11,38.529,50.917,29.535 +2020-08-17 09:15:00,113.89,37.845,50.917,29.535 +2020-08-17 09:30:00,111.12,40.088,50.917,29.535 +2020-08-17 09:45:00,106.99,41.456,50.917,29.535 +2020-08-17 10:00:00,109.37,39.195,49.718999999999994,29.535 +2020-08-17 10:15:00,108.32,40.332,49.718999999999994,29.535 +2020-08-17 10:30:00,115.64,40.201,49.718999999999994,29.535 +2020-08-17 10:45:00,113.32,40.523,49.718999999999994,29.535 +2020-08-17 11:00:00,109.23,38.788000000000004,49.833999999999996,29.535 +2020-08-17 11:15:00,106.15,39.465,49.833999999999996,29.535 +2020-08-17 11:30:00,106.16,41.331,49.833999999999996,29.535 +2020-08-17 11:45:00,112.52,42.663999999999994,49.833999999999996,29.535 +2020-08-17 12:00:00,113.63,39.105,47.832,29.535 +2020-08-17 12:15:00,106.33,38.181,47.832,29.535 +2020-08-17 12:30:00,107.11,36.42,47.832,29.535 +2020-08-17 12:45:00,107.04,36.57,47.832,29.535 +2020-08-17 13:00:00,103.37,36.479,48.03,29.535 +2020-08-17 13:15:00,102.48,35.629,48.03,29.535 +2020-08-17 13:30:00,104.21,34.259,48.03,29.535 +2020-08-17 13:45:00,102.22,35.001,48.03,29.535 +2020-08-17 14:00:00,103.51,35.816,48.157,29.535 +2020-08-17 14:15:00,99.38,35.17,48.157,29.535 +2020-08-17 14:30:00,99.26,33.96,48.157,29.535 +2020-08-17 14:45:00,99.3,35.389,48.157,29.535 +2020-08-17 15:00:00,102.77,36.656,48.897,29.535 +2020-08-17 15:15:00,101.97,33.701,48.897,29.535 +2020-08-17 15:30:00,103.47,32.678000000000004,48.897,29.535 +2020-08-17 15:45:00,104.76,30.66,48.897,29.535 +2020-08-17 16:00:00,106.74,33.74,51.446000000000005,29.535 +2020-08-17 16:15:00,109.18,33.586,51.446000000000005,29.535 +2020-08-17 16:30:00,111.89,33.715,51.446000000000005,29.535 +2020-08-17 16:45:00,110.39,30.851,51.446000000000005,29.535 +2020-08-17 17:00:00,109.89,33.382,57.507,29.535 +2020-08-17 17:15:00,108.72,33.742,57.507,29.535 +2020-08-17 17:30:00,109.15,33.471,57.507,29.535 +2020-08-17 17:45:00,108.54,33.272,57.507,29.535 +2020-08-17 18:00:00,108.29,36.677,57.896,29.535 +2020-08-17 18:15:00,107.32,35.067,57.896,29.535 +2020-08-17 18:30:00,107.79,33.650999999999996,57.896,29.535 +2020-08-17 18:45:00,106.79,36.765,57.896,29.535 +2020-08-17 19:00:00,113.59,38.936,57.891999999999996,29.535 +2020-08-17 19:15:00,107.67,37.709,57.891999999999996,29.535 +2020-08-17 19:30:00,110.58,36.824,57.891999999999996,29.535 +2020-08-17 19:45:00,109.62,36.025999999999996,57.891999999999996,29.535 +2020-08-17 20:00:00,102.56,32.74,64.57300000000001,29.535 +2020-08-17 20:15:00,100.21,33.866,64.57300000000001,29.535 +2020-08-17 20:30:00,98.27,34.029,64.57300000000001,29.535 +2020-08-17 20:45:00,99.3,33.9,64.57300000000001,29.535 +2020-08-17 21:00:00,95.28,31.535,59.431999999999995,29.535 +2020-08-17 21:15:00,96.5,34.459,59.431999999999995,29.535 +2020-08-17 21:30:00,93.09,34.631,59.431999999999995,29.535 +2020-08-17 21:45:00,92.08,34.704,59.431999999999995,29.535 +2020-08-17 22:00:00,89.32,32.049,51.519,29.535 +2020-08-17 22:15:00,87.02,34.274,51.519,29.535 +2020-08-17 22:30:00,82.96,29.033,51.519,29.535 +2020-08-17 22:45:00,87.26,25.968000000000004,51.519,29.535 +2020-08-17 23:00:00,83.17,24.506999999999998,44.501000000000005,29.535 +2020-08-17 23:15:00,84.93,22.086,44.501000000000005,29.535 +2020-08-17 23:30:00,79.26,21.566999999999997,44.501000000000005,29.535 +2020-08-17 23:45:00,75.42,20.524,44.501000000000005,29.535 +2020-08-18 00:00:00,80.51,20.454,44.522,29.535 +2020-08-18 00:15:00,81.43,21.15,44.522,29.535 +2020-08-18 00:30:00,80.49,20.289,44.522,29.535 +2020-08-18 00:45:00,75.85,20.444000000000003,44.522,29.535 +2020-08-18 01:00:00,72.88,20.229,41.441,29.535 +2020-08-18 01:15:00,73.3,19.511,41.441,29.535 +2020-08-18 01:30:00,75.87,18.41,41.441,29.535 +2020-08-18 01:45:00,80.87,18.136,41.441,29.535 +2020-08-18 02:00:00,81.98,18.032,40.203,29.535 +2020-08-18 02:15:00,79.19,17.307000000000002,40.203,29.535 +2020-08-18 02:30:00,76.72,18.492,40.203,29.535 +2020-08-18 02:45:00,82.03,19.179000000000002,40.203,29.535 +2020-08-18 03:00:00,82.91,19.949,39.536,29.535 +2020-08-18 03:15:00,82.37,19.711,39.536,29.535 +2020-08-18 03:30:00,78.01,19.379,39.536,29.535 +2020-08-18 03:45:00,83.18,19.037,39.536,29.535 +2020-08-18 04:00:00,90.43,23.16,40.759,29.535 +2020-08-18 04:15:00,92.1,28.493000000000002,40.759,29.535 +2020-08-18 04:30:00,89.48,26.066999999999997,40.759,29.535 +2020-08-18 04:45:00,92.67,26.177,40.759,29.535 +2020-08-18 05:00:00,97.83,37.921,43.623999999999995,29.535 +2020-08-18 05:15:00,105.65,44.367,43.623999999999995,29.535 +2020-08-18 05:30:00,111.86,39.913000000000004,43.623999999999995,29.535 +2020-08-18 05:45:00,115.65,37.446,43.623999999999995,29.535 +2020-08-18 06:00:00,116.29,37.865,52.684,29.535 +2020-08-18 06:15:00,117.43,38.018,52.684,29.535 +2020-08-18 06:30:00,123.82,37.52,52.684,29.535 +2020-08-18 06:45:00,129.27,39.798,52.684,29.535 +2020-08-18 07:00:00,135.91,39.836,62.676,29.535 +2020-08-18 07:15:00,126.32,40.907,62.676,29.535 +2020-08-18 07:30:00,127.7,39.289,62.676,29.535 +2020-08-18 07:45:00,125.98,40.064,62.676,29.535 +2020-08-18 08:00:00,127.08,37.076,56.161,29.535 +2020-08-18 08:15:00,131.88,39.455,56.161,29.535 +2020-08-18 08:30:00,134.91,39.891,56.161,29.535 +2020-08-18 08:45:00,131.78,41.751000000000005,56.161,29.535 +2020-08-18 09:00:00,132.31,36.759,52.132,29.535 +2020-08-18 09:15:00,134.14,35.897,52.132,29.535 +2020-08-18 09:30:00,140.01,38.738,52.132,29.535 +2020-08-18 09:45:00,140.34,41.32,52.132,29.535 +2020-08-18 10:00:00,137.81,37.816,51.032,29.535 +2020-08-18 10:15:00,133.5,38.835,51.032,29.535 +2020-08-18 10:30:00,134.66,38.716,51.032,29.535 +2020-08-18 10:45:00,131.74,39.949,51.032,29.535 +2020-08-18 11:00:00,125.16,38.181,51.085,29.535 +2020-08-18 11:15:00,124.46,39.236,51.085,29.535 +2020-08-18 11:30:00,124.13,40.074,51.085,29.535 +2020-08-18 11:45:00,115.02,40.953,51.085,29.535 +2020-08-18 12:00:00,109.2,37.262,49.049,29.535 +2020-08-18 12:15:00,108.88,36.66,49.049,29.535 +2020-08-18 12:30:00,112.98,35.681999999999995,49.049,29.535 +2020-08-18 12:45:00,115.32,36.479,49.049,29.535 +2020-08-18 13:00:00,108.39,36.041,49.722,29.535 +2020-08-18 13:15:00,111.75,36.867,49.722,29.535 +2020-08-18 13:30:00,111.55,35.42,49.722,29.535 +2020-08-18 13:45:00,107.18,35.269,49.722,29.535 +2020-08-18 14:00:00,114.92,36.473,49.565,29.535 +2020-08-18 14:15:00,117.53,35.641999999999996,49.565,29.535 +2020-08-18 14:30:00,117.41,34.726,49.565,29.535 +2020-08-18 14:45:00,113.97,35.425,49.565,29.535 +2020-08-18 15:00:00,104.47,36.525,51.108999999999995,29.535 +2020-08-18 15:15:00,107.86,34.426,51.108999999999995,29.535 +2020-08-18 15:30:00,116.75,33.21,51.108999999999995,29.535 +2020-08-18 15:45:00,114.65,31.535999999999998,51.108999999999995,29.535 +2020-08-18 16:00:00,112.4,33.915,52.725,29.535 +2020-08-18 16:15:00,114.87,33.833,52.725,29.535 +2020-08-18 16:30:00,114.02,33.6,52.725,29.535 +2020-08-18 16:45:00,116.97,31.426,52.725,29.535 +2020-08-18 17:00:00,113.73,34.107,58.031000000000006,29.535 +2020-08-18 17:15:00,110.05,34.88,58.031000000000006,29.535 +2020-08-18 17:30:00,118.5,34.19,58.031000000000006,29.535 +2020-08-18 17:45:00,119.33,33.718,58.031000000000006,29.535 +2020-08-18 18:00:00,122.4,36.251999999999995,58.338,29.535 +2020-08-18 18:15:00,114.26,36.035,58.338,29.535 +2020-08-18 18:30:00,115.94,34.396,58.338,29.535 +2020-08-18 18:45:00,118.08,37.306999999999995,58.338,29.535 +2020-08-18 19:00:00,107.79,38.418,58.464,29.535 +2020-08-18 19:15:00,104.16,37.361,58.464,29.535 +2020-08-18 19:30:00,111.36,36.297,58.464,29.535 +2020-08-18 19:45:00,113.13,35.806,58.464,29.535 +2020-08-18 20:00:00,106.38,32.879,63.708,29.535 +2020-08-18 20:15:00,103.17,32.701,63.708,29.535 +2020-08-18 20:30:00,98.53,32.858000000000004,63.708,29.535 +2020-08-18 20:45:00,97.08,33.101,63.708,29.535 +2020-08-18 21:00:00,89.74,31.555,57.06399999999999,29.535 +2020-08-18 21:15:00,91.57,33.103,57.06399999999999,29.535 +2020-08-18 21:30:00,86.58,33.452,57.06399999999999,29.535 +2020-08-18 21:45:00,86.69,33.669000000000004,57.06399999999999,29.535 +2020-08-18 22:00:00,80.92,31.107,52.831,29.535 +2020-08-18 22:15:00,81.51,33.018,52.831,29.535 +2020-08-18 22:30:00,80.13,28.011999999999997,52.831,29.535 +2020-08-18 22:45:00,79.13,24.936999999999998,52.831,29.535 +2020-08-18 23:00:00,72.87,22.809,44.717,29.535 +2020-08-18 23:15:00,76.22,21.831999999999997,44.717,29.535 +2020-08-18 23:30:00,77.08,21.331,44.717,29.535 +2020-08-18 23:45:00,78.95,20.47,44.717,29.535 +2020-08-19 00:00:00,75.55,20.588,41.263000000000005,29.535 +2020-08-19 00:15:00,74.25,21.284000000000002,41.263000000000005,29.535 +2020-08-19 00:30:00,73.14,20.428,41.263000000000005,29.535 +2020-08-19 00:45:00,73.85,20.59,41.263000000000005,29.535 +2020-08-19 01:00:00,72.97,20.358,38.448,29.535 +2020-08-19 01:15:00,75.56,19.652,38.448,29.535 +2020-08-19 01:30:00,70.9,18.563,38.448,29.535 +2020-08-19 01:45:00,73.95,18.288,38.448,29.535 +2020-08-19 02:00:00,73.83,18.187,36.471,29.535 +2020-08-19 02:15:00,75.11,17.482,36.471,29.535 +2020-08-19 02:30:00,74.21,18.646,36.471,29.535 +2020-08-19 02:45:00,74.47,19.331,36.471,29.535 +2020-08-19 03:00:00,76.06,20.089000000000002,36.042,29.535 +2020-08-19 03:15:00,77.07,19.866,36.042,29.535 +2020-08-19 03:30:00,74.69,19.54,36.042,29.535 +2020-08-19 03:45:00,84.15,19.194000000000003,36.042,29.535 +2020-08-19 04:00:00,90.54,23.335,36.705,29.535 +2020-08-19 04:15:00,91.52,28.679000000000002,36.705,29.535 +2020-08-19 04:30:00,88.4,26.26,36.705,29.535 +2020-08-19 04:45:00,91.16,26.372,36.705,29.535 +2020-08-19 05:00:00,95.33,38.171,39.716,29.535 +2020-08-19 05:15:00,104.66,44.675,39.716,29.535 +2020-08-19 05:30:00,106.17,40.223,39.716,29.535 +2020-08-19 05:45:00,113.8,37.723,39.716,29.535 +2020-08-19 06:00:00,118.61,38.113,52.756,29.535 +2020-08-19 06:15:00,117.99,38.286,52.756,29.535 +2020-08-19 06:30:00,114.51,37.786,52.756,29.535 +2020-08-19 06:45:00,111.03,40.067,52.756,29.535 +2020-08-19 07:00:00,120.0,40.104,65.977,29.535 +2020-08-19 07:15:00,121.82,41.191,65.977,29.535 +2020-08-19 07:30:00,118.1,39.595,65.977,29.535 +2020-08-19 07:45:00,117.26,40.374,65.977,29.535 +2020-08-19 08:00:00,114.13,37.389,57.927,29.535 +2020-08-19 08:15:00,117.26,39.741,57.927,29.535 +2020-08-19 08:30:00,120.28,40.171,57.927,29.535 +2020-08-19 08:45:00,118.51,42.016999999999996,57.927,29.535 +2020-08-19 09:00:00,117.01,37.033,54.86,29.535 +2020-08-19 09:15:00,114.75,36.162,54.86,29.535 +2020-08-19 09:30:00,117.13,38.986999999999995,54.86,29.535 +2020-08-19 09:45:00,112.52,41.547,54.86,29.535 +2020-08-19 10:00:00,114.95,38.043,52.818000000000005,29.535 +2020-08-19 10:15:00,116.13,39.04,52.818000000000005,29.535 +2020-08-19 10:30:00,116.65,38.913000000000004,52.818000000000005,29.535 +2020-08-19 10:45:00,111.6,40.138000000000005,52.818000000000005,29.535 +2020-08-19 11:00:00,110.58,38.38,52.937,29.535 +2020-08-19 11:15:00,112.5,39.426,52.937,29.535 +2020-08-19 11:30:00,112.13,40.26,52.937,29.535 +2020-08-19 11:45:00,111.52,41.125,52.937,29.535 +2020-08-19 12:00:00,106.13,37.423,50.826,29.535 +2020-08-19 12:15:00,106.08,36.811,50.826,29.535 +2020-08-19 12:30:00,106.22,35.847,50.826,29.535 +2020-08-19 12:45:00,115.53,36.633,50.826,29.535 +2020-08-19 13:00:00,113.75,36.177,50.556000000000004,29.535 +2020-08-19 13:15:00,112.48,36.993,50.556000000000004,29.535 +2020-08-19 13:30:00,105.73,35.543,50.556000000000004,29.535 +2020-08-19 13:45:00,105.62,35.400999999999996,50.556000000000004,29.535 +2020-08-19 14:00:00,109.57,36.584,51.188,29.535 +2020-08-19 14:15:00,112.24,35.759,51.188,29.535 +2020-08-19 14:30:00,108.23,34.856,51.188,29.535 +2020-08-19 14:45:00,107.5,35.559,51.188,29.535 +2020-08-19 15:00:00,109.94,36.618,52.976000000000006,29.535 +2020-08-19 15:15:00,110.19,34.525999999999996,52.976000000000006,29.535 +2020-08-19 15:30:00,105.86,33.321999999999996,52.976000000000006,29.535 +2020-08-19 15:45:00,109.9,31.653000000000002,52.976000000000006,29.535 +2020-08-19 16:00:00,110.95,34.01,55.463,29.535 +2020-08-19 16:15:00,112.44,33.936,55.463,29.535 +2020-08-19 16:30:00,109.15,33.707,55.463,29.535 +2020-08-19 16:45:00,107.57,31.572,55.463,29.535 +2020-08-19 17:00:00,115.26,34.226,59.435,29.535 +2020-08-19 17:15:00,118.06,35.025,59.435,29.535 +2020-08-19 17:30:00,118.86,34.346,59.435,29.535 +2020-08-19 17:45:00,116.17,33.907,59.435,29.535 +2020-08-19 18:00:00,112.79,36.433,61.387,29.535 +2020-08-19 18:15:00,115.1,36.225,61.387,29.535 +2020-08-19 18:30:00,118.21,34.598,61.387,29.535 +2020-08-19 18:45:00,118.6,37.507,61.387,29.535 +2020-08-19 19:00:00,113.59,38.624,63.323,29.535 +2020-08-19 19:15:00,106.04,37.567,63.323,29.535 +2020-08-19 19:30:00,112.3,36.503,63.323,29.535 +2020-08-19 19:45:00,112.18,36.016,63.323,29.535 +2020-08-19 20:00:00,107.02,33.094,69.083,29.535 +2020-08-19 20:15:00,107.02,32.918,69.083,29.535 +2020-08-19 20:30:00,100.41,33.061,69.083,29.535 +2020-08-19 20:45:00,98.9,33.274,69.083,29.535 +2020-08-19 21:00:00,94.63,31.729,59.957,29.535 +2020-08-19 21:15:00,94.3,33.27,59.957,29.535 +2020-08-19 21:30:00,91.09,33.616,59.957,29.535 +2020-08-19 21:45:00,88.92,33.804,59.957,29.535 +2020-08-19 22:00:00,81.63,31.223000000000003,53.821000000000005,29.535 +2020-08-19 22:15:00,84.95,33.12,53.821000000000005,29.535 +2020-08-19 22:30:00,83.56,28.084,53.821000000000005,29.535 +2020-08-19 22:45:00,83.98,25.009,53.821000000000005,29.535 +2020-08-19 23:00:00,74.72,22.921,45.458,29.535 +2020-08-19 23:15:00,78.64,21.933000000000003,45.458,29.535 +2020-08-19 23:30:00,74.14,21.443,45.458,29.535 +2020-08-19 23:45:00,79.63,20.586,45.458,29.535 +2020-08-20 00:00:00,73.64,20.724,40.36,29.535 +2020-08-20 00:15:00,75.13,21.421,40.36,29.535 +2020-08-20 00:30:00,70.59,20.57,40.36,29.535 +2020-08-20 00:45:00,74.45,20.739,40.36,29.535 +2020-08-20 01:00:00,72.22,20.49,38.552,29.535 +2020-08-20 01:15:00,74.16,19.795,38.552,29.535 +2020-08-20 01:30:00,75.17,18.719,38.552,29.535 +2020-08-20 01:45:00,81.58,18.444000000000003,38.552,29.535 +2020-08-20 02:00:00,80.48,18.346,36.895,29.535 +2020-08-20 02:15:00,77.16,17.660999999999998,36.895,29.535 +2020-08-20 02:30:00,74.84,18.802,36.895,29.535 +2020-08-20 02:45:00,76.18,19.485,36.895,29.535 +2020-08-20 03:00:00,77.16,20.230999999999998,36.565,29.535 +2020-08-20 03:15:00,76.8,20.024,36.565,29.535 +2020-08-20 03:30:00,77.13,19.705,36.565,29.535 +2020-08-20 03:45:00,81.08,19.352999999999998,36.565,29.535 +2020-08-20 04:00:00,82.53,23.513,37.263000000000005,29.535 +2020-08-20 04:15:00,85.3,28.87,37.263000000000005,29.535 +2020-08-20 04:30:00,89.78,26.456,37.263000000000005,29.535 +2020-08-20 04:45:00,92.89,26.572,37.263000000000005,29.535 +2020-08-20 05:00:00,100.42,38.428000000000004,40.412,29.535 +2020-08-20 05:15:00,97.56,44.992,40.412,29.535 +2020-08-20 05:30:00,104.41,40.54,40.412,29.535 +2020-08-20 05:45:00,114.19,38.008,40.412,29.535 +2020-08-20 06:00:00,118.96,38.368,49.825,29.535 +2020-08-20 06:15:00,118.57,38.561,49.825,29.535 +2020-08-20 06:30:00,115.56,38.058,49.825,29.535 +2020-08-20 06:45:00,116.37,40.343,49.825,29.535 +2020-08-20 07:00:00,117.94,40.379,61.082,29.535 +2020-08-20 07:15:00,121.74,41.48,61.082,29.535 +2020-08-20 07:30:00,117.74,39.907,61.082,29.535 +2020-08-20 07:45:00,117.37,40.691,61.082,29.535 +2020-08-20 08:00:00,119.5,37.708,53.961999999999996,29.535 +2020-08-20 08:15:00,119.23,40.032,53.961999999999996,29.535 +2020-08-20 08:30:00,117.61,40.457,53.961999999999996,29.535 +2020-08-20 08:45:00,119.25,42.288999999999994,53.961999999999996,29.535 +2020-08-20 09:00:00,121.78,37.311,50.06100000000001,29.535 +2020-08-20 09:15:00,121.69,36.431999999999995,50.06100000000001,29.535 +2020-08-20 09:30:00,113.46,39.24,50.06100000000001,29.535 +2020-08-20 09:45:00,113.9,41.778,50.06100000000001,29.535 +2020-08-20 10:00:00,119.89,38.274,47.68,29.535 +2020-08-20 10:15:00,119.66,39.247,47.68,29.535 +2020-08-20 10:30:00,118.06,39.114000000000004,47.68,29.535 +2020-08-20 10:45:00,111.47,40.330999999999996,47.68,29.535 +2020-08-20 11:00:00,110.59,38.582,45.93899999999999,29.535 +2020-08-20 11:15:00,113.71,39.62,45.93899999999999,29.535 +2020-08-20 11:30:00,113.77,40.45,45.93899999999999,29.535 +2020-08-20 11:45:00,113.94,41.301,45.93899999999999,29.535 +2020-08-20 12:00:00,110.52,37.588,43.648999999999994,29.535 +2020-08-20 12:15:00,113.88,36.965,43.648999999999994,29.535 +2020-08-20 12:30:00,114.93,36.016,43.648999999999994,29.535 +2020-08-20 12:45:00,114.05,36.791,43.648999999999994,29.535 +2020-08-20 13:00:00,113.61,36.317,42.801,29.535 +2020-08-20 13:15:00,115.95,37.122,42.801,29.535 +2020-08-20 13:30:00,127.37,35.669000000000004,42.801,29.535 +2020-08-20 13:45:00,125.7,35.538000000000004,42.801,29.535 +2020-08-20 14:00:00,120.24,36.696999999999996,43.24,29.535 +2020-08-20 14:15:00,116.22,35.878,43.24,29.535 +2020-08-20 14:30:00,117.36,34.991,43.24,29.535 +2020-08-20 14:45:00,120.32,35.696,43.24,29.535 +2020-08-20 15:00:00,118.94,36.713,45.04600000000001,29.535 +2020-08-20 15:15:00,113.51,34.626999999999995,45.04600000000001,29.535 +2020-08-20 15:30:00,112.59,33.437,45.04600000000001,29.535 +2020-08-20 15:45:00,117.13,31.774,45.04600000000001,29.535 +2020-08-20 16:00:00,112.87,34.106,46.568000000000005,29.535 +2020-08-20 16:15:00,107.6,34.041,46.568000000000005,29.535 +2020-08-20 16:30:00,113.61,33.817,46.568000000000005,29.535 +2020-08-20 16:45:00,113.75,31.721,46.568000000000005,29.535 +2020-08-20 17:00:00,116.5,34.345,50.618,29.535 +2020-08-20 17:15:00,117.63,35.172,50.618,29.535 +2020-08-20 17:30:00,118.34,34.506,50.618,29.535 +2020-08-20 17:45:00,121.4,34.099000000000004,50.618,29.535 +2020-08-20 18:00:00,121.03,36.616,52.806999999999995,29.535 +2020-08-20 18:15:00,118.78,36.42,52.806999999999995,29.535 +2020-08-20 18:30:00,118.33,34.802,52.806999999999995,29.535 +2020-08-20 18:45:00,113.93,37.711,52.806999999999995,29.535 +2020-08-20 19:00:00,110.67,38.834,53.464,29.535 +2020-08-20 19:15:00,108.18,37.775999999999996,53.464,29.535 +2020-08-20 19:30:00,108.15,36.714,53.464,29.535 +2020-08-20 19:45:00,108.46,36.23,53.464,29.535 +2020-08-20 20:00:00,104.07,33.314,56.753,29.535 +2020-08-20 20:15:00,102.81,33.14,56.753,29.535 +2020-08-20 20:30:00,101.22,33.266999999999996,56.753,29.535 +2020-08-20 20:45:00,100.86,33.449,56.753,29.535 +2020-08-20 21:00:00,93.78,31.909000000000002,52.506,29.535 +2020-08-20 21:15:00,95.92,33.44,52.506,29.535 +2020-08-20 21:30:00,88.69,33.784,52.506,29.535 +2020-08-20 21:45:00,90.87,33.941,52.506,29.535 +2020-08-20 22:00:00,83.08,31.343000000000004,48.163000000000004,29.535 +2020-08-20 22:15:00,86.43,33.224000000000004,48.163000000000004,29.535 +2020-08-20 22:30:00,83.99,28.158,48.163000000000004,29.535 +2020-08-20 22:45:00,83.77,25.084,48.163000000000004,29.535 +2020-08-20 23:00:00,75.0,23.035,42.379,29.535 +2020-08-20 23:15:00,77.96,22.035,42.379,29.535 +2020-08-20 23:30:00,75.78,21.557,42.379,29.535 +2020-08-20 23:45:00,78.91,20.704,42.379,29.535 +2020-08-21 00:00:00,75.42,19.239,38.505,29.535 +2020-08-21 00:15:00,75.15,20.131,38.505,29.535 +2020-08-21 00:30:00,73.55,19.534000000000002,38.505,29.535 +2020-08-21 00:45:00,74.33,20.096,38.505,29.535 +2020-08-21 01:00:00,73.08,19.484,37.004,29.535 +2020-08-21 01:15:00,73.78,18.242,37.004,29.535 +2020-08-21 01:30:00,73.77,17.834,37.004,29.535 +2020-08-21 01:45:00,80.71,17.328,37.004,29.535 +2020-08-21 02:00:00,81.21,18.083,36.098,29.535 +2020-08-21 02:15:00,77.73,17.374000000000002,36.098,29.535 +2020-08-21 02:30:00,74.39,19.24,36.098,29.535 +2020-08-21 02:45:00,75.1,19.294,36.098,29.535 +2020-08-21 03:00:00,77.31,20.749000000000002,36.561,29.535 +2020-08-21 03:15:00,77.03,19.445,36.561,29.535 +2020-08-21 03:30:00,76.96,18.914,36.561,29.535 +2020-08-21 03:45:00,83.38,19.355,36.561,29.535 +2020-08-21 04:00:00,88.24,23.659000000000002,37.355,29.535 +2020-08-21 04:15:00,91.71,27.607,37.355,29.535 +2020-08-21 04:30:00,90.26,26.066,37.355,29.535 +2020-08-21 04:45:00,91.87,25.607,37.355,29.535 +2020-08-21 05:00:00,99.2,37.098,40.285,29.535 +2020-08-21 05:15:00,102.18,44.583,40.285,29.535 +2020-08-21 05:30:00,106.7,40.306,40.285,29.535 +2020-08-21 05:45:00,115.67,37.338,40.285,29.535 +2020-08-21 06:00:00,123.11,37.869,52.378,29.535 +2020-08-21 06:15:00,120.91,38.234,52.378,29.535 +2020-08-21 06:30:00,123.41,37.684,52.378,29.535 +2020-08-21 06:45:00,127.16,39.852,52.378,29.535 +2020-08-21 07:00:00,129.29,40.475,60.891999999999996,29.535 +2020-08-21 07:15:00,124.88,42.409,60.891999999999996,29.535 +2020-08-21 07:30:00,117.88,39.092,60.891999999999996,29.535 +2020-08-21 07:45:00,116.37,39.691,60.891999999999996,29.535 +2020-08-21 08:00:00,118.09,37.425,53.652,29.535 +2020-08-21 08:15:00,119.83,40.330999999999996,53.652,29.535 +2020-08-21 08:30:00,122.73,40.656,53.652,29.535 +2020-08-21 08:45:00,121.5,42.353,53.652,29.535 +2020-08-21 09:00:00,118.6,35.285,51.456,29.535 +2020-08-21 09:15:00,120.12,36.135999999999996,51.456,29.535 +2020-08-21 09:30:00,126.02,38.305,51.456,29.535 +2020-08-21 09:45:00,122.6,41.172,51.456,29.535 +2020-08-21 10:00:00,120.93,37.529,49.4,29.535 +2020-08-21 10:15:00,121.21,38.27,49.4,29.535 +2020-08-21 10:30:00,124.91,38.634,49.4,29.535 +2020-08-21 10:45:00,126.82,39.759,49.4,29.535 +2020-08-21 11:00:00,123.43,38.259,48.773,29.535 +2020-08-21 11:15:00,121.31,38.306,48.773,29.535 +2020-08-21 11:30:00,114.97,38.778,48.773,29.535 +2020-08-21 11:45:00,117.42,38.736,48.773,29.535 +2020-08-21 12:00:00,122.33,35.384,46.033,29.535 +2020-08-21 12:15:00,112.57,34.199,46.033,29.535 +2020-08-21 12:30:00,103.79,33.37,46.033,29.535 +2020-08-21 12:45:00,111.23,33.400999999999996,46.033,29.535 +2020-08-21 13:00:00,109.6,33.441,44.38399999999999,29.535 +2020-08-21 13:15:00,108.67,34.389,44.38399999999999,29.535 +2020-08-21 13:30:00,105.43,33.662,44.38399999999999,29.535 +2020-08-21 13:45:00,105.72,33.823,44.38399999999999,29.535 +2020-08-21 14:00:00,109.3,34.238,43.162,29.535 +2020-08-21 14:15:00,119.63,33.829,43.162,29.535 +2020-08-21 14:30:00,116.81,34.333,43.162,29.535 +2020-08-21 14:45:00,115.99,34.4,43.162,29.535 +2020-08-21 15:00:00,108.46,35.311,44.91,29.535 +2020-08-21 15:15:00,102.35,33.022,44.91,29.535 +2020-08-21 15:30:00,102.98,31.35,44.91,29.535 +2020-08-21 15:45:00,109.7,30.392,44.91,29.535 +2020-08-21 16:00:00,115.52,31.933000000000003,47.489,29.535 +2020-08-21 16:15:00,117.53,32.324,47.489,29.535 +2020-08-21 16:30:00,114.19,31.936,47.489,29.535 +2020-08-21 16:45:00,114.63,29.162,47.489,29.535 +2020-08-21 17:00:00,114.87,33.342,52.047,29.535 +2020-08-21 17:15:00,112.6,34.046,52.047,29.535 +2020-08-21 17:30:00,111.56,33.547,52.047,29.535 +2020-08-21 17:45:00,119.81,33.025999999999996,52.047,29.535 +2020-08-21 18:00:00,118.61,35.549,53.306000000000004,29.535 +2020-08-21 18:15:00,111.71,34.506,53.306000000000004,29.535 +2020-08-21 18:30:00,109.02,32.798,53.306000000000004,29.535 +2020-08-21 18:45:00,109.32,36.089,53.306000000000004,29.535 +2020-08-21 19:00:00,104.75,37.998000000000005,53.516000000000005,29.535 +2020-08-21 19:15:00,103.22,37.463,53.516000000000005,29.535 +2020-08-21 19:30:00,105.19,36.463,53.516000000000005,29.535 +2020-08-21 19:45:00,109.91,35.064,53.516000000000005,29.535 +2020-08-21 20:00:00,108.19,32.015,57.88,29.535 +2020-08-21 20:15:00,105.08,32.567,57.88,29.535 +2020-08-21 20:30:00,99.01,32.235,57.88,29.535 +2020-08-21 20:45:00,95.41,31.636,57.88,29.535 +2020-08-21 21:00:00,90.21,31.29,53.32,29.535 +2020-08-21 21:15:00,89.55,34.330999999999996,53.32,29.535 +2020-08-21 21:30:00,92.66,34.534,53.32,29.535 +2020-08-21 21:45:00,91.64,34.841,53.32,29.535 +2020-08-21 22:00:00,87.68,32.082,48.074,29.535 +2020-08-21 22:15:00,84.08,33.724000000000004,48.074,29.535 +2020-08-21 22:30:00,86.02,32.883,48.074,29.535 +2020-08-21 22:45:00,85.3,30.55,48.074,29.535 +2020-08-21 23:00:00,77.83,30.086,41.306999999999995,29.535 +2020-08-21 23:15:00,71.95,27.67,41.306999999999995,29.535 +2020-08-21 23:30:00,73.74,25.559,41.306999999999995,29.535 +2020-08-21 23:45:00,71.29,24.608,41.306999999999995,29.535 +2020-08-22 00:00:00,69.81,20.534000000000002,40.227,29.423000000000002 +2020-08-22 00:15:00,69.02,20.857,40.227,29.423000000000002 +2020-08-22 00:30:00,67.89,19.826,40.227,29.423000000000002 +2020-08-22 00:45:00,72.34,19.723,40.227,29.423000000000002 +2020-08-22 01:00:00,75.79,19.339000000000002,36.303000000000004,29.423000000000002 +2020-08-22 01:15:00,76.02,18.655,36.303000000000004,29.423000000000002 +2020-08-22 01:30:00,75.61,17.533,36.303000000000004,29.423000000000002 +2020-08-22 01:45:00,73.35,18.16,36.303000000000004,29.423000000000002 +2020-08-22 02:00:00,74.97,18.018,33.849000000000004,29.423000000000002 +2020-08-22 02:15:00,75.4,16.616,33.849000000000004,29.423000000000002 +2020-08-22 02:30:00,69.28,17.753,33.849000000000004,29.423000000000002 +2020-08-22 02:45:00,66.05,18.517,33.849000000000004,29.423000000000002 +2020-08-22 03:00:00,72.84,18.707,33.149,29.423000000000002 +2020-08-22 03:15:00,73.03,16.8,33.149,29.423000000000002 +2020-08-22 03:30:00,67.68,16.592,33.149,29.423000000000002 +2020-08-22 03:45:00,66.88,18.373,33.149,29.423000000000002 +2020-08-22 04:00:00,69.12,21.037,32.501,29.423000000000002 +2020-08-22 04:15:00,67.9,24.219,32.501,29.423000000000002 +2020-08-22 04:30:00,68.89,21.195999999999998,32.501,29.423000000000002 +2020-08-22 04:45:00,75.53,20.994,32.501,29.423000000000002 +2020-08-22 05:00:00,76.39,25.175,31.648000000000003,29.423000000000002 +2020-08-22 05:15:00,77.34,23.013,31.648000000000003,29.423000000000002 +2020-08-22 05:30:00,70.85,20.104,31.648000000000003,29.423000000000002 +2020-08-22 05:45:00,75.88,21.084,31.648000000000003,29.423000000000002 +2020-08-22 06:00:00,79.9,31.51,32.552,29.423000000000002 +2020-08-22 06:15:00,81.93,38.769,32.552,29.423000000000002 +2020-08-22 06:30:00,78.71,35.705999999999996,32.552,29.423000000000002 +2020-08-22 06:45:00,78.42,35.068000000000005,32.552,29.423000000000002 +2020-08-22 07:00:00,81.49,34.53,35.181999999999995,29.423000000000002 +2020-08-22 07:15:00,83.52,35.321999999999996,35.181999999999995,29.423000000000002 +2020-08-22 07:30:00,88.71,33.359,35.181999999999995,29.423000000000002 +2020-08-22 07:45:00,83.28,34.743,35.181999999999995,29.423000000000002 +2020-08-22 08:00:00,87.36,33.074,40.35,29.423000000000002 +2020-08-22 08:15:00,83.56,35.699,40.35,29.423000000000002 +2020-08-22 08:30:00,83.76,35.982,40.35,29.423000000000002 +2020-08-22 08:45:00,86.8,38.474000000000004,40.35,29.423000000000002 +2020-08-22 09:00:00,91.83,34.203,42.292,29.423000000000002 +2020-08-22 09:15:00,94.77,35.471,42.292,29.423000000000002 +2020-08-22 09:30:00,93.76,38.058,42.292,29.423000000000002 +2020-08-22 09:45:00,86.84,40.493,42.292,29.423000000000002 +2020-08-22 10:00:00,92.6,37.429,40.084,29.423000000000002 +2020-08-22 10:15:00,82.43,38.467,40.084,29.423000000000002 +2020-08-22 10:30:00,84.17,38.509,40.084,29.423000000000002 +2020-08-22 10:45:00,83.75,39.282,40.084,29.423000000000002 +2020-08-22 11:00:00,82.45,37.674,36.966,29.423000000000002 +2020-08-22 11:15:00,78.36,38.518,36.966,29.423000000000002 +2020-08-22 11:30:00,80.55,39.266,36.966,29.423000000000002 +2020-08-22 11:45:00,84.46,39.841,36.966,29.423000000000002 +2020-08-22 12:00:00,80.83,36.912,35.19,29.423000000000002 +2020-08-22 12:15:00,81.73,36.481,35.19,29.423000000000002 +2020-08-22 12:30:00,83.66,35.539,35.19,29.423000000000002 +2020-08-22 12:45:00,83.6,36.244,35.19,29.423000000000002 +2020-08-22 13:00:00,80.22,35.459,32.277,29.423000000000002 +2020-08-22 13:15:00,81.83,35.946,32.277,29.423000000000002 +2020-08-22 13:30:00,79.68,35.398,32.277,29.423000000000002 +2020-08-22 13:45:00,76.05,34.327,32.277,29.423000000000002 +2020-08-22 14:00:00,76.01,34.762,31.436999999999998,29.423000000000002 +2020-08-22 14:15:00,76.05,33.229,31.436999999999998,29.423000000000002 +2020-08-22 14:30:00,73.17,33.400999999999996,31.436999999999998,29.423000000000002 +2020-08-22 14:45:00,73.18,33.904,31.436999999999998,29.423000000000002 +2020-08-22 15:00:00,73.62,35.177,33.493,29.423000000000002 +2020-08-22 15:15:00,75.64,33.522,33.493,29.423000000000002 +2020-08-22 15:30:00,75.93,32.013000000000005,33.493,29.423000000000002 +2020-08-22 15:45:00,75.66,30.217,33.493,29.423000000000002 +2020-08-22 16:00:00,74.81,33.667,36.593,29.423000000000002 +2020-08-22 16:15:00,75.25,33.234,36.593,29.423000000000002 +2020-08-22 16:30:00,75.96,33.058,36.593,29.423000000000002 +2020-08-22 16:45:00,78.59,30.281,36.593,29.423000000000002 +2020-08-22 17:00:00,80.01,33.37,42.049,29.423000000000002 +2020-08-22 17:15:00,81.17,32.102,42.049,29.423000000000002 +2020-08-22 17:30:00,80.84,31.496,42.049,29.423000000000002 +2020-08-22 17:45:00,79.99,31.468000000000004,42.049,29.423000000000002 +2020-08-22 18:00:00,83.24,35.213,43.755,29.423000000000002 +2020-08-22 18:15:00,78.95,35.67,43.755,29.423000000000002 +2020-08-22 18:30:00,78.74,35.183,43.755,29.423000000000002 +2020-08-22 18:45:00,79.68,35.374,43.755,29.423000000000002 +2020-08-22 19:00:00,79.36,35.782,44.492,29.423000000000002 +2020-08-22 19:15:00,78.0,34.344,44.492,29.423000000000002 +2020-08-22 19:30:00,78.76,34.03,44.492,29.423000000000002 +2020-08-22 19:45:00,78.12,34.227,44.492,29.423000000000002 +2020-08-22 20:00:00,75.97,31.935,40.896,29.423000000000002 +2020-08-22 20:15:00,75.27,31.884,40.896,29.423000000000002 +2020-08-22 20:30:00,77.26,30.746,40.896,29.423000000000002 +2020-08-22 20:45:00,74.04,31.799,40.896,29.423000000000002 +2020-08-22 21:00:00,72.99,30.090999999999998,39.056,29.423000000000002 +2020-08-22 21:15:00,69.37,32.803000000000004,39.056,29.423000000000002 +2020-08-22 21:30:00,65.99,33.161,39.056,29.423000000000002 +2020-08-22 21:45:00,65.64,32.949,39.056,29.423000000000002 +2020-08-22 22:00:00,62.66,30.133000000000003,38.478,29.423000000000002 +2020-08-22 22:15:00,62.96,31.954,38.478,29.423000000000002 +2020-08-22 22:30:00,60.65,30.666999999999998,38.478,29.423000000000002 +2020-08-22 22:45:00,60.45,28.648000000000003,38.478,29.423000000000002 +2020-08-22 23:00:00,57.7,27.596,32.953,29.423000000000002 +2020-08-22 23:15:00,56.86,25.565,32.953,29.423000000000002 +2020-08-22 23:30:00,56.41,25.7,32.953,29.423000000000002 +2020-08-22 23:45:00,56.12,25.105,32.953,29.423000000000002 +2020-08-23 00:00:00,54.05,21.918000000000003,28.584,29.423000000000002 +2020-08-23 00:15:00,54.14,21.205,28.584,29.423000000000002 +2020-08-23 00:30:00,51.82,20.051,28.584,29.423000000000002 +2020-08-23 00:45:00,52.75,19.846,28.584,29.423000000000002 +2020-08-23 01:00:00,50.79,19.685,26.419,29.423000000000002 +2020-08-23 01:15:00,51.91,18.892,26.419,29.423000000000002 +2020-08-23 01:30:00,52.0,17.651,26.419,29.423000000000002 +2020-08-23 01:45:00,51.21,17.939,26.419,29.423000000000002 +2020-08-23 02:00:00,49.98,17.88,25.335,29.423000000000002 +2020-08-23 02:15:00,49.28,17.148,25.335,29.423000000000002 +2020-08-23 02:30:00,49.62,18.421,25.335,29.423000000000002 +2020-08-23 02:45:00,50.01,18.914,25.335,29.423000000000002 +2020-08-23 03:00:00,49.63,19.677,24.805,29.423000000000002 +2020-08-23 03:15:00,50.61,18.027,24.805,29.423000000000002 +2020-08-23 03:30:00,51.05,17.143,24.805,29.423000000000002 +2020-08-23 03:45:00,51.04,18.202,24.805,29.423000000000002 +2020-08-23 04:00:00,52.83,20.862,25.772,29.423000000000002 +2020-08-23 04:15:00,53.3,23.68,25.772,29.423000000000002 +2020-08-23 04:30:00,53.83,21.873,25.772,29.423000000000002 +2020-08-23 04:45:00,55.23,21.249000000000002,25.772,29.423000000000002 +2020-08-23 05:00:00,53.76,25.686999999999998,25.971999999999998,29.423000000000002 +2020-08-23 05:15:00,53.31,22.923000000000002,25.971999999999998,29.423000000000002 +2020-08-23 05:30:00,52.98,19.655,25.971999999999998,29.423000000000002 +2020-08-23 05:45:00,54.2,20.352,25.971999999999998,29.423000000000002 +2020-08-23 06:00:00,55.52,28.631999999999998,26.026,29.423000000000002 +2020-08-23 06:15:00,56.17,36.676,26.026,29.423000000000002 +2020-08-23 06:30:00,57.6,32.996,26.026,29.423000000000002 +2020-08-23 06:45:00,58.37,31.48,26.026,29.423000000000002 +2020-08-23 07:00:00,60.01,31.19,27.396,29.423000000000002 +2020-08-23 07:15:00,59.93,30.531,27.396,29.423000000000002 +2020-08-23 07:30:00,61.88,29.76,27.396,29.423000000000002 +2020-08-23 07:45:00,62.61,31.128,27.396,29.423000000000002 +2020-08-23 08:00:00,63.28,30.125999999999998,30.791999999999998,29.423000000000002 +2020-08-23 08:15:00,61.72,33.788000000000004,30.791999999999998,29.423000000000002 +2020-08-23 08:30:00,60.05,34.823,30.791999999999998,29.423000000000002 +2020-08-23 08:45:00,60.37,37.19,30.791999999999998,29.423000000000002 +2020-08-23 09:00:00,57.88,32.838,32.482,29.423000000000002 +2020-08-23 09:15:00,58.54,33.64,32.482,29.423000000000002 +2020-08-23 09:30:00,61.36,36.603,32.482,29.423000000000002 +2020-08-23 09:45:00,64.5,39.924,32.482,29.423000000000002 +2020-08-23 10:00:00,65.45,37.297,31.951,29.423000000000002 +2020-08-23 10:15:00,63.8,38.42,31.951,29.423000000000002 +2020-08-23 10:30:00,69.36,38.641,31.951,29.423000000000002 +2020-08-23 10:45:00,69.5,40.378,31.951,29.423000000000002 +2020-08-23 11:00:00,68.42,38.444,33.619,29.423000000000002 +2020-08-23 11:15:00,68.97,38.867,33.619,29.423000000000002 +2020-08-23 11:30:00,65.45,40.113,33.619,29.423000000000002 +2020-08-23 11:45:00,65.73,40.89,33.619,29.423000000000002 +2020-08-23 12:00:00,62.07,38.953,30.975,29.423000000000002 +2020-08-23 12:15:00,60.77,37.865,30.975,29.423000000000002 +2020-08-23 12:30:00,57.24,37.19,30.975,29.423000000000002 +2020-08-23 12:45:00,59.1,37.333,30.975,29.423000000000002 +2020-08-23 13:00:00,54.41,36.266999999999996,27.956999999999997,29.423000000000002 +2020-08-23 13:15:00,56.3,36.116,27.956999999999997,29.423000000000002 +2020-08-23 13:30:00,54.14,34.552,27.956999999999997,29.423000000000002 +2020-08-23 13:45:00,57.34,34.536,27.956999999999997,29.423000000000002 +2020-08-23 14:00:00,54.95,36.039,25.555999999999997,29.423000000000002 +2020-08-23 14:15:00,55.93,34.866,25.555999999999997,29.423000000000002 +2020-08-23 14:30:00,53.34,33.878,25.555999999999997,29.423000000000002 +2020-08-23 14:45:00,52.83,33.415,25.555999999999997,29.423000000000002 +2020-08-23 15:00:00,52.56,34.832,26.271,29.423000000000002 +2020-08-23 15:15:00,52.26,32.414,26.271,29.423000000000002 +2020-08-23 15:30:00,53.99,30.730999999999998,26.271,29.423000000000002 +2020-08-23 15:45:00,55.72,29.178,26.271,29.423000000000002 +2020-08-23 16:00:00,60.31,31.070999999999998,30.369,29.423000000000002 +2020-08-23 16:15:00,60.83,30.884,30.369,29.423000000000002 +2020-08-23 16:30:00,63.75,31.62,30.369,29.423000000000002 +2020-08-23 16:45:00,67.7,28.939,30.369,29.423000000000002 +2020-08-23 17:00:00,71.4,32.325,38.787,29.423000000000002 +2020-08-23 17:15:00,73.13,32.479,38.787,29.423000000000002 +2020-08-23 17:30:00,74.84,32.615,38.787,29.423000000000002 +2020-08-23 17:45:00,76.19,32.934,38.787,29.423000000000002 +2020-08-23 18:00:00,78.8,37.244,41.886,29.423000000000002 +2020-08-23 18:15:00,78.18,37.311,41.886,29.423000000000002 +2020-08-23 18:30:00,76.17,36.661,41.886,29.423000000000002 +2020-08-23 18:45:00,77.31,36.91,41.886,29.423000000000002 +2020-08-23 19:00:00,79.28,39.316,42.91,29.423000000000002 +2020-08-23 19:15:00,79.78,36.865,42.91,29.423000000000002 +2020-08-23 19:30:00,81.08,36.332,42.91,29.423000000000002 +2020-08-23 19:45:00,80.3,36.093,42.91,29.423000000000002 +2020-08-23 20:00:00,79.94,33.946999999999996,42.148999999999994,29.423000000000002 +2020-08-23 20:15:00,79.46,33.751,42.148999999999994,29.423000000000002 +2020-08-23 20:30:00,78.65,33.303000000000004,42.148999999999994,29.423000000000002 +2020-08-23 20:45:00,78.43,32.828,42.148999999999994,29.423000000000002 +2020-08-23 21:00:00,77.51,31.070999999999998,40.955999999999996,29.423000000000002 +2020-08-23 21:15:00,78.17,33.514,40.955999999999996,29.423000000000002 +2020-08-23 21:30:00,74.81,33.278,40.955999999999996,29.423000000000002 +2020-08-23 21:45:00,74.82,33.356,40.955999999999996,29.423000000000002 +2020-08-23 22:00:00,70.62,32.385999999999996,39.873000000000005,29.423000000000002 +2020-08-23 22:15:00,71.54,32.687,39.873000000000005,29.423000000000002 +2020-08-23 22:30:00,70.08,30.976999999999997,39.873000000000005,29.423000000000002 +2020-08-23 22:45:00,70.5,27.828000000000003,39.873000000000005,29.423000000000002 +2020-08-23 23:00:00,65.77,26.604,35.510999999999996,29.423000000000002 +2020-08-23 23:15:00,68.03,25.668000000000003,35.510999999999996,29.423000000000002 +2020-08-23 23:30:00,68.09,25.31,35.510999999999996,29.423000000000002 +2020-08-23 23:45:00,67.17,24.844,35.510999999999996,29.423000000000002 +2020-08-24 00:00:00,61.92,23.346999999999998,33.475,29.535 +2020-08-24 00:15:00,61.51,23.287,33.475,29.535 +2020-08-24 00:30:00,62.23,21.79,33.475,29.535 +2020-08-24 00:45:00,64.91,21.246,33.475,29.535 +2020-08-24 01:00:00,63.88,21.413,33.111,29.535 +2020-08-24 01:15:00,64.28,20.656,33.111,29.535 +2020-08-24 01:30:00,63.48,19.754,33.111,29.535 +2020-08-24 01:45:00,64.2,19.951,33.111,29.535 +2020-08-24 02:00:00,63.59,20.304000000000002,32.358000000000004,29.535 +2020-08-24 02:15:00,65.17,18.646,32.358000000000004,29.535 +2020-08-24 02:30:00,66.78,20.039,32.358000000000004,29.535 +2020-08-24 02:45:00,69.86,20.409000000000002,32.358000000000004,29.535 +2020-08-24 03:00:00,74.42,21.589000000000002,30.779,29.535 +2020-08-24 03:15:00,75.12,20.570999999999998,30.779,29.535 +2020-08-24 03:30:00,69.75,20.315,30.779,29.535 +2020-08-24 03:45:00,71.44,20.962,30.779,29.535 +2020-08-24 04:00:00,77.38,26.358,31.416,29.535 +2020-08-24 04:15:00,84.0,31.753,31.416,29.535 +2020-08-24 04:30:00,88.97,29.471,31.416,29.535 +2020-08-24 04:45:00,90.63,29.182,31.416,29.535 +2020-08-24 05:00:00,92.59,39.953,37.221,29.535 +2020-08-24 05:15:00,98.51,46.222,37.221,29.535 +2020-08-24 05:30:00,97.7,41.567,37.221,29.535 +2020-08-24 05:45:00,106.99,39.55,37.221,29.535 +2020-08-24 06:00:00,114.75,38.809,51.891000000000005,29.535 +2020-08-24 06:15:00,115.62,38.935,51.891000000000005,29.535 +2020-08-24 06:30:00,114.57,38.699,51.891000000000005,29.535 +2020-08-24 06:45:00,111.02,41.791000000000004,51.891000000000005,29.535 +2020-08-24 07:00:00,116.17,41.681999999999995,62.282,29.535 +2020-08-24 07:15:00,117.57,43.071000000000005,62.282,29.535 +2020-08-24 07:30:00,116.26,41.563,62.282,29.535 +2020-08-24 07:45:00,111.86,43.251999999999995,62.282,29.535 +2020-08-24 08:00:00,112.24,40.347,54.102,29.535 +2020-08-24 08:15:00,110.91,42.93600000000001,54.102,29.535 +2020-08-24 08:30:00,107.9,43.183,54.102,29.535 +2020-08-24 08:45:00,106.7,45.798,54.102,29.535 +2020-08-24 09:00:00,103.97,40.516999999999996,50.917,29.535 +2020-08-24 09:15:00,112.8,39.77,50.917,29.535 +2020-08-24 09:30:00,109.89,41.897,50.917,29.535 +2020-08-24 09:45:00,115.07,43.108000000000004,50.917,29.535 +2020-08-24 10:00:00,107.02,40.842,49.718999999999994,29.535 +2020-08-24 10:15:00,116.52,41.815,49.718999999999994,29.535 +2020-08-24 10:30:00,119.9,41.635,49.718999999999994,29.535 +2020-08-24 10:45:00,119.31,41.896,49.718999999999994,29.535 +2020-08-24 11:00:00,116.15,40.231,49.833999999999996,29.535 +2020-08-24 11:15:00,121.04,40.848,49.833999999999996,29.535 +2020-08-24 11:30:00,121.33,42.687,49.833999999999996,29.535 +2020-08-24 11:45:00,120.83,43.92,49.833999999999996,29.535 +2020-08-24 12:00:00,115.38,40.277,47.832,29.535 +2020-08-24 12:15:00,118.08,39.281,47.832,29.535 +2020-08-24 12:30:00,110.62,37.626999999999995,47.832,29.535 +2020-08-24 12:45:00,109.16,37.7,47.832,29.535 +2020-08-24 13:00:00,106.38,37.482,48.03,29.535 +2020-08-24 13:15:00,100.21,36.554,48.03,29.535 +2020-08-24 13:30:00,103.29,35.159,48.03,29.535 +2020-08-24 13:45:00,105.95,35.975,48.03,29.535 +2020-08-24 14:00:00,103.3,36.626999999999995,48.157,29.535 +2020-08-24 14:15:00,99.66,36.027,48.157,29.535 +2020-08-24 14:30:00,103.17,34.923,48.157,29.535 +2020-08-24 14:45:00,110.25,36.366,48.157,29.535 +2020-08-24 15:00:00,115.96,37.335,48.897,29.535 +2020-08-24 15:15:00,112.33,34.433,48.897,29.535 +2020-08-24 15:30:00,104.1,33.501,48.897,29.535 +2020-08-24 15:45:00,99.37,31.523000000000003,48.897,29.535 +2020-08-24 16:00:00,108.59,34.434,51.446000000000005,29.535 +2020-08-24 16:15:00,110.63,34.338,51.446000000000005,29.535 +2020-08-24 16:30:00,113.83,34.496,51.446000000000005,29.535 +2020-08-24 16:45:00,113.22,31.912,51.446000000000005,29.535 +2020-08-24 17:00:00,108.34,34.234,57.507,29.535 +2020-08-24 17:15:00,106.39,34.789,57.507,29.535 +2020-08-24 17:30:00,107.38,34.6,57.507,29.535 +2020-08-24 17:45:00,112.93,34.635999999999996,57.507,29.535 +2020-08-24 18:00:00,113.44,37.981,57.896,29.535 +2020-08-24 18:15:00,110.9,36.451,57.896,29.535 +2020-08-24 18:30:00,108.43,35.108000000000004,57.896,29.535 +2020-08-24 18:45:00,106.18,38.216,57.896,29.535 +2020-08-24 19:00:00,105.21,40.431,57.891999999999996,29.535 +2020-08-24 19:15:00,109.56,39.202,57.891999999999996,29.535 +2020-08-24 19:30:00,106.94,38.328,57.891999999999996,29.535 +2020-08-24 19:45:00,101.03,37.554,57.891999999999996,29.535 +2020-08-24 20:00:00,100.29,34.311,64.57300000000001,29.535 +2020-08-24 20:15:00,98.55,35.45,64.57300000000001,29.535 +2020-08-24 20:30:00,97.25,35.505,64.57300000000001,29.535 +2020-08-24 20:45:00,101.29,35.153,64.57300000000001,29.535 +2020-08-24 21:00:00,99.14,32.809,59.431999999999995,29.535 +2020-08-24 21:15:00,93.54,35.671,59.431999999999995,29.535 +2020-08-24 21:30:00,84.27,35.83,59.431999999999995,29.535 +2020-08-24 21:45:00,83.92,35.689,59.431999999999995,29.535 +2020-08-24 22:00:00,75.13,32.900999999999996,51.519,29.535 +2020-08-24 22:15:00,79.97,35.023,51.519,29.535 +2020-08-24 22:30:00,74.93,29.565,51.519,29.535 +2020-08-24 22:45:00,75.97,26.509,51.519,29.535 +2020-08-24 23:00:00,72.01,25.331,44.501000000000005,29.535 +2020-08-24 23:15:00,73.98,22.824,44.501000000000005,29.535 +2020-08-24 23:30:00,73.76,22.38,44.501000000000005,29.535 +2020-08-24 23:45:00,72.64,21.371,44.501000000000005,29.535 +2020-08-25 00:00:00,71.72,21.445,44.522,29.535 +2020-08-25 00:15:00,71.62,22.142,44.522,29.535 +2020-08-25 00:30:00,70.03,21.316999999999997,44.522,29.535 +2020-08-25 00:45:00,71.88,21.523000000000003,44.522,29.535 +2020-08-25 01:00:00,71.19,21.183000000000003,41.441,29.535 +2020-08-25 01:15:00,72.36,20.55,41.441,29.535 +2020-08-25 01:30:00,70.95,19.538,41.441,29.535 +2020-08-25 01:45:00,71.76,19.262,41.441,29.535 +2020-08-25 02:00:00,70.7,19.179000000000002,40.203,29.535 +2020-08-25 02:15:00,69.84,18.6,40.203,29.535 +2020-08-25 02:30:00,70.82,19.628,40.203,29.535 +2020-08-25 02:45:00,70.92,20.293,40.203,29.535 +2020-08-25 03:00:00,72.33,20.983,39.536,29.535 +2020-08-25 03:15:00,72.62,20.854,39.536,29.535 +2020-08-25 03:30:00,75.29,20.564,39.536,29.535 +2020-08-25 03:45:00,77.05,20.184,39.536,29.535 +2020-08-25 04:00:00,84.41,24.456,40.759,29.535 +2020-08-25 04:15:00,89.97,29.886,40.759,29.535 +2020-08-25 04:30:00,92.14,27.509,40.759,29.535 +2020-08-25 04:45:00,92.03,27.641,40.759,29.535 +2020-08-25 05:00:00,99.0,39.819,43.623999999999995,29.535 +2020-08-25 05:15:00,107.55,46.723,43.623999999999995,29.535 +2020-08-25 05:30:00,109.53,42.256,43.623999999999995,29.535 +2020-08-25 05:45:00,114.17,39.54,43.623999999999995,29.535 +2020-08-25 06:00:00,113.12,39.746,52.684,29.535 +2020-08-25 06:15:00,114.58,40.044000000000004,52.684,29.535 +2020-08-25 06:30:00,116.2,39.524,52.684,29.535 +2020-08-25 06:45:00,116.67,41.817,52.684,29.535 +2020-08-25 07:00:00,123.26,41.85,62.676,29.535 +2020-08-25 07:15:00,127.76,43.023,62.676,29.535 +2020-08-25 07:30:00,128.42,41.568000000000005,62.676,29.535 +2020-08-25 07:45:00,121.39,42.361999999999995,62.676,29.535 +2020-08-25 08:00:00,117.12,39.391,56.161,29.535 +2020-08-25 08:15:00,112.74,41.563,56.161,29.535 +2020-08-25 08:30:00,113.5,41.963,56.161,29.535 +2020-08-25 08:45:00,116.16,43.723,56.161,29.535 +2020-08-25 09:00:00,118.01,38.784,52.132,29.535 +2020-08-25 09:15:00,126.14,37.858000000000004,52.132,29.535 +2020-08-25 09:30:00,124.08,40.582,52.132,29.535 +2020-08-25 09:45:00,118.84,43.004,52.132,29.535 +2020-08-25 10:00:00,117.53,39.493,51.032,29.535 +2020-08-25 10:15:00,121.68,40.346,51.032,29.535 +2020-08-25 10:30:00,120.94,40.177,51.032,29.535 +2020-08-25 10:45:00,113.26,41.349,51.032,29.535 +2020-08-25 11:00:00,109.61,39.652,51.085,29.535 +2020-08-25 11:15:00,117.1,40.645,51.085,29.535 +2020-08-25 11:30:00,122.66,41.457,51.085,29.535 +2020-08-25 11:45:00,124.89,42.236000000000004,51.085,29.535 +2020-08-25 12:00:00,115.6,38.455,49.049,29.535 +2020-08-25 12:15:00,113.66,37.78,49.049,29.535 +2020-08-25 12:30:00,120.05,36.912,49.049,29.535 +2020-08-25 12:45:00,118.16,37.631,49.049,29.535 +2020-08-25 13:00:00,117.18,37.067,49.722,29.535 +2020-08-25 13:15:00,108.95,37.815,49.722,29.535 +2020-08-25 13:30:00,104.14,36.343,49.722,29.535 +2020-08-25 13:45:00,104.85,36.264,49.722,29.535 +2020-08-25 14:00:00,109.46,37.303000000000004,49.565,29.535 +2020-08-25 14:15:00,108.21,36.518,49.565,29.535 +2020-08-25 14:30:00,106.98,35.711,49.565,29.535 +2020-08-25 14:45:00,101.38,36.424,49.565,29.535 +2020-08-25 15:00:00,98.49,37.22,51.108999999999995,29.535 +2020-08-25 15:15:00,103.6,35.174,51.108999999999995,29.535 +2020-08-25 15:30:00,106.69,34.051,51.108999999999995,29.535 +2020-08-25 15:45:00,107.37,32.419000000000004,51.108999999999995,29.535 +2020-08-25 16:00:00,106.02,34.625,52.725,29.535 +2020-08-25 16:15:00,105.04,34.601,52.725,29.535 +2020-08-25 16:30:00,111.46,34.395,52.725,29.535 +2020-08-25 16:45:00,113.59,32.505,52.725,29.535 +2020-08-25 17:00:00,115.04,34.974000000000004,58.031000000000006,29.535 +2020-08-25 17:15:00,109.32,35.943000000000005,58.031000000000006,29.535 +2020-08-25 17:30:00,115.28,35.338,58.031000000000006,29.535 +2020-08-25 17:45:00,115.65,35.104,58.031000000000006,29.535 +2020-08-25 18:00:00,116.18,37.575,58.338,29.535 +2020-08-25 18:15:00,111.28,37.442,58.338,29.535 +2020-08-25 18:30:00,110.08,35.876999999999995,58.338,29.535 +2020-08-25 18:45:00,108.95,38.782,58.338,29.535 +2020-08-25 19:00:00,112.98,39.938,58.464,29.535 +2020-08-25 19:15:00,115.45,38.88,58.464,29.535 +2020-08-25 19:30:00,112.47,37.827,58.464,29.535 +2020-08-25 19:45:00,107.22,37.361999999999995,58.464,29.535 +2020-08-25 20:00:00,101.92,34.48,63.708,29.535 +2020-08-25 20:15:00,107.45,34.316,63.708,29.535 +2020-08-25 20:30:00,106.27,34.363,63.708,29.535 +2020-08-25 20:45:00,103.99,34.378,63.708,29.535 +2020-08-25 21:00:00,94.4,32.854,57.06399999999999,29.535 +2020-08-25 21:15:00,95.17,34.338,57.06399999999999,29.535 +2020-08-25 21:30:00,89.29,34.676,57.06399999999999,29.535 +2020-08-25 21:45:00,87.19,34.677,57.06399999999999,29.535 +2020-08-25 22:00:00,78.39,31.979,52.831,29.535 +2020-08-25 22:15:00,81.0,33.783,52.831,29.535 +2020-08-25 22:30:00,76.91,28.561,52.831,29.535 +2020-08-25 22:45:00,80.86,25.493000000000002,52.831,29.535 +2020-08-25 23:00:00,74.5,23.654,44.717,29.535 +2020-08-25 23:15:00,72.88,22.587,44.717,29.535 +2020-08-25 23:30:00,72.05,22.16,44.717,29.535 +2020-08-25 23:45:00,72.32,21.335,44.717,29.535 +2020-08-26 00:00:00,71.27,21.596,41.263000000000005,29.535 +2020-08-26 00:15:00,72.44,22.294,41.263000000000005,29.535 +2020-08-26 00:30:00,72.68,21.475,41.263000000000005,29.535 +2020-08-26 00:45:00,73.43,21.688000000000002,41.263000000000005,29.535 +2020-08-26 01:00:00,69.7,21.326,38.448,29.535 +2020-08-26 01:15:00,71.13,20.708000000000002,38.448,29.535 +2020-08-26 01:30:00,67.88,19.711,38.448,29.535 +2020-08-26 01:45:00,71.52,19.434,38.448,29.535 +2020-08-26 02:00:00,69.26,19.352999999999998,36.471,29.535 +2020-08-26 02:15:00,69.62,18.797,36.471,29.535 +2020-08-26 02:30:00,70.17,19.801,36.471,29.535 +2020-08-26 02:45:00,71.2,20.463,36.471,29.535 +2020-08-26 03:00:00,72.48,21.142,36.042,29.535 +2020-08-26 03:15:00,72.93,21.028000000000002,36.042,29.535 +2020-08-26 03:30:00,74.6,20.743000000000002,36.042,29.535 +2020-08-26 03:45:00,77.43,20.357,36.042,29.535 +2020-08-26 04:00:00,82.27,24.653000000000002,36.705,29.535 +2020-08-26 04:15:00,88.85,30.103,36.705,29.535 +2020-08-26 04:30:00,91.76,27.734,36.705,29.535 +2020-08-26 04:45:00,93.35,27.868000000000002,36.705,29.535 +2020-08-26 05:00:00,101.37,40.118,39.716,29.535 +2020-08-26 05:15:00,108.33,47.097,39.716,29.535 +2020-08-26 05:30:00,110.12,42.623000000000005,39.716,29.535 +2020-08-26 05:45:00,111.53,39.868,39.716,29.535 +2020-08-26 06:00:00,112.68,40.042,52.756,29.535 +2020-08-26 06:15:00,116.55,40.361999999999995,52.756,29.535 +2020-08-26 06:30:00,120.01,39.836999999999996,52.756,29.535 +2020-08-26 06:45:00,121.27,42.13,52.756,29.535 +2020-08-26 07:00:00,119.57,42.163000000000004,65.977,29.535 +2020-08-26 07:15:00,114.72,43.35,65.977,29.535 +2020-08-26 07:30:00,121.57,41.919,65.977,29.535 +2020-08-26 07:45:00,118.18,42.715,65.977,29.535 +2020-08-26 08:00:00,118.81,39.744,57.927,29.535 +2020-08-26 08:15:00,116.2,41.883,57.927,29.535 +2020-08-26 08:30:00,118.47,42.278,57.927,29.535 +2020-08-26 08:45:00,117.16,44.023999999999994,57.927,29.535 +2020-08-26 09:00:00,113.77,39.093,54.86,29.535 +2020-08-26 09:15:00,109.86,38.158,54.86,29.535 +2020-08-26 09:30:00,114.74,40.864000000000004,54.86,29.535 +2020-08-26 09:45:00,115.54,43.261,54.86,29.535 +2020-08-26 10:00:00,115.19,39.749,52.818000000000005,29.535 +2020-08-26 10:15:00,109.01,40.578,52.818000000000005,29.535 +2020-08-26 10:30:00,111.28,40.4,52.818000000000005,29.535 +2020-08-26 10:45:00,114.2,41.563,52.818000000000005,29.535 +2020-08-26 11:00:00,112.86,39.876,52.937,29.535 +2020-08-26 11:15:00,109.67,40.861,52.937,29.535 +2020-08-26 11:30:00,106.25,41.67,52.937,29.535 +2020-08-26 11:45:00,111.83,42.434,52.937,29.535 +2020-08-26 12:00:00,109.68,38.638000000000005,50.826,29.535 +2020-08-26 12:15:00,109.43,37.951,50.826,29.535 +2020-08-26 12:30:00,102.87,37.102,50.826,29.535 +2020-08-26 12:45:00,108.4,37.808,50.826,29.535 +2020-08-26 13:00:00,105.9,37.226,50.556000000000004,29.535 +2020-08-26 13:15:00,105.95,37.961999999999996,50.556000000000004,29.535 +2020-08-26 13:30:00,101.59,36.486999999999995,50.556000000000004,29.535 +2020-08-26 13:45:00,99.54,36.418,50.556000000000004,29.535 +2020-08-26 14:00:00,100.94,37.431999999999995,51.188,29.535 +2020-08-26 14:15:00,105.78,36.654,51.188,29.535 +2020-08-26 14:30:00,108.59,35.864000000000004,51.188,29.535 +2020-08-26 14:45:00,109.21,36.579,51.188,29.535 +2020-08-26 15:00:00,104.05,37.327,52.976000000000006,29.535 +2020-08-26 15:15:00,107.09,35.291,52.976000000000006,29.535 +2020-08-26 15:30:00,108.02,34.181,52.976000000000006,29.535 +2020-08-26 15:45:00,108.26,32.556,52.976000000000006,29.535 +2020-08-26 16:00:00,107.58,34.734,55.463,29.535 +2020-08-26 16:15:00,106.64,34.72,55.463,29.535 +2020-08-26 16:30:00,106.57,34.516,55.463,29.535 +2020-08-26 16:45:00,108.55,32.669000000000004,55.463,29.535 +2020-08-26 17:00:00,113.88,35.105,59.435,29.535 +2020-08-26 17:15:00,118.13,36.104,59.435,29.535 +2020-08-26 17:30:00,118.87,35.510999999999996,59.435,29.535 +2020-08-26 17:45:00,113.11,35.313,59.435,29.535 +2020-08-26 18:00:00,113.43,37.775,61.387,29.535 +2020-08-26 18:15:00,112.92,37.656,61.387,29.535 +2020-08-26 18:30:00,117.05,36.104,61.387,29.535 +2020-08-26 18:45:00,113.66,39.007,61.387,29.535 +2020-08-26 19:00:00,116.87,40.169000000000004,63.323,29.535 +2020-08-26 19:15:00,114.49,39.111,63.323,29.535 +2020-08-26 19:30:00,115.09,38.061,63.323,29.535 +2020-08-26 19:45:00,114.82,37.6,63.323,29.535 +2020-08-26 20:00:00,107.23,34.726,69.083,29.535 +2020-08-26 20:15:00,102.55,34.564,69.083,29.535 +2020-08-26 20:30:00,99.74,34.594,69.083,29.535 +2020-08-26 20:45:00,99.86,34.573,69.083,29.535 +2020-08-26 21:00:00,92.47,33.052,59.957,29.535 +2020-08-26 21:15:00,89.95,34.527,59.957,29.535 +2020-08-26 21:30:00,86.5,34.865,59.957,29.535 +2020-08-26 21:45:00,86.61,34.833,59.957,29.535 +2020-08-26 22:00:00,81.97,32.114000000000004,53.821000000000005,29.535 +2020-08-26 22:15:00,83.28,33.902,53.821000000000005,29.535 +2020-08-26 22:30:00,79.53,28.649,53.821000000000005,29.535 +2020-08-26 22:45:00,84.8,25.583000000000002,53.821000000000005,29.535 +2020-08-26 23:00:00,81.23,23.787,45.458,29.535 +2020-08-26 23:15:00,79.13,22.703000000000003,45.458,29.535 +2020-08-26 23:30:00,78.99,22.287,45.458,29.535 +2020-08-26 23:45:00,78.9,21.468000000000004,45.458,29.535 +2020-08-27 00:00:00,71.33,21.749000000000002,40.36,29.535 +2020-08-27 00:15:00,73.36,22.448,40.36,29.535 +2020-08-27 00:30:00,73.38,21.634,40.36,29.535 +2020-08-27 00:45:00,73.37,21.854,40.36,29.535 +2020-08-27 01:00:00,70.34,21.473000000000003,38.552,29.535 +2020-08-27 01:15:00,71.82,20.868000000000002,38.552,29.535 +2020-08-27 01:30:00,71.13,19.885,38.552,29.535 +2020-08-27 01:45:00,71.82,19.609,38.552,29.535 +2020-08-27 02:00:00,69.67,19.53,36.895,29.535 +2020-08-27 02:15:00,71.68,18.995,36.895,29.535 +2020-08-27 02:30:00,71.41,19.977,36.895,29.535 +2020-08-27 02:45:00,72.88,20.635,36.895,29.535 +2020-08-27 03:00:00,74.73,21.303,36.565,29.535 +2020-08-27 03:15:00,73.5,21.204,36.565,29.535 +2020-08-27 03:30:00,74.7,20.924,36.565,29.535 +2020-08-27 03:45:00,77.44,20.531,36.565,29.535 +2020-08-27 04:00:00,80.24,24.855,37.263000000000005,29.535 +2020-08-27 04:15:00,84.26,30.322,37.263000000000005,29.535 +2020-08-27 04:30:00,88.93,27.961,37.263000000000005,29.535 +2020-08-27 04:45:00,92.35,28.099,37.263000000000005,29.535 +2020-08-27 05:00:00,100.04,40.422,40.412,29.535 +2020-08-27 05:15:00,101.99,47.481,40.412,29.535 +2020-08-27 05:30:00,103.56,43.0,40.412,29.535 +2020-08-27 05:45:00,108.21,40.204,40.412,29.535 +2020-08-27 06:00:00,111.6,40.345,49.825,29.535 +2020-08-27 06:15:00,115.35,40.686,49.825,29.535 +2020-08-27 06:30:00,124.04,40.156,49.825,29.535 +2020-08-27 06:45:00,126.88,42.449,49.825,29.535 +2020-08-27 07:00:00,129.89,42.482,61.082,29.535 +2020-08-27 07:15:00,127.93,43.683,61.082,29.535 +2020-08-27 07:30:00,132.59,42.276,61.082,29.535 +2020-08-27 07:45:00,134.35,43.071999999999996,61.082,29.535 +2020-08-27 08:00:00,130.7,40.103,53.961999999999996,29.535 +2020-08-27 08:15:00,129.47,42.208999999999996,53.961999999999996,29.535 +2020-08-27 08:30:00,135.31,42.599,53.961999999999996,29.535 +2020-08-27 08:45:00,130.08,44.33,53.961999999999996,29.535 +2020-08-27 09:00:00,132.01,39.407,50.06100000000001,29.535 +2020-08-27 09:15:00,137.15,38.461999999999996,50.06100000000001,29.535 +2020-08-27 09:30:00,136.56,41.151,50.06100000000001,29.535 +2020-08-27 09:45:00,138.15,43.523999999999994,50.06100000000001,29.535 +2020-08-27 10:00:00,140.11,40.01,47.68,29.535 +2020-08-27 10:15:00,137.51,40.811,47.68,29.535 +2020-08-27 10:30:00,134.24,40.628,47.68,29.535 +2020-08-27 10:45:00,138.17,41.781000000000006,47.68,29.535 +2020-08-27 11:00:00,132.89,40.105,45.93899999999999,29.535 +2020-08-27 11:15:00,126.1,41.08,45.93899999999999,29.535 +2020-08-27 11:30:00,131.52,41.886,45.93899999999999,29.535 +2020-08-27 11:45:00,134.76,42.635,45.93899999999999,29.535 +2020-08-27 12:00:00,133.16,38.823,43.648999999999994,29.535 +2020-08-27 12:15:00,130.96,38.125,43.648999999999994,29.535 +2020-08-27 12:30:00,125.67,37.294000000000004,43.648999999999994,29.535 +2020-08-27 12:45:00,134.33,37.988,43.648999999999994,29.535 +2020-08-27 13:00:00,127.85,37.389,42.801,29.535 +2020-08-27 13:15:00,124.91,38.113,42.801,29.535 +2020-08-27 13:30:00,127.6,36.634,42.801,29.535 +2020-08-27 13:45:00,132.81,36.576,42.801,29.535 +2020-08-27 14:00:00,129.82,37.563,43.24,29.535 +2020-08-27 14:15:00,126.07,36.792,43.24,29.535 +2020-08-27 14:30:00,121.33,36.021,43.24,29.535 +2020-08-27 14:45:00,126.96,36.736999999999995,43.24,29.535 +2020-08-27 15:00:00,125.31,37.437,45.04600000000001,29.535 +2020-08-27 15:15:00,123.64,35.409,45.04600000000001,29.535 +2020-08-27 15:30:00,117.09,34.314,45.04600000000001,29.535 +2020-08-27 15:45:00,113.24,32.696,45.04600000000001,29.535 +2020-08-27 16:00:00,115.1,34.846,46.568000000000005,29.535 +2020-08-27 16:15:00,117.47,34.841,46.568000000000005,29.535 +2020-08-27 16:30:00,113.23,34.639,46.568000000000005,29.535 +2020-08-27 16:45:00,113.72,32.836999999999996,46.568000000000005,29.535 +2020-08-27 17:00:00,116.96,35.239000000000004,50.618,29.535 +2020-08-27 17:15:00,113.6,36.266999999999996,50.618,29.535 +2020-08-27 17:30:00,111.58,35.686,50.618,29.535 +2020-08-27 17:45:00,112.19,35.525999999999996,50.618,29.535 +2020-08-27 18:00:00,111.35,37.977,52.806999999999995,29.535 +2020-08-27 18:15:00,110.44,37.874,52.806999999999995,29.535 +2020-08-27 18:30:00,108.59,36.330999999999996,52.806999999999995,29.535 +2020-08-27 18:45:00,108.94,39.234,52.806999999999995,29.535 +2020-08-27 19:00:00,111.09,40.403,53.464,29.535 +2020-08-27 19:15:00,108.1,39.346,53.464,29.535 +2020-08-27 19:30:00,108.89,38.298,53.464,29.535 +2020-08-27 19:45:00,104.8,37.842,53.464,29.535 +2020-08-27 20:00:00,99.49,34.976,56.753,29.535 +2020-08-27 20:15:00,100.21,34.816,56.753,29.535 +2020-08-27 20:30:00,96.74,34.83,56.753,29.535 +2020-08-27 20:45:00,94.89,34.772,56.753,29.535 +2020-08-27 21:00:00,91.41,33.254,52.506,29.535 +2020-08-27 21:15:00,90.08,34.719,52.506,29.535 +2020-08-27 21:30:00,85.55,35.056999999999995,52.506,29.535 +2020-08-27 21:45:00,85.17,34.993,52.506,29.535 +2020-08-27 22:00:00,80.57,32.251999999999995,48.163000000000004,29.535 +2020-08-27 22:15:00,82.4,34.023,48.163000000000004,29.535 +2020-08-27 22:30:00,79.2,28.738000000000003,48.163000000000004,29.535 +2020-08-27 22:45:00,79.7,25.674,48.163000000000004,29.535 +2020-08-27 23:00:00,75.57,23.923000000000002,42.379,29.535 +2020-08-27 23:15:00,76.68,22.822,42.379,29.535 +2020-08-27 23:30:00,75.21,22.416,42.379,29.535 +2020-08-27 23:45:00,74.58,21.603,42.379,29.535 +2020-08-28 00:00:00,72.43,20.281,38.505,29.535 +2020-08-28 00:15:00,71.75,21.175,38.505,29.535 +2020-08-28 00:30:00,72.78,20.615,38.505,29.535 +2020-08-28 00:45:00,72.99,21.226999999999997,38.505,29.535 +2020-08-28 01:00:00,70.23,20.479,37.004,29.535 +2020-08-28 01:15:00,72.14,19.332,37.004,29.535 +2020-08-28 01:30:00,71.17,19.017,37.004,29.535 +2020-08-28 01:45:00,72.62,18.511,37.004,29.535 +2020-08-28 02:00:00,71.55,19.285,36.098,29.535 +2020-08-28 02:15:00,72.3,18.726,36.098,29.535 +2020-08-28 02:30:00,72.07,20.434,36.098,29.535 +2020-08-28 02:45:00,72.86,20.463,36.098,29.535 +2020-08-28 03:00:00,73.05,21.838,36.561,29.535 +2020-08-28 03:15:00,75.07,20.644000000000002,36.561,29.535 +2020-08-28 03:30:00,74.45,20.151,36.561,29.535 +2020-08-28 03:45:00,79.36,20.548000000000002,36.561,29.535 +2020-08-28 04:00:00,87.62,25.022,37.355,29.535 +2020-08-28 04:15:00,89.23,29.087,37.355,29.535 +2020-08-28 04:30:00,93.88,27.601,37.355,29.535 +2020-08-28 04:45:00,94.42,27.164,37.355,29.535 +2020-08-28 05:00:00,99.29,39.138000000000005,40.285,29.535 +2020-08-28 05:15:00,100.91,47.137,40.285,29.535 +2020-08-28 05:30:00,109.34,42.821999999999996,40.285,29.535 +2020-08-28 05:45:00,112.16,39.583,40.285,29.535 +2020-08-28 06:00:00,116.49,39.891999999999996,52.378,29.535 +2020-08-28 06:15:00,113.8,40.41,52.378,29.535 +2020-08-28 06:30:00,110.25,39.828,52.378,29.535 +2020-08-28 06:45:00,110.75,42.0,52.378,29.535 +2020-08-28 07:00:00,116.02,42.622,60.891999999999996,29.535 +2020-08-28 07:15:00,113.76,44.653,60.891999999999996,29.535 +2020-08-28 07:30:00,113.32,41.505,60.891999999999996,29.535 +2020-08-28 07:45:00,116.11,42.113,60.891999999999996,29.535 +2020-08-28 08:00:00,110.53,39.859,53.652,29.535 +2020-08-28 08:15:00,110.47,42.542,53.652,29.535 +2020-08-28 08:30:00,118.74,42.833,53.652,29.535 +2020-08-28 08:45:00,118.78,44.428000000000004,53.652,29.535 +2020-08-28 09:00:00,114.6,37.416,51.456,29.535 +2020-08-28 09:15:00,109.6,38.2,51.456,29.535 +2020-08-28 09:30:00,106.67,40.25,51.456,29.535 +2020-08-28 09:45:00,107.3,42.948,51.456,29.535 +2020-08-28 10:00:00,110.6,39.293,49.4,29.535 +2020-08-28 10:15:00,108.84,39.86,49.4,29.535 +2020-08-28 10:30:00,109.67,40.172,49.4,29.535 +2020-08-28 10:45:00,104.36,41.235,49.4,29.535 +2020-08-28 11:00:00,105.31,39.806999999999995,48.773,29.535 +2020-08-28 11:15:00,104.1,39.791,48.773,29.535 +2020-08-28 11:30:00,102.59,40.239000000000004,48.773,29.535 +2020-08-28 11:45:00,100.47,40.095,48.773,29.535 +2020-08-28 12:00:00,97.94,36.64,46.033,29.535 +2020-08-28 12:15:00,100.85,35.379,46.033,29.535 +2020-08-28 12:30:00,98.11,34.671,46.033,29.535 +2020-08-28 12:45:00,98.83,34.62,46.033,29.535 +2020-08-28 13:00:00,96.79,34.536,44.38399999999999,29.535 +2020-08-28 13:15:00,98.89,35.403,44.38399999999999,29.535 +2020-08-28 13:30:00,109.7,34.647,44.38399999999999,29.535 +2020-08-28 13:45:00,107.01,34.881,44.38399999999999,29.535 +2020-08-28 14:00:00,107.86,35.121,43.162,29.535 +2020-08-28 14:15:00,106.56,34.760999999999996,43.162,29.535 +2020-08-28 14:30:00,110.38,35.385,43.162,29.535 +2020-08-28 14:45:00,113.12,35.463,43.162,29.535 +2020-08-28 15:00:00,113.59,36.05,44.91,29.535 +2020-08-28 15:15:00,109.31,33.82,44.91,29.535 +2020-08-28 15:30:00,109.09,32.246,44.91,29.535 +2020-08-28 15:45:00,113.5,31.334,44.91,29.535 +2020-08-28 16:00:00,117.73,32.688,47.489,29.535 +2020-08-28 16:15:00,114.05,33.139,47.489,29.535 +2020-08-28 16:30:00,111.88,32.772,47.489,29.535 +2020-08-28 16:45:00,110.29,30.296,47.489,29.535 +2020-08-28 17:00:00,116.92,34.25,52.047,29.535 +2020-08-28 17:15:00,116.73,35.156,52.047,29.535 +2020-08-28 17:30:00,116.42,34.746,52.047,29.535 +2020-08-28 17:45:00,110.79,34.473,52.047,29.535 +2020-08-28 18:00:00,112.92,36.928000000000004,53.306000000000004,29.535 +2020-08-28 18:15:00,115.02,35.982,53.306000000000004,29.535 +2020-08-28 18:30:00,113.25,34.352,53.306000000000004,29.535 +2020-08-28 18:45:00,112.51,37.635,53.306000000000004,29.535 +2020-08-28 19:00:00,107.58,39.59,53.516000000000005,29.535 +2020-08-28 19:15:00,106.68,39.058,53.516000000000005,29.535 +2020-08-28 19:30:00,106.55,38.073,53.516000000000005,29.535 +2020-08-28 19:45:00,110.05,36.702,53.516000000000005,29.535 +2020-08-28 20:00:00,105.55,33.705999999999996,57.88,29.535 +2020-08-28 20:15:00,103.72,34.273,57.88,29.535 +2020-08-28 20:30:00,95.59,33.826,57.88,29.535 +2020-08-28 20:45:00,90.62,32.982,57.88,29.535 +2020-08-28 21:00:00,84.68,32.658,53.32,29.535 +2020-08-28 21:15:00,83.65,35.631,53.32,29.535 +2020-08-28 21:30:00,80.52,35.832,53.32,29.535 +2020-08-28 21:45:00,82.9,35.915,53.32,29.535 +2020-08-28 22:00:00,83.63,33.01,48.074,29.535 +2020-08-28 22:15:00,83.65,34.539,48.074,29.535 +2020-08-28 22:30:00,78.8,33.479,48.074,29.535 +2020-08-28 22:45:00,76.63,31.156999999999996,48.074,29.535 +2020-08-28 23:00:00,75.72,30.994,41.306999999999995,29.535 +2020-08-28 23:15:00,75.8,28.473000000000003,41.306999999999995,29.535 +2020-08-28 23:30:00,73.3,26.433000000000003,41.306999999999995,29.535 +2020-08-28 23:45:00,66.16,25.524,41.306999999999995,29.535 +2020-08-29 00:00:00,64.44,21.593000000000004,40.227,29.423000000000002 +2020-08-29 00:15:00,71.06,21.918000000000003,40.227,29.423000000000002 +2020-08-29 00:30:00,70.31,20.924,40.227,29.423000000000002 +2020-08-29 00:45:00,70.07,20.871,40.227,29.423000000000002 +2020-08-29 01:00:00,63.31,20.348,36.303000000000004,29.423000000000002 +2020-08-29 01:15:00,64.56,19.761,36.303000000000004,29.423000000000002 +2020-08-29 01:30:00,62.06,18.733,36.303000000000004,29.423000000000002 +2020-08-29 01:45:00,61.97,19.362000000000002,36.303000000000004,29.423000000000002 +2020-08-29 02:00:00,61.2,19.239,33.849000000000004,29.423000000000002 +2020-08-29 02:15:00,65.22,17.988,33.849000000000004,29.423000000000002 +2020-08-29 02:30:00,67.93,18.965,33.849000000000004,29.423000000000002 +2020-08-29 02:45:00,67.63,19.703,33.849000000000004,29.423000000000002 +2020-08-29 03:00:00,62.88,19.814,33.149,29.423000000000002 +2020-08-29 03:15:00,60.75,18.016,33.149,29.423000000000002 +2020-08-29 03:30:00,64.25,17.847,33.149,29.423000000000002 +2020-08-29 03:45:00,69.62,19.58,33.149,29.423000000000002 +2020-08-29 04:00:00,70.43,22.421999999999997,32.501,29.423000000000002 +2020-08-29 04:15:00,67.71,25.729,32.501,29.423000000000002 +2020-08-29 04:30:00,64.26,22.76,32.501,29.423000000000002 +2020-08-29 04:45:00,66.86,22.581,32.501,29.423000000000002 +2020-08-29 05:00:00,72.03,27.261,31.648000000000003,29.423000000000002 +2020-08-29 05:15:00,74.87,25.631,31.648000000000003,29.423000000000002 +2020-08-29 05:30:00,74.46,22.678,31.648000000000003,29.423000000000002 +2020-08-29 05:45:00,72.84,23.379,31.648000000000003,29.423000000000002 +2020-08-29 06:00:00,73.13,33.579,32.552,29.423000000000002 +2020-08-29 06:15:00,77.89,40.993,32.552,29.423000000000002 +2020-08-29 06:30:00,78.78,37.895,32.552,29.423000000000002 +2020-08-29 06:45:00,77.89,37.258,32.552,29.423000000000002 +2020-08-29 07:00:00,77.07,36.72,35.181999999999995,29.423000000000002 +2020-08-29 07:15:00,78.08,37.609,35.181999999999995,29.423000000000002 +2020-08-29 07:30:00,79.75,35.816,35.181999999999995,29.423000000000002 +2020-08-29 07:45:00,82.27,37.204,35.181999999999995,29.423000000000002 +2020-08-29 08:00:00,84.1,35.546,40.35,29.423000000000002 +2020-08-29 08:15:00,84.31,37.943000000000005,40.35,29.423000000000002 +2020-08-29 08:30:00,85.25,38.193000000000005,40.35,29.423000000000002 +2020-08-29 08:45:00,85.89,40.580999999999996,40.35,29.423000000000002 +2020-08-29 09:00:00,89.71,36.368,42.292,29.423000000000002 +2020-08-29 09:15:00,89.66,37.569,42.292,29.423000000000002 +2020-08-29 09:30:00,85.99,40.035,42.292,29.423000000000002 +2020-08-29 09:45:00,80.14,42.298,42.292,29.423000000000002 +2020-08-29 10:00:00,80.79,39.221,40.084,29.423000000000002 +2020-08-29 10:15:00,88.58,40.082,40.084,29.423000000000002 +2020-08-29 10:30:00,90.14,40.073,40.084,29.423000000000002 +2020-08-29 10:45:00,90.37,40.782,40.084,29.423000000000002 +2020-08-29 11:00:00,89.97,39.248000000000005,36.966,29.423000000000002 +2020-08-29 11:15:00,91.84,40.025999999999996,36.966,29.423000000000002 +2020-08-29 11:30:00,89.82,40.753,36.966,29.423000000000002 +2020-08-29 11:45:00,88.72,41.224,36.966,29.423000000000002 +2020-08-29 12:00:00,86.32,38.188,35.19,29.423000000000002 +2020-08-29 12:15:00,84.51,37.68,35.19,29.423000000000002 +2020-08-29 12:30:00,82.92,36.863,35.19,29.423000000000002 +2020-08-29 12:45:00,85.5,37.486,35.19,29.423000000000002 +2020-08-29 13:00:00,81.37,36.576,32.277,29.423000000000002 +2020-08-29 13:15:00,82.13,36.982,32.277,29.423000000000002 +2020-08-29 13:30:00,81.68,36.403,32.277,29.423000000000002 +2020-08-29 13:45:00,81.28,35.406,32.277,29.423000000000002 +2020-08-29 14:00:00,79.6,35.664,31.436999999999998,29.423000000000002 +2020-08-29 14:15:00,79.02,34.179,31.436999999999998,29.423000000000002 +2020-08-29 14:30:00,76.91,34.474000000000004,31.436999999999998,29.423000000000002 +2020-08-29 14:45:00,76.5,34.986999999999995,31.436999999999998,29.423000000000002 +2020-08-29 15:00:00,76.4,35.93,33.493,29.423000000000002 +2020-08-29 15:15:00,77.73,34.336,33.493,29.423000000000002 +2020-08-29 15:30:00,78.14,32.926,33.493,29.423000000000002 +2020-08-29 15:45:00,77.73,31.178,33.493,29.423000000000002 +2020-08-29 16:00:00,78.32,34.436,36.593,29.423000000000002 +2020-08-29 16:15:00,79.7,34.065,36.593,29.423000000000002 +2020-08-29 16:30:00,80.81,33.906,36.593,29.423000000000002 +2020-08-29 16:45:00,82.06,31.432,36.593,29.423000000000002 +2020-08-29 17:00:00,84.59,34.291,42.049,29.423000000000002 +2020-08-29 17:15:00,86.73,33.227,42.049,29.423000000000002 +2020-08-29 17:30:00,85.87,32.711,42.049,29.423000000000002 +2020-08-29 17:45:00,83.17,32.935,42.049,29.423000000000002 +2020-08-29 18:00:00,84.18,36.61,43.755,29.423000000000002 +2020-08-29 18:15:00,83.2,37.168,43.755,29.423000000000002 +2020-08-29 18:30:00,83.27,36.759,43.755,29.423000000000002 +2020-08-29 18:45:00,84.31,36.943000000000005,43.755,29.423000000000002 +2020-08-29 19:00:00,89.05,37.398,44.492,29.423000000000002 +2020-08-29 19:15:00,84.66,35.963,44.492,29.423000000000002 +2020-08-29 19:30:00,86.21,35.665,44.492,29.423000000000002 +2020-08-29 19:45:00,81.85,35.891999999999996,44.492,29.423000000000002 +2020-08-29 20:00:00,77.43,33.655,40.896,29.423000000000002 +2020-08-29 20:15:00,77.44,33.62,40.896,29.423000000000002 +2020-08-29 20:30:00,75.73,32.365,40.896,29.423000000000002 +2020-08-29 20:45:00,76.44,33.168,40.896,29.423000000000002 +2020-08-29 21:00:00,73.3,31.482,39.056,29.423000000000002 +2020-08-29 21:15:00,72.72,34.125,39.056,29.423000000000002 +2020-08-29 21:30:00,70.23,34.483000000000004,39.056,29.423000000000002 +2020-08-29 21:45:00,69.8,34.045,39.056,29.423000000000002 +2020-08-29 22:00:00,66.22,31.079,38.478,29.423000000000002 +2020-08-29 22:15:00,65.01,32.786,38.478,29.423000000000002 +2020-08-29 22:30:00,63.02,31.276999999999997,38.478,29.423000000000002 +2020-08-29 22:45:00,61.99,29.272,38.478,29.423000000000002 +2020-08-29 23:00:00,58.73,28.525,32.953,29.423000000000002 +2020-08-29 23:15:00,57.9,26.384,32.953,29.423000000000002 +2020-08-29 23:30:00,57.04,26.589000000000002,32.953,29.423000000000002 +2020-08-29 23:45:00,56.91,26.037,32.953,29.423000000000002 +2020-08-30 00:00:00,54.27,22.994,28.584,29.423000000000002 +2020-08-30 00:15:00,55.13,22.281999999999996,28.584,29.423000000000002 +2020-08-30 00:30:00,50.94,21.165,28.584,29.423000000000002 +2020-08-30 00:45:00,53.35,21.011,28.584,29.423000000000002 +2020-08-30 01:00:00,51.34,20.706999999999997,26.419,29.423000000000002 +2020-08-30 01:15:00,52.9,20.012999999999998,26.419,29.423000000000002 +2020-08-30 01:30:00,52.18,18.868,26.419,29.423000000000002 +2020-08-30 01:45:00,51.93,19.159000000000002,26.419,29.423000000000002 +2020-08-30 02:00:00,52.18,19.118,25.335,29.423000000000002 +2020-08-30 02:15:00,51.62,18.54,25.335,29.423000000000002 +2020-08-30 02:30:00,51.01,19.651,25.335,29.423000000000002 +2020-08-30 02:45:00,50.76,20.117,25.335,29.423000000000002 +2020-08-30 03:00:00,51.01,20.8,24.805,29.423000000000002 +2020-08-30 03:15:00,51.32,19.261,24.805,29.423000000000002 +2020-08-30 03:30:00,52.27,18.414,24.805,29.423000000000002 +2020-08-30 03:45:00,53.2,19.423,24.805,29.423000000000002 +2020-08-30 04:00:00,53.28,22.268,25.772,29.423000000000002 +2020-08-30 04:15:00,53.79,25.218000000000004,25.772,29.423000000000002 +2020-08-30 04:30:00,54.34,23.467,25.772,29.423000000000002 +2020-08-30 04:45:00,55.42,22.866,25.772,29.423000000000002 +2020-08-30 05:00:00,56.16,27.819000000000003,25.971999999999998,29.423000000000002 +2020-08-30 05:15:00,56.99,25.605999999999998,25.971999999999998,29.423000000000002 +2020-08-30 05:30:00,56.61,22.284000000000002,25.971999999999998,29.423000000000002 +2020-08-30 05:45:00,57.04,22.694000000000003,25.971999999999998,29.423000000000002 +2020-08-30 06:00:00,58.54,30.746,26.026,29.423000000000002 +2020-08-30 06:15:00,59.88,38.948,26.026,29.423000000000002 +2020-08-30 06:30:00,61.66,35.231,26.026,29.423000000000002 +2020-08-30 06:45:00,63.97,33.71,26.026,29.423000000000002 +2020-08-30 07:00:00,66.86,33.422,27.396,29.423000000000002 +2020-08-30 07:15:00,68.13,32.858000000000004,27.396,29.423000000000002 +2020-08-30 07:30:00,70.07,32.260999999999996,27.396,29.423000000000002 +2020-08-30 07:45:00,71.87,33.628,27.396,29.423000000000002 +2020-08-30 08:00:00,74.54,32.635,30.791999999999998,29.423000000000002 +2020-08-30 08:15:00,76.67,36.064,30.791999999999998,29.423000000000002 +2020-08-30 08:30:00,77.37,37.067,30.791999999999998,29.423000000000002 +2020-08-30 08:45:00,78.29,39.330999999999996,30.791999999999998,29.423000000000002 +2020-08-30 09:00:00,78.71,35.037,32.482,29.423000000000002 +2020-08-30 09:15:00,79.95,35.771,32.482,29.423000000000002 +2020-08-30 09:30:00,80.79,38.611999999999995,32.482,29.423000000000002 +2020-08-30 09:45:00,83.14,41.757,32.482,29.423000000000002 +2020-08-30 10:00:00,84.85,39.117,31.951,29.423000000000002 +2020-08-30 10:15:00,87.31,40.061,31.951,29.423000000000002 +2020-08-30 10:30:00,88.3,40.231,31.951,29.423000000000002 +2020-08-30 10:45:00,87.52,41.902,31.951,29.423000000000002 +2020-08-30 11:00:00,84.25,40.043,33.619,29.423000000000002 +2020-08-30 11:15:00,83.28,40.399,33.619,29.423000000000002 +2020-08-30 11:30:00,81.94,41.626000000000005,33.619,29.423000000000002 +2020-08-30 11:45:00,80.98,42.297,33.619,29.423000000000002 +2020-08-30 12:00:00,78.49,40.249,30.975,29.423000000000002 +2020-08-30 12:15:00,77.41,39.084,30.975,29.423000000000002 +2020-08-30 12:30:00,75.82,38.535,30.975,29.423000000000002 +2020-08-30 12:45:00,78.16,38.595,30.975,29.423000000000002 +2020-08-30 13:00:00,72.08,37.405,27.956999999999997,29.423000000000002 +2020-08-30 13:15:00,72.23,37.171,27.956999999999997,29.423000000000002 +2020-08-30 13:30:00,71.97,35.578,27.956999999999997,29.423000000000002 +2020-08-30 13:45:00,72.4,35.635,27.956999999999997,29.423000000000002 +2020-08-30 14:00:00,70.92,36.957,25.555999999999997,29.423000000000002 +2020-08-30 14:15:00,70.88,35.834,25.555999999999997,29.423000000000002 +2020-08-30 14:30:00,70.54,34.972,25.555999999999997,29.423000000000002 +2020-08-30 14:45:00,70.65,34.519,25.555999999999997,29.423000000000002 +2020-08-30 15:00:00,71.32,35.598,26.271,29.423000000000002 +2020-08-30 15:15:00,71.25,33.243,26.271,29.423000000000002 +2020-08-30 15:30:00,71.19,31.660999999999998,26.271,29.423000000000002 +2020-08-30 15:45:00,71.97,30.158,26.271,29.423000000000002 +2020-08-30 16:00:00,73.4,31.854,30.369,29.423000000000002 +2020-08-30 16:15:00,74.06,31.729,30.369,29.423000000000002 +2020-08-30 16:30:00,75.86,32.481,30.369,29.423000000000002 +2020-08-30 16:45:00,78.3,30.105999999999998,30.369,29.423000000000002 +2020-08-30 17:00:00,79.85,33.259,38.787,29.423000000000002 +2020-08-30 17:15:00,80.1,33.619,38.787,29.423000000000002 +2020-08-30 17:30:00,81.58,33.845,38.787,29.423000000000002 +2020-08-30 17:45:00,80.78,34.421,38.787,29.423000000000002 +2020-08-30 18:00:00,83.14,38.659,41.886,29.423000000000002 +2020-08-30 18:15:00,80.9,38.830999999999996,41.886,29.423000000000002 +2020-08-30 18:30:00,80.98,38.26,41.886,29.423000000000002 +2020-08-30 18:45:00,80.43,38.503,41.886,29.423000000000002 +2020-08-30 19:00:00,86.05,40.954,42.91,29.423000000000002 +2020-08-30 19:15:00,82.33,38.507,42.91,29.423000000000002 +2020-08-30 19:30:00,83.91,37.993,42.91,29.423000000000002 +2020-08-30 19:45:00,80.57,37.784,42.91,29.423000000000002 +2020-08-30 20:00:00,78.83,35.695,42.148999999999994,29.423000000000002 +2020-08-30 20:15:00,79.05,35.516,42.148999999999994,29.423000000000002 +2020-08-30 20:30:00,78.41,34.949,42.148999999999994,29.423000000000002 +2020-08-30 20:45:00,79.19,34.219,42.148999999999994,29.423000000000002 +2020-08-30 21:00:00,77.65,32.483000000000004,40.955999999999996,29.423000000000002 +2020-08-30 21:15:00,78.79,34.856,40.955999999999996,29.423000000000002 +2020-08-30 21:30:00,75.78,34.623000000000005,40.955999999999996,29.423000000000002 +2020-08-30 21:45:00,75.79,34.472,40.955999999999996,29.423000000000002 +2020-08-30 22:00:00,72.02,33.349000000000004,39.873000000000005,29.423000000000002 +2020-08-30 22:15:00,72.93,33.534,39.873000000000005,29.423000000000002 +2020-08-30 22:30:00,71.7,31.603,39.873000000000005,29.423000000000002 +2020-08-30 22:45:00,70.88,28.467,39.873000000000005,29.423000000000002 +2020-08-30 23:00:00,66.13,27.553,35.510999999999996,29.423000000000002 +2020-08-30 23:15:00,67.65,26.502,35.510999999999996,29.423000000000002 +2020-08-30 23:30:00,68.19,26.213,35.510999999999996,29.423000000000002 +2020-08-30 23:45:00,66.64,25.791,35.510999999999996,29.423000000000002 +2020-08-31 00:00:00,63.09,24.439,33.475,29.535 +2020-08-31 00:15:00,65.41,24.381,33.475,29.535 +2020-08-31 00:30:00,65.2,22.921,33.475,29.535 +2020-08-31 00:45:00,65.56,22.427,33.475,29.535 +2020-08-31 01:00:00,62.92,22.448,33.111,29.535 +2020-08-31 01:15:00,64.41,21.793000000000003,33.111,29.535 +2020-08-31 01:30:00,64.04,20.988000000000003,33.111,29.535 +2020-08-31 01:45:00,65.01,21.19,33.111,29.535 +2020-08-31 02:00:00,62.9,21.559,32.358000000000004,29.535 +2020-08-31 02:15:00,64.34,20.055999999999997,32.358000000000004,29.535 +2020-08-31 02:30:00,64.2,21.287,32.358000000000004,29.535 +2020-08-31 02:45:00,64.04,21.629,32.358000000000004,29.535 +2020-08-31 03:00:00,65.64,22.729,30.779,29.535 +2020-08-31 03:15:00,66.35,21.822,30.779,29.535 +2020-08-31 03:30:00,72.4,21.601999999999997,30.779,29.535 +2020-08-31 03:45:00,75.77,22.195,30.779,29.535 +2020-08-31 04:00:00,80.59,27.787,31.416,29.535 +2020-08-31 04:15:00,79.36,33.317,31.416,29.535 +2020-08-31 04:30:00,88.89,31.094,31.416,29.535 +2020-08-31 04:45:00,94.27,30.829,31.416,29.535 +2020-08-31 05:00:00,100.31,42.129,37.221,29.535 +2020-08-31 05:15:00,101.92,48.968,37.221,29.535 +2020-08-31 05:30:00,101.22,44.25,37.221,29.535 +2020-08-31 05:45:00,104.81,41.94,37.221,29.535 +2020-08-31 06:00:00,108.02,40.968,51.891000000000005,29.535 +2020-08-31 06:15:00,111.44,41.255,51.891000000000005,29.535 +2020-08-31 06:30:00,112.93,40.977,51.891000000000005,29.535 +2020-08-31 06:45:00,115.62,44.062,51.891000000000005,29.535 +2020-08-31 07:00:00,119.0,43.956,62.282,29.535 +2020-08-31 07:15:00,119.27,45.438,62.282,29.535 +2020-08-31 07:30:00,120.08,44.106,62.282,29.535 +2020-08-31 07:45:00,119.77,45.791000000000004,62.282,29.535 +2020-08-31 08:00:00,121.76,42.894,54.102,29.535 +2020-08-31 08:15:00,123.35,45.245,54.102,29.535 +2020-08-31 08:30:00,124.12,45.458999999999996,54.102,29.535 +2020-08-31 08:45:00,125.71,47.97,54.102,29.535 +2020-08-31 09:00:00,129.33,42.748999999999995,50.917,29.535 +2020-08-31 09:15:00,126.56,41.933,50.917,29.535 +2020-08-31 09:30:00,126.46,43.938,50.917,29.535 +2020-08-31 09:45:00,127.36,44.971000000000004,50.917,29.535 +2020-08-31 10:00:00,128.24,42.68899999999999,49.718999999999994,29.535 +2020-08-31 10:15:00,131.55,43.481,49.718999999999994,29.535 +2020-08-31 10:30:00,130.34,43.248000000000005,49.718999999999994,29.535 +2020-08-31 10:45:00,123.89,43.443000000000005,49.718999999999994,29.535 +2020-08-31 11:00:00,124.43,41.855,49.833999999999996,29.535 +2020-08-31 11:15:00,124.83,42.403,49.833999999999996,29.535 +2020-08-31 11:30:00,129.95,44.224,49.833999999999996,29.535 +2020-08-31 11:45:00,125.36,45.352,49.833999999999996,29.535 +2020-08-31 12:00:00,121.07,41.592,47.832,29.535 +2020-08-31 12:15:00,122.98,40.518,47.832,29.535 +2020-08-31 12:30:00,117.12,38.993,47.832,29.535 +2020-08-31 12:45:00,116.46,38.983000000000004,47.832,29.535 +2020-08-31 13:00:00,120.35,38.641,48.03,29.535 +2020-08-31 13:15:00,120.22,37.631,48.03,29.535 +2020-08-31 13:30:00,118.44,36.205,48.03,29.535 +2020-08-31 13:45:00,111.87,37.094,48.03,29.535 +2020-08-31 14:00:00,111.57,37.562,48.157,29.535 +2020-08-31 14:15:00,114.04,37.012,48.157,29.535 +2020-08-31 14:30:00,113.71,36.038000000000004,48.157,29.535 +2020-08-31 14:45:00,116.13,37.49,48.157,29.535 +2020-08-31 15:00:00,113.44,38.115,48.897,29.535 +2020-08-31 15:15:00,111.84,35.278,48.897,29.535 +2020-08-31 15:30:00,110.24,34.448,48.897,29.535 +2020-08-31 15:45:00,108.51,32.522,48.897,29.535 +2020-08-31 16:00:00,104.99,35.231,51.446000000000005,29.535 +2020-08-31 16:15:00,104.55,35.198,51.446000000000005,29.535 +2020-08-31 16:30:00,108.33,35.369,51.446000000000005,29.535 +2020-08-31 16:45:00,108.42,33.097,51.446000000000005,29.535 +2020-08-31 17:00:00,109.57,35.18,57.507,29.535 +2020-08-31 17:15:00,108.31,35.944,57.507,29.535 +2020-08-31 17:30:00,111.74,35.846,57.507,29.535 +2020-08-31 17:45:00,110.77,36.141999999999996,57.507,29.535 +2020-08-31 18:00:00,113.05,39.413000000000004,57.896,29.535 +2020-08-31 18:15:00,109.66,37.992,57.896,29.535 +2020-08-31 18:30:00,110.56,36.729,57.896,29.535 +2020-08-31 18:45:00,108.24,39.83,57.896,29.535 +2020-08-31 19:00:00,109.87,42.091,57.891999999999996,29.535 +2020-08-31 19:15:00,105.78,40.868,57.891999999999996,29.535 +2020-08-31 19:30:00,103.3,40.012,57.891999999999996,29.535 +2020-08-31 19:45:00,105.3,39.27,57.891999999999996,29.535 +2020-08-31 20:00:00,98.65,36.086999999999996,64.57300000000001,29.535 +2020-08-31 20:15:00,99.73,37.242,64.57300000000001,29.535 +2020-08-31 20:30:00,98.19,37.177,64.57300000000001,29.535 +2020-08-31 20:45:00,98.28,36.565,64.57300000000001,29.535 +2020-08-31 21:00:00,93.39,34.244,59.431999999999995,29.535 +2020-08-31 21:15:00,93.97,37.034,59.431999999999995,29.535 +2020-08-31 21:30:00,89.46,37.196999999999996,59.431999999999995,29.535 +2020-08-31 21:45:00,86.99,36.826,59.431999999999995,29.535 +2020-08-31 22:00:00,86.09,33.882,51.519,29.535 +2020-08-31 22:15:00,89.99,35.885999999999996,51.519,29.535 +2020-08-31 22:30:00,88.27,30.206,51.519,29.535 +2020-08-31 22:45:00,83.31,27.164,51.519,29.535 +2020-08-31 23:00:00,75.5,26.299,44.501000000000005,29.535 +2020-08-31 23:15:00,75.51,23.673000000000002,44.501000000000005,29.535 +2020-08-31 23:30:00,75.27,23.296999999999997,44.501000000000005,29.535 +2020-08-31 23:45:00,75.09,22.335,44.501000000000005,29.535 +2020-09-01 00:00:00,73.47,29.916999999999998,44.438,29.93 +2020-09-01 00:15:00,81.26,30.489,44.438,29.93 +2020-09-01 00:30:00,72.65,30.061,44.438,29.93 +2020-09-01 00:45:00,74.24,30.002,44.438,29.93 +2020-09-01 01:00:00,75.98,29.759,41.468999999999994,29.93 +2020-09-01 01:15:00,79.49,28.94,41.468999999999994,29.93 +2020-09-01 01:30:00,80.77,27.840999999999998,41.468999999999994,29.93 +2020-09-01 01:45:00,80.77,26.919,41.468999999999994,29.93 +2020-09-01 02:00:00,76.57,26.625,39.708,29.93 +2020-09-01 02:15:00,74.41,26.226,39.708,29.93 +2020-09-01 02:30:00,73.57,26.895,39.708,29.93 +2020-09-01 02:45:00,75.06,27.743000000000002,39.708,29.93 +2020-09-01 03:00:00,75.97,28.845,38.919000000000004,29.93 +2020-09-01 03:15:00,76.67,28.206,38.919000000000004,29.93 +2020-09-01 03:30:00,77.0,28.11,38.919000000000004,29.93 +2020-09-01 03:45:00,80.42,28.19,38.919000000000004,29.93 +2020-09-01 04:00:00,83.6,34.74,40.092,29.93 +2020-09-01 04:15:00,85.52,41.981,40.092,29.93 +2020-09-01 04:30:00,93.85,39.315,40.092,29.93 +2020-09-01 04:45:00,102.83,39.861999999999995,40.092,29.93 +2020-09-01 05:00:00,110.21,57.162,43.713,29.93 +2020-09-01 05:15:00,108.06,68.223,43.713,29.93 +2020-09-01 05:30:00,106.25,62.226000000000006,43.713,29.93 +2020-09-01 05:45:00,108.65,58.465,43.713,29.93 +2020-09-01 06:00:00,117.46,58.838,56.033,29.93 +2020-09-01 06:15:00,114.57,60.111000000000004,56.033,29.93 +2020-09-01 06:30:00,114.76,58.723,56.033,29.93 +2020-09-01 06:45:00,117.5,60.365,56.033,29.93 +2020-09-01 07:00:00,120.97,58.69,66.003,29.93 +2020-09-01 07:15:00,121.58,59.825,66.003,29.93 +2020-09-01 07:30:00,122.12,58.056000000000004,66.003,29.93 +2020-09-01 07:45:00,122.42,58.925,66.003,29.93 +2020-09-01 08:00:00,123.81,57.608999999999995,57.474,29.93 +2020-09-01 08:15:00,119.75,59.684,57.474,29.93 +2020-09-01 08:30:00,121.34,58.928999999999995,57.474,29.93 +2020-09-01 08:45:00,119.7,60.385,57.474,29.93 +2020-09-01 09:00:00,119.63,56.747,51.928000000000004,29.93 +2020-09-01 09:15:00,114.95,55.32899999999999,51.928000000000004,29.93 +2020-09-01 09:30:00,111.22,57.449,51.928000000000004,29.93 +2020-09-01 09:45:00,112.13,59.055,51.928000000000004,29.93 +2020-09-01 10:00:00,110.13,55.532,49.46,29.93 +2020-09-01 10:15:00,111.52,56.278999999999996,49.46,29.93 +2020-09-01 10:30:00,112.58,56.043,49.46,29.93 +2020-09-01 10:45:00,113.39,57.13399999999999,49.46,29.93 +2020-09-01 11:00:00,106.2,53.576,48.206,29.93 +2020-09-01 11:15:00,104.73,54.661,48.206,29.93 +2020-09-01 11:30:00,106.63,55.326,48.206,29.93 +2020-09-01 11:45:00,108.22,56.138000000000005,48.206,29.93 +2020-09-01 12:00:00,105.4,52.413999999999994,46.285,29.93 +2020-09-01 12:15:00,110.05,52.071000000000005,46.285,29.93 +2020-09-01 12:30:00,108.08,51.176,46.285,29.93 +2020-09-01 12:45:00,105.77,52.277,46.285,29.93 +2020-09-01 13:00:00,110.03,51.369,46.861999999999995,29.93 +2020-09-01 13:15:00,106.07,51.993,46.861999999999995,29.93 +2020-09-01 13:30:00,107.01,50.818000000000005,46.861999999999995,29.93 +2020-09-01 13:45:00,108.05,49.903999999999996,46.861999999999995,29.93 +2020-09-01 14:00:00,104.78,51.108000000000004,46.488,29.93 +2020-09-01 14:15:00,107.92,50.152,46.488,29.93 +2020-09-01 14:30:00,103.2,48.902,46.488,29.93 +2020-09-01 14:45:00,103.58,49.669,46.488,29.93 +2020-09-01 15:00:00,101.89,50.393,48.442,29.93 +2020-09-01 15:15:00,103.13,48.303000000000004,48.442,29.93 +2020-09-01 15:30:00,103.38,47.102,48.442,29.93 +2020-09-01 15:45:00,102.77,44.833999999999996,48.442,29.93 +2020-09-01 16:00:00,102.66,47.282,50.397,29.93 +2020-09-01 16:15:00,105.53,47.086000000000006,50.397,29.93 +2020-09-01 16:30:00,106.51,47.457,50.397,29.93 +2020-09-01 16:45:00,108.89,45.013999999999996,50.397,29.93 +2020-09-01 17:00:00,109.84,46.915,56.668,29.93 +2020-09-01 17:15:00,109.18,47.858000000000004,56.668,29.93 +2020-09-01 17:30:00,109.98,47.298,56.668,29.93 +2020-09-01 17:45:00,112.11,46.278999999999996,56.668,29.93 +2020-09-01 18:00:00,113.7,48.562,57.957,29.93 +2020-09-01 18:15:00,109.54,48.016999999999996,57.957,29.93 +2020-09-01 18:30:00,111.45,46.04,57.957,29.93 +2020-09-01 18:45:00,109.99,50.2,57.957,29.93 +2020-09-01 19:00:00,111.38,52.652,57.056000000000004,29.93 +2020-09-01 19:15:00,107.84,51.383,57.056000000000004,29.93 +2020-09-01 19:30:00,107.01,50.34,57.056000000000004,29.93 +2020-09-01 19:45:00,106.18,50.461999999999996,57.056000000000004,29.93 +2020-09-01 20:00:00,100.69,48.705,64.156,29.93 +2020-09-01 20:15:00,100.76,48.184,64.156,29.93 +2020-09-01 20:30:00,98.36,47.782,64.156,29.93 +2020-09-01 20:45:00,97.15,47.833,64.156,29.93 +2020-09-01 21:00:00,91.14,46.413000000000004,56.507,29.93 +2020-09-01 21:15:00,91.05,48.013000000000005,56.507,29.93 +2020-09-01 21:30:00,87.36,47.721000000000004,56.507,29.93 +2020-09-01 21:45:00,85.97,47.453,56.507,29.93 +2020-09-01 22:00:00,81.23,45.082,50.728,29.93 +2020-09-01 22:15:00,80.37,45.82899999999999,50.728,29.93 +2020-09-01 22:30:00,79.33,38.537,50.728,29.93 +2020-09-01 22:45:00,78.66,35.022,50.728,29.93 +2020-09-01 23:00:00,74.04,31.743000000000002,43.556999999999995,29.93 +2020-09-01 23:15:00,74.63,31.009,43.556999999999995,29.93 +2020-09-01 23:30:00,74.95,30.879,43.556999999999995,29.93 +2020-09-01 23:45:00,73.76,30.048000000000002,43.556999999999995,29.93 +2020-09-02 00:00:00,70.09,30.121,41.151,29.93 +2020-09-02 00:15:00,71.22,30.691,41.151,29.93 +2020-09-02 00:30:00,70.52,30.272,41.151,29.93 +2020-09-02 00:45:00,70.04,30.22,41.151,29.93 +2020-09-02 01:00:00,70.4,29.961,37.763000000000005,29.93 +2020-09-02 01:15:00,71.55,29.159000000000002,37.763000000000005,29.93 +2020-09-02 01:30:00,70.54,28.076999999999998,37.763000000000005,29.93 +2020-09-02 01:45:00,71.31,27.156,37.763000000000005,29.93 +2020-09-02 02:00:00,70.05,26.865,35.615,29.93 +2020-09-02 02:15:00,70.33,26.489,35.615,29.93 +2020-09-02 02:30:00,70.42,27.131999999999998,35.615,29.93 +2020-09-02 02:45:00,71.01,27.975,35.615,29.93 +2020-09-02 03:00:00,71.66,29.066,35.153,29.93 +2020-09-02 03:15:00,73.29,28.445,35.153,29.93 +2020-09-02 03:30:00,74.2,28.353,35.153,29.93 +2020-09-02 03:45:00,76.12,28.423000000000002,35.153,29.93 +2020-09-02 04:00:00,79.71,35.003,36.203,29.93 +2020-09-02 04:15:00,83.25,42.268,36.203,29.93 +2020-09-02 04:30:00,93.94,39.609,36.203,29.93 +2020-09-02 04:45:00,98.95,40.161,36.203,29.93 +2020-09-02 05:00:00,102.22,57.54600000000001,39.922,29.93 +2020-09-02 05:15:00,99.73,68.696,39.922,29.93 +2020-09-02 05:30:00,104.42,62.69,39.922,29.93 +2020-09-02 05:45:00,106.12,58.883,39.922,29.93 +2020-09-02 06:00:00,110.69,59.221000000000004,56.443999999999996,29.93 +2020-09-02 06:15:00,110.55,60.515,56.443999999999996,29.93 +2020-09-02 06:30:00,112.02,59.126999999999995,56.443999999999996,29.93 +2020-09-02 06:45:00,111.67,60.766000000000005,56.443999999999996,29.93 +2020-09-02 07:00:00,114.69,59.091,68.683,29.93 +2020-09-02 07:15:00,114.16,60.243,68.683,29.93 +2020-09-02 07:30:00,112.53,58.504,68.683,29.93 +2020-09-02 07:45:00,112.99,59.375,68.683,29.93 +2020-09-02 08:00:00,112.66,58.06399999999999,59.003,29.93 +2020-09-02 08:15:00,112.55,60.106,59.003,29.93 +2020-09-02 08:30:00,111.76,59.354,59.003,29.93 +2020-09-02 08:45:00,108.42,60.795,59.003,29.93 +2020-09-02 09:00:00,107.65,57.165,56.21,29.93 +2020-09-02 09:15:00,106.69,55.736000000000004,56.21,29.93 +2020-09-02 09:30:00,106.79,57.836000000000006,56.21,29.93 +2020-09-02 09:45:00,106.64,59.413999999999994,56.21,29.93 +2020-09-02 10:00:00,106.81,55.888999999999996,52.358999999999995,29.93 +2020-09-02 10:15:00,107.49,56.603,52.358999999999995,29.93 +2020-09-02 10:30:00,104.67,56.357,52.358999999999995,29.93 +2020-09-02 10:45:00,104.68,57.435,52.358999999999995,29.93 +2020-09-02 11:00:00,102.67,53.89,51.161,29.93 +2020-09-02 11:15:00,101.54,54.961000000000006,51.161,29.93 +2020-09-02 11:30:00,105.83,55.623999999999995,51.161,29.93 +2020-09-02 11:45:00,103.98,56.419,51.161,29.93 +2020-09-02 12:00:00,99.78,52.676,49.119,29.93 +2020-09-02 12:15:00,101.12,52.318999999999996,49.119,29.93 +2020-09-02 12:30:00,101.87,51.45,49.119,29.93 +2020-09-02 12:45:00,104.35,52.54,49.119,29.93 +2020-09-02 13:00:00,100.22,51.608000000000004,49.187,29.93 +2020-09-02 13:15:00,101.85,52.223,49.187,29.93 +2020-09-02 13:30:00,99.0,51.04600000000001,49.187,29.93 +2020-09-02 13:45:00,102.96,50.141999999999996,49.187,29.93 +2020-09-02 14:00:00,102.01,51.306999999999995,49.787,29.93 +2020-09-02 14:15:00,102.18,50.363,49.787,29.93 +2020-09-02 14:30:00,100.6,49.137,49.787,29.93 +2020-09-02 14:45:00,103.38,49.903,49.787,29.93 +2020-09-02 15:00:00,106.74,50.576,51.458999999999996,29.93 +2020-09-02 15:15:00,101.18,48.5,51.458999999999996,29.93 +2020-09-02 15:30:00,102.11,47.323,51.458999999999996,29.93 +2020-09-02 15:45:00,100.59,45.065,51.458999999999996,29.93 +2020-09-02 16:00:00,102.98,47.48,53.663000000000004,29.93 +2020-09-02 16:15:00,103.91,47.295,53.663000000000004,29.93 +2020-09-02 16:30:00,108.38,47.666000000000004,53.663000000000004,29.93 +2020-09-02 16:45:00,107.21,45.275,53.663000000000004,29.93 +2020-09-02 17:00:00,108.81,47.14,58.183,29.93 +2020-09-02 17:15:00,109.2,48.111000000000004,58.183,29.93 +2020-09-02 17:30:00,109.84,47.558,58.183,29.93 +2020-09-02 17:45:00,111.27,46.574,58.183,29.93 +2020-09-02 18:00:00,113.28,48.843,60.141000000000005,29.93 +2020-09-02 18:15:00,110.36,48.303999999999995,60.141000000000005,29.93 +2020-09-02 18:30:00,108.48,46.339,60.141000000000005,29.93 +2020-09-02 18:45:00,112.25,50.495,60.141000000000005,29.93 +2020-09-02 19:00:00,116.4,52.958999999999996,60.582,29.93 +2020-09-02 19:15:00,114.79,51.688,60.582,29.93 +2020-09-02 19:30:00,113.75,50.644,60.582,29.93 +2020-09-02 19:45:00,114.25,50.766000000000005,60.582,29.93 +2020-09-02 20:00:00,110.94,49.023,66.61,29.93 +2020-09-02 20:15:00,106.94,48.503,66.61,29.93 +2020-09-02 20:30:00,102.41,48.07899999999999,66.61,29.93 +2020-09-02 20:45:00,98.12,48.092,66.61,29.93 +2020-09-02 21:00:00,91.53,46.675,57.658,29.93 +2020-09-02 21:15:00,91.25,48.266000000000005,57.658,29.93 +2020-09-02 21:30:00,86.71,47.976000000000006,57.658,29.93 +2020-09-02 21:45:00,86.21,47.672,57.658,29.93 +2020-09-02 22:00:00,80.75,45.28,51.81,29.93 +2020-09-02 22:15:00,81.19,46.005,51.81,29.93 +2020-09-02 22:30:00,80.24,38.691,51.81,29.93 +2020-09-02 22:45:00,83.69,35.178000000000004,51.81,29.93 +2020-09-02 23:00:00,82.31,31.945,42.93600000000001,29.93 +2020-09-02 23:15:00,84.53,31.188000000000002,42.93600000000001,29.93 +2020-09-02 23:30:00,80.72,31.066,42.93600000000001,29.93 +2020-09-02 23:45:00,74.93,30.238000000000003,42.93600000000001,29.93 +2020-09-03 00:00:00,78.78,30.328000000000003,39.211,29.93 +2020-09-03 00:15:00,80.39,30.897,39.211,29.93 +2020-09-03 00:30:00,80.32,30.485,39.211,29.93 +2020-09-03 00:45:00,73.95,30.44,39.211,29.93 +2020-09-03 01:00:00,73.78,30.165,37.607,29.93 +2020-09-03 01:15:00,79.05,29.381,37.607,29.93 +2020-09-03 01:30:00,79.16,28.316,37.607,29.93 +2020-09-03 01:45:00,76.51,27.395,37.607,29.93 +2020-09-03 02:00:00,74.11,27.108,36.44,29.93 +2020-09-03 02:15:00,80.14,26.754,36.44,29.93 +2020-09-03 02:30:00,79.59,27.372,36.44,29.93 +2020-09-03 02:45:00,78.57,28.211,36.44,29.93 +2020-09-03 03:00:00,73.82,29.289,36.116,29.93 +2020-09-03 03:15:00,72.93,28.685,36.116,29.93 +2020-09-03 03:30:00,74.34,28.599,36.116,29.93 +2020-09-03 03:45:00,77.3,28.656999999999996,36.116,29.93 +2020-09-03 04:00:00,81.78,35.27,37.398,29.93 +2020-09-03 04:15:00,82.8,42.558,37.398,29.93 +2020-09-03 04:30:00,91.14,39.907,37.398,29.93 +2020-09-03 04:45:00,98.67,40.465,37.398,29.93 +2020-09-03 05:00:00,108.57,57.937,41.776,29.93 +2020-09-03 05:15:00,103.7,69.179,41.776,29.93 +2020-09-03 05:30:00,104.33,63.162,41.776,29.93 +2020-09-03 05:45:00,105.67,59.306999999999995,41.776,29.93 +2020-09-03 06:00:00,110.59,59.608999999999995,55.61,29.93 +2020-09-03 06:15:00,111.02,60.928000000000004,55.61,29.93 +2020-09-03 06:30:00,112.63,59.537,55.61,29.93 +2020-09-03 06:45:00,113.01,61.175,55.61,29.93 +2020-09-03 07:00:00,115.07,59.497,67.13600000000001,29.93 +2020-09-03 07:15:00,114.55,60.665,67.13600000000001,29.93 +2020-09-03 07:30:00,112.42,58.958,67.13600000000001,29.93 +2020-09-03 07:45:00,112.27,59.832,67.13600000000001,29.93 +2020-09-03 08:00:00,109.14,58.523999999999994,57.55,29.93 +2020-09-03 08:15:00,107.8,60.532,57.55,29.93 +2020-09-03 08:30:00,111.96,59.786,57.55,29.93 +2020-09-03 08:45:00,108.99,61.208,57.55,29.93 +2020-09-03 09:00:00,108.66,57.586000000000006,52.931999999999995,29.93 +2020-09-03 09:15:00,108.25,56.148999999999994,52.931999999999995,29.93 +2020-09-03 09:30:00,107.97,58.228,52.931999999999995,29.93 +2020-09-03 09:45:00,106.96,59.778,52.931999999999995,29.93 +2020-09-03 10:00:00,107.82,56.248999999999995,50.36600000000001,29.93 +2020-09-03 10:15:00,109.16,56.93,50.36600000000001,29.93 +2020-09-03 10:30:00,108.59,56.674,50.36600000000001,29.93 +2020-09-03 10:45:00,107.7,57.74,50.36600000000001,29.93 +2020-09-03 11:00:00,107.03,54.208,47.893,29.93 +2020-09-03 11:15:00,107.11,55.266000000000005,47.893,29.93 +2020-09-03 11:30:00,109.86,55.926,47.893,29.93 +2020-09-03 11:45:00,106.11,56.703,47.893,29.93 +2020-09-03 12:00:00,104.22,52.94,45.271,29.93 +2020-09-03 12:15:00,106.28,52.57,45.271,29.93 +2020-09-03 12:30:00,105.6,51.727,45.271,29.93 +2020-09-03 12:45:00,104.71,52.805,45.271,29.93 +2020-09-03 13:00:00,106.15,51.85,44.351000000000006,29.93 +2020-09-03 13:15:00,107.6,52.457,44.351000000000006,29.93 +2020-09-03 13:30:00,113.46,51.277,44.351000000000006,29.93 +2020-09-03 13:45:00,112.08,50.383,44.351000000000006,29.93 +2020-09-03 14:00:00,113.14,51.511,44.99,29.93 +2020-09-03 14:15:00,108.1,50.577,44.99,29.93 +2020-09-03 14:30:00,104.21,49.376000000000005,44.99,29.93 +2020-09-03 14:45:00,103.23,50.141000000000005,44.99,29.93 +2020-09-03 15:00:00,107.19,50.763000000000005,46.869,29.93 +2020-09-03 15:15:00,106.83,48.701,46.869,29.93 +2020-09-03 15:30:00,105.79,47.54600000000001,46.869,29.93 +2020-09-03 15:45:00,104.72,45.301,46.869,29.93 +2020-09-03 16:00:00,105.33,47.681000000000004,48.902,29.93 +2020-09-03 16:15:00,105.72,47.508,48.902,29.93 +2020-09-03 16:30:00,107.33,47.879,48.902,29.93 +2020-09-03 16:45:00,110.5,45.538999999999994,48.902,29.93 +2020-09-03 17:00:00,112.54,47.36600000000001,53.244,29.93 +2020-09-03 17:15:00,111.29,48.365,53.244,29.93 +2020-09-03 17:30:00,110.8,47.821999999999996,53.244,29.93 +2020-09-03 17:45:00,111.93,46.872,53.244,29.93 +2020-09-03 18:00:00,112.15,49.126999999999995,54.343999999999994,29.93 +2020-09-03 18:15:00,110.62,48.596000000000004,54.343999999999994,29.93 +2020-09-03 18:30:00,112.94,46.643,54.343999999999994,29.93 +2020-09-03 18:45:00,111.47,50.794,54.343999999999994,29.93 +2020-09-03 19:00:00,112.83,53.269,54.332,29.93 +2020-09-03 19:15:00,109.18,51.998000000000005,54.332,29.93 +2020-09-03 19:30:00,109.17,50.953,54.332,29.93 +2020-09-03 19:45:00,112.48,51.073,54.332,29.93 +2020-09-03 20:00:00,107.89,49.346000000000004,58.06,29.93 +2020-09-03 20:15:00,107.07,48.826,58.06,29.93 +2020-09-03 20:30:00,101.06,48.38,58.06,29.93 +2020-09-03 20:45:00,99.03,48.354,58.06,29.93 +2020-09-03 21:00:00,91.24,46.941,52.411,29.93 +2020-09-03 21:15:00,98.02,48.522,52.411,29.93 +2020-09-03 21:30:00,96.57,48.235,52.411,29.93 +2020-09-03 21:45:00,96.41,47.895,52.411,29.93 +2020-09-03 22:00:00,88.52,45.481,47.148999999999994,29.93 +2020-09-03 22:15:00,84.04,46.18600000000001,47.148999999999994,29.93 +2020-09-03 22:30:00,85.59,38.847,47.148999999999994,29.93 +2020-09-03 22:45:00,88.8,35.336999999999996,47.148999999999994,29.93 +2020-09-03 23:00:00,84.03,32.149,40.814,29.93 +2020-09-03 23:15:00,81.23,31.37,40.814,29.93 +2020-09-03 23:30:00,78.97,31.255,40.814,29.93 +2020-09-03 23:45:00,84.43,30.430999999999997,40.814,29.93 +2020-09-04 00:00:00,79.88,28.809,39.153,29.93 +2020-09-04 00:15:00,81.93,29.595,39.153,29.93 +2020-09-04 00:30:00,76.83,29.414,39.153,29.93 +2020-09-04 00:45:00,81.19,29.766,39.153,29.93 +2020-09-04 01:00:00,78.59,29.094,37.228,29.93 +2020-09-04 01:15:00,75.78,27.914,37.228,29.93 +2020-09-04 01:30:00,74.53,27.471,37.228,29.93 +2020-09-04 01:45:00,76.53,26.340999999999998,37.228,29.93 +2020-09-04 02:00:00,79.34,26.897,35.851,29.93 +2020-09-04 02:15:00,80.68,26.505,35.851,29.93 +2020-09-04 02:30:00,79.68,27.898000000000003,35.851,29.93 +2020-09-04 02:45:00,72.95,28.131,35.851,29.93 +2020-09-04 03:00:00,71.7,29.756,36.54,29.93 +2020-09-04 03:15:00,72.28,28.221,36.54,29.93 +2020-09-04 03:30:00,76.49,27.927,36.54,29.93 +2020-09-04 03:45:00,83.91,28.791999999999998,36.54,29.93 +2020-09-04 04:00:00,87.81,35.59,37.578,29.93 +2020-09-04 04:15:00,87.55,41.49100000000001,37.578,29.93 +2020-09-04 04:30:00,92.55,39.719,37.578,29.93 +2020-09-04 04:45:00,98.47,39.561,37.578,29.93 +2020-09-04 05:00:00,103.34,56.532,40.387,29.93 +2020-09-04 05:15:00,107.52,68.905,40.387,29.93 +2020-09-04 05:30:00,105.74,63.184,40.387,29.93 +2020-09-04 05:45:00,109.74,58.878,40.387,29.93 +2020-09-04 06:00:00,115.98,59.403999999999996,54.668,29.93 +2020-09-04 06:15:00,115.47,60.705,54.668,29.93 +2020-09-04 06:30:00,117.38,59.156000000000006,54.668,29.93 +2020-09-04 06:45:00,119.84,60.883,54.668,29.93 +2020-09-04 07:00:00,121.76,59.673,63.971000000000004,29.93 +2020-09-04 07:15:00,124.37,61.793,63.971000000000004,29.93 +2020-09-04 07:30:00,124.71,58.375,63.971000000000004,29.93 +2020-09-04 07:45:00,124.2,58.946000000000005,63.971000000000004,29.93 +2020-09-04 08:00:00,125.37,58.18,56.042,29.93 +2020-09-04 08:15:00,124.6,60.688,56.042,29.93 +2020-09-04 08:30:00,128.49,59.994,56.042,29.93 +2020-09-04 08:45:00,128.74,61.058,56.042,29.93 +2020-09-04 09:00:00,130.79,55.483000000000004,52.832,29.93 +2020-09-04 09:15:00,128.22,55.792,52.832,29.93 +2020-09-04 09:30:00,125.85,57.18899999999999,52.832,29.93 +2020-09-04 09:45:00,122.8,59.033,52.832,29.93 +2020-09-04 10:00:00,123.79,55.185,50.044,29.93 +2020-09-04 10:15:00,126.55,55.742,50.044,29.93 +2020-09-04 10:30:00,128.28,55.945,50.044,29.93 +2020-09-04 10:45:00,124.38,56.851000000000006,50.044,29.93 +2020-09-04 11:00:00,124.72,53.549,49.06100000000001,29.93 +2020-09-04 11:15:00,126.1,53.519,49.06100000000001,29.93 +2020-09-04 11:30:00,129.93,54.115,49.06100000000001,29.93 +2020-09-04 11:45:00,120.43,54.05,49.06100000000001,29.93 +2020-09-04 12:00:00,116.34,50.803999999999995,45.595,29.93 +2020-09-04 12:15:00,122.71,49.553999999999995,45.595,29.93 +2020-09-04 12:30:00,109.57,48.861999999999995,45.595,29.93 +2020-09-04 12:45:00,103.66,49.325,45.595,29.93 +2020-09-04 13:00:00,100.92,48.995,43.218,29.93 +2020-09-04 13:15:00,101.16,49.873000000000005,43.218,29.93 +2020-09-04 13:30:00,100.93,49.382,43.218,29.93 +2020-09-04 13:45:00,100.61,48.751999999999995,43.218,29.93 +2020-09-04 14:00:00,99.88,48.977,41.926,29.93 +2020-09-04 14:15:00,100.39,48.406000000000006,41.926,29.93 +2020-09-04 14:30:00,103.67,48.613,41.926,29.93 +2020-09-04 14:45:00,105.03,48.823,41.926,29.93 +2020-09-04 15:00:00,106.17,49.254,43.79,29.93 +2020-09-04 15:15:00,101.95,46.93600000000001,43.79,29.93 +2020-09-04 15:30:00,95.93,45.091,43.79,29.93 +2020-09-04 15:45:00,97.23,43.538999999999994,43.79,29.93 +2020-09-04 16:00:00,99.92,44.988,45.895,29.93 +2020-09-04 16:15:00,100.88,45.3,45.895,29.93 +2020-09-04 16:30:00,104.55,45.525,45.895,29.93 +2020-09-04 16:45:00,106.79,42.534,45.895,29.93 +2020-09-04 17:00:00,106.67,45.867,51.36,29.93 +2020-09-04 17:15:00,105.13,46.685,51.36,29.93 +2020-09-04 17:30:00,106.1,46.251999999999995,51.36,29.93 +2020-09-04 17:45:00,108.53,45.159,51.36,29.93 +2020-09-04 18:00:00,108.52,47.55,52.985,29.93 +2020-09-04 18:15:00,105.88,46.141999999999996,52.985,29.93 +2020-09-04 18:30:00,108.88,44.162,52.985,29.93 +2020-09-04 18:45:00,110.64,48.68,52.985,29.93 +2020-09-04 19:00:00,111.33,52.07,52.602,29.93 +2020-09-04 19:15:00,112.44,51.495,52.602,29.93 +2020-09-04 19:30:00,112.3,50.453,52.602,29.93 +2020-09-04 19:45:00,106.77,49.611999999999995,52.602,29.93 +2020-09-04 20:00:00,98.61,47.763000000000005,58.063,29.93 +2020-09-04 20:15:00,99.3,47.946999999999996,58.063,29.93 +2020-09-04 20:30:00,95.69,47.044,58.063,29.93 +2020-09-04 20:45:00,93.25,46.33,58.063,29.93 +2020-09-04 21:00:00,88.17,46.15,50.135,29.93 +2020-09-04 21:15:00,88.23,49.258,50.135,29.93 +2020-09-04 21:30:00,85.09,48.846000000000004,50.135,29.93 +2020-09-04 21:45:00,85.78,48.718999999999994,50.135,29.93 +2020-09-04 22:00:00,82.3,46.283,45.165,29.93 +2020-09-04 22:15:00,81.47,46.729,45.165,29.93 +2020-09-04 22:30:00,77.2,44.387,45.165,29.93 +2020-09-04 22:45:00,79.3,42.086000000000006,45.165,29.93 +2020-09-04 23:00:00,74.88,40.385,39.121,29.93 +2020-09-04 23:15:00,70.48,37.946999999999996,39.121,29.93 +2020-09-04 23:30:00,72.77,36.041,39.121,29.93 +2020-09-04 23:45:00,73.16,35.028,39.121,29.93 +2020-09-05 00:00:00,69.6,29.750999999999998,38.49,29.816 +2020-09-05 00:15:00,65.9,29.398000000000003,38.49,29.816 +2020-09-05 00:30:00,61.59,28.978,38.49,29.816 +2020-09-05 00:45:00,64.02,28.78,38.49,29.816 +2020-09-05 01:00:00,62.98,28.416999999999998,34.5,29.816 +2020-09-05 01:15:00,69.08,27.651,34.5,29.816 +2020-09-05 01:30:00,69.9,26.445999999999998,34.5,29.816 +2020-09-05 01:45:00,65.62,26.391,34.5,29.816 +2020-09-05 02:00:00,59.22,26.165,32.236,29.816 +2020-09-05 02:15:00,61.62,25.051,32.236,29.816 +2020-09-05 02:30:00,60.74,25.576999999999998,32.236,29.816 +2020-09-05 02:45:00,61.81,26.514,32.236,29.816 +2020-09-05 03:00:00,59.94,26.993000000000002,32.067,29.816 +2020-09-05 03:15:00,61.24,24.705,32.067,29.816 +2020-09-05 03:30:00,61.65,24.504,32.067,29.816 +2020-09-05 03:45:00,61.59,26.679000000000002,32.067,29.816 +2020-09-05 04:00:00,61.57,31.316999999999997,33.071,29.816 +2020-09-05 04:15:00,59.69,36.134,33.071,29.816 +2020-09-05 04:30:00,62.02,32.619,33.071,29.816 +2020-09-05 04:45:00,63.77,32.647,33.071,29.816 +2020-09-05 05:00:00,64.24,40.606,33.014,29.816 +2020-09-05 05:15:00,65.87,41.105,33.014,29.816 +2020-09-05 05:30:00,65.26,36.77,33.014,29.816 +2020-09-05 05:45:00,64.15,37.029,33.014,29.816 +2020-09-05 06:00:00,67.9,49.608999999999995,34.628,29.816 +2020-09-05 06:15:00,70.61,59.795,34.628,29.816 +2020-09-05 06:30:00,71.88,55.083,34.628,29.816 +2020-09-05 06:45:00,72.73,52.895,34.628,29.816 +2020-09-05 07:00:00,75.06,49.843999999999994,38.871,29.816 +2020-09-05 07:15:00,73.03,50.681999999999995,38.871,29.816 +2020-09-05 07:30:00,77.02,48.961999999999996,38.871,29.816 +2020-09-05 07:45:00,79.22,50.842,38.871,29.816 +2020-09-05 08:00:00,77.29,51.131,43.293,29.816 +2020-09-05 08:15:00,78.24,53.879,43.293,29.816 +2020-09-05 08:30:00,76.23,53.385,43.293,29.816 +2020-09-05 08:45:00,75.72,55.662,43.293,29.816 +2020-09-05 09:00:00,74.97,53.013000000000005,44.559,29.816 +2020-09-05 09:15:00,72.64,53.833,44.559,29.816 +2020-09-05 09:30:00,73.09,55.76,44.559,29.816 +2020-09-05 09:45:00,75.25,57.203,44.559,29.816 +2020-09-05 10:00:00,74.29,53.878,42.091,29.816 +2020-09-05 10:15:00,73.61,54.733000000000004,42.091,29.816 +2020-09-05 10:30:00,73.64,54.651,42.091,29.816 +2020-09-05 10:45:00,71.59,55.418,42.091,29.816 +2020-09-05 11:00:00,70.86,52.035,38.505,29.816 +2020-09-05 11:15:00,67.38,52.648,38.505,29.816 +2020-09-05 11:30:00,67.78,53.333999999999996,38.505,29.816 +2020-09-05 11:45:00,67.79,53.699,38.505,29.816 +2020-09-05 12:00:00,64.86,50.608999999999995,35.388000000000005,29.816 +2020-09-05 12:15:00,64.27,50.17,35.388000000000005,29.816 +2020-09-05 12:30:00,61.73,49.437,35.388000000000005,29.816 +2020-09-05 12:45:00,61.93,50.407,35.388000000000005,29.816 +2020-09-05 13:00:00,57.25,49.278999999999996,31.355999999999998,29.816 +2020-09-05 13:15:00,60.01,49.393,31.355999999999998,29.816 +2020-09-05 13:30:00,60.1,48.998000000000005,31.355999999999998,29.816 +2020-09-05 13:45:00,58.58,47.281000000000006,31.355999999999998,29.816 +2020-09-05 14:00:00,62.4,47.692,30.522,29.816 +2020-09-05 14:15:00,62.32,45.977,30.522,29.816 +2020-09-05 14:30:00,69.06,45.586000000000006,30.522,29.816 +2020-09-05 14:45:00,67.31,46.24,30.522,29.816 +2020-09-05 15:00:00,64.95,47.103,34.36,29.816 +2020-09-05 15:15:00,68.02,45.523,34.36,29.816 +2020-09-05 15:30:00,73.3,44.092,34.36,29.816 +2020-09-05 15:45:00,66.23,41.758,34.36,29.816 +2020-09-05 16:00:00,70.0,44.927,39.507,29.816 +2020-09-05 16:15:00,70.69,44.593999999999994,39.507,29.816 +2020-09-05 16:30:00,72.97,45.01,39.507,29.816 +2020-09-05 16:45:00,75.43,42.166000000000004,39.507,29.816 +2020-09-05 17:00:00,78.45,44.356,47.151,29.816 +2020-09-05 17:15:00,79.25,43.55,47.151,29.816 +2020-09-05 17:30:00,80.34,42.998000000000005,47.151,29.816 +2020-09-05 17:45:00,81.44,42.316,47.151,29.816 +2020-09-05 18:00:00,84.42,45.887,50.303999999999995,29.816 +2020-09-05 18:15:00,82.25,46.185,50.303999999999995,29.816 +2020-09-05 18:30:00,82.55,45.576,50.303999999999995,29.816 +2020-09-05 18:45:00,85.27,46.61600000000001,50.303999999999995,29.816 +2020-09-05 19:00:00,83.52,48.75899999999999,50.622,29.816 +2020-09-05 19:15:00,83.04,47.233000000000004,50.622,29.816 +2020-09-05 19:30:00,81.56,46.961999999999996,50.622,29.816 +2020-09-05 19:45:00,80.53,47.653,50.622,29.816 +2020-09-05 20:00:00,77.83,46.8,45.391000000000005,29.816 +2020-09-05 20:15:00,76.29,46.696000000000005,45.391000000000005,29.816 +2020-09-05 20:30:00,75.74,44.957,45.391000000000005,29.816 +2020-09-05 20:45:00,75.02,45.777,45.391000000000005,29.816 +2020-09-05 21:00:00,71.28,44.6,39.98,29.816 +2020-09-05 21:15:00,70.5,47.446000000000005,39.98,29.816 +2020-09-05 21:30:00,67.18,47.358999999999995,39.98,29.816 +2020-09-05 21:45:00,67.05,46.666000000000004,39.98,29.816 +2020-09-05 22:00:00,64.24,44.354,37.53,29.816 +2020-09-05 22:15:00,62.85,45.309,37.53,29.816 +2020-09-05 22:30:00,60.44,43.391000000000005,37.53,29.816 +2020-09-05 22:45:00,60.44,41.656000000000006,37.53,29.816 +2020-09-05 23:00:00,55.7,39.704,30.97,29.816 +2020-09-05 23:15:00,55.88,37.408,30.97,29.816 +2020-09-05 23:30:00,55.87,37.45,30.97,29.816 +2020-09-05 23:45:00,54.85,36.455,30.97,29.816 +2020-09-06 00:00:00,51.94,31.230999999999998,27.24,29.816 +2020-09-06 00:15:00,52.98,29.822,27.24,29.816 +2020-09-06 00:30:00,51.93,29.235,27.24,29.816 +2020-09-06 00:45:00,52.47,29.033,27.24,29.816 +2020-09-06 01:00:00,50.9,28.868000000000002,25.662,29.816 +2020-09-06 01:15:00,51.69,28.144000000000002,25.662,29.816 +2020-09-06 01:30:00,51.06,26.9,25.662,29.816 +2020-09-06 01:45:00,51.75,26.471999999999998,25.662,29.816 +2020-09-06 02:00:00,50.22,26.226,25.67,29.816 +2020-09-06 02:15:00,50.83,25.649,25.67,29.816 +2020-09-06 02:30:00,50.93,26.423000000000002,25.67,29.816 +2020-09-06 02:45:00,51.01,27.158,25.67,29.816 +2020-09-06 03:00:00,50.72,28.236,24.258000000000003,29.816 +2020-09-06 03:15:00,51.86,26.129,24.258000000000003,29.816 +2020-09-06 03:30:00,51.93,25.463,24.258000000000003,29.816 +2020-09-06 03:45:00,52.05,26.918000000000003,24.258000000000003,29.816 +2020-09-06 04:00:00,52.55,31.531,25.051,29.816 +2020-09-06 04:15:00,52.95,35.867,25.051,29.816 +2020-09-06 04:30:00,53.59,33.545,25.051,29.816 +2020-09-06 04:45:00,54.33,33.202,25.051,29.816 +2020-09-06 05:00:00,56.46,40.988,25.145,29.816 +2020-09-06 05:15:00,55.34,40.624,25.145,29.816 +2020-09-06 05:30:00,53.86,35.900999999999996,25.145,29.816 +2020-09-06 05:45:00,55.65,35.897,25.145,29.816 +2020-09-06 06:00:00,56.53,46.333999999999996,26.371,29.816 +2020-09-06 06:15:00,57.41,57.077,26.371,29.816 +2020-09-06 06:30:00,58.46,51.619,26.371,29.816 +2020-09-06 06:45:00,60.16,48.426,26.371,29.816 +2020-09-06 07:00:00,60.1,45.915,28.756999999999998,29.816 +2020-09-06 07:15:00,60.26,45.225,28.756999999999998,29.816 +2020-09-06 07:30:00,65.45,44.508,28.756999999999998,29.816 +2020-09-06 07:45:00,66.55,46.265,28.756999999999998,29.816 +2020-09-06 08:00:00,66.48,47.442,32.82,29.816 +2020-09-06 08:15:00,65.64,51.174,32.82,29.816 +2020-09-06 08:30:00,58.67,51.653,32.82,29.816 +2020-09-06 08:45:00,61.25,54.093,32.82,29.816 +2020-09-06 09:00:00,63.13,51.301,35.534,29.816 +2020-09-06 09:15:00,57.61,51.75,35.534,29.816 +2020-09-06 09:30:00,58.16,54.012,35.534,29.816 +2020-09-06 09:45:00,60.38,56.286,35.534,29.816 +2020-09-06 10:00:00,61.07,53.742,35.925,29.816 +2020-09-06 10:15:00,60.78,54.75,35.925,29.816 +2020-09-06 10:30:00,63.8,54.928000000000004,35.925,29.816 +2020-09-06 10:45:00,61.65,56.318000000000005,35.925,29.816 +2020-09-06 11:00:00,61.14,52.763999999999996,37.056,29.816 +2020-09-06 11:15:00,57.92,52.993,37.056,29.816 +2020-09-06 11:30:00,60.04,54.018,37.056,29.816 +2020-09-06 11:45:00,59.7,54.666000000000004,37.056,29.816 +2020-09-06 12:00:00,59.47,52.391999999999996,33.124,29.816 +2020-09-06 12:15:00,59.46,51.644,33.124,29.816 +2020-09-06 12:30:00,57.73,50.933,33.124,29.816 +2020-09-06 12:45:00,56.0,51.214,33.124,29.816 +2020-09-06 13:00:00,53.2,49.711000000000006,29.874000000000002,29.816 +2020-09-06 13:15:00,54.82,49.708,29.874000000000002,29.816 +2020-09-06 13:30:00,54.67,48.316,29.874000000000002,29.816 +2020-09-06 13:45:00,53.78,47.49100000000001,29.874000000000002,29.816 +2020-09-06 14:00:00,52.66,48.938,27.302,29.816 +2020-09-06 14:15:00,55.42,47.761,27.302,29.816 +2020-09-06 14:30:00,57.66,46.483000000000004,27.302,29.816 +2020-09-06 14:45:00,57.04,46.153,27.302,29.816 +2020-09-06 15:00:00,57.93,46.895,27.642,29.816 +2020-09-06 15:15:00,56.35,44.723,27.642,29.816 +2020-09-06 15:30:00,55.04,43.233000000000004,27.642,29.816 +2020-09-06 15:45:00,55.71,41.247,27.642,29.816 +2020-09-06 16:00:00,59.68,43.17,31.945999999999998,29.816 +2020-09-06 16:15:00,60.8,42.937,31.945999999999998,29.816 +2020-09-06 16:30:00,66.16,44.26,31.945999999999998,29.816 +2020-09-06 16:45:00,71.81,41.54,31.945999999999998,29.816 +2020-09-06 17:00:00,76.25,44.00899999999999,40.387,29.816 +2020-09-06 17:15:00,77.49,44.511,40.387,29.816 +2020-09-06 17:30:00,78.34,44.708,40.387,29.816 +2020-09-06 17:45:00,74.93,44.711000000000006,40.387,29.816 +2020-09-06 18:00:00,79.12,48.699,44.575,29.816 +2020-09-06 18:15:00,78.19,48.806999999999995,44.575,29.816 +2020-09-06 18:30:00,80.41,47.744,44.575,29.816 +2020-09-06 18:45:00,80.48,49.102,44.575,29.816 +2020-09-06 19:00:00,92.24,53.111000000000004,45.623999999999995,29.816 +2020-09-06 19:15:00,91.93,50.673,45.623999999999995,29.816 +2020-09-06 19:30:00,89.23,50.163999999999994,45.623999999999995,29.816 +2020-09-06 19:45:00,85.71,50.617,45.623999999999995,29.816 +2020-09-06 20:00:00,81.21,49.949,44.583999999999996,29.816 +2020-09-06 20:15:00,87.39,49.841,44.583999999999996,29.816 +2020-09-06 20:30:00,86.81,48.938,44.583999999999996,29.816 +2020-09-06 20:45:00,86.6,48.099,44.583999999999996,29.816 +2020-09-06 21:00:00,78.52,46.498999999999995,39.732,29.816 +2020-09-06 21:15:00,79.0,48.997,39.732,29.816 +2020-09-06 21:30:00,80.14,48.376999999999995,39.732,29.816 +2020-09-06 21:45:00,77.29,47.978,39.732,29.816 +2020-09-06 22:00:00,73.53,47.293,38.571,29.816 +2020-09-06 22:15:00,73.0,46.657,38.571,29.816 +2020-09-06 22:30:00,71.65,43.901,38.571,29.816 +2020-09-06 22:45:00,71.15,40.95,38.571,29.816 +2020-09-06 23:00:00,65.54,38.457,33.121,29.816 +2020-09-06 23:15:00,66.93,37.468,33.121,29.816 +2020-09-06 23:30:00,66.52,37.135999999999996,33.121,29.816 +2020-09-06 23:45:00,66.55,36.387,33.121,29.816 +2020-09-07 00:00:00,64.09,33.271,32.506,29.93 +2020-09-07 00:15:00,66.1,32.906,32.506,29.93 +2020-09-07 00:30:00,65.16,32.003,32.506,29.93 +2020-09-07 00:45:00,72.59,31.397,32.506,29.93 +2020-09-07 01:00:00,71.19,31.555999999999997,31.121,29.93 +2020-09-07 01:15:00,72.55,30.802,31.121,29.93 +2020-09-07 01:30:00,70.95,29.899,31.121,29.93 +2020-09-07 01:45:00,71.59,29.4,31.121,29.93 +2020-09-07 02:00:00,71.83,29.554000000000002,29.605999999999998,29.93 +2020-09-07 02:15:00,71.29,28.28,29.605999999999998,29.93 +2020-09-07 02:30:00,65.32,29.213,29.605999999999998,29.93 +2020-09-07 02:45:00,68.55,29.741,29.605999999999998,29.93 +2020-09-07 03:00:00,73.65,31.401,28.124000000000002,29.93 +2020-09-07 03:15:00,75.4,30.138,28.124000000000002,29.93 +2020-09-07 03:30:00,77.07,30.045,28.124000000000002,29.93 +2020-09-07 03:45:00,72.74,31.016,28.124000000000002,29.93 +2020-09-07 04:00:00,78.77,38.909,29.743000000000002,29.93 +2020-09-07 04:15:00,78.27,46.333999999999996,29.743000000000002,29.93 +2020-09-07 04:30:00,81.92,43.856,29.743000000000002,29.93 +2020-09-07 04:45:00,87.93,43.864,29.743000000000002,29.93 +2020-09-07 05:00:00,95.33,59.738,36.191,29.93 +2020-09-07 05:15:00,99.43,70.842,36.191,29.93 +2020-09-07 05:30:00,100.39,64.77199999999999,36.191,29.93 +2020-09-07 05:45:00,103.16,61.369,36.191,29.93 +2020-09-07 06:00:00,107.79,60.716,55.277,29.93 +2020-09-07 06:15:00,107.35,61.775,55.277,29.93 +2020-09-07 06:30:00,111.41,60.729,55.277,29.93 +2020-09-07 06:45:00,110.88,63.191,55.277,29.93 +2020-09-07 07:00:00,111.5,61.396,65.697,29.93 +2020-09-07 07:15:00,111.03,62.872,65.697,29.93 +2020-09-07 07:30:00,108.26,61.34,65.697,29.93 +2020-09-07 07:45:00,107.84,63.056999999999995,65.697,29.93 +2020-09-07 08:00:00,106.73,61.815,57.028,29.93 +2020-09-07 08:15:00,106.0,64.19800000000001,57.028,29.93 +2020-09-07 08:30:00,105.72,63.345,57.028,29.93 +2020-09-07 08:45:00,105.41,65.547,57.028,29.93 +2020-09-07 09:00:00,105.06,61.758,52.633,29.93 +2020-09-07 09:15:00,103.6,60.206,52.633,29.93 +2020-09-07 09:30:00,104.14,61.515,52.633,29.93 +2020-09-07 09:45:00,108.19,61.792,52.633,29.93 +2020-09-07 10:00:00,105.88,59.531000000000006,50.647,29.93 +2020-09-07 10:15:00,107.1,60.333999999999996,50.647,29.93 +2020-09-07 10:30:00,106.5,59.99,50.647,29.93 +2020-09-07 10:45:00,110.53,60.085,50.647,29.93 +2020-09-07 11:00:00,112.98,56.413000000000004,50.245,29.93 +2020-09-07 11:15:00,107.64,57.105,50.245,29.93 +2020-09-07 11:30:00,101.36,58.91,50.245,29.93 +2020-09-07 11:45:00,103.67,59.924,50.245,29.93 +2020-09-07 12:00:00,108.34,56.413000000000004,46.956,29.93 +2020-09-07 12:15:00,106.87,55.748000000000005,46.956,29.93 +2020-09-07 12:30:00,97.46,54.169,46.956,29.93 +2020-09-07 12:45:00,99.84,54.635,46.956,29.93 +2020-09-07 13:00:00,97.74,53.949,47.383,29.93 +2020-09-07 13:15:00,100.52,52.99100000000001,47.383,29.93 +2020-09-07 13:30:00,97.51,51.676,47.383,29.93 +2020-09-07 13:45:00,96.25,51.64,47.383,29.93 +2020-09-07 14:00:00,98.27,52.184,47.1,29.93 +2020-09-07 14:15:00,99.93,51.449,47.1,29.93 +2020-09-07 14:30:00,104.1,49.985,47.1,29.93 +2020-09-07 14:45:00,107.04,51.446999999999996,47.1,29.93 +2020-09-07 15:00:00,106.36,52.048,49.355,29.93 +2020-09-07 15:15:00,104.11,49.193999999999996,49.355,29.93 +2020-09-07 15:30:00,105.03,48.29,49.355,29.93 +2020-09-07 15:45:00,108.61,45.835,49.355,29.93 +2020-09-07 16:00:00,109.53,48.643,52.14,29.93 +2020-09-07 16:15:00,109.89,48.38399999999999,52.14,29.93 +2020-09-07 16:30:00,113.1,49.004,52.14,29.93 +2020-09-07 16:45:00,113.41,46.208,52.14,29.93 +2020-09-07 17:00:00,113.76,47.629,58.705,29.93 +2020-09-07 17:15:00,113.0,48.368,58.705,29.93 +2020-09-07 17:30:00,115.63,48.17,58.705,29.93 +2020-09-07 17:45:00,114.27,47.673,58.705,29.93 +2020-09-07 18:00:00,115.1,50.723,59.153,29.93 +2020-09-07 18:15:00,112.76,48.966,59.153,29.93 +2020-09-07 18:30:00,113.45,47.331,59.153,29.93 +2020-09-07 18:45:00,118.05,51.548,59.153,29.93 +2020-09-07 19:00:00,114.98,55.098,61.483000000000004,29.93 +2020-09-07 19:15:00,112.15,53.696999999999996,61.483000000000004,29.93 +2020-09-07 19:30:00,113.66,52.918,61.483000000000004,29.93 +2020-09-07 19:45:00,113.35,52.733000000000004,61.483000000000004,29.93 +2020-09-07 20:00:00,109.69,50.699,67.55,29.93 +2020-09-07 20:15:00,107.25,51.534,67.55,29.93 +2020-09-07 20:30:00,106.23,50.851000000000006,67.55,29.93 +2020-09-07 20:45:00,103.57,50.35,67.55,29.93 +2020-09-07 21:00:00,96.79,48.262,60.026,29.93 +2020-09-07 21:15:00,92.0,51.003,60.026,29.93 +2020-09-07 21:30:00,87.06,50.658,60.026,29.93 +2020-09-07 21:45:00,91.31,49.968999999999994,60.026,29.93 +2020-09-07 22:00:00,88.7,47.123000000000005,52.736999999999995,29.93 +2020-09-07 22:15:00,87.47,48.068999999999996,52.736999999999995,29.93 +2020-09-07 22:30:00,82.02,40.363,52.736999999999995,29.93 +2020-09-07 22:45:00,84.25,36.84,52.736999999999995,29.93 +2020-09-07 23:00:00,81.67,34.525999999999996,44.408,29.93 +2020-09-07 23:15:00,82.18,32.357,44.408,29.93 +2020-09-07 23:30:00,79.67,32.315,44.408,29.93 +2020-09-07 23:45:00,80.93,31.396,44.408,29.93 +2020-09-08 00:00:00,78.69,31.4,44.438,29.93 +2020-09-08 00:15:00,78.24,31.965,44.438,29.93 +2020-09-08 00:30:00,74.74,31.59,44.438,29.93 +2020-09-08 00:45:00,78.91,31.578000000000003,44.438,29.93 +2020-09-08 01:00:00,78.69,31.218000000000004,41.468999999999994,29.93 +2020-09-08 01:15:00,79.73,30.53,41.468999999999994,29.93 +2020-09-08 01:30:00,74.2,29.549,41.468999999999994,29.93 +2020-09-08 01:45:00,79.22,28.631999999999998,41.468999999999994,29.93 +2020-09-08 02:00:00,78.78,28.364,39.708,29.93 +2020-09-08 02:15:00,79.67,28.124000000000002,39.708,29.93 +2020-09-08 02:30:00,78.51,28.616999999999997,39.708,29.93 +2020-09-08 02:45:00,78.4,29.432,39.708,29.93 +2020-09-08 03:00:00,81.3,30.445999999999998,38.919000000000004,29.93 +2020-09-08 03:15:00,79.9,29.929000000000002,38.919000000000004,29.93 +2020-09-08 03:30:00,77.62,29.871,38.919000000000004,29.93 +2020-09-08 03:45:00,81.23,29.865,38.919000000000004,29.93 +2020-09-08 04:00:00,88.53,36.65,40.092,29.93 +2020-09-08 04:15:00,91.69,44.075,40.092,29.93 +2020-09-08 04:30:00,94.06,41.461999999999996,40.092,29.93 +2020-09-08 04:45:00,99.19,42.047,40.092,29.93 +2020-09-08 05:00:00,107.52,59.983000000000004,43.713,29.93 +2020-09-08 05:15:00,111.02,71.717,43.713,29.93 +2020-09-08 05:30:00,106.44,65.628,43.713,29.93 +2020-09-08 05:45:00,110.66,61.523,43.713,29.93 +2020-09-08 06:00:00,113.9,61.646,56.033,29.93 +2020-09-08 06:15:00,116.71,63.085,56.033,29.93 +2020-09-08 06:30:00,119.58,61.678000000000004,56.033,29.93 +2020-09-08 06:45:00,119.15,63.302,56.033,29.93 +2020-09-08 07:00:00,121.33,61.621,66.003,29.93 +2020-09-08 07:15:00,120.99,62.865,66.003,29.93 +2020-09-08 07:30:00,121.51,61.317,66.003,29.93 +2020-09-08 07:45:00,119.76,62.193999999999996,66.003,29.93 +2020-09-08 08:00:00,113.06,60.905,57.474,29.93 +2020-09-08 08:15:00,112.42,62.732,57.474,29.93 +2020-09-08 08:30:00,112.09,62.012,57.474,29.93 +2020-09-08 08:45:00,114.08,63.347,57.474,29.93 +2020-09-08 09:00:00,111.56,59.766999999999996,51.928000000000004,29.93 +2020-09-08 09:15:00,113.34,58.276,51.928000000000004,29.93 +2020-09-08 09:30:00,112.99,60.253,51.928000000000004,29.93 +2020-09-08 09:45:00,116.22,61.656000000000006,51.928000000000004,29.93 +2020-09-08 10:00:00,117.84,58.111000000000004,49.46,29.93 +2020-09-08 10:15:00,109.46,58.625,49.46,29.93 +2020-09-08 10:30:00,109.25,58.316,49.46,29.93 +2020-09-08 10:45:00,109.93,59.315,49.46,29.93 +2020-09-08 11:00:00,105.93,55.848,48.206,29.93 +2020-09-08 11:15:00,109.06,56.84,48.206,29.93 +2020-09-08 11:30:00,107.95,57.488,48.206,29.93 +2020-09-08 11:45:00,108.6,58.178000000000004,48.206,29.93 +2020-09-08 12:00:00,108.71,54.305,46.285,29.93 +2020-09-08 12:15:00,108.48,53.868,46.285,29.93 +2020-09-08 12:30:00,108.83,53.159,46.285,29.93 +2020-09-08 12:45:00,105.3,54.181999999999995,46.285,29.93 +2020-09-08 13:00:00,104.99,53.108000000000004,46.861999999999995,29.93 +2020-09-08 13:15:00,107.88,53.677,46.861999999999995,29.93 +2020-09-08 13:30:00,110.14,52.479,46.861999999999995,29.93 +2020-09-08 13:45:00,107.86,51.63,46.861999999999995,29.93 +2020-09-08 14:00:00,108.5,52.566,46.488,29.93 +2020-09-08 14:15:00,109.29,51.687,46.488,29.93 +2020-09-08 14:30:00,103.98,50.617,46.488,29.93 +2020-09-08 14:45:00,103.36,51.375,46.488,29.93 +2020-09-08 15:00:00,104.44,51.73,48.442,29.93 +2020-09-08 15:15:00,102.88,49.745,48.442,29.93 +2020-09-08 15:30:00,100.95,48.707,48.442,29.93 +2020-09-08 15:45:00,101.93,46.519,48.442,29.93 +2020-09-08 16:00:00,106.74,48.718999999999994,50.397,29.93 +2020-09-08 16:15:00,109.62,48.608000000000004,50.397,29.93 +2020-09-08 16:30:00,111.41,48.976000000000006,50.397,29.93 +2020-09-08 16:45:00,111.89,46.9,50.397,29.93 +2020-09-08 17:00:00,114.63,48.536,56.668,29.93 +2020-09-08 17:15:00,112.41,49.675,56.668,29.93 +2020-09-08 17:30:00,113.49,49.18,56.668,29.93 +2020-09-08 17:45:00,115.51,48.411,56.668,29.93 +2020-09-08 18:00:00,115.96,50.586999999999996,57.957,29.93 +2020-09-08 18:15:00,112.95,50.101000000000006,57.957,29.93 +2020-09-08 18:30:00,113.99,48.21,57.957,29.93 +2020-09-08 18:45:00,116.35,52.343,57.957,29.93 +2020-09-08 19:00:00,120.65,54.869,57.056000000000004,29.93 +2020-09-08 19:15:00,118.5,53.596000000000004,57.056000000000004,29.93 +2020-09-08 19:30:00,120.53,52.551,57.056000000000004,29.93 +2020-09-08 19:45:00,111.59,52.668,57.056000000000004,29.93 +2020-09-08 20:00:00,103.31,51.019,64.156,29.93 +2020-09-08 20:15:00,102.93,50.501000000000005,64.156,29.93 +2020-09-08 20:30:00,100.46,49.946999999999996,64.156,29.93 +2020-09-08 20:45:00,102.55,49.711000000000006,64.156,29.93 +2020-09-08 21:00:00,101.45,48.318000000000005,56.507,29.93 +2020-09-08 21:15:00,102.7,49.847,56.507,29.93 +2020-09-08 21:30:00,97.23,49.58,56.507,29.93 +2020-09-08 21:45:00,96.21,49.055,56.507,29.93 +2020-09-08 22:00:00,91.16,46.526,50.728,29.93 +2020-09-08 22:15:00,91.57,47.123000000000005,50.728,29.93 +2020-09-08 22:30:00,84.85,39.665,50.728,29.93 +2020-09-08 22:45:00,86.91,36.171,50.728,29.93 +2020-09-08 23:00:00,84.69,33.22,43.556999999999995,29.93 +2020-09-08 23:15:00,85.78,32.317,43.556999999999995,29.93 +2020-09-08 23:30:00,82.52,32.239000000000004,43.556999999999995,29.93 +2020-09-08 23:45:00,85.59,31.436999999999998,43.556999999999995,29.93 +2020-09-09 00:00:00,82.31,31.622,41.151,29.93 +2020-09-09 00:15:00,83.13,32.187,41.151,29.93 +2020-09-09 00:30:00,79.51,31.819000000000003,41.151,29.93 +2020-09-09 00:45:00,77.56,31.813000000000002,41.151,29.93 +2020-09-09 01:00:00,79.6,31.435,37.763000000000005,29.93 +2020-09-09 01:15:00,81.91,30.766,37.763000000000005,29.93 +2020-09-09 01:30:00,82.15,29.803,37.763000000000005,29.93 +2020-09-09 01:45:00,79.99,28.886999999999997,37.763000000000005,29.93 +2020-09-09 02:00:00,74.52,28.623,35.615,29.93 +2020-09-09 02:15:00,74.8,28.405,35.615,29.93 +2020-09-09 02:30:00,81.69,28.874000000000002,35.615,29.93 +2020-09-09 02:45:00,82.63,29.683000000000003,35.615,29.93 +2020-09-09 03:00:00,83.44,30.685,35.153,29.93 +2020-09-09 03:15:00,78.79,30.186,35.153,29.93 +2020-09-09 03:30:00,84.28,30.133000000000003,35.153,29.93 +2020-09-09 03:45:00,86.29,30.113000000000003,35.153,29.93 +2020-09-09 04:00:00,89.48,36.935,36.203,29.93 +2020-09-09 04:15:00,89.42,44.39,36.203,29.93 +2020-09-09 04:30:00,94.57,41.785,36.203,29.93 +2020-09-09 04:45:00,102.54,42.376000000000005,36.203,29.93 +2020-09-09 05:00:00,108.61,60.409,39.922,29.93 +2020-09-09 05:15:00,107.95,72.247,39.922,29.93 +2020-09-09 05:30:00,111.12,66.142,39.922,29.93 +2020-09-09 05:45:00,111.45,61.985,39.922,29.93 +2020-09-09 06:00:00,113.78,62.07,56.443999999999996,29.93 +2020-09-09 06:15:00,116.37,63.535,56.443999999999996,29.93 +2020-09-09 06:30:00,119.03,62.123000000000005,56.443999999999996,29.93 +2020-09-09 06:45:00,118.81,63.743,56.443999999999996,29.93 +2020-09-09 07:00:00,118.73,62.062,68.683,29.93 +2020-09-09 07:15:00,118.39,63.321000000000005,68.683,29.93 +2020-09-09 07:30:00,116.09,61.806000000000004,68.683,29.93 +2020-09-09 07:45:00,114.53,62.681999999999995,68.683,29.93 +2020-09-09 08:00:00,113.91,61.396,59.003,29.93 +2020-09-09 08:15:00,112.48,63.184,59.003,29.93 +2020-09-09 08:30:00,113.23,62.471000000000004,59.003,29.93 +2020-09-09 08:45:00,114.44,63.788000000000004,59.003,29.93 +2020-09-09 09:00:00,111.57,60.216,56.21,29.93 +2020-09-09 09:15:00,109.34,58.714,56.21,29.93 +2020-09-09 09:30:00,109.66,60.67100000000001,56.21,29.93 +2020-09-09 09:45:00,109.6,62.044,56.21,29.93 +2020-09-09 10:00:00,108.97,58.495,52.358999999999995,29.93 +2020-09-09 10:15:00,108.59,58.974,52.358999999999995,29.93 +2020-09-09 10:30:00,107.81,58.653,52.358999999999995,29.93 +2020-09-09 10:45:00,107.09,59.638999999999996,52.358999999999995,29.93 +2020-09-09 11:00:00,105.96,56.187,51.161,29.93 +2020-09-09 11:15:00,104.7,57.163999999999994,51.161,29.93 +2020-09-09 11:30:00,105.82,57.81,51.161,29.93 +2020-09-09 11:45:00,102.87,58.482,51.161,29.93 +2020-09-09 12:00:00,103.56,54.586000000000006,49.119,29.93 +2020-09-09 12:15:00,100.18,54.136,49.119,29.93 +2020-09-09 12:30:00,98.72,53.456,49.119,29.93 +2020-09-09 12:45:00,100.89,54.467,49.119,29.93 +2020-09-09 13:00:00,101.22,53.369,49.187,29.93 +2020-09-09 13:15:00,101.0,53.93,49.187,29.93 +2020-09-09 13:30:00,103.88,52.728,49.187,29.93 +2020-09-09 13:45:00,102.94,51.888000000000005,49.187,29.93 +2020-09-09 14:00:00,103.48,52.784,49.787,29.93 +2020-09-09 14:15:00,104.49,51.917,49.787,29.93 +2020-09-09 14:30:00,101.7,50.873999999999995,49.787,29.93 +2020-09-09 14:45:00,101.43,51.63,49.787,29.93 +2020-09-09 15:00:00,101.46,51.931000000000004,51.458999999999996,29.93 +2020-09-09 15:15:00,101.99,49.961000000000006,51.458999999999996,29.93 +2020-09-09 15:30:00,102.58,48.946999999999996,51.458999999999996,29.93 +2020-09-09 15:45:00,104.51,46.772,51.458999999999996,29.93 +2020-09-09 16:00:00,104.61,48.935,53.663000000000004,29.93 +2020-09-09 16:15:00,109.4,48.835,53.663000000000004,29.93 +2020-09-09 16:30:00,108.58,49.201,53.663000000000004,29.93 +2020-09-09 16:45:00,109.96,47.181000000000004,53.663000000000004,29.93 +2020-09-09 17:00:00,112.76,48.776,58.183,29.93 +2020-09-09 17:15:00,112.11,49.945,58.183,29.93 +2020-09-09 17:30:00,115.42,49.458999999999996,58.183,29.93 +2020-09-09 17:45:00,113.9,48.728,58.183,29.93 +2020-09-09 18:00:00,112.53,50.887,60.141000000000005,29.93 +2020-09-09 18:15:00,111.03,50.411,60.141000000000005,29.93 +2020-09-09 18:30:00,112.22,48.534,60.141000000000005,29.93 +2020-09-09 18:45:00,115.87,52.662,60.141000000000005,29.93 +2020-09-09 19:00:00,114.45,55.199,60.582,29.93 +2020-09-09 19:15:00,114.54,53.925,60.582,29.93 +2020-09-09 19:30:00,114.12,52.881,60.582,29.93 +2020-09-09 19:45:00,114.17,52.997,60.582,29.93 +2020-09-09 20:00:00,107.24,51.36600000000001,66.61,29.93 +2020-09-09 20:15:00,100.92,50.848,66.61,29.93 +2020-09-09 20:30:00,105.27,50.271,66.61,29.93 +2020-09-09 20:45:00,105.34,49.992,66.61,29.93 +2020-09-09 21:00:00,100.98,48.603,57.658,29.93 +2020-09-09 21:15:00,97.14,50.12,57.658,29.93 +2020-09-09 21:30:00,95.75,49.858999999999995,57.658,29.93 +2020-09-09 21:45:00,95.25,49.29600000000001,57.658,29.93 +2020-09-09 22:00:00,92.5,46.744,51.81,29.93 +2020-09-09 22:15:00,86.93,47.318000000000005,51.81,29.93 +2020-09-09 22:30:00,87.86,39.836,51.81,29.93 +2020-09-09 22:45:00,86.09,36.346,51.81,29.93 +2020-09-09 23:00:00,84.21,33.443000000000005,42.93600000000001,29.93 +2020-09-09 23:15:00,82.99,32.514,42.93600000000001,29.93 +2020-09-09 23:30:00,83.34,32.443000000000005,42.93600000000001,29.93 +2020-09-09 23:45:00,84.51,31.645,42.93600000000001,29.93 +2020-09-10 00:00:00,80.33,31.846,39.211,29.93 +2020-09-10 00:15:00,77.06,32.409,39.211,29.93 +2020-09-10 00:30:00,80.13,32.05,39.211,29.93 +2020-09-10 00:45:00,81.28,32.049,39.211,29.93 +2020-09-10 01:00:00,81.02,31.653000000000002,37.607,29.93 +2020-09-10 01:15:00,75.14,31.005,37.607,29.93 +2020-09-10 01:30:00,73.91,30.059,37.607,29.93 +2020-09-10 01:45:00,77.05,29.145,37.607,29.93 +2020-09-10 02:00:00,80.23,28.884,36.44,29.93 +2020-09-10 02:15:00,81.87,28.69,36.44,29.93 +2020-09-10 02:30:00,73.49,29.133000000000003,36.44,29.93 +2020-09-10 02:45:00,74.47,29.936999999999998,36.44,29.93 +2020-09-10 03:00:00,77.1,30.927,36.116,29.93 +2020-09-10 03:15:00,80.63,30.445,36.116,29.93 +2020-09-10 03:30:00,83.84,30.397,36.116,29.93 +2020-09-10 03:45:00,83.29,30.363000000000003,36.116,29.93 +2020-09-10 04:00:00,83.53,37.223,37.398,29.93 +2020-09-10 04:15:00,89.58,44.708,37.398,29.93 +2020-09-10 04:30:00,94.75,42.11,37.398,29.93 +2020-09-10 04:45:00,100.81,42.708999999999996,37.398,29.93 +2020-09-10 05:00:00,101.58,60.841,41.776,29.93 +2020-09-10 05:15:00,104.3,72.78699999999999,41.776,29.93 +2020-09-10 05:30:00,107.36,66.663,41.776,29.93 +2020-09-10 05:45:00,107.9,62.453,41.776,29.93 +2020-09-10 06:00:00,114.18,62.5,55.61,29.93 +2020-09-10 06:15:00,116.19,63.99,55.61,29.93 +2020-09-10 06:30:00,117.06,62.575,55.61,29.93 +2020-09-10 06:45:00,118.62,64.19,55.61,29.93 +2020-09-10 07:00:00,119.66,62.50899999999999,67.13600000000001,29.93 +2020-09-10 07:15:00,118.33,63.782,67.13600000000001,29.93 +2020-09-10 07:30:00,118.98,62.299,67.13600000000001,29.93 +2020-09-10 07:45:00,118.75,63.175,67.13600000000001,29.93 +2020-09-10 08:00:00,117.68,61.891999999999996,57.55,29.93 +2020-09-10 08:15:00,118.15,63.641000000000005,57.55,29.93 +2020-09-10 08:30:00,118.58,62.933,57.55,29.93 +2020-09-10 08:45:00,119.2,64.232,57.55,29.93 +2020-09-10 09:00:00,121.14,60.669,52.931999999999995,29.93 +2020-09-10 09:15:00,118.91,59.157,52.931999999999995,29.93 +2020-09-10 09:30:00,120.24,61.093,52.931999999999995,29.93 +2020-09-10 09:45:00,124.85,62.435,52.931999999999995,29.93 +2020-09-10 10:00:00,125.58,58.882,50.36600000000001,29.93 +2020-09-10 10:15:00,124.15,59.327,50.36600000000001,29.93 +2020-09-10 10:30:00,125.44,58.995,50.36600000000001,29.93 +2020-09-10 10:45:00,121.44,59.967,50.36600000000001,29.93 +2020-09-10 11:00:00,119.2,56.528,47.893,29.93 +2020-09-10 11:15:00,121.84,57.49100000000001,47.893,29.93 +2020-09-10 11:30:00,126.86,58.136,47.893,29.93 +2020-09-10 11:45:00,126.23,58.79,47.893,29.93 +2020-09-10 12:00:00,125.52,54.871,45.271,29.93 +2020-09-10 12:15:00,125.28,54.406000000000006,45.271,29.93 +2020-09-10 12:30:00,120.23,53.754,45.271,29.93 +2020-09-10 12:45:00,121.25,54.754,45.271,29.93 +2020-09-10 13:00:00,119.37,53.633,44.351000000000006,29.93 +2020-09-10 13:15:00,120.66,54.18600000000001,44.351000000000006,29.93 +2020-09-10 13:30:00,119.3,52.98,44.351000000000006,29.93 +2020-09-10 13:45:00,117.09,52.148,44.351000000000006,29.93 +2020-09-10 14:00:00,117.58,53.005,44.99,29.93 +2020-09-10 14:15:00,117.93,52.148999999999994,44.99,29.93 +2020-09-10 14:30:00,115.71,51.13399999999999,44.99,29.93 +2020-09-10 14:45:00,114.86,51.888000000000005,44.99,29.93 +2020-09-10 15:00:00,113.58,52.13399999999999,46.869,29.93 +2020-09-10 15:15:00,112.65,50.178999999999995,46.869,29.93 +2020-09-10 15:30:00,113.09,49.191,46.869,29.93 +2020-09-10 15:45:00,113.8,47.027,46.869,29.93 +2020-09-10 16:00:00,114.36,49.151,48.902,29.93 +2020-09-10 16:15:00,116.87,49.066,48.902,29.93 +2020-09-10 16:30:00,117.85,49.43,48.902,29.93 +2020-09-10 16:45:00,119.64,47.464,48.902,29.93 +2020-09-10 17:00:00,120.81,49.018,53.244,29.93 +2020-09-10 17:15:00,118.06,50.216,53.244,29.93 +2020-09-10 17:30:00,118.85,49.74100000000001,53.244,29.93 +2020-09-10 17:45:00,118.18,49.047,53.244,29.93 +2020-09-10 18:00:00,119.17,51.18899999999999,54.343999999999994,29.93 +2020-09-10 18:15:00,118.52,50.725,54.343999999999994,29.93 +2020-09-10 18:30:00,117.98,48.86,54.343999999999994,29.93 +2020-09-10 18:45:00,117.71,52.985,54.343999999999994,29.93 +2020-09-10 19:00:00,118.08,55.531000000000006,54.332,29.93 +2020-09-10 19:15:00,117.58,54.257,54.332,29.93 +2020-09-10 19:30:00,115.86,53.214,54.332,29.93 +2020-09-10 19:45:00,111.56,53.33,54.332,29.93 +2020-09-10 20:00:00,103.85,51.715,58.06,29.93 +2020-09-10 20:15:00,104.58,51.196999999999996,58.06,29.93 +2020-09-10 20:30:00,99.86,50.598,58.06,29.93 +2020-09-10 20:45:00,103.55,50.276,58.06,29.93 +2020-09-10 21:00:00,99.44,48.89,52.411,29.93 +2020-09-10 21:15:00,100.62,50.397,52.411,29.93 +2020-09-10 21:30:00,92.72,50.141000000000005,52.411,29.93 +2020-09-10 21:45:00,88.73,49.54,52.411,29.93 +2020-09-10 22:00:00,87.31,46.963,47.148999999999994,29.93 +2020-09-10 22:15:00,88.58,47.515,47.148999999999994,29.93 +2020-09-10 22:30:00,84.17,40.009,47.148999999999994,29.93 +2020-09-10 22:45:00,83.22,36.524,47.148999999999994,29.93 +2020-09-10 23:00:00,80.89,33.669000000000004,40.814,29.93 +2020-09-10 23:15:00,82.71,32.713,40.814,29.93 +2020-09-10 23:30:00,78.26,32.649,40.814,29.93 +2020-09-10 23:45:00,82.96,31.855999999999998,40.814,29.93 +2020-09-11 00:00:00,75.6,30.344,39.153,29.93 +2020-09-11 00:15:00,80.38,31.125,39.153,29.93 +2020-09-11 00:30:00,80.19,30.996,39.153,29.93 +2020-09-11 00:45:00,79.37,31.391,39.153,29.93 +2020-09-11 01:00:00,72.6,30.596999999999998,37.228,29.93 +2020-09-11 01:15:00,79.38,29.555,37.228,29.93 +2020-09-11 01:30:00,77.58,29.232,37.228,29.93 +2020-09-11 01:45:00,80.0,28.109,37.228,29.93 +2020-09-11 02:00:00,76.16,28.691,35.851,29.93 +2020-09-11 02:15:00,80.66,28.458000000000002,35.851,29.93 +2020-09-11 02:30:00,81.2,29.677,35.851,29.93 +2020-09-11 02:45:00,80.38,29.875999999999998,35.851,29.93 +2020-09-11 03:00:00,76.4,31.41,36.54,29.93 +2020-09-11 03:15:00,80.73,29.999000000000002,36.54,29.93 +2020-09-11 03:30:00,84.41,29.741999999999997,36.54,29.93 +2020-09-11 03:45:00,81.82,30.514,36.54,29.93 +2020-09-11 04:00:00,85.33,37.564,37.578,29.93 +2020-09-11 04:15:00,91.54,43.666000000000004,37.578,29.93 +2020-09-11 04:30:00,94.67,41.95,37.578,29.93 +2020-09-11 04:45:00,96.14,41.833,37.578,29.93 +2020-09-11 05:00:00,101.04,59.476000000000006,40.387,29.93 +2020-09-11 05:15:00,102.73,72.569,40.387,29.93 +2020-09-11 05:30:00,108.87,66.734,40.387,29.93 +2020-09-11 05:45:00,111.53,62.067,40.387,29.93 +2020-09-11 06:00:00,116.13,62.336000000000006,54.668,29.93 +2020-09-11 06:15:00,118.68,63.81100000000001,54.668,29.93 +2020-09-11 06:30:00,120.17,62.233999999999995,54.668,29.93 +2020-09-11 06:45:00,123.12,63.937,54.668,29.93 +2020-09-11 07:00:00,125.11,62.724,63.971000000000004,29.93 +2020-09-11 07:15:00,125.24,64.94800000000001,63.971000000000004,29.93 +2020-09-11 07:30:00,125.46,61.755,63.971000000000004,29.93 +2020-09-11 07:45:00,125.76,62.323,63.971000000000004,29.93 +2020-09-11 08:00:00,124.69,61.582,56.042,29.93 +2020-09-11 08:15:00,125.79,63.826,56.042,29.93 +2020-09-11 08:30:00,127.99,63.174,56.042,29.93 +2020-09-11 08:45:00,128.03,64.111,56.042,29.93 +2020-09-11 09:00:00,127.4,58.596000000000004,52.832,29.93 +2020-09-11 09:15:00,127.15,58.831,52.832,29.93 +2020-09-11 09:30:00,126.24,60.083999999999996,52.832,29.93 +2020-09-11 09:45:00,126.73,61.718,52.832,29.93 +2020-09-11 10:00:00,123.95,57.843999999999994,50.044,29.93 +2020-09-11 10:15:00,126.91,58.161,50.044,29.93 +2020-09-11 10:30:00,126.92,58.288000000000004,50.044,29.93 +2020-09-11 10:45:00,126.94,59.099,50.044,29.93 +2020-09-11 11:00:00,124.25,55.891999999999996,49.06100000000001,29.93 +2020-09-11 11:15:00,122.43,55.766000000000005,49.06100000000001,29.93 +2020-09-11 11:30:00,123.76,56.348,49.06100000000001,29.93 +2020-09-11 11:45:00,121.22,56.158,49.06100000000001,29.93 +2020-09-11 12:00:00,117.99,52.754,45.595,29.93 +2020-09-11 12:15:00,118.71,51.409,45.595,29.93 +2020-09-11 12:30:00,115.33,50.912,45.595,29.93 +2020-09-11 12:45:00,114.1,51.29600000000001,45.595,29.93 +2020-09-11 13:00:00,113.23,50.799,43.218,29.93 +2020-09-11 13:15:00,112.25,51.623000000000005,43.218,29.93 +2020-09-11 13:30:00,110.79,51.105,43.218,29.93 +2020-09-11 13:45:00,111.31,50.538000000000004,43.218,29.93 +2020-09-11 14:00:00,110.24,50.486999999999995,41.926,29.93 +2020-09-11 14:15:00,112.95,49.996,41.926,29.93 +2020-09-11 14:30:00,108.86,50.391999999999996,41.926,29.93 +2020-09-11 14:45:00,112.25,50.591,41.926,29.93 +2020-09-11 15:00:00,111.4,50.64,43.79,29.93 +2020-09-11 15:15:00,111.65,48.431000000000004,43.79,29.93 +2020-09-11 15:30:00,110.79,46.753,43.79,29.93 +2020-09-11 15:45:00,107.67,45.287,43.79,29.93 +2020-09-11 16:00:00,107.07,46.474,45.895,29.93 +2020-09-11 16:15:00,108.07,46.873999999999995,45.895,29.93 +2020-09-11 16:30:00,106.95,47.09,45.895,29.93 +2020-09-11 16:45:00,105.52,44.477,45.895,29.93 +2020-09-11 17:00:00,109.06,47.535,51.36,29.93 +2020-09-11 17:15:00,109.74,48.553000000000004,51.36,29.93 +2020-09-11 17:30:00,110.3,48.187,51.36,29.93 +2020-09-11 17:45:00,111.89,47.353,51.36,29.93 +2020-09-11 18:00:00,111.62,49.632,52.985,29.93 +2020-09-11 18:15:00,109.54,48.294,52.985,29.93 +2020-09-11 18:30:00,111.46,46.401,52.985,29.93 +2020-09-11 18:45:00,114.19,50.891999999999996,52.985,29.93 +2020-09-11 19:00:00,112.04,54.356,52.602,29.93 +2020-09-11 19:15:00,108.7,53.778,52.602,29.93 +2020-09-11 19:30:00,105.58,52.738,52.602,29.93 +2020-09-11 19:45:00,103.63,51.891999999999996,52.602,29.93 +2020-09-11 20:00:00,98.96,50.158,58.063,29.93 +2020-09-11 20:15:00,100.7,50.345,58.063,29.93 +2020-09-11 20:30:00,97.79,49.286,58.063,29.93 +2020-09-11 20:45:00,98.82,48.273999999999994,58.063,29.93 +2020-09-11 21:00:00,92.16,48.119,50.135,29.93 +2020-09-11 21:15:00,87.15,51.151,50.135,29.93 +2020-09-11 21:30:00,82.01,50.773999999999994,50.135,29.93 +2020-09-11 21:45:00,83.79,50.385,50.135,29.93 +2020-09-11 22:00:00,84.45,47.785,45.165,29.93 +2020-09-11 22:15:00,82.84,48.075,45.165,29.93 +2020-09-11 22:30:00,74.05,45.567,45.165,29.93 +2020-09-11 22:45:00,75.88,43.292,45.165,29.93 +2020-09-11 23:00:00,67.88,41.926,39.121,29.93 +2020-09-11 23:15:00,66.06,39.306999999999995,39.121,29.93 +2020-09-11 23:30:00,65.9,37.449,39.121,29.93 +2020-09-11 23:45:00,73.95,36.47,39.121,29.93 +2020-09-12 00:00:00,72.49,31.303,38.49,29.816 +2020-09-12 00:15:00,70.54,30.943,38.49,29.816 +2020-09-12 00:30:00,63.74,30.576,38.49,29.816 +2020-09-12 00:45:00,64.17,30.421999999999997,38.49,29.816 +2020-09-12 01:00:00,63.03,29.933000000000003,34.5,29.816 +2020-09-12 01:15:00,61.92,29.307,34.5,29.816 +2020-09-12 01:30:00,61.64,28.223000000000003,34.5,29.816 +2020-09-12 01:45:00,61.96,28.176,34.5,29.816 +2020-09-12 02:00:00,62.24,27.976,32.236,29.816 +2020-09-12 02:15:00,67.5,27.022,32.236,29.816 +2020-09-12 02:30:00,67.15,27.374000000000002,32.236,29.816 +2020-09-12 02:45:00,66.48,28.275,32.236,29.816 +2020-09-12 03:00:00,65.68,28.665,32.067,29.816 +2020-09-12 03:15:00,61.69,26.500999999999998,32.067,29.816 +2020-09-12 03:30:00,61.21,26.336,32.067,29.816 +2020-09-12 03:45:00,61.06,28.415,32.067,29.816 +2020-09-12 04:00:00,62.25,33.311,33.071,29.816 +2020-09-12 04:15:00,61.64,38.334,33.071,29.816 +2020-09-12 04:30:00,61.43,34.878,33.071,29.816 +2020-09-12 04:45:00,64.14,34.945,33.071,29.816 +2020-09-12 05:00:00,66.46,43.589,33.014,29.816 +2020-09-12 05:15:00,68.23,44.825,33.014,29.816 +2020-09-12 05:30:00,66.76,40.367,33.014,29.816 +2020-09-12 05:45:00,68.02,40.258,33.014,29.816 +2020-09-12 06:00:00,70.3,52.581,34.628,29.816 +2020-09-12 06:15:00,72.97,62.941,34.628,29.816 +2020-09-12 06:30:00,74.15,58.201,34.628,29.816 +2020-09-12 06:45:00,76.25,55.983999999999995,34.628,29.816 +2020-09-12 07:00:00,77.74,52.931999999999995,38.871,29.816 +2020-09-12 07:15:00,80.76,53.872,38.871,29.816 +2020-09-12 07:30:00,82.04,52.38,38.871,29.816 +2020-09-12 07:45:00,79.99,54.251999999999995,38.871,29.816 +2020-09-12 08:00:00,80.65,54.566,43.293,29.816 +2020-09-12 08:15:00,84.33,57.047,43.293,29.816 +2020-09-12 08:30:00,80.92,56.593,43.293,29.816 +2020-09-12 08:45:00,82.18,58.745,43.293,29.816 +2020-09-12 09:00:00,73.56,56.155,44.559,29.816 +2020-09-12 09:15:00,72.42,56.9,44.559,29.816 +2020-09-12 09:30:00,72.7,58.683,44.559,29.816 +2020-09-12 09:45:00,77.44,59.915,44.559,29.816 +2020-09-12 10:00:00,76.92,56.563,42.091,29.816 +2020-09-12 10:15:00,82.93,57.177,42.091,29.816 +2020-09-12 10:30:00,75.13,57.018,42.091,29.816 +2020-09-12 10:45:00,78.26,57.68899999999999,42.091,29.816 +2020-09-12 11:00:00,75.56,54.4,38.505,29.816 +2020-09-12 11:15:00,72.28,54.917,38.505,29.816 +2020-09-12 11:30:00,70.41,55.589,38.505,29.816 +2020-09-12 11:45:00,69.55,55.831,38.505,29.816 +2020-09-12 12:00:00,69.43,52.578,35.388000000000005,29.816 +2020-09-12 12:15:00,67.6,52.043,35.388000000000005,29.816 +2020-09-12 12:30:00,66.35,51.507,35.388000000000005,29.816 +2020-09-12 12:45:00,66.07,52.397,35.388000000000005,29.816 +2020-09-12 13:00:00,63.61,51.104,31.355999999999998,29.816 +2020-09-12 13:15:00,68.82,51.163999999999994,31.355999999999998,29.816 +2020-09-12 13:30:00,66.78,50.739,31.355999999999998,29.816 +2020-09-12 13:45:00,66.36,49.083999999999996,31.355999999999998,29.816 +2020-09-12 14:00:00,68.36,49.22,30.522,29.816 +2020-09-12 14:15:00,69.67,47.583999999999996,30.522,29.816 +2020-09-12 14:30:00,66.25,47.385,30.522,29.816 +2020-09-12 14:45:00,67.5,48.027,30.522,29.816 +2020-09-12 15:00:00,68.44,48.504,34.36,29.816 +2020-09-12 15:15:00,69.12,47.034,34.36,29.816 +2020-09-12 15:30:00,69.44,45.773,34.36,29.816 +2020-09-12 15:45:00,70.61,43.525,34.36,29.816 +2020-09-12 16:00:00,73.84,46.43,39.507,29.816 +2020-09-12 16:15:00,75.23,46.185,39.507,29.816 +2020-09-12 16:30:00,75.45,46.589,39.507,29.816 +2020-09-12 16:45:00,77.86,44.126999999999995,39.507,29.816 +2020-09-12 17:00:00,81.34,46.038000000000004,47.151,29.816 +2020-09-12 17:15:00,84.36,45.433,47.151,29.816 +2020-09-12 17:30:00,82.72,44.951,47.151,29.816 +2020-09-12 17:45:00,83.58,44.531000000000006,47.151,29.816 +2020-09-12 18:00:00,85.61,47.988,50.303999999999995,29.816 +2020-09-12 18:15:00,84.62,48.356,50.303999999999995,29.816 +2020-09-12 18:30:00,89.46,47.836999999999996,50.303999999999995,29.816 +2020-09-12 18:45:00,89.86,48.85,50.303999999999995,29.816 +2020-09-12 19:00:00,88.15,51.066,50.622,29.816 +2020-09-12 19:15:00,83.92,49.538000000000004,50.622,29.816 +2020-09-12 19:30:00,83.24,49.269,50.622,29.816 +2020-09-12 19:45:00,82.65,49.957,50.622,29.816 +2020-09-12 20:00:00,80.31,49.22,45.391000000000005,29.816 +2020-09-12 20:15:00,77.48,49.12,45.391000000000005,29.816 +2020-09-12 20:30:00,76.12,47.223,45.391000000000005,29.816 +2020-09-12 20:45:00,75.87,47.74100000000001,45.391000000000005,29.816 +2020-09-12 21:00:00,71.68,46.589,39.98,29.816 +2020-09-12 21:15:00,70.68,49.358999999999995,39.98,29.816 +2020-09-12 21:30:00,68.86,49.309,39.98,29.816 +2020-09-12 21:45:00,68.45,48.352,39.98,29.816 +2020-09-12 22:00:00,64.74,45.873999999999995,37.53,29.816 +2020-09-12 22:15:00,65.48,46.672,37.53,29.816 +2020-09-12 22:30:00,62.36,44.59,37.53,29.816 +2020-09-12 22:45:00,62.8,42.88,37.53,29.816 +2020-09-12 23:00:00,57.88,41.266999999999996,30.97,29.816 +2020-09-12 23:15:00,56.65,38.785,30.97,29.816 +2020-09-12 23:30:00,58.13,38.875,30.97,29.816 +2020-09-12 23:45:00,57.91,37.913000000000004,30.97,29.816 +2020-09-13 00:00:00,53.28,32.799,27.24,29.816 +2020-09-13 00:15:00,53.54,31.384,27.24,29.816 +2020-09-13 00:30:00,53.48,30.849,27.24,29.816 +2020-09-13 00:45:00,53.88,30.689,27.24,29.816 +2020-09-13 01:00:00,51.24,30.398000000000003,25.662,29.816 +2020-09-13 01:15:00,52.62,29.816,25.662,29.816 +2020-09-13 01:30:00,52.7,28.694000000000003,25.662,29.816 +2020-09-13 01:45:00,52.44,28.274,25.662,29.816 +2020-09-13 02:00:00,53.25,28.054000000000002,25.67,29.816 +2020-09-13 02:15:00,52.44,27.636999999999997,25.67,29.816 +2020-09-13 02:30:00,51.82,28.238000000000003,25.67,29.816 +2020-09-13 02:45:00,51.81,28.935,25.67,29.816 +2020-09-13 03:00:00,50.96,29.924,24.258000000000003,29.816 +2020-09-13 03:15:00,52.6,27.941,24.258000000000003,29.816 +2020-09-13 03:30:00,52.63,27.311,24.258000000000003,29.816 +2020-09-13 03:45:00,52.9,28.666999999999998,24.258000000000003,29.816 +2020-09-13 04:00:00,53.7,33.546,25.051,29.816 +2020-09-13 04:15:00,54.59,38.092,25.051,29.816 +2020-09-13 04:30:00,54.4,35.829,25.051,29.816 +2020-09-13 04:45:00,55.85,35.528,25.051,29.816 +2020-09-13 05:00:00,57.42,44.011,25.145,29.816 +2020-09-13 05:15:00,58.05,44.397,25.145,29.816 +2020-09-13 05:30:00,55.99,39.544000000000004,25.145,29.816 +2020-09-13 05:45:00,56.86,39.168,25.145,29.816 +2020-09-13 06:00:00,57.81,49.345,26.371,29.816 +2020-09-13 06:15:00,58.65,60.265,26.371,29.816 +2020-09-13 06:30:00,58.81,54.775,26.371,29.816 +2020-09-13 06:45:00,60.74,51.551,26.371,29.816 +2020-09-13 07:00:00,61.74,49.041000000000004,28.756999999999998,29.816 +2020-09-13 07:15:00,61.61,48.45,28.756999999999998,29.816 +2020-09-13 07:30:00,61.01,47.961999999999996,28.756999999999998,29.816 +2020-09-13 07:45:00,61.51,49.708999999999996,28.756999999999998,29.816 +2020-09-13 08:00:00,61.6,50.91,32.82,29.816 +2020-09-13 08:15:00,63.67,54.369,32.82,29.816 +2020-09-13 08:30:00,61.43,54.89,32.82,29.816 +2020-09-13 08:45:00,60.33,57.203,32.82,29.816 +2020-09-13 09:00:00,58.83,54.472,35.534,29.816 +2020-09-13 09:15:00,60.35,54.845,35.534,29.816 +2020-09-13 09:30:00,60.42,56.964,35.534,29.816 +2020-09-13 09:45:00,60.83,59.023999999999994,35.534,29.816 +2020-09-13 10:00:00,61.52,56.451,35.925,29.816 +2020-09-13 10:15:00,63.03,57.215,35.925,29.816 +2020-09-13 10:30:00,64.13,57.315,35.925,29.816 +2020-09-13 10:45:00,64.9,58.61,35.925,29.816 +2020-09-13 11:00:00,61.16,55.151,37.056,29.816 +2020-09-13 11:15:00,60.3,55.281000000000006,37.056,29.816 +2020-09-13 11:30:00,56.6,56.294,37.056,29.816 +2020-09-13 11:45:00,58.49,56.818000000000005,37.056,29.816 +2020-09-13 12:00:00,52.37,54.376999999999995,33.124,29.816 +2020-09-13 12:15:00,54.75,53.535,33.124,29.816 +2020-09-13 12:30:00,50.06,53.025,33.124,29.816 +2020-09-13 12:45:00,50.17,53.224,33.124,29.816 +2020-09-13 13:00:00,49.32,51.556999999999995,29.874000000000002,29.816 +2020-09-13 13:15:00,49.67,51.497,29.874000000000002,29.816 +2020-09-13 13:30:00,48.44,50.077,29.874000000000002,29.816 +2020-09-13 13:45:00,49.63,49.31399999999999,29.874000000000002,29.816 +2020-09-13 14:00:00,49.39,50.481,27.302,29.816 +2020-09-13 14:15:00,50.02,49.385,27.302,29.816 +2020-09-13 14:30:00,50.96,48.302,27.302,29.816 +2020-09-13 14:45:00,52.31,47.96,27.302,29.816 +2020-09-13 15:00:00,52.47,48.31100000000001,27.642,29.816 +2020-09-13 15:15:00,56.04,46.251000000000005,27.642,29.816 +2020-09-13 15:30:00,55.91,44.931000000000004,27.642,29.816 +2020-09-13 15:45:00,58.62,43.033,27.642,29.816 +2020-09-13 16:00:00,63.63,44.687,31.945999999999998,29.816 +2020-09-13 16:15:00,63.63,44.543,31.945999999999998,29.816 +2020-09-13 16:30:00,67.25,45.853,31.945999999999998,29.816 +2020-09-13 16:45:00,69.54,43.519,31.945999999999998,29.816 +2020-09-13 17:00:00,73.29,45.705,40.387,29.816 +2020-09-13 17:15:00,75.48,46.41,40.387,29.816 +2020-09-13 17:30:00,76.49,46.678000000000004,40.387,29.816 +2020-09-13 17:45:00,80.06,46.943999999999996,40.387,29.816 +2020-09-13 18:00:00,80.24,50.817,44.575,29.816 +2020-09-13 18:15:00,80.31,50.998000000000005,44.575,29.816 +2020-09-13 18:30:00,86.23,50.026,44.575,29.816 +2020-09-13 18:45:00,84.95,51.357,44.575,29.816 +2020-09-13 19:00:00,83.91,55.43899999999999,45.623999999999995,29.816 +2020-09-13 19:15:00,82.55,53.001000000000005,45.623999999999995,29.816 +2020-09-13 19:30:00,81.33,52.494,45.623999999999995,29.816 +2020-09-13 19:45:00,82.57,52.943999999999996,45.623999999999995,29.816 +2020-09-13 20:00:00,82.59,52.394,44.583999999999996,29.816 +2020-09-13 20:15:00,85.52,52.29,44.583999999999996,29.816 +2020-09-13 20:30:00,81.65,51.228,44.583999999999996,29.816 +2020-09-13 20:45:00,80.24,50.083999999999996,44.583999999999996,29.816 +2020-09-13 21:00:00,74.96,48.508,39.732,29.816 +2020-09-13 21:15:00,80.14,50.928000000000004,39.732,29.816 +2020-09-13 21:30:00,82.33,50.347,39.732,29.816 +2020-09-13 21:45:00,81.42,49.684,39.732,29.816 +2020-09-13 22:00:00,76.5,48.83,38.571,29.816 +2020-09-13 22:15:00,71.7,48.037,38.571,29.816 +2020-09-13 22:30:00,69.2,45.117,38.571,29.816 +2020-09-13 22:45:00,69.21,42.193000000000005,38.571,29.816 +2020-09-13 23:00:00,66.31,40.039,33.121,29.816 +2020-09-13 23:15:00,68.89,38.861,33.121,29.816 +2020-09-13 23:30:00,67.54,38.577,33.121,29.816 +2020-09-13 23:45:00,72.63,37.861999999999995,33.121,29.816 +2020-09-14 00:00:00,70.49,34.855,32.506,29.93 +2020-09-14 00:15:00,71.68,34.483000000000004,32.506,29.93 +2020-09-14 00:30:00,66.33,33.631,32.506,29.93 +2020-09-14 00:45:00,63.56,33.067,32.506,29.93 +2020-09-14 01:00:00,65.34,33.099000000000004,31.121,29.93 +2020-09-14 01:15:00,71.25,32.488,31.121,29.93 +2020-09-14 01:30:00,71.36,31.708000000000002,31.121,29.93 +2020-09-14 01:45:00,70.39,31.218000000000004,31.121,29.93 +2020-09-14 02:00:00,67.0,31.398000000000003,29.605999999999998,29.93 +2020-09-14 02:15:00,72.09,30.285,29.605999999999998,29.93 +2020-09-14 02:30:00,73.13,31.045,29.605999999999998,29.93 +2020-09-14 02:45:00,72.63,31.535,29.605999999999998,29.93 +2020-09-14 03:00:00,70.81,33.106,28.124000000000002,29.93 +2020-09-14 03:15:00,74.52,31.967,28.124000000000002,29.93 +2020-09-14 03:30:00,76.44,31.909000000000002,28.124000000000002,29.93 +2020-09-14 03:45:00,75.06,32.779,28.124000000000002,29.93 +2020-09-14 04:00:00,77.33,40.942,29.743000000000002,29.93 +2020-09-14 04:15:00,83.88,48.583999999999996,29.743000000000002,29.93 +2020-09-14 04:30:00,89.01,46.166000000000004,29.743000000000002,29.93 +2020-09-14 04:45:00,92.93,46.215,29.743000000000002,29.93 +2020-09-14 05:00:00,94.93,62.798,36.191,29.93 +2020-09-14 05:15:00,104.51,74.667,36.191,29.93 +2020-09-14 05:30:00,102.86,68.46,36.191,29.93 +2020-09-14 05:45:00,103.19,64.678,36.191,29.93 +2020-09-14 06:00:00,108.82,63.763999999999996,55.277,29.93 +2020-09-14 06:15:00,111.8,65.002,55.277,29.93 +2020-09-14 06:30:00,112.82,63.923,55.277,29.93 +2020-09-14 06:45:00,111.4,66.351,55.277,29.93 +2020-09-14 07:00:00,115.98,64.559,65.697,29.93 +2020-09-14 07:15:00,110.78,66.132,65.697,29.93 +2020-09-14 07:30:00,110.77,64.83,65.697,29.93 +2020-09-14 07:45:00,108.81,66.53399999999999,65.697,29.93 +2020-09-14 08:00:00,107.66,65.313,57.028,29.93 +2020-09-14 08:15:00,106.67,67.42,57.028,29.93 +2020-09-14 08:30:00,111.75,66.61,57.028,29.93 +2020-09-14 08:45:00,114.96,68.684,57.028,29.93 +2020-09-14 09:00:00,107.61,64.957,52.633,29.93 +2020-09-14 09:15:00,104.76,63.328,52.633,29.93 +2020-09-14 09:30:00,104.17,64.493,52.633,29.93 +2020-09-14 09:45:00,103.52,64.554,52.633,29.93 +2020-09-14 10:00:00,104.08,62.263000000000005,50.647,29.93 +2020-09-14 10:15:00,103.09,62.821000000000005,50.647,29.93 +2020-09-14 10:30:00,103.8,62.4,50.647,29.93 +2020-09-14 10:45:00,103.53,62.397,50.647,29.93 +2020-09-14 11:00:00,102.76,58.821999999999996,50.245,29.93 +2020-09-14 11:15:00,103.69,59.413000000000004,50.245,29.93 +2020-09-14 11:30:00,102.69,61.208,50.245,29.93 +2020-09-14 11:45:00,105.2,62.097,50.245,29.93 +2020-09-14 12:00:00,101.81,58.416000000000004,46.956,29.93 +2020-09-14 12:15:00,103.71,57.656000000000006,46.956,29.93 +2020-09-14 12:30:00,101.04,56.28,46.956,29.93 +2020-09-14 12:45:00,103.68,56.665,46.956,29.93 +2020-09-14 13:00:00,99.52,55.81399999999999,47.383,29.93 +2020-09-14 13:15:00,98.82,54.8,47.383,29.93 +2020-09-14 13:30:00,100.67,53.45399999999999,47.383,29.93 +2020-09-14 13:45:00,102.23,53.481,47.383,29.93 +2020-09-14 14:00:00,102.81,53.742,47.1,29.93 +2020-09-14 14:15:00,100.22,53.089,47.1,29.93 +2020-09-14 14:30:00,100.3,51.821999999999996,47.1,29.93 +2020-09-14 14:45:00,98.73,53.272,47.1,29.93 +2020-09-14 15:00:00,99.67,53.479,49.355,29.93 +2020-09-14 15:15:00,101.22,50.736999999999995,49.355,29.93 +2020-09-14 15:30:00,103.35,50.004,49.355,29.93 +2020-09-14 15:45:00,102.28,47.64,49.355,29.93 +2020-09-14 16:00:00,103.7,50.174,52.14,29.93 +2020-09-14 16:15:00,104.69,50.006,52.14,29.93 +2020-09-14 16:30:00,107.28,50.61,52.14,29.93 +2020-09-14 16:45:00,109.54,48.20399999999999,52.14,29.93 +2020-09-14 17:00:00,111.52,49.339,58.705,29.93 +2020-09-14 17:15:00,109.57,50.282,58.705,29.93 +2020-09-14 17:30:00,112.39,50.155,58.705,29.93 +2020-09-14 17:45:00,111.73,49.925,58.705,29.93 +2020-09-14 18:00:00,113.13,52.857,59.153,29.93 +2020-09-14 18:15:00,111.59,51.177,59.153,29.93 +2020-09-14 18:30:00,117.13,49.633,59.153,29.93 +2020-09-14 18:45:00,115.67,53.824,59.153,29.93 +2020-09-14 19:00:00,112.01,57.446000000000005,61.483000000000004,29.93 +2020-09-14 19:15:00,107.89,56.045,61.483000000000004,29.93 +2020-09-14 19:30:00,105.2,55.27,61.483000000000004,29.93 +2020-09-14 19:45:00,108.61,55.082,61.483000000000004,29.93 +2020-09-14 20:00:00,105.34,53.168,67.55,29.93 +2020-09-14 20:15:00,106.11,54.007,67.55,29.93 +2020-09-14 20:30:00,103.61,53.163000000000004,67.55,29.93 +2020-09-14 20:45:00,94.43,52.355,67.55,29.93 +2020-09-14 21:00:00,92.43,50.291000000000004,60.026,29.93 +2020-09-14 21:15:00,89.42,52.953,60.026,29.93 +2020-09-14 21:30:00,85.77,52.648999999999994,60.026,29.93 +2020-09-14 21:45:00,91.92,51.695,60.026,29.93 +2020-09-14 22:00:00,88.36,48.677,52.736999999999995,29.93 +2020-09-14 22:15:00,88.46,49.465,52.736999999999995,29.93 +2020-09-14 22:30:00,82.45,41.596000000000004,52.736999999999995,29.93 +2020-09-14 22:45:00,82.32,38.1,52.736999999999995,29.93 +2020-09-14 23:00:00,80.56,36.128,44.408,29.93 +2020-09-14 23:15:00,81.23,33.766,44.408,29.93 +2020-09-14 23:30:00,76.93,33.77,44.408,29.93 +2020-09-14 23:45:00,75.97,32.887,44.408,29.93 +2020-09-15 00:00:00,76.99,32.999,44.438,29.93 +2020-09-15 00:15:00,79.26,33.558,44.438,29.93 +2020-09-15 00:30:00,78.82,33.234,44.438,29.93 +2020-09-15 00:45:00,74.27,33.263000000000005,44.438,29.93 +2020-09-15 01:00:00,70.44,32.773,41.468999999999994,29.93 +2020-09-15 01:15:00,74.86,32.23,41.468999999999994,29.93 +2020-09-15 01:30:00,77.73,31.374000000000002,41.468999999999994,29.93 +2020-09-15 01:45:00,78.5,30.465999999999998,41.468999999999994,29.93 +2020-09-15 02:00:00,77.58,30.223000000000003,39.708,29.93 +2020-09-15 02:15:00,76.77,30.145,39.708,29.93 +2020-09-15 02:30:00,78.83,30.465,39.708,29.93 +2020-09-15 02:45:00,78.93,31.241,39.708,29.93 +2020-09-15 03:00:00,79.89,32.166,38.919000000000004,29.93 +2020-09-15 03:15:00,80.14,31.774,38.919000000000004,29.93 +2020-09-15 03:30:00,82.91,31.75,38.919000000000004,29.93 +2020-09-15 03:45:00,83.91,31.641,38.919000000000004,29.93 +2020-09-15 04:00:00,85.18,38.702,40.092,29.93 +2020-09-15 04:15:00,89.49,46.347,40.092,29.93 +2020-09-15 04:30:00,94.24,43.79600000000001,40.092,29.93 +2020-09-15 04:45:00,96.41,44.424,40.092,29.93 +2020-09-15 05:00:00,99.04,63.07899999999999,43.713,29.93 +2020-09-15 05:15:00,103.98,75.593,43.713,29.93 +2020-09-15 05:30:00,107.4,69.36,43.713,29.93 +2020-09-15 05:45:00,109.39,64.872,43.713,29.93 +2020-09-15 06:00:00,117.19,64.73100000000001,56.033,29.93 +2020-09-15 06:15:00,114.09,66.351,56.033,29.93 +2020-09-15 06:30:00,115.14,64.90899999999999,56.033,29.93 +2020-09-15 06:45:00,116.49,66.49600000000001,56.033,29.93 +2020-09-15 07:00:00,118.41,64.818,66.003,29.93 +2020-09-15 07:15:00,117.26,66.15899999999999,66.003,29.93 +2020-09-15 07:30:00,117.65,64.84100000000001,66.003,29.93 +2020-09-15 07:45:00,116.28,65.70100000000001,66.003,29.93 +2020-09-15 08:00:00,113.85,64.434,57.474,29.93 +2020-09-15 08:15:00,110.41,65.98100000000001,57.474,29.93 +2020-09-15 08:30:00,110.67,65.303,57.474,29.93 +2020-09-15 08:45:00,110.98,66.51,57.474,29.93 +2020-09-15 09:00:00,110.26,62.99100000000001,51.928000000000004,29.93 +2020-09-15 09:15:00,109.79,61.425,51.928000000000004,29.93 +2020-09-15 09:30:00,108.62,63.258,51.928000000000004,29.93 +2020-09-15 09:45:00,108.66,64.442,51.928000000000004,29.93 +2020-09-15 10:00:00,108.71,60.86600000000001,49.46,29.93 +2020-09-15 10:15:00,108.89,61.133,49.46,29.93 +2020-09-15 10:30:00,108.34,60.746,49.46,29.93 +2020-09-15 10:45:00,108.25,61.647,49.46,29.93 +2020-09-15 11:00:00,106.43,58.276,48.206,29.93 +2020-09-15 11:15:00,104.48,59.166000000000004,48.206,29.93 +2020-09-15 11:30:00,106.87,59.805,48.206,29.93 +2020-09-15 11:45:00,104.72,60.371,48.206,29.93 +2020-09-15 12:00:00,102.62,56.325,46.285,29.93 +2020-09-15 12:15:00,101.2,55.792,46.285,29.93 +2020-09-15 12:30:00,102.37,55.288999999999994,46.285,29.93 +2020-09-15 12:45:00,101.53,56.23,46.285,29.93 +2020-09-15 13:00:00,101.21,54.992,46.861999999999995,29.93 +2020-09-15 13:15:00,100.74,55.506,46.861999999999995,29.93 +2020-09-15 13:30:00,100.23,54.276,46.861999999999995,29.93 +2020-09-15 13:45:00,102.07,53.488,46.861999999999995,29.93 +2020-09-15 14:00:00,100.98,54.14,46.488,29.93 +2020-09-15 14:15:00,101.37,53.342,46.488,29.93 +2020-09-15 14:30:00,102.21,52.472,46.488,29.93 +2020-09-15 14:45:00,104.2,53.218,46.488,29.93 +2020-09-15 15:00:00,103.31,53.176,48.442,29.93 +2020-09-15 15:15:00,105.66,51.303999999999995,48.442,29.93 +2020-09-15 15:30:00,104.22,50.43899999999999,48.442,29.93 +2020-09-15 15:45:00,106.45,48.342,48.442,29.93 +2020-09-15 16:00:00,107.2,50.266000000000005,50.397,29.93 +2020-09-15 16:15:00,108.47,50.245,50.397,29.93 +2020-09-15 16:30:00,111.24,50.597,50.397,29.93 +2020-09-15 16:45:00,112.38,48.913000000000004,50.397,29.93 +2020-09-15 17:00:00,118.39,50.25899999999999,56.668,29.93 +2020-09-15 17:15:00,113.96,51.603,56.668,29.93 +2020-09-15 17:30:00,113.72,51.18,56.668,29.93 +2020-09-15 17:45:00,114.84,50.68,56.668,29.93 +2020-09-15 18:00:00,117.58,52.738,57.957,29.93 +2020-09-15 18:15:00,116.18,52.331,57.957,29.93 +2020-09-15 18:30:00,120.29,50.532,57.957,29.93 +2020-09-15 18:45:00,119.15,54.638999999999996,57.957,29.93 +2020-09-15 19:00:00,120.04,57.236000000000004,57.056000000000004,29.93 +2020-09-15 19:15:00,118.63,55.964,57.056000000000004,29.93 +2020-09-15 19:30:00,113.73,54.924,57.056000000000004,29.93 +2020-09-15 19:45:00,113.54,55.038000000000004,57.056000000000004,29.93 +2020-09-15 20:00:00,105.26,53.512,64.156,29.93 +2020-09-15 20:15:00,101.39,52.998000000000005,64.156,29.93 +2020-09-15 20:30:00,98.17,52.282,64.156,29.93 +2020-09-15 20:45:00,97.7,51.735,64.156,29.93 +2020-09-15 21:00:00,98.43,50.36600000000001,56.507,29.93 +2020-09-15 21:15:00,100.0,51.815,56.507,29.93 +2020-09-15 21:30:00,94.23,51.591,56.507,29.93 +2020-09-15 21:45:00,89.6,50.799,56.507,29.93 +2020-09-15 22:00:00,85.63,48.097,50.728,29.93 +2020-09-15 22:15:00,88.34,48.534,50.728,29.93 +2020-09-15 22:30:00,87.42,40.913000000000004,50.728,29.93 +2020-09-15 22:45:00,85.3,37.449,50.728,29.93 +2020-09-15 23:00:00,78.95,34.841,43.556999999999995,29.93 +2020-09-15 23:15:00,83.3,33.741,43.556999999999995,29.93 +2020-09-15 23:30:00,83.87,33.709,43.556999999999995,29.93 +2020-09-15 23:45:00,78.6,32.943000000000005,43.556999999999995,29.93 +2020-09-16 00:00:00,73.44,39.084,41.151,29.93 +2020-09-16 00:15:00,73.98,39.830999999999996,41.151,29.93 +2020-09-16 00:30:00,78.99,39.635,41.151,29.93 +2020-09-16 00:45:00,80.82,39.476,41.151,29.93 +2020-09-16 01:00:00,76.35,39.616,37.763000000000005,29.93 +2020-09-16 01:15:00,73.59,38.859,37.763000000000005,29.93 +2020-09-16 01:30:00,72.26,37.896,37.763000000000005,29.93 +2020-09-16 01:45:00,78.96,37.031,37.763000000000005,29.93 +2020-09-16 02:00:00,78.44,37.302,35.615,29.93 +2020-09-16 02:15:00,78.12,37.166,35.615,29.93 +2020-09-16 02:30:00,72.51,37.736,35.615,29.93 +2020-09-16 02:45:00,78.41,38.519,35.615,29.93 +2020-09-16 03:00:00,82.31,40.568000000000005,35.153,29.93 +2020-09-16 03:15:00,81.48,40.628,35.153,29.93 +2020-09-16 03:30:00,78.89,40.606,35.153,29.93 +2020-09-16 03:45:00,80.9,41.105,35.153,29.93 +2020-09-16 04:00:00,85.15,49.181000000000004,36.203,29.93 +2020-09-16 04:15:00,89.89,57.271,36.203,29.93 +2020-09-16 04:30:00,92.92,55.732,36.203,29.93 +2020-09-16 04:45:00,92.18,56.861000000000004,36.203,29.93 +2020-09-16 05:00:00,99.03,78.215,39.922,29.93 +2020-09-16 05:15:00,104.76,95.447,39.922,29.93 +2020-09-16 05:30:00,108.0,89.35799999999999,39.922,29.93 +2020-09-16 05:45:00,109.51,83.431,39.922,29.93 +2020-09-16 06:00:00,113.65,83.573,56.443999999999996,29.93 +2020-09-16 06:15:00,114.5,86.17,56.443999999999996,29.93 +2020-09-16 06:30:00,115.4,84.446,56.443999999999996,29.93 +2020-09-16 06:45:00,117.08,85.50399999999999,56.443999999999996,29.93 +2020-09-16 07:00:00,119.77,85.01899999999999,68.683,29.93 +2020-09-16 07:15:00,119.16,86.70100000000001,68.683,29.93 +2020-09-16 07:30:00,124.41,85.609,68.683,29.93 +2020-09-16 07:45:00,120.27,86.053,68.683,29.93 +2020-09-16 08:00:00,119.32,82.478,59.003,29.93 +2020-09-16 08:15:00,118.97,83.669,59.003,29.93 +2020-09-16 08:30:00,117.59,81.854,59.003,29.93 +2020-09-16 08:45:00,117.4,82.26899999999999,59.003,29.93 +2020-09-16 09:00:00,118.31,76.666,56.21,29.93 +2020-09-16 09:15:00,120.11,74.795,56.21,29.93 +2020-09-16 09:30:00,122.52,75.825,56.21,29.93 +2020-09-16 09:45:00,124.42,76.199,56.21,29.93 +2020-09-16 10:00:00,126.1,73.703,52.358999999999995,29.93 +2020-09-16 10:15:00,126.69,73.807,52.358999999999995,29.93 +2020-09-16 10:30:00,121.23,73.306,52.358999999999995,29.93 +2020-09-16 10:45:00,118.65,73.608,52.358999999999995,29.93 +2020-09-16 11:00:00,109.55,71.77600000000001,51.161,29.93 +2020-09-16 11:15:00,111.88,72.722,51.161,29.93 +2020-09-16 11:30:00,117.41,73.042,51.161,29.93 +2020-09-16 11:45:00,116.4,72.767,51.161,29.93 +2020-09-16 12:00:00,107.59,70.317,49.119,29.93 +2020-09-16 12:15:00,105.17,70.104,49.119,29.93 +2020-09-16 12:30:00,102.46,69.05199999999999,49.119,29.93 +2020-09-16 12:45:00,103.14,69.616,49.119,29.93 +2020-09-16 13:00:00,101.75,69.403,49.187,29.93 +2020-09-16 13:15:00,101.95,69.571,49.187,29.93 +2020-09-16 13:30:00,104.52,68.53399999999999,49.187,29.93 +2020-09-16 13:45:00,101.25,67.885,49.187,29.93 +2020-09-16 14:00:00,102.1,68.471,49.787,29.93 +2020-09-16 14:15:00,103.0,68.127,49.787,29.93 +2020-09-16 14:30:00,103.44,66.956,49.787,29.93 +2020-09-16 14:45:00,104.37,67.27600000000001,49.787,29.93 +2020-09-16 15:00:00,102.18,67.498,51.458999999999996,29.93 +2020-09-16 15:15:00,104.63,66.195,51.458999999999996,29.93 +2020-09-16 15:30:00,104.49,65.661,51.458999999999996,29.93 +2020-09-16 15:45:00,106.34,64.396,51.458999999999996,29.93 +2020-09-16 16:00:00,111.51,65.649,53.663000000000004,29.93 +2020-09-16 16:15:00,108.92,65.133,53.663000000000004,29.93 +2020-09-16 16:30:00,110.62,65.763,53.663000000000004,29.93 +2020-09-16 16:45:00,111.54,63.458999999999996,53.663000000000004,29.93 +2020-09-16 17:00:00,116.11,64.80199999999999,58.183,29.93 +2020-09-16 17:15:00,114.55,65.96,58.183,29.93 +2020-09-16 17:30:00,114.66,65.595,58.183,29.93 +2020-09-16 17:45:00,115.07,65.561,58.183,29.93 +2020-09-16 18:00:00,117.2,65.617,60.141000000000005,29.93 +2020-09-16 18:15:00,116.17,65.419,60.141000000000005,29.93 +2020-09-16 18:30:00,119.01,64.142,60.141000000000005,29.93 +2020-09-16 18:45:00,118.27,68.15,60.141000000000005,29.93 +2020-09-16 19:00:00,116.67,67.969,60.582,29.93 +2020-09-16 19:15:00,116.96,66.834,60.582,29.93 +2020-09-16 19:30:00,113.33,66.178,60.582,29.93 +2020-09-16 19:45:00,115.63,66.4,60.582,29.93 +2020-09-16 20:00:00,104.66,64.4,66.61,29.93 +2020-09-16 20:15:00,107.17,62.825,66.61,29.93 +2020-09-16 20:30:00,104.72,61.527,66.61,29.93 +2020-09-16 20:45:00,104.85,61.146,66.61,29.93 +2020-09-16 21:00:00,98.44,59.425,57.658,29.93 +2020-09-16 21:15:00,92.27,60.768,57.658,29.93 +2020-09-16 21:30:00,95.86,59.723,57.658,29.93 +2020-09-16 21:45:00,94.6,58.926,57.658,29.93 +2020-09-16 22:00:00,90.23,57.67,51.81,29.93 +2020-09-16 22:15:00,84.5,56.826,51.81,29.93 +2020-09-16 22:30:00,82.73,48.071000000000005,51.81,29.93 +2020-09-16 22:45:00,80.31,44.38,51.81,29.93 +2020-09-16 23:00:00,82.5,40.444,42.93600000000001,29.93 +2020-09-16 23:15:00,82.69,40.001999999999995,42.93600000000001,29.93 +2020-09-16 23:30:00,82.91,40.023,42.93600000000001,29.93 +2020-09-16 23:45:00,78.13,39.402,42.93600000000001,29.93 +2020-09-17 00:00:00,79.44,39.347,39.211,29.93 +2020-09-17 00:15:00,80.59,40.09,39.211,29.93 +2020-09-17 00:30:00,78.36,39.903,39.211,29.93 +2020-09-17 00:45:00,75.57,39.747,39.211,29.93 +2020-09-17 01:00:00,79.12,39.88,37.607,29.93 +2020-09-17 01:15:00,78.2,39.145,37.607,29.93 +2020-09-17 01:30:00,77.23,38.2,37.607,29.93 +2020-09-17 01:45:00,74.58,37.333,37.607,29.93 +2020-09-17 02:00:00,78.04,37.611,36.44,29.93 +2020-09-17 02:15:00,79.78,37.494,36.44,29.93 +2020-09-17 02:30:00,76.24,38.041,36.44,29.93 +2020-09-17 02:45:00,75.01,38.82,36.44,29.93 +2020-09-17 03:00:00,77.9,40.855,36.116,29.93 +2020-09-17 03:15:00,79.65,40.934,36.116,29.93 +2020-09-17 03:30:00,81.81,40.917,36.116,29.93 +2020-09-17 03:45:00,78.48,41.401,36.116,29.93 +2020-09-17 04:00:00,87.76,49.50899999999999,37.398,29.93 +2020-09-17 04:15:00,91.21,57.629,37.398,29.93 +2020-09-17 04:30:00,95.34,56.093999999999994,37.398,29.93 +2020-09-17 04:45:00,94.05,57.232,37.398,29.93 +2020-09-17 05:00:00,98.21,78.67699999999999,41.776,29.93 +2020-09-17 05:15:00,104.48,96.00299999999999,41.776,29.93 +2020-09-17 05:30:00,108.42,89.899,41.776,29.93 +2020-09-17 05:45:00,108.7,83.925,41.776,29.93 +2020-09-17 06:00:00,114.23,84.03399999999999,55.61,29.93 +2020-09-17 06:15:00,113.86,86.65299999999999,55.61,29.93 +2020-09-17 06:30:00,116.05,84.93299999999999,55.61,29.93 +2020-09-17 06:45:00,116.34,85.98899999999999,55.61,29.93 +2020-09-17 07:00:00,117.73,85.50200000000001,67.13600000000001,29.93 +2020-09-17 07:15:00,117.01,87.2,67.13600000000001,29.93 +2020-09-17 07:30:00,115.49,86.14299999999999,67.13600000000001,29.93 +2020-09-17 07:45:00,114.64,86.59,67.13600000000001,29.93 +2020-09-17 08:00:00,113.27,83.023,57.55,29.93 +2020-09-17 08:15:00,113.0,84.181,57.55,29.93 +2020-09-17 08:30:00,112.72,82.383,57.55,29.93 +2020-09-17 08:45:00,115.23,82.779,57.55,29.93 +2020-09-17 09:00:00,116.21,77.181,52.931999999999995,29.93 +2020-09-17 09:15:00,118.42,75.3,52.931999999999995,29.93 +2020-09-17 09:30:00,115.37,76.311,52.931999999999995,29.93 +2020-09-17 09:45:00,110.86,76.655,52.931999999999995,29.93 +2020-09-17 10:00:00,114.03,74.156,50.36600000000001,29.93 +2020-09-17 10:15:00,117.04,74.223,50.36600000000001,29.93 +2020-09-17 10:30:00,119.88,73.707,50.36600000000001,29.93 +2020-09-17 10:45:00,114.08,73.994,50.36600000000001,29.93 +2020-09-17 11:00:00,112.85,72.17399999999999,47.893,29.93 +2020-09-17 11:15:00,111.3,73.104,47.893,29.93 +2020-09-17 11:30:00,112.41,73.421,47.893,29.93 +2020-09-17 11:45:00,109.95,73.13,47.893,29.93 +2020-09-17 12:00:00,109.44,70.656,45.271,29.93 +2020-09-17 12:15:00,107.34,70.431,45.271,29.93 +2020-09-17 12:30:00,105.72,69.41,45.271,29.93 +2020-09-17 12:45:00,114.51,69.967,45.271,29.93 +2020-09-17 13:00:00,108.61,69.725,44.351000000000006,29.93 +2020-09-17 13:15:00,107.32,69.892,44.351000000000006,29.93 +2020-09-17 13:30:00,104.8,68.85300000000001,44.351000000000006,29.93 +2020-09-17 13:45:00,105.93,68.209,44.351000000000006,29.93 +2020-09-17 14:00:00,105.83,68.747,44.99,29.93 +2020-09-17 14:15:00,108.76,68.418,44.99,29.93 +2020-09-17 14:30:00,104.88,67.279,44.99,29.93 +2020-09-17 14:45:00,103.7,67.595,44.99,29.93 +2020-09-17 15:00:00,105.59,67.77199999999999,46.869,29.93 +2020-09-17 15:15:00,105.16,66.488,46.869,29.93 +2020-09-17 15:30:00,103.15,65.985,46.869,29.93 +2020-09-17 15:45:00,107.04,64.735,46.869,29.93 +2020-09-17 16:00:00,107.68,65.949,48.902,29.93 +2020-09-17 16:15:00,110.33,65.449,48.902,29.93 +2020-09-17 16:30:00,109.86,66.07600000000001,48.902,29.93 +2020-09-17 16:45:00,111.84,63.827,48.902,29.93 +2020-09-17 17:00:00,114.46,65.133,53.244,29.93 +2020-09-17 17:15:00,113.14,66.313,53.244,29.93 +2020-09-17 17:30:00,113.05,65.952,53.244,29.93 +2020-09-17 17:45:00,114.69,65.947,53.244,29.93 +2020-09-17 18:00:00,115.77,65.985,54.343999999999994,29.93 +2020-09-17 18:15:00,118.59,65.783,54.343999999999994,29.93 +2020-09-17 18:30:00,118.36,64.518,54.343999999999994,29.93 +2020-09-17 18:45:00,120.85,68.518,54.343999999999994,29.93 +2020-09-17 19:00:00,115.71,68.35,54.332,29.93 +2020-09-17 19:15:00,116.59,67.211,54.332,29.93 +2020-09-17 19:30:00,109.14,66.551,54.332,29.93 +2020-09-17 19:45:00,110.22,66.765,54.332,29.93 +2020-09-17 20:00:00,101.81,64.786,58.06,29.93 +2020-09-17 20:15:00,102.55,63.208,58.06,29.93 +2020-09-17 20:30:00,100.73,61.886,58.06,29.93 +2020-09-17 20:45:00,100.68,61.467,58.06,29.93 +2020-09-17 21:00:00,95.01,59.748999999999995,52.411,29.93 +2020-09-17 21:15:00,100.41,61.083,52.411,29.93 +2020-09-17 21:30:00,97.41,60.045,52.411,29.93 +2020-09-17 21:45:00,94.7,59.213,52.411,29.93 +2020-09-17 22:00:00,85.44,57.93899999999999,47.148999999999994,29.93 +2020-09-17 22:15:00,89.95,57.071000000000005,47.148999999999994,29.93 +2020-09-17 22:30:00,89.79,48.309,47.148999999999994,29.93 +2020-09-17 22:45:00,89.53,44.622,47.148999999999994,29.93 +2020-09-17 23:00:00,81.01,40.726,40.814,29.93 +2020-09-17 23:15:00,79.55,40.256,40.814,29.93 +2020-09-17 23:30:00,83.76,40.281,40.814,29.93 +2020-09-17 23:45:00,83.95,39.659,40.814,29.93 +2020-09-18 00:00:00,80.29,38.004,39.153,29.93 +2020-09-18 00:15:00,74.72,38.957,39.153,29.93 +2020-09-18 00:30:00,73.6,38.939,39.153,29.93 +2020-09-18 00:45:00,72.77,39.126,39.153,29.93 +2020-09-18 01:00:00,71.06,38.888000000000005,37.228,29.93 +2020-09-18 01:15:00,72.86,37.991,37.228,29.93 +2020-09-18 01:30:00,71.99,37.521,37.228,29.93 +2020-09-18 01:45:00,72.65,36.501999999999995,37.228,29.93 +2020-09-18 02:00:00,72.83,37.49,35.851,29.93 +2020-09-18 02:15:00,78.45,37.321999999999996,35.851,29.93 +2020-09-18 02:30:00,72.91,38.597,35.851,29.93 +2020-09-18 02:45:00,73.98,38.884,35.851,29.93 +2020-09-18 03:00:00,74.75,41.191,36.54,29.93 +2020-09-18 03:15:00,77.42,40.675,36.54,29.93 +2020-09-18 03:30:00,77.21,40.485,36.54,29.93 +2020-09-18 03:45:00,81.51,41.681000000000004,36.54,29.93 +2020-09-18 04:00:00,90.84,49.988,37.578,29.93 +2020-09-18 04:15:00,91.02,56.937,37.578,29.93 +2020-09-18 04:30:00,86.07,56.161,37.578,29.93 +2020-09-18 04:45:00,92.31,56.513999999999996,37.578,29.93 +2020-09-18 05:00:00,108.17,77.343,40.387,29.93 +2020-09-18 05:15:00,112.85,95.874,40.387,29.93 +2020-09-18 05:30:00,108.76,90.18299999999999,40.387,29.93 +2020-09-18 05:45:00,109.88,83.81,40.387,29.93 +2020-09-18 06:00:00,117.15,84.181,54.668,29.93 +2020-09-18 06:15:00,115.51,86.54899999999999,54.668,29.93 +2020-09-18 06:30:00,115.18,84.565,54.668,29.93 +2020-09-18 06:45:00,116.95,85.958,54.668,29.93 +2020-09-18 07:00:00,118.68,85.72399999999999,63.971000000000004,29.93 +2020-09-18 07:15:00,118.95,88.38600000000001,63.971000000000004,29.93 +2020-09-18 07:30:00,116.43,85.897,63.971000000000004,29.93 +2020-09-18 07:45:00,116.62,85.943,63.971000000000004,29.93 +2020-09-18 08:00:00,114.28,82.63,56.042,29.93 +2020-09-18 08:15:00,112.83,84.12299999999999,56.042,29.93 +2020-09-18 08:30:00,114.36,82.556,56.042,29.93 +2020-09-18 08:45:00,115.55,82.37,56.042,29.93 +2020-09-18 09:00:00,114.17,75.26,52.832,29.93 +2020-09-18 09:15:00,111.77,74.903,52.832,29.93 +2020-09-18 09:30:00,111.15,75.277,52.832,29.93 +2020-09-18 09:45:00,111.61,75.834,52.832,29.93 +2020-09-18 10:00:00,107.89,72.842,50.044,29.93 +2020-09-18 10:15:00,109.25,72.935,50.044,29.93 +2020-09-18 10:30:00,109.02,72.765,50.044,29.93 +2020-09-18 10:45:00,107.6,72.832,50.044,29.93 +2020-09-18 11:00:00,107.22,71.185,49.06100000000001,29.93 +2020-09-18 11:15:00,115.43,71.064,49.06100000000001,29.93 +2020-09-18 11:30:00,114.09,71.685,49.06100000000001,29.93 +2020-09-18 11:45:00,112.5,70.729,49.06100000000001,29.93 +2020-09-18 12:00:00,109.27,68.899,45.595,29.93 +2020-09-18 12:15:00,107.26,67.53399999999999,45.595,29.93 +2020-09-18 12:30:00,111.87,66.678,45.595,29.93 +2020-09-18 12:45:00,112.45,66.866,45.595,29.93 +2020-09-18 13:00:00,107.19,67.291,43.218,29.93 +2020-09-18 13:15:00,111.3,67.846,43.218,29.93 +2020-09-18 13:30:00,109.47,67.354,43.218,29.93 +2020-09-18 13:45:00,112.2,66.903,43.218,29.93 +2020-09-18 14:00:00,112.75,66.477,41.926,29.93 +2020-09-18 14:15:00,113.18,66.402,41.926,29.93 +2020-09-18 14:30:00,113.34,66.488,41.926,29.93 +2020-09-18 14:45:00,112.16,66.43,41.926,29.93 +2020-09-18 15:00:00,107.96,66.34899999999999,43.79,29.93 +2020-09-18 15:15:00,108.95,64.778,43.79,29.93 +2020-09-18 15:30:00,111.25,63.426,43.79,29.93 +2020-09-18 15:45:00,111.89,62.758,43.79,29.93 +2020-09-18 16:00:00,114.19,63.008,45.895,29.93 +2020-09-18 16:15:00,112.94,62.958,45.895,29.93 +2020-09-18 16:30:00,113.82,63.486000000000004,45.895,29.93 +2020-09-18 16:45:00,116.38,60.707,45.895,29.93 +2020-09-18 17:00:00,117.22,63.245,51.36,29.93 +2020-09-18 17:15:00,116.42,64.20100000000001,51.36,29.93 +2020-09-18 17:30:00,115.99,63.861000000000004,51.36,29.93 +2020-09-18 17:45:00,115.41,63.695,51.36,29.93 +2020-09-18 18:00:00,117.73,64.012,52.985,29.93 +2020-09-18 18:15:00,116.7,63.016000000000005,52.985,29.93 +2020-09-18 18:30:00,116.11,61.803000000000004,52.985,29.93 +2020-09-18 18:45:00,115.8,66.096,52.985,29.93 +2020-09-18 19:00:00,113.5,66.872,52.602,29.93 +2020-09-18 19:15:00,108.61,66.538,52.602,29.93 +2020-09-18 19:30:00,106.11,65.805,52.602,29.93 +2020-09-18 19:45:00,104.55,65.138,52.602,29.93 +2020-09-18 20:00:00,95.47,63.067,58.063,29.93 +2020-09-18 20:15:00,94.24,62.063,58.063,29.93 +2020-09-18 20:30:00,92.29,60.351000000000006,58.063,29.93 +2020-09-18 20:45:00,95.23,59.466,58.063,29.93 +2020-09-18 21:00:00,87.91,58.849,50.135,29.93 +2020-09-18 21:15:00,85.57,61.511,50.135,29.93 +2020-09-18 21:30:00,78.26,60.382,50.135,29.93 +2020-09-18 21:45:00,84.38,59.817,50.135,29.93 +2020-09-18 22:00:00,79.5,58.698,45.165,29.93 +2020-09-18 22:15:00,81.75,57.588,45.165,29.93 +2020-09-18 22:30:00,74.47,54.06,45.165,29.93 +2020-09-18 22:45:00,77.94,51.985,45.165,29.93 +2020-09-18 23:00:00,76.49,49.233000000000004,39.121,29.93 +2020-09-18 23:15:00,74.79,47.049,39.121,29.93 +2020-09-18 23:30:00,72.85,45.343999999999994,39.121,29.93 +2020-09-18 23:45:00,71.53,44.448,39.121,29.93 +2020-09-19 00:00:00,71.22,38.38,38.49,29.816 +2020-09-19 00:15:00,70.98,37.655,38.49,29.816 +2020-09-19 00:30:00,62.35,37.674,38.49,29.816 +2020-09-19 00:45:00,70.07,37.529,38.49,29.816 +2020-09-19 01:00:00,68.84,37.658,34.5,29.816 +2020-09-19 01:15:00,66.83,36.938,34.5,29.816 +2020-09-19 01:30:00,59.53,35.755,34.5,29.816 +2020-09-19 01:45:00,60.48,35.583,34.5,29.816 +2020-09-19 02:00:00,61.5,36.045,32.236,29.816 +2020-09-19 02:15:00,67.13,35.221,32.236,29.816 +2020-09-19 02:30:00,59.26,35.586999999999996,32.236,29.816 +2020-09-19 02:45:00,59.5,36.468,32.236,29.816 +2020-09-19 03:00:00,58.35,37.939,32.067,29.816 +2020-09-19 03:15:00,60.56,36.596,32.067,29.816 +2020-09-19 03:30:00,58.82,36.205,32.067,29.816 +2020-09-19 03:45:00,60.46,38.491,32.067,29.816 +2020-09-19 04:00:00,61.29,44.302,33.071,29.816 +2020-09-19 04:15:00,60.56,49.925,33.071,29.816 +2020-09-19 04:30:00,57.39,47.338,33.071,29.816 +2020-09-19 04:45:00,61.32,47.757,33.071,29.816 +2020-09-19 05:00:00,63.99,58.761,33.014,29.816 +2020-09-19 05:15:00,64.16,64.37100000000001,33.014,29.816 +2020-09-19 05:30:00,65.42,59.894,33.014,29.816 +2020-09-19 05:45:00,62.16,58.2,33.014,29.816 +2020-09-19 06:00:00,67.73,71.545,34.628,29.816 +2020-09-19 06:15:00,67.75,83.959,34.628,29.816 +2020-09-19 06:30:00,71.21,78.477,34.628,29.816 +2020-09-19 06:45:00,72.05,75.16,34.628,29.816 +2020-09-19 07:00:00,74.14,72.542,38.871,29.816 +2020-09-19 07:15:00,71.97,73.934,38.871,29.816 +2020-09-19 07:30:00,76.89,73.319,38.871,29.816 +2020-09-19 07:45:00,80.01,75.127,38.871,29.816 +2020-09-19 08:00:00,80.53,73.286,43.293,29.816 +2020-09-19 08:15:00,82.15,75.622,43.293,29.816 +2020-09-19 08:30:00,81.09,74.517,43.293,29.816 +2020-09-19 08:45:00,76.52,75.882,43.293,29.816 +2020-09-19 09:00:00,75.61,71.438,44.559,29.816 +2020-09-19 09:15:00,74.54,71.634,44.559,29.816 +2020-09-19 09:30:00,74.81,72.601,44.559,29.816 +2020-09-19 09:45:00,76.44,72.857,44.559,29.816 +2020-09-19 10:00:00,76.74,70.247,42.091,29.816 +2020-09-19 10:15:00,77.89,70.598,42.091,29.816 +2020-09-19 10:30:00,82.26,70.22800000000001,42.091,29.816 +2020-09-19 10:45:00,79.12,70.435,42.091,29.816 +2020-09-19 11:00:00,78.34,68.741,38.505,29.816 +2020-09-19 11:15:00,81.3,68.98899999999999,38.505,29.816 +2020-09-19 11:30:00,77.75,69.456,38.505,29.816 +2020-09-19 11:45:00,76.77,68.646,38.505,29.816 +2020-09-19 12:00:00,74.59,66.622,35.388000000000005,29.816 +2020-09-19 12:15:00,77.13,66.02600000000001,35.388000000000005,29.816 +2020-09-19 12:30:00,69.95,65.217,35.388000000000005,29.816 +2020-09-19 12:45:00,65.4,65.63600000000001,35.388000000000005,29.816 +2020-09-19 13:00:00,66.49,65.413,31.355999999999998,29.816 +2020-09-19 13:15:00,69.13,64.945,31.355999999999998,29.816 +2020-09-19 13:30:00,67.92,64.436,31.355999999999998,29.816 +2020-09-19 13:45:00,71.32,63.224,31.355999999999998,29.816 +2020-09-19 14:00:00,68.7,63.16,30.522,29.816 +2020-09-19 14:15:00,68.61,62.07899999999999,30.522,29.816 +2020-09-19 14:30:00,70.26,61.327,30.522,29.816 +2020-09-19 14:45:00,70.21,61.659,30.522,29.816 +2020-09-19 15:00:00,71.68,62.038999999999994,34.36,29.816 +2020-09-19 15:15:00,71.93,61.226000000000006,34.36,29.816 +2020-09-19 15:30:00,73.29,60.536,34.36,29.816 +2020-09-19 15:45:00,75.49,59.263999999999996,34.36,29.816 +2020-09-19 16:00:00,78.43,60.763999999999996,39.507,29.816 +2020-09-19 16:15:00,79.18,60.381,39.507,29.816 +2020-09-19 16:30:00,79.14,61.047,39.507,29.816 +2020-09-19 16:45:00,81.7,58.574,39.507,29.816 +2020-09-19 17:00:00,83.48,60.071999999999996,47.151,29.816 +2020-09-19 17:15:00,84.27,60.06100000000001,47.151,29.816 +2020-09-19 17:30:00,85.58,59.604,47.151,29.816 +2020-09-19 17:45:00,85.8,59.68600000000001,47.151,29.816 +2020-09-19 18:00:00,89.34,60.965,50.303999999999995,29.816 +2020-09-19 18:15:00,89.84,61.67100000000001,50.303999999999995,29.816 +2020-09-19 18:30:00,90.7,61.82,50.303999999999995,29.816 +2020-09-19 18:45:00,89.05,62.67,50.303999999999995,29.816 +2020-09-19 19:00:00,87.56,62.681000000000004,50.622,29.816 +2020-09-19 19:15:00,84.6,61.475,50.622,29.816 +2020-09-19 19:30:00,82.62,61.503,50.622,29.816 +2020-09-19 19:45:00,81.68,62.065,50.622,29.816 +2020-09-19 20:00:00,77.1,61.138999999999996,45.391000000000005,29.816 +2020-09-19 20:15:00,73.8,60.276,45.391000000000005,29.816 +2020-09-19 20:30:00,72.52,57.81,45.391000000000005,29.816 +2020-09-19 20:45:00,74.56,58.104,45.391000000000005,29.816 +2020-09-19 21:00:00,70.72,57.077,39.98,29.816 +2020-09-19 21:15:00,71.27,59.6,39.98,29.816 +2020-09-19 21:30:00,68.48,58.958,39.98,29.816 +2020-09-19 21:45:00,68.12,57.851000000000006,39.98,29.816 +2020-09-19 22:00:00,63.93,57.068999999999996,37.53,29.816 +2020-09-19 22:15:00,68.03,56.803000000000004,37.53,29.816 +2020-09-19 22:30:00,62.43,54.72,37.53,29.816 +2020-09-19 22:45:00,64.15,53.43600000000001,37.53,29.816 +2020-09-19 23:00:00,60.71,50.88399999999999,30.97,29.816 +2020-09-19 23:15:00,60.35,48.526,30.97,29.816 +2020-09-19 23:30:00,59.11,48.121,30.97,29.816 +2020-09-19 23:45:00,56.8,46.816,30.97,29.816 +2020-09-20 00:00:00,55.45,39.763000000000005,27.24,29.816 +2020-09-20 00:15:00,55.34,38.111999999999995,27.24,29.816 +2020-09-20 00:30:00,53.87,37.93,27.24,29.816 +2020-09-20 00:45:00,54.21,37.898,27.24,29.816 +2020-09-20 01:00:00,52.57,38.174,25.662,29.816 +2020-09-20 01:15:00,53.16,37.672,25.662,29.816 +2020-09-20 01:30:00,53.67,36.552,25.662,29.816 +2020-09-20 01:45:00,54.29,36.016999999999996,25.662,29.816 +2020-09-20 02:00:00,51.96,36.338,25.67,29.816 +2020-09-20 02:15:00,53.19,35.809,25.67,29.816 +2020-09-20 02:30:00,53.0,36.524,25.67,29.816 +2020-09-20 02:45:00,53.05,37.319,25.67,29.816 +2020-09-20 03:00:00,52.73,39.335,24.258000000000003,29.816 +2020-09-20 03:15:00,53.33,38.053000000000004,24.258000000000003,29.816 +2020-09-20 03:30:00,52.77,37.523,24.258000000000003,29.816 +2020-09-20 03:45:00,53.76,39.198,24.258000000000003,29.816 +2020-09-20 04:00:00,54.65,44.95399999999999,25.051,29.816 +2020-09-20 04:15:00,54.55,50.015,25.051,29.816 +2020-09-20 04:30:00,55.06,48.416000000000004,25.051,29.816 +2020-09-20 04:45:00,55.46,48.582,25.051,29.816 +2020-09-20 05:00:00,56.45,58.891999999999996,25.145,29.816 +2020-09-20 05:15:00,57.48,63.419,25.145,29.816 +2020-09-20 05:30:00,58.07,58.583,25.145,29.816 +2020-09-20 05:45:00,58.21,56.698,25.145,29.816 +2020-09-20 06:00:00,59.41,68.219,26.371,29.816 +2020-09-20 06:15:00,60.9,80.828,26.371,29.816 +2020-09-20 06:30:00,62.42,74.55,26.371,29.816 +2020-09-20 06:45:00,64.42,70.22399999999999,26.371,29.816 +2020-09-20 07:00:00,66.46,68.428,28.756999999999998,29.816 +2020-09-20 07:15:00,67.42,68.416,28.756999999999998,29.816 +2020-09-20 07:30:00,69.0,68.42699999999999,28.756999999999998,29.816 +2020-09-20 07:45:00,71.83,70.008,28.756999999999998,29.816 +2020-09-20 08:00:00,72.34,69.197,32.82,29.816 +2020-09-20 08:15:00,74.03,72.319,32.82,29.816 +2020-09-20 08:30:00,74.41,72.318,32.82,29.816 +2020-09-20 08:45:00,74.79,74.168,32.82,29.816 +2020-09-20 09:00:00,76.91,69.527,35.534,29.816 +2020-09-20 09:15:00,76.22,69.52,35.534,29.816 +2020-09-20 09:30:00,78.51,70.729,35.534,29.816 +2020-09-20 09:45:00,78.9,71.635,35.534,29.816 +2020-09-20 10:00:00,78.27,70.111,35.925,29.816 +2020-09-20 10:15:00,84.18,70.676,35.925,29.816 +2020-09-20 10:30:00,87.27,70.625,35.925,29.816 +2020-09-20 10:45:00,86.96,70.958,35.925,29.816 +2020-09-20 11:00:00,84.99,69.303,37.056,29.816 +2020-09-20 11:15:00,85.58,69.262,37.056,29.816 +2020-09-20 11:30:00,85.55,69.827,37.056,29.816 +2020-09-20 11:45:00,82.98,69.359,37.056,29.816 +2020-09-20 12:00:00,78.15,67.827,33.124,29.816 +2020-09-20 12:15:00,79.24,67.391,33.124,29.816 +2020-09-20 12:30:00,78.19,66.303,33.124,29.816 +2020-09-20 12:45:00,78.17,65.979,33.124,29.816 +2020-09-20 13:00:00,77.34,65.316,29.874000000000002,29.816 +2020-09-20 13:15:00,74.05,65.38,29.874000000000002,29.816 +2020-09-20 13:30:00,74.77,64.042,29.874000000000002,29.816 +2020-09-20 13:45:00,77.63,63.398,29.874000000000002,29.816 +2020-09-20 14:00:00,79.84,64.18,27.302,29.816 +2020-09-20 14:15:00,80.59,63.778999999999996,27.302,29.816 +2020-09-20 14:30:00,80.69,62.596000000000004,27.302,29.816 +2020-09-20 14:45:00,79.0,62.066,27.302,29.816 +2020-09-20 15:00:00,76.77,62.028,27.642,29.816 +2020-09-20 15:15:00,78.69,60.915,27.642,29.816 +2020-09-20 15:30:00,79.78,60.31399999999999,27.642,29.816 +2020-09-20 15:45:00,80.99,59.467,27.642,29.816 +2020-09-20 16:00:00,82.95,60.283,31.945999999999998,29.816 +2020-09-20 16:15:00,83.28,59.808,31.945999999999998,29.816 +2020-09-20 16:30:00,84.6,61.248999999999995,31.945999999999998,29.816 +2020-09-20 16:45:00,89.69,58.915,31.945999999999998,29.816 +2020-09-20 17:00:00,94.49,60.631,40.387,29.816 +2020-09-20 17:15:00,95.66,61.602,40.387,29.816 +2020-09-20 17:30:00,95.71,61.797,40.387,29.816 +2020-09-20 17:45:00,97.43,62.872,40.387,29.816 +2020-09-20 18:00:00,98.66,64.33,44.575,29.816 +2020-09-20 18:15:00,95.42,65.113,44.575,29.816 +2020-09-20 18:30:00,96.3,64.515,44.575,29.816 +2020-09-20 18:45:00,92.61,65.957,44.575,29.816 +2020-09-20 19:00:00,91.79,67.405,45.623999999999995,29.816 +2020-09-20 19:15:00,93.46,65.542,45.623999999999995,29.816 +2020-09-20 19:30:00,95.78,65.34,45.623999999999995,29.816 +2020-09-20 19:45:00,94.24,65.939,45.623999999999995,29.816 +2020-09-20 20:00:00,87.59,65.218,44.583999999999996,29.816 +2020-09-20 20:15:00,90.25,64.521,44.583999999999996,29.816 +2020-09-20 20:30:00,92.85,62.952,44.583999999999996,29.816 +2020-09-20 20:45:00,92.59,61.67100000000001,44.583999999999996,29.816 +2020-09-20 21:00:00,85.14,59.825,39.732,29.816 +2020-09-20 21:15:00,82.41,61.951,39.732,29.816 +2020-09-20 21:30:00,79.04,60.927,39.732,29.816 +2020-09-20 21:45:00,80.63,60.081,39.732,29.816 +2020-09-20 22:00:00,78.14,60.426,38.571,29.816 +2020-09-20 22:15:00,79.55,58.713,38.571,29.816 +2020-09-20 22:30:00,75.33,55.406000000000006,38.571,29.816 +2020-09-20 22:45:00,75.99,52.976000000000006,38.571,29.816 +2020-09-20 23:00:00,74.75,49.496,33.121,29.816 +2020-09-20 23:15:00,77.14,48.525,33.121,29.816 +2020-09-20 23:30:00,75.39,47.949,33.121,29.816 +2020-09-20 23:45:00,71.17,46.997,33.121,29.816 +2020-09-21 00:00:00,63.72,42.257,32.506,29.93 +2020-09-21 00:15:00,71.94,41.973,32.506,29.93 +2020-09-21 00:30:00,71.02,41.553999999999995,32.506,29.93 +2020-09-21 00:45:00,73.89,41.096000000000004,32.506,29.93 +2020-09-21 01:00:00,68.98,41.651,31.121,29.93 +2020-09-21 01:15:00,69.62,41.038000000000004,31.121,29.93 +2020-09-21 01:30:00,73.22,40.213,31.121,29.93 +2020-09-21 01:45:00,73.73,39.638000000000005,31.121,29.93 +2020-09-21 02:00:00,72.86,40.29,29.605999999999998,29.93 +2020-09-21 02:15:00,70.41,39.441,29.605999999999998,29.93 +2020-09-21 02:30:00,75.57,40.343,29.605999999999998,29.93 +2020-09-21 02:45:00,75.89,40.859,29.605999999999998,29.93 +2020-09-21 03:00:00,73.84,43.576,28.124000000000002,29.93 +2020-09-21 03:15:00,72.28,43.278,28.124000000000002,29.93 +2020-09-21 03:30:00,78.86,43.173,28.124000000000002,29.93 +2020-09-21 03:45:00,81.91,44.346000000000004,28.124000000000002,29.93 +2020-09-21 04:00:00,83.6,53.56399999999999,29.743000000000002,29.93 +2020-09-21 04:15:00,81.79,61.896,29.743000000000002,29.93 +2020-09-21 04:30:00,85.13,60.549,29.743000000000002,29.93 +2020-09-21 04:45:00,90.52,61.034,29.743000000000002,29.93 +2020-09-21 05:00:00,99.97,80.40899999999999,36.191,29.93 +2020-09-21 05:15:00,106.86,97.719,36.191,29.93 +2020-09-21 05:30:00,113.92,91.788,36.191,29.93 +2020-09-21 05:45:00,121.46,86.161,36.191,29.93 +2020-09-21 06:00:00,123.22,85.652,55.277,29.93 +2020-09-21 06:15:00,121.57,87.79899999999999,55.277,29.93 +2020-09-21 06:30:00,121.97,86.494,55.277,29.93 +2020-09-21 06:45:00,124.18,88.302,55.277,29.93 +2020-09-21 07:00:00,126.05,87.74700000000001,65.697,29.93 +2020-09-21 07:15:00,123.56,89.73700000000001,65.697,29.93 +2020-09-21 07:30:00,119.15,88.95200000000001,65.697,29.93 +2020-09-21 07:45:00,121.25,90.06200000000001,65.697,29.93 +2020-09-21 08:00:00,122.84,86.54299999999999,57.028,29.93 +2020-09-21 08:15:00,123.58,88.176,57.028,29.93 +2020-09-21 08:30:00,124.68,86.37299999999999,57.028,29.93 +2020-09-21 08:45:00,123.86,87.43,57.028,29.93 +2020-09-21 09:00:00,122.73,81.84100000000001,52.633,29.93 +2020-09-21 09:15:00,122.01,79.57300000000001,52.633,29.93 +2020-09-21 09:30:00,121.3,79.82,52.633,29.93 +2020-09-21 09:45:00,123.09,79.15100000000001,52.633,29.93 +2020-09-21 10:00:00,127.26,77.78,50.647,29.93 +2020-09-21 10:15:00,126.28,78.107,50.647,29.93 +2020-09-21 10:30:00,120.76,77.462,50.647,29.93 +2020-09-21 10:45:00,121.89,76.899,50.647,29.93 +2020-09-21 11:00:00,118.55,74.681,50.245,29.93 +2020-09-21 11:15:00,120.27,75.36399999999999,50.245,29.93 +2020-09-21 11:30:00,121.52,76.82600000000001,50.245,29.93 +2020-09-21 11:45:00,118.95,76.563,50.245,29.93 +2020-09-21 12:00:00,109.74,74.55,46.956,29.93 +2020-09-21 12:15:00,113.88,74.17699999999999,46.956,29.93 +2020-09-21 12:30:00,105.64,72.477,46.956,29.93 +2020-09-21 12:45:00,114.96,72.624,46.956,29.93 +2020-09-21 13:00:00,114.37,72.619,47.383,29.93 +2020-09-21 13:15:00,107.54,71.649,47.383,29.93 +2020-09-21 13:30:00,102.4,70.263,47.383,29.93 +2020-09-21 13:45:00,107.14,70.24600000000001,47.383,29.93 +2020-09-21 14:00:00,122.16,70.2,47.1,29.93 +2020-09-21 14:15:00,123.25,70.017,47.1,29.93 +2020-09-21 14:30:00,111.73,68.586,47.1,29.93 +2020-09-21 14:45:00,113.21,69.455,47.1,29.93 +2020-09-21 15:00:00,114.42,69.688,49.355,29.93 +2020-09-21 15:15:00,113.68,67.744,49.355,29.93 +2020-09-21 15:30:00,115.25,67.443,49.355,29.93 +2020-09-21 15:45:00,112.83,66.139,49.355,29.93 +2020-09-21 16:00:00,116.9,67.553,52.14,29.93 +2020-09-21 16:15:00,113.27,66.914,52.14,29.93 +2020-09-21 16:30:00,113.73,67.6,52.14,29.93 +2020-09-21 16:45:00,116.11,64.986,52.14,29.93 +2020-09-21 17:00:00,119.45,65.829,58.705,29.93 +2020-09-21 17:15:00,118.12,66.80199999999999,58.705,29.93 +2020-09-21 17:30:00,117.64,66.57,58.705,29.93 +2020-09-21 17:45:00,115.33,66.952,58.705,29.93 +2020-09-21 18:00:00,117.55,67.64,59.153,29.93 +2020-09-21 18:15:00,117.76,66.494,59.153,29.93 +2020-09-21 18:30:00,118.86,65.55,59.153,29.93 +2020-09-21 18:45:00,118.16,69.444,59.153,29.93 +2020-09-21 19:00:00,113.92,70.17,61.483000000000004,29.93 +2020-09-21 19:15:00,109.99,68.964,61.483000000000004,29.93 +2020-09-21 19:30:00,107.98,68.619,61.483000000000004,29.93 +2020-09-21 19:45:00,105.49,68.542,61.483000000000004,29.93 +2020-09-21 20:00:00,105.98,66.316,67.55,29.93 +2020-09-21 20:15:00,107.86,65.946,67.55,29.93 +2020-09-21 20:30:00,105.11,64.217,67.55,29.93 +2020-09-21 20:45:00,95.78,63.5,67.55,29.93 +2020-09-21 21:00:00,94.14,61.354,60.026,29.93 +2020-09-21 21:15:00,93.33,63.472,60.026,29.93 +2020-09-21 21:30:00,94.41,62.532,60.026,29.93 +2020-09-21 21:45:00,94.3,61.356,60.026,29.93 +2020-09-21 22:00:00,89.01,59.428000000000004,52.736999999999995,29.93 +2020-09-21 22:15:00,84.04,58.778999999999996,52.736999999999995,29.93 +2020-09-21 22:30:00,83.53,49.756,52.736999999999995,29.93 +2020-09-21 22:45:00,85.82,46.015,52.736999999999995,29.93 +2020-09-21 23:00:00,83.32,42.842,44.408,29.93 +2020-09-21 23:15:00,76.73,41.349,44.408,29.93 +2020-09-21 23:30:00,74.23,41.486000000000004,44.408,29.93 +2020-09-21 23:45:00,81.41,40.847,44.408,29.93 +2020-09-22 00:00:00,79.25,40.692,44.438,29.93 +2020-09-22 00:15:00,79.02,41.415,44.438,29.93 +2020-09-22 00:30:00,71.81,41.271,44.438,29.93 +2020-09-22 00:45:00,70.35,41.129,44.438,29.93 +2020-09-22 01:00:00,78.04,41.229,41.468999999999994,29.93 +2020-09-22 01:15:00,79.59,40.599000000000004,41.468999999999994,29.93 +2020-09-22 01:30:00,77.85,39.745,41.468999999999994,29.93 +2020-09-22 01:45:00,74.14,38.878,41.468999999999994,29.93 +2020-09-22 02:00:00,76.1,39.185,39.708,29.93 +2020-09-22 02:15:00,78.9,39.167,39.708,29.93 +2020-09-22 02:30:00,78.9,39.602,39.708,29.93 +2020-09-22 02:45:00,74.95,40.354,39.708,29.93 +2020-09-22 03:00:00,72.1,42.326,38.919000000000004,29.93 +2020-09-22 03:15:00,77.33,42.497,38.919000000000004,29.93 +2020-09-22 03:30:00,75.89,42.504,38.919000000000004,29.93 +2020-09-22 03:45:00,80.08,42.905,38.919000000000004,29.93 +2020-09-22 04:00:00,83.28,51.184,40.092,29.93 +2020-09-22 04:15:00,83.18,59.461999999999996,40.092,29.93 +2020-09-22 04:30:00,88.24,57.948,40.092,29.93 +2020-09-22 04:45:00,92.26,59.123999999999995,40.092,29.93 +2020-09-22 05:00:00,101.36,81.043,43.713,29.93 +2020-09-22 05:15:00,112.59,98.867,43.713,29.93 +2020-09-22 05:30:00,117.29,92.676,43.713,29.93 +2020-09-22 05:45:00,118.66,86.45299999999999,43.713,29.93 +2020-09-22 06:00:00,117.1,86.402,56.033,29.93 +2020-09-22 06:15:00,118.12,89.12799999999999,56.033,29.93 +2020-09-22 06:30:00,117.39,87.425,56.033,29.93 +2020-09-22 06:45:00,118.72,88.47200000000001,56.033,29.93 +2020-09-22 07:00:00,120.28,87.978,66.003,29.93 +2020-09-22 07:15:00,118.56,89.75299999999999,66.003,29.93 +2020-09-22 07:30:00,117.78,88.869,66.003,29.93 +2020-09-22 07:45:00,117.36,89.32600000000001,66.003,29.93 +2020-09-22 08:00:00,115.63,85.794,57.474,29.93 +2020-09-22 08:15:00,115.97,86.789,57.474,29.93 +2020-09-22 08:30:00,116.15,85.074,57.474,29.93 +2020-09-22 08:45:00,116.14,85.37,57.474,29.93 +2020-09-22 09:00:00,113.46,79.796,51.928000000000004,29.93 +2020-09-22 09:15:00,114.86,77.87100000000001,51.928000000000004,29.93 +2020-09-22 09:30:00,116.65,78.78399999999999,51.928000000000004,29.93 +2020-09-22 09:45:00,117.21,78.98100000000001,51.928000000000004,29.93 +2020-09-22 10:00:00,116.31,76.461,49.46,29.93 +2020-09-22 10:15:00,112.61,76.334,49.46,29.93 +2020-09-22 10:30:00,112.62,75.747,49.46,29.93 +2020-09-22 10:45:00,113.46,75.955,49.46,29.93 +2020-09-22 11:00:00,111.98,74.19800000000001,48.206,29.93 +2020-09-22 11:15:00,107.81,75.045,48.206,29.93 +2020-09-22 11:30:00,113.38,75.354,48.206,29.93 +2020-09-22 11:45:00,106.2,74.97399999999999,48.206,29.93 +2020-09-22 12:00:00,105.03,72.382,46.285,29.93 +2020-09-22 12:15:00,104.5,72.095,46.285,29.93 +2020-09-22 12:30:00,103.2,71.24,46.285,29.93 +2020-09-22 12:45:00,99.21,71.758,46.285,29.93 +2020-09-22 13:00:00,102.59,71.366,46.861999999999995,29.93 +2020-09-22 13:15:00,108.02,71.529,46.861999999999995,29.93 +2020-09-22 13:30:00,103.9,70.479,46.861999999999995,29.93 +2020-09-22 13:45:00,102.03,69.86,46.861999999999995,29.93 +2020-09-22 14:00:00,106.58,70.15899999999999,46.488,29.93 +2020-09-22 14:15:00,105.28,69.903,46.488,29.93 +2020-09-22 14:30:00,101.09,68.925,46.488,29.93 +2020-09-22 14:45:00,102.61,69.22399999999999,46.488,29.93 +2020-09-22 15:00:00,105.33,69.167,48.442,29.93 +2020-09-22 15:15:00,104.23,67.98,48.442,29.93 +2020-09-22 15:30:00,103.53,67.63600000000001,48.442,29.93 +2020-09-22 15:45:00,105.75,66.461,48.442,29.93 +2020-09-22 16:00:00,108.11,67.479,50.397,29.93 +2020-09-22 16:15:00,108.64,67.058,50.397,29.93 +2020-09-22 16:30:00,110.45,67.669,50.397,29.93 +2020-09-22 16:45:00,113.26,65.699,50.397,29.93 +2020-09-22 17:00:00,114.67,66.813,56.668,29.93 +2020-09-22 17:15:00,114.65,68.11,56.668,29.93 +2020-09-22 17:30:00,116.47,67.773,56.668,29.93 +2020-09-22 17:45:00,117.39,67.911,56.668,29.93 +2020-09-22 18:00:00,121.5,67.854,57.957,29.93 +2020-09-22 18:15:00,119.7,67.63600000000001,57.957,29.93 +2020-09-22 18:30:00,121.25,66.432,57.957,29.93 +2020-09-22 18:45:00,119.41,70.4,57.957,29.93 +2020-09-22 19:00:00,117.27,70.289,57.056000000000004,29.93 +2020-09-22 19:15:00,114.03,69.138,57.056000000000004,29.93 +2020-09-22 19:30:00,113.68,68.457,57.056000000000004,29.93 +2020-09-22 19:45:00,108.16,68.626,57.056000000000004,29.93 +2020-09-22 20:00:00,106.04,66.758,64.156,29.93 +2020-09-22 20:15:00,110.06,65.166,64.156,29.93 +2020-09-22 20:30:00,108.07,63.717,64.156,29.93 +2020-09-22 20:45:00,100.9,63.108999999999995,64.156,29.93 +2020-09-22 21:00:00,93.55,61.407,56.507,29.93 +2020-09-22 21:15:00,93.78,62.69,56.507,29.93 +2020-09-22 21:30:00,90.09,61.68899999999999,56.507,29.93 +2020-09-22 21:45:00,92.81,60.684,56.507,29.93 +2020-09-22 22:00:00,88.4,59.316,50.728,29.93 +2020-09-22 22:15:00,90.76,58.328,50.728,29.93 +2020-09-22 22:30:00,83.74,49.538000000000004,50.728,29.93 +2020-09-22 22:45:00,79.35,45.873000000000005,50.728,29.93 +2020-09-22 23:00:00,73.73,42.176,43.556999999999995,29.93 +2020-09-22 23:15:00,74.31,41.556999999999995,43.556999999999995,29.93 +2020-09-22 23:30:00,72.56,41.6,43.556999999999995,29.93 +2020-09-22 23:45:00,73.7,40.976000000000006,43.556999999999995,29.93 +2020-09-23 00:00:00,74.14,40.966,41.151,29.93 +2020-09-23 00:15:00,80.43,41.68600000000001,41.151,29.93 +2020-09-23 00:30:00,80.03,41.549,41.151,29.93 +2020-09-23 00:45:00,79.17,41.41,41.151,29.93 +2020-09-23 01:00:00,72.52,41.504,37.763000000000005,29.93 +2020-09-23 01:15:00,76.54,40.896,37.763000000000005,29.93 +2020-09-23 01:30:00,79.93,40.059,37.763000000000005,29.93 +2020-09-23 01:45:00,81.24,39.193000000000005,37.763000000000005,29.93 +2020-09-23 02:00:00,78.02,39.505,35.615,29.93 +2020-09-23 02:15:00,77.07,39.507,35.615,29.93 +2020-09-23 02:30:00,81.33,39.919000000000004,35.615,29.93 +2020-09-23 02:45:00,81.34,40.667,35.615,29.93 +2020-09-23 03:00:00,77.9,42.626000000000005,35.153,29.93 +2020-09-23 03:15:00,76.25,42.815,35.153,29.93 +2020-09-23 03:30:00,84.12,42.827,35.153,29.93 +2020-09-23 03:45:00,87.89,43.21,35.153,29.93 +2020-09-23 04:00:00,91.84,51.525,36.203,29.93 +2020-09-23 04:15:00,87.47,59.836999999999996,36.203,29.93 +2020-09-23 04:30:00,95.22,58.328,36.203,29.93 +2020-09-23 04:45:00,101.67,59.511,36.203,29.93 +2020-09-23 05:00:00,111.18,81.527,39.922,29.93 +2020-09-23 05:15:00,112.35,99.454,39.922,29.93 +2020-09-23 05:30:00,111.69,93.244,39.922,29.93 +2020-09-23 05:45:00,116.38,86.97,39.922,29.93 +2020-09-23 06:00:00,119.88,86.88600000000001,56.443999999999996,29.93 +2020-09-23 06:15:00,119.76,89.635,56.443999999999996,29.93 +2020-09-23 06:30:00,120.0,87.935,56.443999999999996,29.93 +2020-09-23 06:45:00,121.7,88.979,56.443999999999996,29.93 +2020-09-23 07:00:00,124.88,88.484,68.683,29.93 +2020-09-23 07:15:00,120.09,90.274,68.683,29.93 +2020-09-23 07:30:00,121.21,89.425,68.683,29.93 +2020-09-23 07:45:00,123.12,89.883,68.683,29.93 +2020-09-23 08:00:00,116.84,86.35799999999999,59.003,29.93 +2020-09-23 08:15:00,115.12,87.31700000000001,59.003,29.93 +2020-09-23 08:30:00,115.48,85.62,59.003,29.93 +2020-09-23 08:45:00,115.51,85.897,59.003,29.93 +2020-09-23 09:00:00,114.02,80.328,56.21,29.93 +2020-09-23 09:15:00,114.6,78.39399999999999,56.21,29.93 +2020-09-23 09:30:00,110.5,79.286,56.21,29.93 +2020-09-23 09:45:00,108.97,79.453,56.21,29.93 +2020-09-23 10:00:00,108.11,76.928,52.358999999999995,29.93 +2020-09-23 10:15:00,109.82,76.764,52.358999999999995,29.93 +2020-09-23 10:30:00,110.15,76.161,52.358999999999995,29.93 +2020-09-23 10:45:00,105.93,76.354,52.358999999999995,29.93 +2020-09-23 11:00:00,105.59,74.609,51.161,29.93 +2020-09-23 11:15:00,106.75,75.438,51.161,29.93 +2020-09-23 11:30:00,104.38,75.747,51.161,29.93 +2020-09-23 11:45:00,103.61,75.348,51.161,29.93 +2020-09-23 12:00:00,100.04,72.733,49.119,29.93 +2020-09-23 12:15:00,106.82,72.434,49.119,29.93 +2020-09-23 12:30:00,109.78,71.612,49.119,29.93 +2020-09-23 12:45:00,102.31,72.122,49.119,29.93 +2020-09-23 13:00:00,103.09,71.70100000000001,49.187,29.93 +2020-09-23 13:15:00,103.29,71.862,49.187,29.93 +2020-09-23 13:30:00,102.62,70.81,49.187,29.93 +2020-09-23 13:45:00,105.34,70.196,49.187,29.93 +2020-09-23 14:00:00,107.99,70.447,49.787,29.93 +2020-09-23 14:15:00,105.73,70.204,49.787,29.93 +2020-09-23 14:30:00,107.7,69.259,49.787,29.93 +2020-09-23 14:45:00,109.29,69.555,49.787,29.93 +2020-09-23 15:00:00,108.43,69.45100000000001,51.458999999999996,29.93 +2020-09-23 15:15:00,104.72,68.285,51.458999999999996,29.93 +2020-09-23 15:30:00,107.19,67.971,51.458999999999996,29.93 +2020-09-23 15:45:00,107.85,66.812,51.458999999999996,29.93 +2020-09-23 16:00:00,109.45,67.789,53.663000000000004,29.93 +2020-09-23 16:15:00,108.62,67.385,53.663000000000004,29.93 +2020-09-23 16:30:00,110.5,67.992,53.663000000000004,29.93 +2020-09-23 16:45:00,113.22,66.078,53.663000000000004,29.93 +2020-09-23 17:00:00,116.47,67.153,58.183,29.93 +2020-09-23 17:15:00,114.39,68.475,58.183,29.93 +2020-09-23 17:30:00,116.38,68.142,58.183,29.93 +2020-09-23 17:45:00,117.99,68.31,58.183,29.93 +2020-09-23 18:00:00,119.46,68.234,60.141000000000005,29.93 +2020-09-23 18:15:00,119.27,68.014,60.141000000000005,29.93 +2020-09-23 18:30:00,119.58,66.82,60.141000000000005,29.93 +2020-09-23 18:45:00,119.51,70.78399999999999,60.141000000000005,29.93 +2020-09-23 19:00:00,115.57,70.683,60.582,29.93 +2020-09-23 19:15:00,111.65,69.531,60.582,29.93 +2020-09-23 19:30:00,110.06,68.845,60.582,29.93 +2020-09-23 19:45:00,107.79,69.005,60.582,29.93 +2020-09-23 20:00:00,103.36,67.161,66.61,29.93 +2020-09-23 20:15:00,99.89,65.566,66.61,29.93 +2020-09-23 20:30:00,99.32,64.09,66.61,29.93 +2020-09-23 20:45:00,101.02,63.443000000000005,66.61,29.93 +2020-09-23 21:00:00,98.0,61.744,57.658,29.93 +2020-09-23 21:15:00,99.6,63.016999999999996,57.658,29.93 +2020-09-23 21:30:00,86.71,62.025,57.658,29.93 +2020-09-23 21:45:00,90.43,60.985,57.658,29.93 +2020-09-23 22:00:00,84.37,59.597,51.81,29.93 +2020-09-23 22:15:00,91.09,58.586000000000006,51.81,29.93 +2020-09-23 22:30:00,88.12,49.79,51.81,29.93 +2020-09-23 22:45:00,83.92,46.13,51.81,29.93 +2020-09-23 23:00:00,76.12,42.474,42.93600000000001,29.93 +2020-09-23 23:15:00,75.88,41.823,42.93600000000001,29.93 +2020-09-23 23:30:00,79.32,41.87,42.93600000000001,29.93 +2020-09-23 23:45:00,82.59,41.246,42.93600000000001,29.93 +2020-09-24 00:00:00,79.44,41.243,39.211,29.93 +2020-09-24 00:15:00,76.44,41.958999999999996,39.211,29.93 +2020-09-24 00:30:00,75.48,41.83,39.211,29.93 +2020-09-24 00:45:00,79.62,41.693999999999996,39.211,29.93 +2020-09-24 01:00:00,76.77,41.78,37.607,29.93 +2020-09-24 01:15:00,80.51,41.193000000000005,37.607,29.93 +2020-09-24 01:30:00,72.87,40.375,37.607,29.93 +2020-09-24 01:45:00,79.28,39.509,37.607,29.93 +2020-09-24 02:00:00,78.34,39.827,36.44,29.93 +2020-09-24 02:15:00,78.78,39.849000000000004,36.44,29.93 +2020-09-24 02:30:00,74.7,40.239000000000004,36.44,29.93 +2020-09-24 02:45:00,82.77,40.981,36.44,29.93 +2020-09-24 03:00:00,81.53,42.928000000000004,36.116,29.93 +2020-09-24 03:15:00,79.01,43.135,36.116,29.93 +2020-09-24 03:30:00,78.21,43.151,36.116,29.93 +2020-09-24 03:45:00,82.11,43.516999999999996,36.116,29.93 +2020-09-24 04:00:00,87.36,51.869,37.398,29.93 +2020-09-24 04:15:00,93.6,60.213,37.398,29.93 +2020-09-24 04:30:00,97.83,58.708999999999996,37.398,29.93 +2020-09-24 04:45:00,99.6,59.9,37.398,29.93 +2020-09-24 05:00:00,109.7,82.016,41.776,29.93 +2020-09-24 05:15:00,108.24,100.046,41.776,29.93 +2020-09-24 05:30:00,110.1,93.816,41.776,29.93 +2020-09-24 05:45:00,112.5,87.49,41.776,29.93 +2020-09-24 06:00:00,118.92,87.375,55.61,29.93 +2020-09-24 06:15:00,120.61,90.146,55.61,29.93 +2020-09-24 06:30:00,123.08,88.449,55.61,29.93 +2020-09-24 06:45:00,124.17,89.49,55.61,29.93 +2020-09-24 07:00:00,126.37,88.994,67.13600000000001,29.93 +2020-09-24 07:15:00,125.34,90.79899999999999,67.13600000000001,29.93 +2020-09-24 07:30:00,127.26,89.985,67.13600000000001,29.93 +2020-09-24 07:45:00,127.08,90.443,67.13600000000001,29.93 +2020-09-24 08:00:00,125.35,86.925,57.55,29.93 +2020-09-24 08:15:00,126.55,87.84899999999999,57.55,29.93 +2020-09-24 08:30:00,129.1,86.16799999999999,57.55,29.93 +2020-09-24 08:45:00,130.06,86.425,57.55,29.93 +2020-09-24 09:00:00,128.57,80.861,52.931999999999995,29.93 +2020-09-24 09:15:00,126.84,78.918,52.931999999999995,29.93 +2020-09-24 09:30:00,123.67,79.79,52.931999999999995,29.93 +2020-09-24 09:45:00,127.27,79.92699999999999,52.931999999999995,29.93 +2020-09-24 10:00:00,125.65,77.398,50.36600000000001,29.93 +2020-09-24 10:15:00,124.59,77.194,50.36600000000001,29.93 +2020-09-24 10:30:00,120.94,76.577,50.36600000000001,29.93 +2020-09-24 10:45:00,116.89,76.75399999999999,50.36600000000001,29.93 +2020-09-24 11:00:00,110.52,75.02,47.893,29.93 +2020-09-24 11:15:00,109.17,75.833,47.893,29.93 +2020-09-24 11:30:00,110.97,76.141,47.893,29.93 +2020-09-24 11:45:00,106.21,75.725,47.893,29.93 +2020-09-24 12:00:00,102.53,73.084,45.271,29.93 +2020-09-24 12:15:00,102.03,72.77199999999999,45.271,29.93 +2020-09-24 12:30:00,99.75,71.986,45.271,29.93 +2020-09-24 12:45:00,99.48,72.488,45.271,29.93 +2020-09-24 13:00:00,98.27,72.03699999999999,44.351000000000006,29.93 +2020-09-24 13:15:00,99.53,72.196,44.351000000000006,29.93 +2020-09-24 13:30:00,100.61,71.143,44.351000000000006,29.93 +2020-09-24 13:45:00,102.58,70.532,44.351000000000006,29.93 +2020-09-24 14:00:00,103.51,70.735,44.99,29.93 +2020-09-24 14:15:00,103.08,70.508,44.99,29.93 +2020-09-24 14:30:00,101.16,69.596,44.99,29.93 +2020-09-24 14:45:00,101.81,69.888,44.99,29.93 +2020-09-24 15:00:00,101.94,69.737,46.869,29.93 +2020-09-24 15:15:00,103.21,68.59,46.869,29.93 +2020-09-24 15:30:00,103.97,68.308,46.869,29.93 +2020-09-24 15:45:00,104.84,67.164,46.869,29.93 +2020-09-24 16:00:00,108.09,68.101,48.902,29.93 +2020-09-24 16:15:00,109.23,67.714,48.902,29.93 +2020-09-24 16:30:00,110.09,68.317,48.902,29.93 +2020-09-24 16:45:00,113.09,66.46,48.902,29.93 +2020-09-24 17:00:00,116.14,67.495,53.244,29.93 +2020-09-24 17:15:00,116.08,68.84100000000001,53.244,29.93 +2020-09-24 17:30:00,117.28,68.513,53.244,29.93 +2020-09-24 17:45:00,116.1,68.711,53.244,29.93 +2020-09-24 18:00:00,120.4,68.615,54.343999999999994,29.93 +2020-09-24 18:15:00,120.5,68.393,54.343999999999994,29.93 +2020-09-24 18:30:00,123.4,67.212,54.343999999999994,29.93 +2020-09-24 18:45:00,118.83,71.17,54.343999999999994,29.93 +2020-09-24 19:00:00,115.06,71.08,54.332,29.93 +2020-09-24 19:15:00,112.24,69.925,54.332,29.93 +2020-09-24 19:30:00,114.88,69.235,54.332,29.93 +2020-09-24 19:45:00,110.88,69.387,54.332,29.93 +2020-09-24 20:00:00,107.62,67.565,58.06,29.93 +2020-09-24 20:15:00,108.78,65.968,58.06,29.93 +2020-09-24 20:30:00,104.84,64.46600000000001,58.06,29.93 +2020-09-24 20:45:00,96.86,63.781000000000006,58.06,29.93 +2020-09-24 21:00:00,94.23,62.083999999999996,52.411,29.93 +2020-09-24 21:15:00,89.92,63.346000000000004,52.411,29.93 +2020-09-24 21:30:00,87.79,62.361000000000004,52.411,29.93 +2020-09-24 21:45:00,89.98,61.287,52.411,29.93 +2020-09-24 22:00:00,88.04,59.88,47.148999999999994,29.93 +2020-09-24 22:15:00,89.31,58.845,47.148999999999994,29.93 +2020-09-24 22:30:00,80.71,50.044,47.148999999999994,29.93 +2020-09-24 22:45:00,79.84,46.388000000000005,47.148999999999994,29.93 +2020-09-24 23:00:00,71.75,42.773,40.814,29.93 +2020-09-24 23:15:00,77.77,42.091,40.814,29.93 +2020-09-24 23:30:00,81.26,42.141000000000005,40.814,29.93 +2020-09-24 23:45:00,80.31,41.516999999999996,40.814,29.93 +2020-09-25 00:00:00,74.0,39.914,39.153,29.93 +2020-09-25 00:15:00,72.28,40.838,39.153,29.93 +2020-09-25 00:30:00,77.46,40.879,39.153,29.93 +2020-09-25 00:45:00,78.16,41.085,39.153,29.93 +2020-09-25 01:00:00,76.04,40.797,37.228,29.93 +2020-09-25 01:15:00,73.4,40.051,37.228,29.93 +2020-09-25 01:30:00,76.94,39.709,37.228,29.93 +2020-09-25 01:45:00,79.03,38.69,37.228,29.93 +2020-09-25 02:00:00,75.49,39.719,35.851,29.93 +2020-09-25 02:15:00,75.88,39.689,35.851,29.93 +2020-09-25 02:30:00,78.09,40.806,35.851,29.93 +2020-09-25 02:45:00,74.34,41.058,35.851,29.93 +2020-09-25 03:00:00,73.16,43.275,36.54,29.93 +2020-09-25 03:15:00,77.62,42.89,36.54,29.93 +2020-09-25 03:30:00,83.89,42.731,36.54,29.93 +2020-09-25 03:45:00,88.6,43.809,36.54,29.93 +2020-09-25 04:00:00,88.15,52.363,37.578,29.93 +2020-09-25 04:15:00,90.7,59.54,37.578,29.93 +2020-09-25 04:30:00,93.93,58.795,37.578,29.93 +2020-09-25 04:45:00,97.88,59.202,37.578,29.93 +2020-09-25 05:00:00,100.27,80.708,40.387,29.93 +2020-09-25 05:15:00,109.18,99.95299999999999,40.387,29.93 +2020-09-25 05:30:00,109.6,94.13,40.387,29.93 +2020-09-25 05:45:00,110.57,87.402,40.387,29.93 +2020-09-25 06:00:00,115.32,87.54799999999999,54.668,29.93 +2020-09-25 06:15:00,115.66,90.071,54.668,29.93 +2020-09-25 06:30:00,118.26,88.10700000000001,54.668,29.93 +2020-09-25 06:45:00,117.52,89.484,54.668,29.93 +2020-09-25 07:00:00,120.63,89.242,63.971000000000004,29.93 +2020-09-25 07:15:00,121.05,92.009,63.971000000000004,29.93 +2020-09-25 07:30:00,117.32,89.76299999999999,63.971000000000004,29.93 +2020-09-25 07:45:00,117.02,89.816,63.971000000000004,29.93 +2020-09-25 08:00:00,113.23,86.553,56.042,29.93 +2020-09-25 08:15:00,112.83,87.81,56.042,29.93 +2020-09-25 08:30:00,112.79,86.36,56.042,29.93 +2020-09-25 08:45:00,114.62,86.03399999999999,56.042,29.93 +2020-09-25 09:00:00,110.41,78.957,52.832,29.93 +2020-09-25 09:15:00,113.15,78.538,52.832,29.93 +2020-09-25 09:30:00,115.51,78.775,52.832,29.93 +2020-09-25 09:45:00,121.32,79.123,52.832,29.93 +2020-09-25 10:00:00,122.11,76.09899999999999,50.044,29.93 +2020-09-25 10:15:00,119.95,75.921,50.044,29.93 +2020-09-25 10:30:00,119.89,75.65,50.044,29.93 +2020-09-25 10:45:00,123.44,75.60600000000001,50.044,29.93 +2020-09-25 11:00:00,126.14,74.045,49.06100000000001,29.93 +2020-09-25 11:15:00,126.84,73.806,49.06100000000001,29.93 +2020-09-25 11:30:00,127.73,74.418,49.06100000000001,29.93 +2020-09-25 11:45:00,125.85,73.337,49.06100000000001,29.93 +2020-09-25 12:00:00,124.09,71.339,45.595,29.93 +2020-09-25 12:15:00,125.3,69.887,45.595,29.93 +2020-09-25 12:30:00,123.99,69.266,45.595,29.93 +2020-09-25 12:45:00,122.75,69.4,45.595,29.93 +2020-09-25 13:00:00,119.15,69.617,43.218,29.93 +2020-09-25 13:15:00,119.21,70.164,43.218,29.93 +2020-09-25 13:30:00,117.01,69.656,43.218,29.93 +2020-09-25 13:45:00,117.78,69.24,43.218,29.93 +2020-09-25 14:00:00,116.77,68.476,41.926,29.93 +2020-09-25 14:15:00,115.25,68.501,41.926,29.93 +2020-09-25 14:30:00,113.74,68.818,41.926,29.93 +2020-09-25 14:45:00,110.79,68.735,41.926,29.93 +2020-09-25 15:00:00,110.83,68.325,43.79,29.93 +2020-09-25 15:15:00,108.35,66.892,43.79,29.93 +2020-09-25 15:30:00,109.43,65.762,43.79,29.93 +2020-09-25 15:45:00,113.35,65.20100000000001,43.79,29.93 +2020-09-25 16:00:00,112.61,65.172,45.895,29.93 +2020-09-25 16:15:00,111.78,65.234,45.895,29.93 +2020-09-25 16:30:00,114.66,65.737,45.895,29.93 +2020-09-25 16:45:00,114.16,63.353,45.895,29.93 +2020-09-25 17:00:00,116.51,65.618,51.36,29.93 +2020-09-25 17:15:00,117.46,66.74,51.36,29.93 +2020-09-25 17:30:00,116.53,66.434,51.36,29.93 +2020-09-25 17:45:00,119.69,66.47399999999999,51.36,29.93 +2020-09-25 18:00:00,124.62,66.657,52.985,29.93 +2020-09-25 18:15:00,119.12,65.642,52.985,29.93 +2020-09-25 18:30:00,119.12,64.514,52.985,29.93 +2020-09-25 18:45:00,114.05,68.764,52.985,29.93 +2020-09-25 19:00:00,110.75,69.617,52.602,29.93 +2020-09-25 19:15:00,109.43,69.267,52.602,29.93 +2020-09-25 19:30:00,107.76,68.505,52.602,29.93 +2020-09-25 19:45:00,104.87,67.777,52.602,29.93 +2020-09-25 20:00:00,100.97,65.862,58.063,29.93 +2020-09-25 20:15:00,102.74,64.84,58.063,29.93 +2020-09-25 20:30:00,100.5,62.946999999999996,58.063,29.93 +2020-09-25 20:45:00,93.97,61.794,58.063,29.93 +2020-09-25 21:00:00,88.38,61.198,50.135,29.93 +2020-09-25 21:15:00,87.81,63.786,50.135,29.93 +2020-09-25 21:30:00,85.24,62.715,50.135,29.93 +2020-09-25 21:45:00,86.18,61.906000000000006,50.135,29.93 +2020-09-25 22:00:00,83.96,60.652,45.165,29.93 +2020-09-25 22:15:00,79.17,59.376000000000005,45.165,29.93 +2020-09-25 22:30:00,75.1,55.809,45.165,29.93 +2020-09-25 22:45:00,77.09,53.768,45.165,29.93 +2020-09-25 23:00:00,79.16,51.29600000000001,39.121,29.93 +2020-09-25 23:15:00,75.29,48.898,39.121,29.93 +2020-09-25 23:30:00,70.25,47.217,39.121,29.93 +2020-09-25 23:45:00,67.44,46.32,39.121,29.93 +2020-09-26 00:00:00,64.11,40.303000000000004,38.49,29.816 +2020-09-26 00:15:00,64.22,39.549,38.49,29.816 +2020-09-26 00:30:00,71.12,39.624,38.49,29.816 +2020-09-26 00:45:00,71.01,39.498000000000005,38.49,29.816 +2020-09-26 01:00:00,69.95,39.577,34.5,29.816 +2020-09-26 01:15:00,63.99,39.008,34.5,29.816 +2020-09-26 01:30:00,69.4,37.955,34.5,29.816 +2020-09-26 01:45:00,69.93,37.782,34.5,29.816 +2020-09-26 02:00:00,68.96,38.286,32.236,29.816 +2020-09-26 02:15:00,64.21,37.6,32.236,29.816 +2020-09-26 02:30:00,68.74,37.809,32.236,29.816 +2020-09-26 02:45:00,67.86,38.655,32.236,29.816 +2020-09-26 03:00:00,62.1,40.036,32.067,29.816 +2020-09-26 03:15:00,61.48,38.824,32.067,29.816 +2020-09-26 03:30:00,62.24,38.464,32.067,29.816 +2020-09-26 03:45:00,62.77,40.629,32.067,29.816 +2020-09-26 04:00:00,63.44,46.69,33.071,29.816 +2020-09-26 04:15:00,63.08,52.544,33.071,29.816 +2020-09-26 04:30:00,63.34,49.988,33.071,29.816 +2020-09-26 04:45:00,65.2,50.463,33.071,29.816 +2020-09-26 05:00:00,67.69,62.15,33.014,29.816 +2020-09-26 05:15:00,69.25,68.483,33.014,29.816 +2020-09-26 05:30:00,71.19,63.869,33.014,29.816 +2020-09-26 05:45:00,70.98,61.817,33.014,29.816 +2020-09-26 06:00:00,72.82,74.937,34.628,29.816 +2020-09-26 06:15:00,73.93,87.507,34.628,29.816 +2020-09-26 06:30:00,75.0,82.045,34.628,29.816 +2020-09-26 06:45:00,77.15,78.709,34.628,29.816 +2020-09-26 07:00:00,78.72,76.085,38.871,29.816 +2020-09-26 07:15:00,78.7,77.581,38.871,29.816 +2020-09-26 07:30:00,79.43,77.208,38.871,29.816 +2020-09-26 07:45:00,81.14,79.021,38.871,29.816 +2020-09-26 08:00:00,82.04,77.229,43.293,29.816 +2020-09-26 08:15:00,81.29,79.325,43.293,29.816 +2020-09-26 08:30:00,79.31,78.339,43.293,29.816 +2020-09-26 08:45:00,78.64,79.563,43.293,29.816 +2020-09-26 09:00:00,77.73,75.152,44.559,29.816 +2020-09-26 09:15:00,77.3,75.286,44.559,29.816 +2020-09-26 09:30:00,76.01,76.116,44.559,29.816 +2020-09-26 09:45:00,76.69,76.161,44.559,29.816 +2020-09-26 10:00:00,77.09,73.52,42.091,29.816 +2020-09-26 10:15:00,78.02,73.59899999999999,42.091,29.816 +2020-09-26 10:30:00,79.27,73.126,42.091,29.816 +2020-09-26 10:45:00,75.73,73.221,42.091,29.816 +2020-09-26 11:00:00,74.16,71.61399999999999,38.505,29.816 +2020-09-26 11:15:00,73.22,71.741,38.505,29.816 +2020-09-26 11:30:00,74.17,72.20100000000001,38.505,29.816 +2020-09-26 11:45:00,72.12,71.267,38.505,29.816 +2020-09-26 12:00:00,70.46,69.072,35.388000000000005,29.816 +2020-09-26 12:15:00,67.44,68.39,35.388000000000005,29.816 +2020-09-26 12:30:00,71.03,67.819,35.388000000000005,29.816 +2020-09-26 12:45:00,69.48,68.183,35.388000000000005,29.816 +2020-09-26 13:00:00,63.24,67.752,31.355999999999998,29.816 +2020-09-26 13:15:00,64.45,67.275,31.355999999999998,29.816 +2020-09-26 13:30:00,64.55,66.75,31.355999999999998,29.816 +2020-09-26 13:45:00,63.96,65.571,31.355999999999998,29.816 +2020-09-26 14:00:00,64.09,65.168,30.522,29.816 +2020-09-26 14:15:00,67.34,64.189,30.522,29.816 +2020-09-26 14:30:00,65.35,63.668,30.522,29.816 +2020-09-26 14:45:00,66.06,63.978,30.522,29.816 +2020-09-26 15:00:00,67.79,64.027,34.36,29.816 +2020-09-26 15:15:00,66.51,63.351000000000006,34.36,29.816 +2020-09-26 15:30:00,68.16,62.885,34.36,29.816 +2020-09-26 15:45:00,72.59,61.718999999999994,34.36,29.816 +2020-09-26 16:00:00,75.45,62.938,39.507,29.816 +2020-09-26 16:15:00,77.55,62.669,39.507,29.816 +2020-09-26 16:30:00,79.63,63.308,39.507,29.816 +2020-09-26 16:45:00,83.05,61.232,39.507,29.816 +2020-09-26 17:00:00,85.81,62.456,47.151,29.816 +2020-09-26 17:15:00,86.82,62.61,47.151,29.816 +2020-09-26 17:30:00,88.1,62.18899999999999,47.151,29.816 +2020-09-26 17:45:00,90.04,62.478,47.151,29.816 +2020-09-26 18:00:00,95.33,63.623000000000005,50.303999999999995,29.816 +2020-09-26 18:15:00,94.65,64.312,50.303999999999995,29.816 +2020-09-26 18:30:00,97.77,64.545,50.303999999999995,29.816 +2020-09-26 18:45:00,95.72,65.35300000000001,50.303999999999995,29.816 +2020-09-26 19:00:00,90.07,65.441,50.622,29.816 +2020-09-26 19:15:00,86.83,64.219,50.622,29.816 +2020-09-26 19:30:00,85.33,64.218,50.622,29.816 +2020-09-26 19:45:00,84.12,64.718,50.622,29.816 +2020-09-26 20:00:00,79.3,63.951,45.391000000000005,29.816 +2020-09-26 20:15:00,80.25,63.068000000000005,45.391000000000005,29.816 +2020-09-26 20:30:00,78.57,60.422,45.391000000000005,29.816 +2020-09-26 20:45:00,77.53,60.446000000000005,45.391000000000005,29.816 +2020-09-26 21:00:00,73.77,59.438,39.98,29.816 +2020-09-26 21:15:00,75.16,61.888000000000005,39.98,29.816 +2020-09-26 21:30:00,69.54,61.303999999999995,39.98,29.816 +2020-09-26 21:45:00,69.49,59.95399999999999,39.98,29.816 +2020-09-26 22:00:00,66.64,59.036,37.53,29.816 +2020-09-26 22:15:00,67.12,58.604,37.53,29.816 +2020-09-26 22:30:00,63.4,56.483000000000004,37.53,29.816 +2020-09-26 22:45:00,63.85,55.235,37.53,29.816 +2020-09-26 23:00:00,58.44,52.963,30.97,29.816 +2020-09-26 23:15:00,57.4,50.388000000000005,30.97,29.816 +2020-09-26 23:30:00,57.88,50.007,30.97,29.816 +2020-09-26 23:45:00,57.35,48.701,30.97,29.816 +2020-09-27 00:00:00,58.71,41.699,27.24,29.816 +2020-09-27 00:15:00,55.74,40.016999999999996,27.24,29.816 +2020-09-27 00:30:00,54.56,39.891,27.24,29.816 +2020-09-27 00:45:00,54.16,39.876999999999995,27.24,29.816 +2020-09-27 01:00:00,52.05,40.103,25.662,29.816 +2020-09-27 01:15:00,53.71,39.753,25.662,29.816 +2020-09-27 01:30:00,52.61,38.762,25.662,29.816 +2020-09-27 01:45:00,52.16,38.228,25.662,29.816 +2020-09-27 02:00:00,52.19,38.591,25.67,29.816 +2020-09-27 02:15:00,52.28,38.199,25.67,29.816 +2020-09-27 02:30:00,52.04,38.759,25.67,29.816 +2020-09-27 02:45:00,51.99,39.516,25.67,29.816 +2020-09-27 03:00:00,52.66,41.443000000000005,24.258000000000003,29.816 +2020-09-27 03:15:00,52.96,40.293,24.258000000000003,29.816 +2020-09-27 03:30:00,54.26,39.794000000000004,24.258000000000003,29.816 +2020-09-27 03:45:00,54.89,41.345,24.258000000000003,29.816 +2020-09-27 04:00:00,55.49,47.354,25.051,29.816 +2020-09-27 04:15:00,55.87,52.648999999999994,25.051,29.816 +2020-09-27 04:30:00,55.54,51.083999999999996,25.051,29.816 +2020-09-27 04:45:00,56.78,51.303999999999995,25.051,29.816 +2020-09-27 05:00:00,58.11,62.303999999999995,25.145,29.816 +2020-09-27 05:15:00,58.1,67.563,25.145,29.816 +2020-09-27 05:30:00,58.65,62.586000000000006,25.145,29.816 +2020-09-27 05:45:00,58.18,60.34,25.145,29.816 +2020-09-27 06:00:00,59.68,71.635,26.371,29.816 +2020-09-27 06:15:00,60.43,84.40100000000001,26.371,29.816 +2020-09-27 06:30:00,61.96,78.143,26.371,29.816 +2020-09-27 06:45:00,63.71,73.797,26.371,29.816 +2020-09-27 07:00:00,64.31,71.99600000000001,28.756999999999998,29.816 +2020-09-27 07:15:00,65.55,72.084,28.756999999999998,29.816 +2020-09-27 07:30:00,66.0,72.34,28.756999999999998,29.816 +2020-09-27 07:45:00,66.81,73.921,28.756999999999998,29.816 +2020-09-27 08:00:00,66.27,73.15899999999999,32.82,29.816 +2020-09-27 08:15:00,65.26,76.039,32.82,29.816 +2020-09-27 08:30:00,64.16,76.156,32.82,29.816 +2020-09-27 08:45:00,63.97,77.865,32.82,29.816 +2020-09-27 09:00:00,62.41,73.256,35.534,29.816 +2020-09-27 09:15:00,62.07,73.188,35.534,29.816 +2020-09-27 09:30:00,61.44,74.26,35.534,29.816 +2020-09-27 09:45:00,62.57,74.954,35.534,29.816 +2020-09-27 10:00:00,63.81,73.396,35.925,29.816 +2020-09-27 10:15:00,64.87,73.69,35.925,29.816 +2020-09-27 10:30:00,65.54,73.535,35.925,29.816 +2020-09-27 10:45:00,66.79,73.756,35.925,29.816 +2020-09-27 11:00:00,64.87,72.186,37.056,29.816 +2020-09-27 11:15:00,63.25,72.028,37.056,29.816 +2020-09-27 11:30:00,60.2,72.585,37.056,29.816 +2020-09-27 11:45:00,59.31,71.992,37.056,29.816 +2020-09-27 12:00:00,54.52,70.286,33.124,29.816 +2020-09-27 12:15:00,56.11,69.765,33.124,29.816 +2020-09-27 12:30:00,55.91,68.917,33.124,29.816 +2020-09-27 12:45:00,58.63,68.538,33.124,29.816 +2020-09-27 13:00:00,58.5,67.667,29.874000000000002,29.816 +2020-09-27 13:15:00,55.01,67.723,29.874000000000002,29.816 +2020-09-27 13:30:00,55.43,66.368,29.874000000000002,29.816 +2020-09-27 13:45:00,55.98,65.756,29.874000000000002,29.816 +2020-09-27 14:00:00,57.32,66.19800000000001,27.302,29.816 +2020-09-27 14:15:00,57.14,65.899,27.302,29.816 +2020-09-27 14:30:00,57.98,64.95,27.302,29.816 +2020-09-27 14:45:00,60.21,64.396,27.302,29.816 +2020-09-27 15:00:00,61.57,64.02600000000001,27.642,29.816 +2020-09-27 15:15:00,62.18,63.051,27.642,29.816 +2020-09-27 15:30:00,64.05,62.675,27.642,29.816 +2020-09-27 15:45:00,67.12,61.934,27.642,29.816 +2020-09-27 16:00:00,70.42,62.468,31.945999999999998,29.816 +2020-09-27 16:15:00,73.68,62.107,31.945999999999998,29.816 +2020-09-27 16:30:00,76.58,63.519,31.945999999999998,29.816 +2020-09-27 16:45:00,79.25,61.583999999999996,31.945999999999998,29.816 +2020-09-27 17:00:00,82.98,63.023,40.387,29.816 +2020-09-27 17:15:00,82.75,64.161,40.387,29.816 +2020-09-27 17:30:00,87.05,64.39399999999999,40.387,29.816 +2020-09-27 17:45:00,86.61,65.675,40.387,29.816 +2020-09-27 18:00:00,90.18,67.0,44.575,29.816 +2020-09-27 18:15:00,89.39,67.767,44.575,29.816 +2020-09-27 18:30:00,89.29,67.25399999999999,44.575,29.816 +2020-09-27 18:45:00,87.47,68.656,44.575,29.816 +2020-09-27 19:00:00,91.61,70.178,45.623999999999995,29.816 +2020-09-27 19:15:00,93.17,68.29899999999999,45.623999999999995,29.816 +2020-09-27 19:30:00,92.79,68.07,45.623999999999995,29.816 +2020-09-27 19:45:00,86.34,68.607,45.623999999999995,29.816 +2020-09-27 20:00:00,78.55,68.045,44.583999999999996,29.816 +2020-09-27 20:15:00,81.4,67.33,44.583999999999996,29.816 +2020-09-27 20:30:00,83.71,65.579,44.583999999999996,29.816 +2020-09-27 20:45:00,80.58,64.027,44.583999999999996,29.816 +2020-09-27 21:00:00,84.36,62.199,39.732,29.816 +2020-09-27 21:15:00,89.77,64.25,39.732,29.816 +2020-09-27 21:30:00,86.37,63.286,39.732,29.816 +2020-09-27 21:45:00,80.3,62.198,39.732,29.816 +2020-09-27 22:00:00,75.34,62.406000000000006,38.571,29.816 +2020-09-27 22:15:00,77.9,60.526,38.571,29.816 +2020-09-27 22:30:00,78.22,57.183,38.571,29.816 +2020-09-27 22:45:00,80.64,54.788999999999994,38.571,29.816 +2020-09-27 23:00:00,76.15,51.59,33.121,29.816 +2020-09-27 23:15:00,70.67,50.4,33.121,29.816 +2020-09-27 23:30:00,68.9,49.846000000000004,33.121,29.816 +2020-09-27 23:45:00,69.98,48.894,33.121,29.816 +2020-09-28 00:00:00,72.56,44.205,32.506,29.93 +2020-09-28 00:15:00,73.53,43.888999999999996,32.506,29.93 +2020-09-28 00:30:00,72.62,43.526,32.506,29.93 +2020-09-28 00:45:00,68.54,43.083999999999996,32.506,29.93 +2020-09-28 01:00:00,65.59,43.588,31.121,29.93 +2020-09-28 01:15:00,65.31,43.13,31.121,29.93 +2020-09-28 01:30:00,66.33,42.431999999999995,31.121,29.93 +2020-09-28 01:45:00,66.87,41.858999999999995,31.121,29.93 +2020-09-28 02:00:00,64.01,42.553999999999995,29.605999999999998,29.93 +2020-09-28 02:15:00,69.97,41.842,29.605999999999998,29.93 +2020-09-28 02:30:00,74.06,42.589,29.605999999999998,29.93 +2020-09-28 02:45:00,75.8,43.067,29.605999999999998,29.93 +2020-09-28 03:00:00,72.48,45.693999999999996,28.124000000000002,29.93 +2020-09-28 03:15:00,69.89,45.528999999999996,28.124000000000002,29.93 +2020-09-28 03:30:00,74.11,45.453,28.124000000000002,29.93 +2020-09-28 03:45:00,79.39,46.504,28.124000000000002,29.93 +2020-09-28 04:00:00,83.04,55.977,29.743000000000002,29.93 +2020-09-28 04:15:00,81.76,64.546,29.743000000000002,29.93 +2020-09-28 04:30:00,89.53,63.233000000000004,29.743000000000002,29.93 +2020-09-28 04:45:00,96.21,63.772,29.743000000000002,29.93 +2020-09-28 05:00:00,105.6,83.844,36.191,29.93 +2020-09-28 05:15:00,107.36,101.895,36.191,29.93 +2020-09-28 05:30:00,107.75,95.816,36.191,29.93 +2020-09-28 05:45:00,111.23,89.825,36.191,29.93 +2020-09-28 06:00:00,115.68,89.09299999999999,55.277,29.93 +2020-09-28 06:15:00,115.66,91.397,55.277,29.93 +2020-09-28 06:30:00,117.81,90.111,55.277,29.93 +2020-09-28 06:45:00,119.85,91.897,55.277,29.93 +2020-09-28 07:00:00,122.15,91.338,65.697,29.93 +2020-09-28 07:15:00,122.14,93.427,65.697,29.93 +2020-09-28 07:30:00,121.34,92.88600000000001,65.697,29.93 +2020-09-28 07:45:00,116.32,93.995,65.697,29.93 +2020-09-28 08:00:00,117.15,90.522,57.028,29.93 +2020-09-28 08:15:00,115.61,91.912,57.028,29.93 +2020-09-28 08:30:00,119.14,90.226,57.028,29.93 +2020-09-28 08:45:00,116.91,91.141,57.028,29.93 +2020-09-28 09:00:00,116.01,85.585,52.633,29.93 +2020-09-28 09:15:00,119.67,83.25399999999999,52.633,29.93 +2020-09-28 09:30:00,118.68,83.365,52.633,29.93 +2020-09-28 09:45:00,122.1,82.484,52.633,29.93 +2020-09-28 10:00:00,117.58,81.078,50.647,29.93 +2020-09-28 10:15:00,119.15,81.132,50.647,29.93 +2020-09-28 10:30:00,116.21,80.383,50.647,29.93 +2020-09-28 10:45:00,116.73,79.708,50.647,29.93 +2020-09-28 11:00:00,119.42,77.577,50.245,29.93 +2020-09-28 11:15:00,116.62,78.139,50.245,29.93 +2020-09-28 11:30:00,121.85,79.594,50.245,29.93 +2020-09-28 11:45:00,117.42,79.208,50.245,29.93 +2020-09-28 12:00:00,113.11,77.02,46.956,29.93 +2020-09-28 12:15:00,116.0,76.562,46.956,29.93 +2020-09-28 12:30:00,113.7,75.102,46.956,29.93 +2020-09-28 12:45:00,110.78,75.194,46.956,29.93 +2020-09-28 13:00:00,109.56,74.983,47.383,29.93 +2020-09-28 13:15:00,112.19,74.00399999999999,47.383,29.93 +2020-09-28 13:30:00,112.32,72.598,47.383,29.93 +2020-09-28 13:45:00,113.59,72.613,47.383,29.93 +2020-09-28 14:00:00,115.79,72.227,47.1,29.93 +2020-09-28 14:15:00,115.49,72.146,47.1,29.93 +2020-09-28 14:30:00,114.12,70.952,47.1,29.93 +2020-09-28 14:45:00,114.56,71.797,47.1,29.93 +2020-09-28 15:00:00,117.46,71.695,49.355,29.93 +2020-09-28 15:15:00,116.47,69.889,49.355,29.93 +2020-09-28 15:30:00,114.37,69.814,49.355,29.93 +2020-09-28 15:45:00,114.24,68.618,49.355,29.93 +2020-09-28 16:00:00,115.27,69.747,52.14,29.93 +2020-09-28 16:15:00,117.99,69.223,52.14,29.93 +2020-09-28 16:30:00,117.86,69.88,52.14,29.93 +2020-09-28 16:45:00,120.05,67.665,52.14,29.93 +2020-09-28 17:00:00,122.78,68.229,58.705,29.93 +2020-09-28 17:15:00,121.13,69.37100000000001,58.705,29.93 +2020-09-28 17:30:00,122.96,69.17699999999999,58.705,29.93 +2020-09-28 17:45:00,120.89,69.768,58.705,29.93 +2020-09-28 18:00:00,124.3,70.32,59.153,29.93 +2020-09-28 18:15:00,121.62,69.16,59.153,29.93 +2020-09-28 18:30:00,123.48,68.303,59.153,29.93 +2020-09-28 18:45:00,119.09,72.157,59.153,29.93 +2020-09-28 19:00:00,115.15,72.955,61.483000000000004,29.93 +2020-09-28 19:15:00,109.93,71.735,61.483000000000004,29.93 +2020-09-28 19:30:00,107.66,71.362,61.483000000000004,29.93 +2020-09-28 19:45:00,110.06,71.22399999999999,61.483000000000004,29.93 +2020-09-28 20:00:00,99.87,69.15899999999999,67.55,29.93 +2020-09-28 20:15:00,98.53,68.77,67.55,29.93 +2020-09-28 20:30:00,102.19,66.858,67.55,29.93 +2020-09-28 20:45:00,103.9,65.867,67.55,29.93 +2020-09-28 21:00:00,96.81,63.74,60.026,29.93 +2020-09-28 21:15:00,91.9,65.782,60.026,29.93 +2020-09-28 21:30:00,86.66,64.904,60.026,29.93 +2020-09-28 21:45:00,91.92,63.486999999999995,60.026,29.93 +2020-09-28 22:00:00,89.43,61.42,52.736999999999995,29.93 +2020-09-28 22:15:00,89.06,60.604,52.736999999999995,29.93 +2020-09-28 22:30:00,81.86,51.547,52.736999999999995,29.93 +2020-09-28 22:45:00,86.05,47.843999999999994,52.736999999999995,29.93 +2020-09-28 23:00:00,82.52,44.95,44.408,29.93 +2020-09-28 23:15:00,80.8,43.236000000000004,44.408,29.93 +2020-09-28 23:30:00,76.73,43.395,44.408,29.93 +2020-09-28 23:45:00,79.66,42.756,44.408,29.93 +2020-09-29 00:00:00,77.73,42.65,44.438,29.93 +2020-09-29 00:15:00,76.61,43.342,44.438,29.93 +2020-09-29 00:30:00,76.57,43.253,44.438,29.93 +2020-09-29 00:45:00,78.99,43.126999999999995,44.438,29.93 +2020-09-29 01:00:00,77.92,43.176,41.468999999999994,29.93 +2020-09-29 01:15:00,78.18,42.699,41.468999999999994,29.93 +2020-09-29 01:30:00,74.21,41.974,41.468999999999994,29.93 +2020-09-29 01:45:00,77.34,41.108999999999995,41.468999999999994,29.93 +2020-09-29 02:00:00,78.32,41.458,39.708,29.93 +2020-09-29 02:15:00,77.97,41.577,39.708,29.93 +2020-09-29 02:30:00,78.04,41.858000000000004,39.708,29.93 +2020-09-29 02:45:00,80.85,42.573,39.708,29.93 +2020-09-29 03:00:00,79.93,44.456,38.919000000000004,29.93 +2020-09-29 03:15:00,76.51,44.75899999999999,38.919000000000004,29.93 +2020-09-29 03:30:00,79.49,44.794,38.919000000000004,29.93 +2020-09-29 03:45:00,80.02,45.07,38.919000000000004,29.93 +2020-09-29 04:00:00,89.45,53.608999999999995,40.092,29.93 +2020-09-29 04:15:00,93.05,62.126999999999995,40.092,29.93 +2020-09-29 04:30:00,95.48,60.648,40.092,29.93 +2020-09-29 04:45:00,95.39,61.878,40.092,29.93 +2020-09-29 05:00:00,103.88,84.5,43.713,29.93 +2020-09-29 05:15:00,108.14,103.072,43.713,29.93 +2020-09-29 05:30:00,110.41,96.729,43.713,29.93 +2020-09-29 05:45:00,114.39,90.139,43.713,29.93 +2020-09-29 06:00:00,118.77,89.865,56.033,29.93 +2020-09-29 06:15:00,120.88,92.749,56.033,29.93 +2020-09-29 06:30:00,121.79,91.06299999999999,56.033,29.93 +2020-09-29 06:45:00,123.75,92.088,56.033,29.93 +2020-09-29 07:00:00,126.35,91.59100000000001,66.003,29.93 +2020-09-29 07:15:00,126.55,93.463,66.003,29.93 +2020-09-29 07:30:00,125.28,92.823,66.003,29.93 +2020-09-29 07:45:00,123.7,93.275,66.003,29.93 +2020-09-29 08:00:00,120.71,89.791,57.474,29.93 +2020-09-29 08:15:00,117.0,90.537,57.474,29.93 +2020-09-29 08:30:00,116.25,88.941,57.474,29.93 +2020-09-29 08:45:00,114.5,89.095,57.474,29.93 +2020-09-29 09:00:00,115.58,83.553,51.928000000000004,29.93 +2020-09-29 09:15:00,120.06,81.567,51.928000000000004,29.93 +2020-09-29 09:30:00,116.6,82.34299999999999,51.928000000000004,29.93 +2020-09-29 09:45:00,112.53,82.32700000000001,51.928000000000004,29.93 +2020-09-29 10:00:00,117.82,79.771,49.46,29.93 +2020-09-29 10:15:00,117.6,79.37100000000001,49.46,29.93 +2020-09-29 10:30:00,115.38,78.68,49.46,29.93 +2020-09-29 10:45:00,115.03,78.775,49.46,29.93 +2020-09-29 11:00:00,112.32,77.102,48.206,29.93 +2020-09-29 11:15:00,114.51,77.828,48.206,29.93 +2020-09-29 11:30:00,115.85,78.133,48.206,29.93 +2020-09-29 11:45:00,122.55,77.62899999999999,48.206,29.93 +2020-09-29 12:00:00,121.21,74.861,46.285,29.93 +2020-09-29 12:15:00,123.65,74.48899999999999,46.285,29.93 +2020-09-29 12:30:00,119.98,73.876,46.285,29.93 +2020-09-29 12:45:00,119.13,74.34,46.285,29.93 +2020-09-29 13:00:00,117.91,73.741,46.861999999999995,29.93 +2020-09-29 13:15:00,116.67,73.89399999999999,46.861999999999995,29.93 +2020-09-29 13:30:00,117.65,72.825,46.861999999999995,29.93 +2020-09-29 13:45:00,118.25,72.237,46.861999999999995,29.93 +2020-09-29 14:00:00,115.97,72.195,46.488,29.93 +2020-09-29 14:15:00,115.47,72.04,46.488,29.93 +2020-09-29 14:30:00,114.39,71.3,46.488,29.93 +2020-09-29 14:45:00,114.29,71.57600000000001,46.488,29.93 +2020-09-29 15:00:00,110.86,71.184,48.442,29.93 +2020-09-29 15:15:00,112.31,70.13600000000001,48.442,29.93 +2020-09-29 15:30:00,111.91,70.016,48.442,29.93 +2020-09-29 15:45:00,114.05,68.95,48.442,29.93 +2020-09-29 16:00:00,116.1,69.681,50.397,29.93 +2020-09-29 16:15:00,115.08,69.375,50.397,29.93 +2020-09-29 16:30:00,117.86,69.956,50.397,29.93 +2020-09-29 16:45:00,119.27,68.388,50.397,29.93 +2020-09-29 17:00:00,122.25,69.221,56.668,29.93 +2020-09-29 17:15:00,118.99,70.688,56.668,29.93 +2020-09-29 17:30:00,119.0,70.389,56.668,29.93 +2020-09-29 17:45:00,121.21,70.738,56.668,29.93 +2020-09-29 18:00:00,124.72,70.545,57.957,29.93 +2020-09-29 18:15:00,120.93,70.315,57.957,29.93 +2020-09-29 18:30:00,122.34,69.196,57.957,29.93 +2020-09-29 18:45:00,118.19,73.126,57.957,29.93 +2020-09-29 19:00:00,111.46,73.086,57.056000000000004,29.93 +2020-09-29 19:15:00,107.02,71.922,57.056000000000004,29.93 +2020-09-29 19:30:00,105.82,71.21300000000001,57.056000000000004,29.93 +2020-09-29 19:45:00,104.05,71.321,57.056000000000004,29.93 +2020-09-29 20:00:00,99.51,69.616,64.156,29.93 +2020-09-29 20:15:00,95.64,68.005,64.156,29.93 +2020-09-29 20:30:00,91.07,66.372,64.156,29.93 +2020-09-29 20:45:00,88.43,65.49,64.156,29.93 +2020-09-29 21:00:00,82.9,63.803999999999995,56.507,29.93 +2020-09-29 21:15:00,78.99,65.01100000000001,56.507,29.93 +2020-09-29 21:30:00,76.78,64.074,56.507,29.93 +2020-09-29 21:45:00,75.2,62.826,56.507,29.93 +2020-09-29 22:00:00,71.1,61.318999999999996,50.728,29.93 +2020-09-29 22:15:00,70.7,60.165,50.728,29.93 +2020-09-29 22:30:00,67.54,51.342,50.728,29.93 +2020-09-29 22:45:00,65.87,47.714,50.728,29.93 +2020-09-29 23:00:00,77.01,44.299,43.556999999999995,29.93 +2020-09-29 23:15:00,80.01,43.45399999999999,43.556999999999995,29.93 +2020-09-29 23:30:00,80.68,43.519,43.556999999999995,29.93 +2020-09-29 23:45:00,80.11,42.897,43.556999999999995,29.93 +2020-09-30 00:00:00,75.35,42.93600000000001,41.151,29.93 +2020-09-30 00:15:00,78.11,43.623000000000005,41.151,29.93 +2020-09-30 00:30:00,79.8,43.541000000000004,41.151,29.93 +2020-09-30 00:45:00,81.56,43.416000000000004,41.151,29.93 +2020-09-30 01:00:00,76.14,43.457,37.763000000000005,29.93 +2020-09-30 01:15:00,75.09,43.004,37.763000000000005,29.93 +2020-09-30 01:30:00,75.45,42.298,37.763000000000005,29.93 +2020-09-30 01:45:00,80.02,41.433,37.763000000000005,29.93 +2020-09-30 02:00:00,79.63,41.788000000000004,35.615,29.93 +2020-09-30 02:15:00,78.9,41.925,35.615,29.93 +2020-09-30 02:30:00,76.86,42.18600000000001,35.615,29.93 +2020-09-30 02:45:00,78.72,42.895,35.615,29.93 +2020-09-30 03:00:00,81.85,44.765,35.153,29.93 +2020-09-30 03:15:00,81.74,45.086999999999996,35.153,29.93 +2020-09-30 03:30:00,82.99,45.126000000000005,35.153,29.93 +2020-09-30 03:45:00,83.55,45.383,35.153,29.93 +2020-09-30 04:00:00,88.62,53.961000000000006,36.203,29.93 +2020-09-30 04:15:00,91.38,62.516000000000005,36.203,29.93 +2020-09-30 04:30:00,94.94,61.042,36.203,29.93 +2020-09-30 04:45:00,97.42,62.28,36.203,29.93 +2020-09-30 05:00:00,103.8,85.005,39.922,29.93 +2020-09-30 05:15:00,107.95,103.689,39.922,29.93 +2020-09-30 05:30:00,110.12,97.321,39.922,29.93 +2020-09-30 05:45:00,113.38,90.678,39.922,29.93 +2020-09-30 06:00:00,119.1,90.37100000000001,56.443999999999996,29.93 +2020-09-30 06:15:00,117.38,93.279,56.443999999999996,29.93 +2020-09-30 06:30:00,119.12,91.594,56.443999999999996,29.93 +2020-09-30 06:45:00,120.5,92.615,56.443999999999996,29.93 +2020-09-30 07:00:00,123.29,92.118,68.683,29.93 +2020-09-30 07:15:00,121.03,94.00399999999999,68.683,29.93 +2020-09-30 07:30:00,122.13,93.398,68.683,29.93 +2020-09-30 07:45:00,120.93,93.84700000000001,68.683,29.93 +2020-09-30 08:00:00,117.66,90.37,59.003,29.93 +2020-09-30 08:15:00,117.3,91.08,59.003,29.93 +2020-09-30 08:30:00,116.66,89.5,59.003,29.93 +2020-09-30 08:45:00,116.56,89.633,59.003,29.93 +2020-09-30 09:00:00,115.38,84.096,56.21,29.93 +2020-09-30 09:15:00,115.67,82.101,56.21,29.93 +2020-09-30 09:30:00,115.08,82.85799999999999,56.21,29.93 +2020-09-30 09:45:00,114.96,82.81,56.21,29.93 +2020-09-30 10:00:00,115.1,80.25,52.358999999999995,29.93 +2020-09-30 10:15:00,116.5,79.81,52.358999999999995,29.93 +2020-09-30 10:30:00,113.95,79.10300000000001,52.358999999999995,29.93 +2020-09-30 10:45:00,112.23,79.183,52.358999999999995,29.93 +2020-09-30 11:00:00,108.77,77.523,51.161,29.93 +2020-09-30 11:15:00,108.69,78.23100000000001,51.161,29.93 +2020-09-30 11:30:00,109.39,78.536,51.161,29.93 +2020-09-30 11:45:00,108.48,78.013,51.161,29.93 +2020-09-30 12:00:00,104.28,75.219,49.119,29.93 +2020-09-30 12:15:00,106.05,74.836,49.119,29.93 +2020-09-30 12:30:00,104.62,74.258,49.119,29.93 +2020-09-30 12:45:00,103.69,74.71300000000001,49.119,29.93 +2020-09-30 13:00:00,102.95,74.086,49.187,29.93 +2020-09-30 13:15:00,102.97,74.237,49.187,29.93 +2020-09-30 13:30:00,103.28,73.165,49.187,29.93 +2020-09-30 13:45:00,103.85,72.581,49.187,29.93 +2020-09-30 14:00:00,104.84,72.49,49.787,29.93 +2020-09-30 14:15:00,104.58,72.35,49.787,29.93 +2020-09-30 14:30:00,105.13,71.645,49.787,29.93 +2020-09-30 14:45:00,105.67,71.917,49.787,29.93 +2020-09-30 15:00:00,105.44,71.477,51.458999999999996,29.93 +2020-09-30 15:15:00,106.06,70.44800000000001,51.458999999999996,29.93 +2020-09-30 15:30:00,106.29,70.362,51.458999999999996,29.93 +2020-09-30 15:45:00,107.58,69.31,51.458999999999996,29.93 +2020-09-30 16:00:00,110.43,70.0,53.663000000000004,29.93 +2020-09-30 16:15:00,109.91,69.711,53.663000000000004,29.93 +2020-09-30 16:30:00,111.8,70.28699999999999,53.663000000000004,29.93 +2020-09-30 16:45:00,113.95,68.777,53.663000000000004,29.93 +2020-09-30 17:00:00,117.7,69.568,58.183,29.93 +2020-09-30 17:15:00,116.19,71.06,58.183,29.93 +2020-09-30 17:30:00,119.57,70.767,58.183,29.93 +2020-09-30 17:45:00,120.66,71.14699999999999,58.183,29.93 +2020-09-30 18:00:00,122.31,70.935,60.141000000000005,29.93 +2020-09-30 18:15:00,120.45,70.704,60.141000000000005,29.93 +2020-09-30 18:30:00,121.29,69.597,60.141000000000005,29.93 +2020-09-30 18:45:00,119.4,73.52199999999999,60.141000000000005,29.93 +2020-09-30 19:00:00,116.88,73.491,60.582,29.93 +2020-09-30 19:15:00,115.1,72.325,60.582,29.93 +2020-09-30 19:30:00,111.94,71.613,60.582,29.93 +2020-09-30 19:45:00,110.07,71.71300000000001,60.582,29.93 +2020-09-30 20:00:00,104.26,70.03,66.61,29.93 +2020-09-30 20:15:00,105.15,68.417,66.61,29.93 +2020-09-30 20:30:00,101.51,66.757,66.61,29.93 +2020-09-30 20:45:00,102.47,65.836,66.61,29.93 +2020-09-30 21:00:00,96.11,64.15100000000001,57.658,29.93 +2020-09-30 21:15:00,94.9,65.348,57.658,29.93 +2020-09-30 21:30:00,93.22,64.42,57.658,29.93 +2020-09-30 21:45:00,94.05,63.138000000000005,57.658,29.93 +2020-09-30 22:00:00,89.15,61.61,51.81,29.93 +2020-09-30 22:15:00,86.92,60.433,51.81,29.93 +2020-09-30 22:30:00,83.39,51.606,51.81,29.93 +2020-09-30 22:45:00,81.96,47.983999999999995,51.81,29.93 +2020-09-30 23:00:00,72.81,44.608999999999995,42.93600000000001,29.93 +2020-09-30 23:15:00,74.96,43.731,42.93600000000001,29.93 +2020-09-30 23:30:00,74.25,43.799,42.93600000000001,29.93 +2020-09-30 23:45:00,77.92,43.177,42.93600000000001,29.93 +2020-10-01 00:00:00,74.36,48.013000000000005,42.746,31.349 +2020-10-01 00:15:00,73.35,49.218999999999994,42.746,31.349 +2020-10-01 00:30:00,71.89,48.891000000000005,42.746,31.349 +2020-10-01 00:45:00,74.82,48.773,42.746,31.349 +2020-10-01 01:00:00,74.95,49.074,40.025999999999996,31.349 +2020-10-01 01:15:00,73.4,48.443000000000005,40.025999999999996,31.349 +2020-10-01 01:30:00,70.75,47.641000000000005,40.025999999999996,31.349 +2020-10-01 01:45:00,74.21,47.16,40.025999999999996,31.349 +2020-10-01 02:00:00,74.54,47.581,38.154,31.349 +2020-10-01 02:15:00,75.11,47.598,38.154,31.349 +2020-10-01 02:30:00,70.83,48.211000000000006,38.154,31.349 +2020-10-01 02:45:00,74.98,48.898999999999994,38.154,31.349 +2020-10-01 03:00:00,76.99,51.108999999999995,37.575,31.349 +2020-10-01 03:15:00,78.83,51.843,37.575,31.349 +2020-10-01 03:30:00,75.95,51.895,37.575,31.349 +2020-10-01 03:45:00,81.07,52.646,37.575,31.349 +2020-10-01 04:00:00,86.24,61.338,39.154,31.349 +2020-10-01 04:15:00,88.04,69.964,39.154,31.349 +2020-10-01 04:30:00,85.63,69.25,39.154,31.349 +2020-10-01 04:45:00,92.33,70.865,39.154,31.349 +2020-10-01 05:00:00,98.87,96.057,44.085,31.349 +2020-10-01 05:15:00,108.96,118.06,44.085,31.349 +2020-10-01 05:30:00,116.11,112.14200000000001,44.085,31.349 +2020-10-01 05:45:00,112.83,104.488,44.085,31.349 +2020-10-01 06:00:00,118.1,104.522,57.49,31.349 +2020-10-01 06:15:00,117.62,108.09299999999999,57.49,31.349 +2020-10-01 06:30:00,119.15,106.42200000000001,57.49,31.349 +2020-10-01 06:45:00,121.05,107.11200000000001,57.49,31.349 +2020-10-01 07:00:00,122.34,107.375,73.617,31.349 +2020-10-01 07:15:00,120.54,109.579,73.617,31.349 +2020-10-01 07:30:00,118.69,109.189,73.617,31.349 +2020-10-01 07:45:00,117.8,109.36,73.617,31.349 +2020-10-01 08:00:00,115.95,107.73899999999999,69.281,31.349 +2020-10-01 08:15:00,115.95,108.02799999999999,69.281,31.349 +2020-10-01 08:30:00,122.25,105.654,69.281,31.349 +2020-10-01 08:45:00,124.29,104.889,69.281,31.349 +2020-10-01 09:00:00,122.13,100.23700000000001,63.926,31.349 +2020-10-01 09:15:00,121.05,98.084,63.926,31.349 +2020-10-01 09:30:00,116.55,98.544,63.926,31.349 +2020-10-01 09:45:00,115.32,98.477,63.926,31.349 +2020-10-01 10:00:00,116.93,95.274,59.442,31.349 +2020-10-01 10:15:00,119.48,94.848,59.442,31.349 +2020-10-01 10:30:00,116.82,93.89399999999999,59.442,31.349 +2020-10-01 10:45:00,115.21,93.82700000000001,59.442,31.349 +2020-10-01 11:00:00,110.64,90.531,56.771,31.349 +2020-10-01 11:15:00,110.57,91.235,56.771,31.349 +2020-10-01 11:30:00,114.18,91.44200000000001,56.771,31.349 +2020-10-01 11:45:00,110.72,91.22200000000001,56.771,31.349 +2020-10-01 12:00:00,110.27,87.663,53.701,31.349 +2020-10-01 12:15:00,110.53,87.29899999999999,53.701,31.349 +2020-10-01 12:30:00,110.68,86.45700000000001,53.701,31.349 +2020-10-01 12:45:00,110.33,86.771,53.701,31.349 +2020-10-01 13:00:00,109.27,86.516,52.364,31.349 +2020-10-01 13:15:00,108.61,86.375,52.364,31.349 +2020-10-01 13:30:00,106.82,85.52,52.364,31.349 +2020-10-01 13:45:00,108.3,85.07799999999999,52.364,31.349 +2020-10-01 14:00:00,106.34,84.788,53.419,31.349 +2020-10-01 14:15:00,103.21,85.045,53.419,31.349 +2020-10-01 14:30:00,104.24,84.14200000000001,53.419,31.349 +2020-10-01 14:45:00,101.78,84.021,53.419,31.349 +2020-10-01 15:00:00,102.18,84.177,56.744,31.349 +2020-10-01 15:15:00,105.7,83.725,56.744,31.349 +2020-10-01 15:30:00,109.3,83.95100000000001,56.744,31.349 +2020-10-01 15:45:00,112.38,83.89299999999999,56.744,31.349 +2020-10-01 16:00:00,110.92,83.941,60.458,31.349 +2020-10-01 16:15:00,110.81,83.338,60.458,31.349 +2020-10-01 16:30:00,115.07,84.031,60.458,31.349 +2020-10-01 16:45:00,114.72,82.064,60.458,31.349 +2020-10-01 17:00:00,119.03,82.493,66.295,31.349 +2020-10-01 17:15:00,116.61,83.67299999999999,66.295,31.349 +2020-10-01 17:30:00,118.16,83.4,66.295,31.349 +2020-10-01 17:45:00,121.44,84.021,66.295,31.349 +2020-10-01 18:00:00,125.72,83.735,68.468,31.349 +2020-10-01 18:15:00,122.88,83.59700000000001,68.468,31.349 +2020-10-01 18:30:00,124.48,82.62799999999999,68.468,31.349 +2020-10-01 18:45:00,119.47,86.285,68.468,31.349 +2020-10-01 19:00:00,116.32,86.665,66.39399999999999,31.349 +2020-10-01 19:15:00,113.44,85.539,66.39399999999999,31.349 +2020-10-01 19:30:00,108.44,85.01100000000001,66.39399999999999,31.349 +2020-10-01 19:45:00,109.78,85.054,66.39399999999999,31.349 +2020-10-01 20:00:00,106.35,84.295,63.183,31.349 +2020-10-01 20:15:00,108.46,82.07799999999999,63.183,31.349 +2020-10-01 20:30:00,106.1,81.286,63.183,31.349 +2020-10-01 20:45:00,101.39,80.277,63.183,31.349 +2020-10-01 21:00:00,93.83,78.27,55.133,31.349 +2020-10-01 21:15:00,92.88,78.835,55.133,31.349 +2020-10-01 21:30:00,91.65,77.945,55.133,31.349 +2020-10-01 21:45:00,92.82,76.352,55.133,31.349 +2020-10-01 22:00:00,85.0,73.477,50.111999999999995,31.349 +2020-10-01 22:15:00,85.89,71.318,50.111999999999995,31.349 +2020-10-01 22:30:00,82.6,60.872,50.111999999999995,31.349 +2020-10-01 22:45:00,86.0,55.68899999999999,50.111999999999995,31.349 +2020-10-01 23:00:00,78.43,50.915,44.536,31.349 +2020-10-01 23:15:00,77.59,50.651,44.536,31.349 +2020-10-01 23:30:00,77.83,50.118,44.536,31.349 +2020-10-01 23:45:00,79.72,50.093999999999994,44.536,31.349 +2020-10-02 00:00:00,74.84,46.853,42.291000000000004,31.349 +2020-10-02 00:15:00,76.22,48.257,42.291000000000004,31.349 +2020-10-02 00:30:00,70.57,48.04,42.291000000000004,31.349 +2020-10-02 00:45:00,72.88,48.211000000000006,42.291000000000004,31.349 +2020-10-02 01:00:00,69.56,48.17,41.008,31.349 +2020-10-02 01:15:00,70.58,47.573,41.008,31.349 +2020-10-02 01:30:00,77.02,47.115,41.008,31.349 +2020-10-02 01:45:00,78.7,46.528999999999996,41.008,31.349 +2020-10-02 02:00:00,78.05,47.533,39.521,31.349 +2020-10-02 02:15:00,73.35,47.49,39.521,31.349 +2020-10-02 02:30:00,78.63,48.773,39.521,31.349 +2020-10-02 02:45:00,79.6,49.075,39.521,31.349 +2020-10-02 03:00:00,74.8,51.332,39.812,31.349 +2020-10-02 03:15:00,75.56,51.751000000000005,39.812,31.349 +2020-10-02 03:30:00,79.48,51.661,39.812,31.349 +2020-10-02 03:45:00,85.82,53.034,39.812,31.349 +2020-10-02 04:00:00,90.92,61.923,41.22,31.349 +2020-10-02 04:15:00,87.14,69.571,41.22,31.349 +2020-10-02 04:30:00,91.86,69.50399999999999,41.22,31.349 +2020-10-02 04:45:00,98.29,70.303,41.22,31.349 +2020-10-02 05:00:00,108.33,94.79799999999999,45.115,31.349 +2020-10-02 05:15:00,107.36,118.024,45.115,31.349 +2020-10-02 05:30:00,109.91,112.62,45.115,31.349 +2020-10-02 05:45:00,112.72,104.635,45.115,31.349 +2020-10-02 06:00:00,119.78,104.962,59.06100000000001,31.349 +2020-10-02 06:15:00,119.47,108.075,59.06100000000001,31.349 +2020-10-02 06:30:00,123.95,106.051,59.06100000000001,31.349 +2020-10-02 06:45:00,121.8,107.29899999999999,59.06100000000001,31.349 +2020-10-02 07:00:00,122.66,107.613,71.874,31.349 +2020-10-02 07:15:00,121.44,110.766,71.874,31.349 +2020-10-02 07:30:00,120.73,109.22200000000001,71.874,31.349 +2020-10-02 07:45:00,118.08,108.91799999999999,71.874,31.349 +2020-10-02 08:00:00,116.05,107.29700000000001,68.439,31.349 +2020-10-02 08:15:00,115.68,107.771,68.439,31.349 +2020-10-02 08:30:00,115.03,105.775,68.439,31.349 +2020-10-02 08:45:00,114.17,104.251,68.439,31.349 +2020-10-02 09:00:00,111.59,98.507,65.523,31.349 +2020-10-02 09:15:00,110.49,97.64299999999999,65.523,31.349 +2020-10-02 09:30:00,110.15,97.525,65.523,31.349 +2020-10-02 09:45:00,108.89,97.596,65.523,31.349 +2020-10-02 10:00:00,107.9,93.774,62.005,31.349 +2020-10-02 10:15:00,108.37,93.501,62.005,31.349 +2020-10-02 10:30:00,108.22,92.79,62.005,31.349 +2020-10-02 10:45:00,109.74,92.461,62.005,31.349 +2020-10-02 11:00:00,105.01,89.28200000000001,60.351000000000006,31.349 +2020-10-02 11:15:00,103.83,88.994,60.351000000000006,31.349 +2020-10-02 11:30:00,107.03,89.801,60.351000000000006,31.349 +2020-10-02 11:45:00,102.87,89.085,60.351000000000006,31.349 +2020-10-02 12:00:00,100.19,86.24,55.331,31.349 +2020-10-02 12:15:00,99.6,84.566,55.331,31.349 +2020-10-02 12:30:00,97.98,83.89200000000001,55.331,31.349 +2020-10-02 12:45:00,96.69,84.041,55.331,31.349 +2020-10-02 13:00:00,95.31,84.48899999999999,53.361999999999995,31.349 +2020-10-02 13:15:00,96.32,84.825,53.361999999999995,31.349 +2020-10-02 13:30:00,95.77,84.383,53.361999999999995,31.349 +2020-10-02 13:45:00,96.42,84.069,53.361999999999995,31.349 +2020-10-02 14:00:00,97.78,82.788,51.708,31.349 +2020-10-02 14:15:00,97.81,83.20100000000001,51.708,31.349 +2020-10-02 14:30:00,97.63,83.339,51.708,31.349 +2020-10-02 14:45:00,99.23,83.00299999999999,51.708,31.349 +2020-10-02 15:00:00,99.88,82.859,54.571000000000005,31.349 +2020-10-02 15:15:00,98.98,82.102,54.571000000000005,31.349 +2020-10-02 15:30:00,100.68,81.37100000000001,54.571000000000005,31.349 +2020-10-02 15:45:00,102.59,81.78399999999999,54.571000000000005,31.349 +2020-10-02 16:00:00,107.25,80.862,58.662,31.349 +2020-10-02 16:15:00,107.43,80.667,58.662,31.349 +2020-10-02 16:30:00,108.62,81.304,58.662,31.349 +2020-10-02 16:45:00,109.89,78.92399999999999,58.662,31.349 +2020-10-02 17:00:00,114.07,80.343,65.941,31.349 +2020-10-02 17:15:00,111.49,81.27,65.941,31.349 +2020-10-02 17:30:00,115.35,80.94800000000001,65.941,31.349 +2020-10-02 17:45:00,115.86,81.39699999999999,65.941,31.349 +2020-10-02 18:00:00,122.12,81.46300000000001,65.628,31.349 +2020-10-02 18:15:00,118.93,80.623,65.628,31.349 +2020-10-02 18:30:00,117.78,79.774,65.628,31.349 +2020-10-02 18:45:00,115.24,83.65299999999999,65.628,31.349 +2020-10-02 19:00:00,111.24,84.94,63.662,31.349 +2020-10-02 19:15:00,106.67,84.70299999999999,63.662,31.349 +2020-10-02 19:30:00,105.91,84.031,63.662,31.349 +2020-10-02 19:45:00,102.96,83.294,63.662,31.349 +2020-10-02 20:00:00,92.61,82.48899999999999,61.945,31.349 +2020-10-02 20:15:00,99.16,80.71600000000001,61.945,31.349 +2020-10-02 20:30:00,98.55,79.605,61.945,31.349 +2020-10-02 20:45:00,96.27,78.343,61.945,31.349 +2020-10-02 21:00:00,88.3,77.288,53.903,31.349 +2020-10-02 21:15:00,85.55,78.967,53.903,31.349 +2020-10-02 21:30:00,80.59,78.02,53.903,31.349 +2020-10-02 21:45:00,84.21,76.735,53.903,31.349 +2020-10-02 22:00:00,80.6,74.171,48.403999999999996,31.349 +2020-10-02 22:15:00,79.32,71.794,48.403999999999996,31.349 +2020-10-02 22:30:00,71.59,66.65899999999999,48.403999999999996,31.349 +2020-10-02 22:45:00,70.6,63.413000000000004,48.403999999999996,31.349 +2020-10-02 23:00:00,62.12,59.43899999999999,41.07,31.349 +2020-10-02 23:15:00,61.6,57.461999999999996,41.07,31.349 +2020-10-02 23:30:00,61.56,55.302,41.07,31.349 +2020-10-02 23:45:00,59.87,54.931000000000004,41.07,31.349 +2020-10-03 00:00:00,57.65,47.711000000000006,11.117,31.177 +2020-10-03 00:15:00,57.59,46.246,11.117,31.177 +2020-10-03 00:30:00,56.67,46.058,11.117,31.177 +2020-10-03 00:45:00,56.96,46.294,11.117,31.177 +2020-10-03 01:00:00,56.34,46.769,10.685,31.177 +2020-10-03 01:15:00,56.06,46.481,10.685,31.177 +2020-10-03 01:30:00,55.87,45.493,10.685,31.177 +2020-10-03 01:45:00,55.71,45.202,10.685,31.177 +2020-10-03 02:00:00,55.96,45.67,7.925,31.177 +2020-10-03 02:15:00,56.34,45.118,7.925,31.177 +2020-10-03 02:30:00,56.14,45.926,7.925,31.177 +2020-10-03 02:45:00,56.27,46.731,7.925,31.177 +2020-10-03 03:00:00,55.64,48.928000000000004,7.627999999999999,31.177 +2020-10-03 03:15:00,57.67,48.434,7.627999999999999,31.177 +2020-10-03 03:30:00,57.91,48.034,7.627999999999999,31.177 +2020-10-03 03:45:00,58.8,49.805,7.627999999999999,31.177 +2020-10-03 04:00:00,60.47,55.907,7.986000000000001,31.177 +2020-10-03 04:15:00,59.42,61.435,7.986000000000001,31.177 +2020-10-03 04:30:00,59.0,60.357,7.986000000000001,31.177 +2020-10-03 04:45:00,58.38,60.965,7.986000000000001,31.177 +2020-10-03 05:00:00,59.9,73.63,9.039,31.177 +2020-10-03 05:15:00,59.86,81.95299999999999,9.039,31.177 +2020-10-03 05:30:00,60.28,77.28,9.039,31.177 +2020-10-03 05:45:00,61.97,73.884,9.039,31.177 +2020-10-03 06:00:00,62.0,86.214,10.683,31.177 +2020-10-03 06:15:00,62.94,100.009,10.683,31.177 +2020-10-03 06:30:00,64.88,93.45,10.683,31.177 +2020-10-03 06:45:00,68.16,88.402,10.683,31.177 +2020-10-03 07:00:00,74.5,87.154,14.055,31.177 +2020-10-03 07:15:00,72.95,87.805,14.055,31.177 +2020-10-03 07:30:00,76.05,88.484,14.055,31.177 +2020-10-03 07:45:00,75.81,89.97200000000001,14.055,31.177 +2020-10-03 08:00:00,77.66,91.262,17.652,31.177 +2020-10-03 08:15:00,78.36,93.70299999999999,17.652,31.177 +2020-10-03 08:30:00,76.08,93.544,17.652,31.177 +2020-10-03 08:45:00,77.02,94.595,17.652,31.177 +2020-10-03 09:00:00,76.35,90.97,21.353,31.177 +2020-10-03 09:15:00,79.16,90.64,21.353,31.177 +2020-10-03 09:30:00,78.99,91.32799999999999,21.353,31.177 +2020-10-03 09:45:00,81.23,91.696,21.353,31.177 +2020-10-03 10:00:00,83.68,89.508,23.467,31.177 +2020-10-03 10:15:00,86.8,89.756,23.467,31.177 +2020-10-03 10:30:00,87.14,89.29700000000001,23.467,31.177 +2020-10-03 10:45:00,87.03,89.06,23.467,31.177 +2020-10-03 11:00:00,82.41,86.065,24.539,31.177 +2020-10-03 11:15:00,82.83,85.729,24.539,31.177 +2020-10-03 11:30:00,76.93,86.07799999999999,24.539,31.177 +2020-10-03 11:45:00,78.79,85.66799999999999,24.539,31.177 +2020-10-03 12:00:00,74.79,82.721,21.488000000000003,31.177 +2020-10-03 12:15:00,72.19,82.29799999999999,21.488000000000003,31.177 +2020-10-03 12:30:00,59.91,81.195,21.488000000000003,31.177 +2020-10-03 12:45:00,62.91,80.593,21.488000000000003,31.177 +2020-10-03 13:00:00,61.74,79.922,18.776,31.177 +2020-10-03 13:15:00,59.78,80.102,18.776,31.177 +2020-10-03 13:30:00,66.56,78.882,18.776,31.177 +2020-10-03 13:45:00,70.81,78.37899999999999,18.776,31.177 +2020-10-03 14:00:00,68.47,78.347,17.301,31.177 +2020-10-03 14:15:00,68.4,78.655,17.301,31.177 +2020-10-03 14:30:00,67.72,77.69800000000001,17.301,31.177 +2020-10-03 14:45:00,64.03,76.969,17.301,31.177 +2020-10-03 15:00:00,66.02,76.7,21.236,31.177 +2020-10-03 15:15:00,68.96,76.618,21.236,31.177 +2020-10-03 15:30:00,70.76,76.907,21.236,31.177 +2020-10-03 15:45:00,72.25,77.335,21.236,31.177 +2020-10-03 16:00:00,74.29,77.045,28.31,31.177 +2020-10-03 16:15:00,75.52,76.52199999999999,28.31,31.177 +2020-10-03 16:30:00,78.34,77.89699999999999,28.31,31.177 +2020-10-03 16:45:00,82.8,76.039,28.31,31.177 +2020-10-03 17:00:00,87.35,76.783,41.687,31.177 +2020-10-03 17:15:00,87.12,77.97,41.687,31.177 +2020-10-03 17:30:00,88.97,78.093,41.687,31.177 +2020-10-03 17:45:00,90.28,79.843,41.687,31.177 +2020-10-03 18:00:00,94.76,80.597,49.201,31.177 +2020-10-03 18:15:00,92.25,81.734,49.201,31.177 +2020-10-03 18:30:00,94.32,81.203,49.201,31.177 +2020-10-03 18:45:00,91.48,82.59100000000001,49.201,31.177 +2020-10-03 19:00:00,92.73,84.505,51.937,31.177 +2020-10-03 19:15:00,98.28,83.11,51.937,31.177 +2020-10-03 19:30:00,96.89,82.96,51.937,31.177 +2020-10-03 19:45:00,88.92,83.443,51.937,31.177 +2020-10-03 20:00:00,85.03,84.101,52.617,31.177 +2020-10-03 20:15:00,85.02,83.164,52.617,31.177 +2020-10-03 20:30:00,90.9,82.34700000000001,52.617,31.177 +2020-10-03 20:45:00,91.31,80.497,52.617,31.177 +2020-10-03 21:00:00,85.41,78.417,46.238,31.177 +2020-10-03 21:15:00,84.03,79.649,46.238,31.177 +2020-10-03 21:30:00,86.88,79.069,46.238,31.177 +2020-10-03 21:45:00,86.4,77.541,46.238,31.177 +2020-10-03 22:00:00,81.78,76.17,48.275,31.177 +2020-10-03 22:15:00,78.99,73.67,48.275,31.177 +2020-10-03 22:30:00,80.17,69.319,48.275,31.177 +2020-10-03 22:45:00,79.39,65.98899999999999,48.275,31.177 +2020-10-03 23:00:00,55.75,61.363,38.071999999999996,31.177 +2020-10-03 23:15:00,57.09,60.38399999999999,38.071999999999996,31.177 +2020-10-03 23:30:00,53.87,58.913999999999994,38.071999999999996,31.177 +2020-10-03 23:45:00,56.4,58.196000000000005,38.071999999999996,31.177 +2020-10-04 00:00:00,53.94,48.018,28.229,31.177 +2020-10-04 00:15:00,54.47,46.545,28.229,31.177 +2020-10-04 00:30:00,52.62,46.365,28.229,31.177 +2020-10-04 00:45:00,53.51,46.6,28.229,31.177 +2020-10-04 01:00:00,50.25,47.07899999999999,25.669,31.177 +2020-10-04 01:15:00,52.39,46.812,25.669,31.177 +2020-10-04 01:30:00,51.82,45.841,25.669,31.177 +2020-10-04 01:45:00,51.72,45.548,25.669,31.177 +2020-10-04 02:00:00,51.1,46.023999999999994,24.948,31.177 +2020-10-04 02:15:00,52.15,45.488,24.948,31.177 +2020-10-04 02:30:00,51.41,46.276,24.948,31.177 +2020-10-04 02:45:00,50.99,47.077,24.948,31.177 +2020-10-04 03:00:00,52.2,49.261,24.445,31.177 +2020-10-04 03:15:00,51.81,48.788000000000004,24.445,31.177 +2020-10-04 03:30:00,52.33,48.39,24.445,31.177 +2020-10-04 03:45:00,53.15,50.145,24.445,31.177 +2020-10-04 04:00:00,53.33,56.275,25.839000000000002,31.177 +2020-10-04 04:15:00,53.92,61.833,25.839000000000002,31.177 +2020-10-04 04:30:00,54.43,60.755,25.839000000000002,31.177 +2020-10-04 04:45:00,54.79,61.372,25.839000000000002,31.177 +2020-10-04 05:00:00,57.04,74.122,26.803,31.177 +2020-10-04 05:15:00,56.99,82.527,26.803,31.177 +2020-10-04 05:30:00,58.51,77.84100000000001,26.803,31.177 +2020-10-04 05:45:00,59.57,74.402,26.803,31.177 +2020-10-04 06:00:00,60.65,86.709,28.147,31.177 +2020-10-04 06:15:00,59.54,100.521,28.147,31.177 +2020-10-04 06:30:00,61.12,93.97399999999999,28.147,31.177 +2020-10-04 06:45:00,61.43,88.93,28.147,31.177 +2020-10-04 07:00:00,66.21,87.679,31.116,31.177 +2020-10-04 07:15:00,65.25,88.34700000000001,31.116,31.177 +2020-10-04 07:30:00,64.85,89.059,31.116,31.177 +2020-10-04 07:45:00,65.24,90.551,31.116,31.177 +2020-10-04 08:00:00,63.88,91.851,35.739000000000004,31.177 +2020-10-04 08:15:00,66.44,94.265,35.739000000000004,31.177 +2020-10-04 08:30:00,70.93,94.132,35.739000000000004,31.177 +2020-10-04 08:45:00,67.85,95.15899999999999,35.739000000000004,31.177 +2020-10-04 09:00:00,66.06,91.535,39.455999999999996,31.177 +2020-10-04 09:15:00,67.28,91.199,39.455999999999996,31.177 +2020-10-04 09:30:00,70.83,91.869,39.455999999999996,31.177 +2020-10-04 09:45:00,77.44,92.211,39.455999999999996,31.177 +2020-10-04 10:00:00,78.19,90.01700000000001,41.343999999999994,31.177 +2020-10-04 10:15:00,81.74,90.225,41.343999999999994,31.177 +2020-10-04 10:30:00,83.73,89.74799999999999,41.343999999999994,31.177 +2020-10-04 10:45:00,84.45,89.494,41.343999999999994,31.177 +2020-10-04 11:00:00,83.31,86.509,43.645,31.177 +2020-10-04 11:15:00,81.0,86.15299999999999,43.645,31.177 +2020-10-04 11:30:00,79.77,86.50200000000001,43.645,31.177 +2020-10-04 11:45:00,79.1,86.075,43.645,31.177 +2020-10-04 12:00:00,75.39,83.105,39.796,31.177 +2020-10-04 12:15:00,75.42,82.67200000000001,39.796,31.177 +2020-10-04 12:30:00,74.51,81.604,39.796,31.177 +2020-10-04 12:45:00,74.31,80.998,39.796,31.177 +2020-10-04 13:00:00,73.03,80.295,36.343,31.177 +2020-10-04 13:15:00,72.61,80.479,36.343,31.177 +2020-10-04 13:30:00,73.42,79.259,36.343,31.177 +2020-10-04 13:45:00,73.96,78.757,36.343,31.177 +2020-10-04 14:00:00,72.48,78.671,33.162,31.177 +2020-10-04 14:15:00,72.19,78.99600000000001,33.162,31.177 +2020-10-04 14:30:00,73.52,78.075,33.162,31.177 +2020-10-04 14:45:00,73.96,77.342,33.162,31.177 +2020-10-04 15:00:00,74.74,77.039,33.215,31.177 +2020-10-04 15:15:00,74.08,76.976,33.215,31.177 +2020-10-04 15:30:00,74.78,77.30199999999999,33.215,31.177 +2020-10-04 15:45:00,76.21,77.745,33.215,31.177 +2020-10-04 16:00:00,78.09,77.42,37.385999999999996,31.177 +2020-10-04 16:15:00,79.1,76.916,37.385999999999996,31.177 +2020-10-04 16:30:00,80.57,78.28699999999999,37.385999999999996,31.177 +2020-10-04 16:45:00,82.81,76.484,37.385999999999996,31.177 +2020-10-04 17:00:00,87.51,77.188,46.618,31.177 +2020-10-04 17:15:00,86.86,78.395,46.618,31.177 +2020-10-04 17:30:00,92.1,78.51899999999999,46.618,31.177 +2020-10-04 17:45:00,89.55,80.29,46.618,31.177 +2020-10-04 18:00:00,95.29,81.03,50.111000000000004,31.177 +2020-10-04 18:15:00,88.95,82.15100000000001,50.111000000000004,31.177 +2020-10-04 18:30:00,91.01,81.631,50.111000000000004,31.177 +2020-10-04 18:45:00,87.31,83.012,50.111000000000004,31.177 +2020-10-04 19:00:00,86.3,84.939,50.25,31.177 +2020-10-04 19:15:00,84.42,83.538,50.25,31.177 +2020-10-04 19:30:00,83.14,83.38,50.25,31.177 +2020-10-04 19:45:00,81.46,83.845,50.25,31.177 +2020-10-04 20:00:00,77.95,84.52600000000001,44.265,31.177 +2020-10-04 20:15:00,79.6,83.583,44.265,31.177 +2020-10-04 20:30:00,77.92,82.73899999999999,44.265,31.177 +2020-10-04 20:45:00,78.85,80.859,44.265,31.177 +2020-10-04 21:00:00,77.61,78.781,39.717,31.177 +2020-10-04 21:15:00,77.95,80.00399999999999,39.717,31.177 +2020-10-04 21:30:00,75.4,79.433,39.717,31.177 +2020-10-04 21:45:00,75.62,77.875,39.717,31.177 +2020-10-04 22:00:00,72.93,76.492,39.224000000000004,31.177 +2020-10-04 22:15:00,72.3,73.969,39.224000000000004,31.177 +2020-10-04 22:30:00,69.94,69.63,39.224000000000004,31.177 +2020-10-04 22:45:00,69.71,66.307,39.224000000000004,31.177 +2020-10-04 23:00:00,65.21,61.708999999999996,33.518,31.177 +2020-10-04 23:15:00,66.43,60.7,33.518,31.177 +2020-10-04 23:30:00,65.32,59.232,33.518,31.177 +2020-10-04 23:45:00,65.1,58.507,33.518,31.177 +2020-10-05 00:00:00,66.33,50.821999999999996,34.301,31.349 +2020-10-05 00:15:00,64.68,50.945,34.301,31.349 +2020-10-05 00:30:00,63.44,50.6,34.301,31.349 +2020-10-05 00:45:00,65.02,50.398,34.301,31.349 +2020-10-05 01:00:00,64.78,51.103,34.143,31.349 +2020-10-05 01:15:00,64.46,50.661,34.143,31.349 +2020-10-05 01:30:00,64.07,49.93899999999999,34.143,31.349 +2020-10-05 01:45:00,64.5,49.631,34.143,31.349 +2020-10-05 02:00:00,63.51,50.364,33.650999999999996,31.349 +2020-10-05 02:15:00,65.03,49.815,33.650999999999996,31.349 +2020-10-05 02:30:00,65.36,50.809,33.650999999999996,31.349 +2020-10-05 02:45:00,66.21,51.282,33.650999999999996,31.349 +2020-10-05 03:00:00,69.64,54.224,32.599000000000004,31.349 +2020-10-05 03:15:00,74.23,54.818999999999996,32.599000000000004,31.349 +2020-10-05 03:30:00,75.31,54.718,32.599000000000004,31.349 +2020-10-05 03:45:00,73.63,55.974,32.599000000000004,31.349 +2020-10-05 04:00:00,76.43,65.589,33.785,31.349 +2020-10-05 04:15:00,77.8,74.464,33.785,31.349 +2020-10-05 04:30:00,89.37,73.941,33.785,31.349 +2020-10-05 04:45:00,95.43,74.844,33.785,31.349 +2020-10-05 05:00:00,102.6,97.568,41.285,31.349 +2020-10-05 05:15:00,101.26,119.60700000000001,41.285,31.349 +2020-10-05 05:30:00,105.1,114.088,41.285,31.349 +2020-10-05 05:45:00,112.77,106.70100000000001,41.285,31.349 +2020-10-05 06:00:00,116.26,106.459,60.486000000000004,31.349 +2020-10-05 06:15:00,116.2,109.37,60.486000000000004,31.349 +2020-10-05 06:30:00,117.92,108.182,60.486000000000004,31.349 +2020-10-05 06:45:00,120.49,109.552,60.486000000000004,31.349 +2020-10-05 07:00:00,124.95,109.79,74.012,31.349 +2020-10-05 07:15:00,123.49,112.266,74.012,31.349 +2020-10-05 07:30:00,122.31,112.215,74.012,31.349 +2020-10-05 07:45:00,122.8,112.87799999999999,74.012,31.349 +2020-10-05 08:00:00,122.15,111.291,69.569,31.349 +2020-10-05 08:15:00,123.81,112.141,69.569,31.349 +2020-10-05 08:30:00,125.38,109.84100000000001,69.569,31.349 +2020-10-05 08:45:00,126.19,109.616,69.569,31.349 +2020-10-05 09:00:00,123.98,105.119,66.152,31.349 +2020-10-05 09:15:00,123.63,102.352,66.152,31.349 +2020-10-05 09:30:00,123.4,102.083,66.152,31.349 +2020-10-05 09:45:00,125.12,101.262,66.152,31.349 +2020-10-05 10:00:00,124.46,99.031,62.923,31.349 +2020-10-05 10:15:00,127.71,98.98,62.923,31.349 +2020-10-05 10:30:00,127.77,97.86399999999999,62.923,31.349 +2020-10-05 10:45:00,125.66,97.073,62.923,31.349 +2020-10-05 11:00:00,120.69,93.165,61.522,31.349 +2020-10-05 11:15:00,117.37,93.73,61.522,31.349 +2020-10-05 11:30:00,112.63,95.04799999999999,61.522,31.349 +2020-10-05 11:45:00,108.66,94.689,61.522,31.349 +2020-10-05 12:00:00,102.98,91.74600000000001,58.632,31.349 +2020-10-05 12:15:00,103.41,91.36,58.632,31.349 +2020-10-05 12:30:00,100.84,89.89299999999999,58.632,31.349 +2020-10-05 12:45:00,101.8,89.97,58.632,31.349 +2020-10-05 13:00:00,100.62,89.954,59.06,31.349 +2020-10-05 13:15:00,100.28,89.065,59.06,31.349 +2020-10-05 13:30:00,100.19,87.693,59.06,31.349 +2020-10-05 13:45:00,100.01,87.664,59.06,31.349 +2020-10-05 14:00:00,100.8,86.809,59.791000000000004,31.349 +2020-10-05 14:15:00,101.96,87.161,59.791000000000004,31.349 +2020-10-05 14:30:00,103.14,85.947,59.791000000000004,31.349 +2020-10-05 14:45:00,104.3,86.251,59.791000000000004,31.349 +2020-10-05 15:00:00,107.02,86.544,61.148,31.349 +2020-10-05 15:15:00,104.62,85.551,61.148,31.349 +2020-10-05 15:30:00,105.02,85.93,61.148,31.349 +2020-10-05 15:45:00,104.28,85.939,61.148,31.349 +2020-10-05 16:00:00,105.06,86.02600000000001,66.009,31.349 +2020-10-05 16:15:00,106.92,85.244,66.009,31.349 +2020-10-05 16:30:00,109.95,85.84,66.009,31.349 +2020-10-05 16:45:00,111.32,83.586,66.009,31.349 +2020-10-05 17:00:00,116.22,83.50299999999999,73.683,31.349 +2020-10-05 17:15:00,114.24,84.52600000000001,73.683,31.349 +2020-10-05 17:30:00,117.49,84.211,73.683,31.349 +2020-10-05 17:45:00,122.2,85.152,73.683,31.349 +2020-10-05 18:00:00,125.09,85.385,72.848,31.349 +2020-10-05 18:15:00,121.39,84.57,72.848,31.349 +2020-10-05 18:30:00,120.7,83.915,72.848,31.349 +2020-10-05 18:45:00,118.89,87.323,72.848,31.349 +2020-10-05 19:00:00,119.68,88.385,71.139,31.349 +2020-10-05 19:15:00,117.11,87.249,71.139,31.349 +2020-10-05 19:30:00,112.56,87.075,71.139,31.349 +2020-10-05 19:45:00,108.13,86.844,71.139,31.349 +2020-10-05 20:00:00,101.98,85.89200000000001,69.667,31.349 +2020-10-05 20:15:00,101.73,84.719,69.667,31.349 +2020-10-05 20:30:00,97.75,83.385,69.667,31.349 +2020-10-05 20:45:00,97.25,82.257,69.667,31.349 +2020-10-05 21:00:00,93.19,80.05,61.166000000000004,31.349 +2020-10-05 21:15:00,97.6,81.041,61.166000000000004,31.349 +2020-10-05 21:30:00,94.2,80.37899999999999,61.166000000000004,31.349 +2020-10-05 21:45:00,93.44,78.468,61.166000000000004,31.349 +2020-10-05 22:00:00,85.58,74.764,52.772,31.349 +2020-10-05 22:15:00,82.71,72.807,52.772,31.349 +2020-10-05 22:30:00,81.05,62.21,52.772,31.349 +2020-10-05 22:45:00,84.7,56.94,52.772,31.349 +2020-10-05 23:00:00,82.54,52.706,45.136,31.349 +2020-10-05 23:15:00,82.36,51.802,45.136,31.349 +2020-10-05 23:30:00,74.67,51.42,45.136,31.349 +2020-10-05 23:45:00,79.64,51.446999999999996,45.136,31.349 +2020-10-06 00:00:00,78.11,49.55,47.35,31.349 +2020-10-06 00:15:00,81.09,50.716,47.35,31.349 +2020-10-06 00:30:00,75.23,50.428000000000004,47.35,31.349 +2020-10-06 00:45:00,74.03,50.303999999999995,47.35,31.349 +2020-10-06 01:00:00,70.95,50.623999999999995,43.424,31.349 +2020-10-06 01:15:00,79.65,50.096000000000004,43.424,31.349 +2020-10-06 01:30:00,79.59,49.382,43.424,31.349 +2020-10-06 01:45:00,78.21,48.891999999999996,43.424,31.349 +2020-10-06 02:00:00,72.09,49.352,41.778999999999996,31.349 +2020-10-06 02:15:00,78.09,49.449,41.778999999999996,31.349 +2020-10-06 02:30:00,79.6,49.968999999999994,41.778999999999996,31.349 +2020-10-06 02:45:00,79.98,50.633,41.778999999999996,31.349 +2020-10-06 03:00:00,79.25,52.776,40.771,31.349 +2020-10-06 03:15:00,82.62,53.61,40.771,31.349 +2020-10-06 03:30:00,83.6,53.68,40.771,31.349 +2020-10-06 03:45:00,83.88,54.345,40.771,31.349 +2020-10-06 04:00:00,83.35,63.176,41.816,31.349 +2020-10-06 04:15:00,89.08,71.957,41.816,31.349 +2020-10-06 04:30:00,94.83,71.24600000000001,41.816,31.349 +2020-10-06 04:45:00,100.98,72.905,41.816,31.349 +2020-10-06 05:00:00,99.83,98.514,45.842,31.349 +2020-10-06 05:15:00,101.8,120.931,45.842,31.349 +2020-10-06 05:30:00,108.7,114.943,45.842,31.349 +2020-10-06 05:45:00,110.57,107.079,45.842,31.349 +2020-10-06 06:00:00,117.91,106.995,59.12,31.349 +2020-10-06 06:15:00,116.24,110.652,59.12,31.349 +2020-10-06 06:30:00,118.48,109.045,59.12,31.349 +2020-10-06 06:45:00,119.02,109.751,59.12,31.349 +2020-10-06 07:00:00,123.59,110.00200000000001,70.33,31.349 +2020-10-06 07:15:00,121.19,112.28299999999999,70.33,31.349 +2020-10-06 07:30:00,117.92,112.06200000000001,70.33,31.349 +2020-10-06 07:45:00,117.6,112.25200000000001,70.33,31.349 +2020-10-06 08:00:00,114.16,110.685,67.788,31.349 +2020-10-06 08:15:00,114.34,110.84,67.788,31.349 +2020-10-06 08:30:00,118.79,108.587,67.788,31.349 +2020-10-06 08:45:00,121.22,107.713,67.788,31.349 +2020-10-06 09:00:00,113.61,103.059,62.622,31.349 +2020-10-06 09:15:00,114.17,100.876,62.622,31.349 +2020-10-06 09:30:00,117.63,101.25,62.622,31.349 +2020-10-06 09:45:00,116.96,101.04799999999999,62.622,31.349 +2020-10-06 10:00:00,111.74,97.815,60.887,31.349 +2020-10-06 10:15:00,115.0,97.189,60.887,31.349 +2020-10-06 10:30:00,113.09,96.147,60.887,31.349 +2020-10-06 10:45:00,113.9,95.99799999999999,60.887,31.349 +2020-10-06 11:00:00,115.53,92.74600000000001,59.812,31.349 +2020-10-06 11:15:00,116.95,93.359,59.812,31.349 +2020-10-06 11:30:00,115.24,93.559,59.812,31.349 +2020-10-06 11:45:00,109.97,93.256,59.812,31.349 +2020-10-06 12:00:00,105.42,89.58,56.614,31.349 +2020-10-06 12:15:00,102.97,89.16799999999999,56.614,31.349 +2020-10-06 12:30:00,103.1,88.50299999999999,56.614,31.349 +2020-10-06 12:45:00,106.67,88.796,56.614,31.349 +2020-10-06 13:00:00,103.21,88.37899999999999,56.824,31.349 +2020-10-06 13:15:00,101.73,88.26,56.824,31.349 +2020-10-06 13:30:00,102.74,87.40100000000001,56.824,31.349 +2020-10-06 13:45:00,104.36,86.963,56.824,31.349 +2020-10-06 14:00:00,104.47,86.411,57.623999999999995,31.349 +2020-10-06 14:15:00,105.34,86.749,57.623999999999995,31.349 +2020-10-06 14:30:00,104.12,86.022,57.623999999999995,31.349 +2020-10-06 14:45:00,104.25,85.884,57.623999999999995,31.349 +2020-10-06 15:00:00,104.24,85.866,59.724,31.349 +2020-10-06 15:15:00,105.47,85.515,59.724,31.349 +2020-10-06 15:30:00,105.21,85.925,59.724,31.349 +2020-10-06 15:45:00,107.87,85.944,59.724,31.349 +2020-10-06 16:00:00,110.19,85.816,61.64,31.349 +2020-10-06 16:15:00,113.18,85.307,61.64,31.349 +2020-10-06 16:30:00,112.84,85.98299999999999,61.64,31.349 +2020-10-06 16:45:00,115.02,84.288,61.64,31.349 +2020-10-06 17:00:00,119.5,84.516,68.962,31.349 +2020-10-06 17:15:00,116.33,85.795,68.962,31.349 +2020-10-06 17:30:00,121.99,85.531,68.962,31.349 +2020-10-06 17:45:00,123.39,86.258,68.962,31.349 +2020-10-06 18:00:00,127.05,85.90100000000001,69.149,31.349 +2020-10-06 18:15:00,122.1,85.682,69.149,31.349 +2020-10-06 18:30:00,121.85,84.766,69.149,31.349 +2020-10-06 18:45:00,120.37,88.39,69.149,31.349 +2020-10-06 19:00:00,118.45,88.83200000000001,68.832,31.349 +2020-10-06 19:15:00,118.71,87.679,68.832,31.349 +2020-10-06 19:30:00,115.61,87.10600000000001,68.832,31.349 +2020-10-06 19:45:00,112.12,87.06,68.832,31.349 +2020-10-06 20:00:00,100.95,86.41799999999999,66.403,31.349 +2020-10-06 20:15:00,100.94,84.17299999999999,66.403,31.349 +2020-10-06 20:30:00,98.55,83.243,66.403,31.349 +2020-10-06 20:45:00,98.12,82.089,66.403,31.349 +2020-10-06 21:00:00,91.59,80.08800000000001,57.352,31.349 +2020-10-06 21:15:00,90.82,80.605,57.352,31.349 +2020-10-06 21:30:00,87.87,79.758,57.352,31.349 +2020-10-06 21:45:00,90.21,78.021,57.352,31.349 +2020-10-06 22:00:00,90.01,75.087,51.148999999999994,31.349 +2020-10-06 22:15:00,89.32,72.814,51.148999999999994,31.349 +2020-10-06 22:30:00,85.07,62.431999999999995,51.148999999999994,31.349 +2020-10-06 22:45:00,83.99,57.276,51.148999999999994,31.349 +2020-10-06 23:00:00,78.1,52.643,41.8,31.349 +2020-10-06 23:15:00,82.01,52.229,41.8,31.349 +2020-10-06 23:30:00,84.4,51.70399999999999,41.8,31.349 +2020-10-06 23:45:00,87.64,51.651,41.8,31.349 +2020-10-07 00:00:00,80.25,49.86,42.269,31.349 +2020-10-07 00:15:00,80.19,51.019,42.269,31.349 +2020-10-07 00:30:00,81.39,50.736999999999995,42.269,31.349 +2020-10-07 00:45:00,86.62,50.611999999999995,42.269,31.349 +2020-10-07 01:00:00,82.23,50.937,38.527,31.349 +2020-10-07 01:15:00,79.29,50.428999999999995,38.527,31.349 +2020-10-07 01:30:00,81.05,49.733000000000004,38.527,31.349 +2020-10-07 01:45:00,83.23,49.24100000000001,38.527,31.349 +2020-10-07 02:00:00,83.52,49.708,36.393,31.349 +2020-10-07 02:15:00,82.09,49.821000000000005,36.393,31.349 +2020-10-07 02:30:00,79.19,50.321999999999996,36.393,31.349 +2020-10-07 02:45:00,79.81,50.982,36.393,31.349 +2020-10-07 03:00:00,86.92,53.113,36.167,31.349 +2020-10-07 03:15:00,87.2,53.966,36.167,31.349 +2020-10-07 03:30:00,88.26,54.038999999999994,36.167,31.349 +2020-10-07 03:45:00,86.31,54.68600000000001,36.167,31.349 +2020-10-07 04:00:00,94.04,63.54600000000001,38.092,31.349 +2020-10-07 04:15:00,97.41,72.358,38.092,31.349 +2020-10-07 04:30:00,98.81,71.648,38.092,31.349 +2020-10-07 04:45:00,98.37,73.316,38.092,31.349 +2020-10-07 05:00:00,104.2,99.01,42.268,31.349 +2020-10-07 05:15:00,110.49,121.51100000000001,42.268,31.349 +2020-10-07 05:30:00,112.99,115.508,42.268,31.349 +2020-10-07 05:45:00,120.34,107.602,42.268,31.349 +2020-10-07 06:00:00,123.12,107.494,60.158,31.349 +2020-10-07 06:15:00,115.11,111.17,60.158,31.349 +2020-10-07 06:30:00,121.4,109.574,60.158,31.349 +2020-10-07 06:45:00,121.67,110.28299999999999,60.158,31.349 +2020-10-07 07:00:00,123.41,110.53299999999999,74.792,31.349 +2020-10-07 07:15:00,121.71,112.82700000000001,74.792,31.349 +2020-10-07 07:30:00,121.33,112.64,74.792,31.349 +2020-10-07 07:45:00,119.53,112.833,74.792,31.349 +2020-10-07 08:00:00,115.82,111.277,70.499,31.349 +2020-10-07 08:15:00,116.66,111.404,70.499,31.349 +2020-10-07 08:30:00,116.5,109.17399999999999,70.499,31.349 +2020-10-07 08:45:00,116.09,108.279,70.499,31.349 +2020-10-07 09:00:00,113.61,103.625,68.892,31.349 +2020-10-07 09:15:00,113.03,101.435,68.892,31.349 +2020-10-07 09:30:00,112.32,101.794,68.892,31.349 +2020-10-07 09:45:00,113.24,101.564,68.892,31.349 +2020-10-07 10:00:00,113.52,98.324,66.88600000000001,31.349 +2020-10-07 10:15:00,116.01,97.65799999999999,66.88600000000001,31.349 +2020-10-07 10:30:00,112.9,96.59899999999999,66.88600000000001,31.349 +2020-10-07 10:45:00,113.01,96.432,66.88600000000001,31.349 +2020-10-07 11:00:00,110.74,93.19,66.187,31.349 +2020-10-07 11:15:00,107.17,93.78399999999999,66.187,31.349 +2020-10-07 11:30:00,106.12,93.98299999999999,66.187,31.349 +2020-10-07 11:45:00,106.28,93.664,66.187,31.349 +2020-10-07 12:00:00,103.85,89.963,62.18,31.349 +2020-10-07 12:15:00,104.47,89.54299999999999,62.18,31.349 +2020-10-07 12:30:00,103.29,88.913,62.18,31.349 +2020-10-07 12:45:00,104.27,89.20200000000001,62.18,31.349 +2020-10-07 13:00:00,103.01,88.75399999999999,62.23,31.349 +2020-10-07 13:15:00,102.19,88.63799999999999,62.23,31.349 +2020-10-07 13:30:00,102.91,87.77799999999999,62.23,31.349 +2020-10-07 13:45:00,103.83,87.34,62.23,31.349 +2020-10-07 14:00:00,104.04,86.73700000000001,63.721000000000004,31.349 +2020-10-07 14:15:00,103.67,87.09,63.721000000000004,31.349 +2020-10-07 14:30:00,102.78,86.399,63.721000000000004,31.349 +2020-10-07 14:45:00,102.93,86.258,63.721000000000004,31.349 +2020-10-07 15:00:00,104.88,86.205,66.523,31.349 +2020-10-07 15:15:00,108.17,85.874,66.523,31.349 +2020-10-07 15:30:00,106.16,86.321,66.523,31.349 +2020-10-07 15:45:00,106.9,86.355,66.523,31.349 +2020-10-07 16:00:00,110.44,86.19200000000001,69.679,31.349 +2020-10-07 16:15:00,110.4,85.70200000000001,69.679,31.349 +2020-10-07 16:30:00,112.36,86.374,69.679,31.349 +2020-10-07 16:45:00,114.77,84.734,69.679,31.349 +2020-10-07 17:00:00,117.12,84.92200000000001,75.04,31.349 +2020-10-07 17:15:00,116.68,86.22,75.04,31.349 +2020-10-07 17:30:00,121.96,85.959,75.04,31.349 +2020-10-07 17:45:00,123.54,86.70700000000001,75.04,31.349 +2020-10-07 18:00:00,125.9,86.336,75.915,31.349 +2020-10-07 18:15:00,122.45,86.101,75.915,31.349 +2020-10-07 18:30:00,122.89,85.197,75.915,31.349 +2020-10-07 18:45:00,119.42,88.81299999999999,75.915,31.349 +2020-10-07 19:00:00,114.78,89.26799999999999,74.66,31.349 +2020-10-07 19:15:00,112.06,88.11,74.66,31.349 +2020-10-07 19:30:00,107.91,87.527,74.66,31.349 +2020-10-07 19:45:00,108.91,87.464,74.66,31.349 +2020-10-07 20:00:00,101.7,86.845,71.204,31.349 +2020-10-07 20:15:00,104.07,84.595,71.204,31.349 +2020-10-07 20:30:00,101.57,83.637,71.204,31.349 +2020-10-07 20:45:00,99.17,82.454,71.204,31.349 +2020-10-07 21:00:00,94.53,80.454,61.052,31.349 +2020-10-07 21:15:00,94.88,80.96,61.052,31.349 +2020-10-07 21:30:00,96.49,80.123,61.052,31.349 +2020-10-07 21:45:00,95.82,78.357,61.052,31.349 +2020-10-07 22:00:00,91.81,75.411,54.691,31.349 +2020-10-07 22:15:00,85.9,73.115,54.691,31.349 +2020-10-07 22:30:00,83.52,62.748000000000005,54.691,31.349 +2020-10-07 22:45:00,79.93,57.597,54.691,31.349 +2020-10-07 23:00:00,76.11,52.993,45.18,31.349 +2020-10-07 23:15:00,76.08,52.548,45.18,31.349 +2020-10-07 23:30:00,75.38,52.023999999999994,45.18,31.349 +2020-10-07 23:45:00,79.99,51.966,45.18,31.349 +2020-10-08 00:00:00,79.69,50.172,42.746,31.349 +2020-10-08 00:15:00,79.41,51.321000000000005,42.746,31.349 +2020-10-08 00:30:00,77.72,51.048,42.746,31.349 +2020-10-08 00:45:00,77.1,50.92,42.746,31.349 +2020-10-08 01:00:00,77.16,51.248999999999995,40.025999999999996,31.349 +2020-10-08 01:15:00,79.65,50.761,40.025999999999996,31.349 +2020-10-08 01:30:00,78.99,50.083999999999996,40.025999999999996,31.349 +2020-10-08 01:45:00,73.79,49.59,40.025999999999996,31.349 +2020-10-08 02:00:00,79.29,50.065,38.154,31.349 +2020-10-08 02:15:00,80.26,50.193999999999996,38.154,31.349 +2020-10-08 02:30:00,78.95,50.676,38.154,31.349 +2020-10-08 02:45:00,76.08,51.33,38.154,31.349 +2020-10-08 03:00:00,75.33,53.449,37.575,31.349 +2020-10-08 03:15:00,82.42,54.321999999999996,37.575,31.349 +2020-10-08 03:30:00,84.01,54.4,37.575,31.349 +2020-10-08 03:45:00,86.07,55.028,37.575,31.349 +2020-10-08 04:00:00,84.0,63.916000000000004,39.154,31.349 +2020-10-08 04:15:00,90.23,72.76100000000001,39.154,31.349 +2020-10-08 04:30:00,94.69,72.051,39.154,31.349 +2020-10-08 04:45:00,97.5,73.727,39.154,31.349 +2020-10-08 05:00:00,97.92,99.507,44.085,31.349 +2020-10-08 05:15:00,101.71,122.09299999999999,44.085,31.349 +2020-10-08 05:30:00,107.92,116.074,44.085,31.349 +2020-10-08 05:45:00,111.57,108.126,44.085,31.349 +2020-10-08 06:00:00,116.78,107.995,57.49,31.349 +2020-10-08 06:15:00,114.86,111.68700000000001,57.49,31.349 +2020-10-08 06:30:00,114.18,110.105,57.49,31.349 +2020-10-08 06:45:00,118.63,110.81700000000001,57.49,31.349 +2020-10-08 07:00:00,120.92,111.065,73.617,31.349 +2020-10-08 07:15:00,119.12,113.374,73.617,31.349 +2020-10-08 07:30:00,117.33,113.219,73.617,31.349 +2020-10-08 07:45:00,116.58,113.415,73.617,31.349 +2020-10-08 08:00:00,115.2,111.869,69.281,31.349 +2020-10-08 08:15:00,114.49,111.969,69.281,31.349 +2020-10-08 08:30:00,113.74,109.76299999999999,69.281,31.349 +2020-10-08 08:45:00,113.82,108.845,69.281,31.349 +2020-10-08 09:00:00,111.25,104.189,63.926,31.349 +2020-10-08 09:15:00,110.51,101.995,63.926,31.349 +2020-10-08 09:30:00,110.18,102.336,63.926,31.349 +2020-10-08 09:45:00,110.35,102.079,63.926,31.349 +2020-10-08 10:00:00,108.45,98.833,59.442,31.349 +2020-10-08 10:15:00,109.67,98.12799999999999,59.442,31.349 +2020-10-08 10:30:00,108.62,97.05,59.442,31.349 +2020-10-08 10:45:00,107.19,96.867,59.442,31.349 +2020-10-08 11:00:00,105.31,93.634,56.771,31.349 +2020-10-08 11:15:00,106.63,94.209,56.771,31.349 +2020-10-08 11:30:00,106.21,94.40700000000001,56.771,31.349 +2020-10-08 11:45:00,108.79,94.072,56.771,31.349 +2020-10-08 12:00:00,101.41,90.34700000000001,53.701,31.349 +2020-10-08 12:15:00,101.81,89.917,53.701,31.349 +2020-10-08 12:30:00,99.91,89.323,53.701,31.349 +2020-10-08 12:45:00,100.67,89.609,53.701,31.349 +2020-10-08 13:00:00,99.91,89.12799999999999,52.364,31.349 +2020-10-08 13:15:00,100.85,89.016,52.364,31.349 +2020-10-08 13:30:00,99.77,88.156,52.364,31.349 +2020-10-08 13:45:00,102.06,87.71799999999999,52.364,31.349 +2020-10-08 14:00:00,101.66,87.06200000000001,53.419,31.349 +2020-10-08 14:15:00,102.04,87.431,53.419,31.349 +2020-10-08 14:30:00,101.37,86.77600000000001,53.419,31.349 +2020-10-08 14:45:00,102.03,86.632,53.419,31.349 +2020-10-08 15:00:00,102.38,86.545,56.744,31.349 +2020-10-08 15:15:00,106.15,86.235,56.744,31.349 +2020-10-08 15:30:00,105.14,86.71700000000001,56.744,31.349 +2020-10-08 15:45:00,106.58,86.766,56.744,31.349 +2020-10-08 16:00:00,108.41,86.568,60.458,31.349 +2020-10-08 16:15:00,109.23,86.09700000000001,60.458,31.349 +2020-10-08 16:30:00,111.33,86.765,60.458,31.349 +2020-10-08 16:45:00,112.69,85.179,60.458,31.349 +2020-10-08 17:00:00,117.22,85.32600000000001,66.295,31.349 +2020-10-08 17:15:00,114.98,86.646,66.295,31.349 +2020-10-08 17:30:00,121.72,86.387,66.295,31.349 +2020-10-08 17:45:00,122.79,87.156,66.295,31.349 +2020-10-08 18:00:00,124.63,86.772,68.468,31.349 +2020-10-08 18:15:00,124.71,86.521,68.468,31.349 +2020-10-08 18:30:00,121.3,85.62799999999999,68.468,31.349 +2020-10-08 18:45:00,120.86,89.238,68.468,31.349 +2020-10-08 19:00:00,121.84,89.704,66.39399999999999,31.349 +2020-10-08 19:15:00,118.99,88.54,66.39399999999999,31.349 +2020-10-08 19:30:00,110.25,87.949,66.39399999999999,31.349 +2020-10-08 19:45:00,112.82,87.868,66.39399999999999,31.349 +2020-10-08 20:00:00,104.39,87.273,63.183,31.349 +2020-10-08 20:15:00,104.11,85.01700000000001,63.183,31.349 +2020-10-08 20:30:00,96.44,84.031,63.183,31.349 +2020-10-08 20:45:00,97.74,82.82,63.183,31.349 +2020-10-08 21:00:00,95.47,80.82,55.133,31.349 +2020-10-08 21:15:00,94.29,81.316,55.133,31.349 +2020-10-08 21:30:00,88.11,80.48899999999999,55.133,31.349 +2020-10-08 21:45:00,94.31,78.695,55.133,31.349 +2020-10-08 22:00:00,86.54,75.735,50.111999999999995,31.349 +2020-10-08 22:15:00,84.64,73.417,50.111999999999995,31.349 +2020-10-08 22:30:00,84.73,63.065,50.111999999999995,31.349 +2020-10-08 22:45:00,80.12,57.919,50.111999999999995,31.349 +2020-10-08 23:00:00,82.03,53.343,44.536,31.349 +2020-10-08 23:15:00,83.35,52.867,44.536,31.349 +2020-10-08 23:30:00,82.16,52.345,44.536,31.349 +2020-10-08 23:45:00,78.69,52.281000000000006,44.536,31.349 +2020-10-09 00:00:00,78.1,49.02,42.291000000000004,31.349 +2020-10-09 00:15:00,79.85,50.364,42.291000000000004,31.349 +2020-10-09 00:30:00,79.77,50.202,42.291000000000004,31.349 +2020-10-09 00:45:00,76.69,50.361999999999995,42.291000000000004,31.349 +2020-10-09 01:00:00,73.43,50.349,41.008,31.349 +2020-10-09 01:15:00,80.07,49.895,41.008,31.349 +2020-10-09 01:30:00,78.35,49.56100000000001,41.008,31.349 +2020-10-09 01:45:00,75.18,48.961999999999996,41.008,31.349 +2020-10-09 02:00:00,76.68,50.022,39.521,31.349 +2020-10-09 02:15:00,74.18,50.09,39.521,31.349 +2020-10-09 02:30:00,79.65,51.242,39.521,31.349 +2020-10-09 02:45:00,79.83,51.511,39.521,31.349 +2020-10-09 03:00:00,80.63,53.677,39.812,31.349 +2020-10-09 03:15:00,76.92,54.235,39.812,31.349 +2020-10-09 03:30:00,78.86,54.17,39.812,31.349 +2020-10-09 03:45:00,81.09,55.42,39.812,31.349 +2020-10-09 04:00:00,87.69,64.506,41.22,31.349 +2020-10-09 04:15:00,90.33,72.376,41.22,31.349 +2020-10-09 04:30:00,89.24,72.312,41.22,31.349 +2020-10-09 04:45:00,95.09,73.171,41.22,31.349 +2020-10-09 05:00:00,107.98,98.257,45.115,31.349 +2020-10-09 05:15:00,110.72,122.072,45.115,31.349 +2020-10-09 05:30:00,111.56,116.56299999999999,45.115,31.349 +2020-10-09 05:45:00,111.91,108.28200000000001,45.115,31.349 +2020-10-09 06:00:00,119.82,108.445,59.06100000000001,31.349 +2020-10-09 06:15:00,118.17,111.68,59.06100000000001,31.349 +2020-10-09 06:30:00,118.58,109.745,59.06100000000001,31.349 +2020-10-09 06:45:00,120.01,111.014,59.06100000000001,31.349 +2020-10-09 07:00:00,122.14,111.315,71.874,31.349 +2020-10-09 07:15:00,120.54,114.571,71.874,31.349 +2020-10-09 07:30:00,118.81,113.26100000000001,71.874,31.349 +2020-10-09 07:45:00,118.12,112.98,71.874,31.349 +2020-10-09 08:00:00,116.07,111.43299999999999,68.439,31.349 +2020-10-09 08:15:00,113.32,111.71600000000001,68.439,31.349 +2020-10-09 08:30:00,113.35,109.887,68.439,31.349 +2020-10-09 08:45:00,116.44,108.208,68.439,31.349 +2020-10-09 09:00:00,120.09,102.461,65.523,31.349 +2020-10-09 09:15:00,119.56,101.556,65.523,31.349 +2020-10-09 09:30:00,119.2,101.32,65.523,31.349 +2020-10-09 09:45:00,120.05,101.2,65.523,31.349 +2020-10-09 10:00:00,118.05,97.335,62.005,31.349 +2020-10-09 10:15:00,120.4,96.78399999999999,62.005,31.349 +2020-10-09 10:30:00,117.17,95.949,62.005,31.349 +2020-10-09 10:45:00,113.01,95.501,62.005,31.349 +2020-10-09 11:00:00,109.41,92.38600000000001,60.351000000000006,31.349 +2020-10-09 11:15:00,112.78,91.969,60.351000000000006,31.349 +2020-10-09 11:30:00,116.56,92.76799999999999,60.351000000000006,31.349 +2020-10-09 11:45:00,115.51,91.935,60.351000000000006,31.349 +2020-10-09 12:00:00,112.96,88.92399999999999,55.331,31.349 +2020-10-09 12:15:00,112.0,87.18700000000001,55.331,31.349 +2020-10-09 12:30:00,109.13,86.762,55.331,31.349 +2020-10-09 12:45:00,106.59,86.881,55.331,31.349 +2020-10-09 13:00:00,98.34,87.105,53.361999999999995,31.349 +2020-10-09 13:15:00,98.84,87.469,53.361999999999995,31.349 +2020-10-09 13:30:00,99.07,87.02,53.361999999999995,31.349 +2020-10-09 13:45:00,99.93,86.71,53.361999999999995,31.349 +2020-10-09 14:00:00,99.24,85.06299999999999,51.708,31.349 +2020-10-09 14:15:00,99.81,85.588,51.708,31.349 +2020-10-09 14:30:00,100.03,85.976,51.708,31.349 +2020-10-09 14:45:00,101.58,85.617,51.708,31.349 +2020-10-09 15:00:00,101.74,85.23100000000001,54.571000000000005,31.349 +2020-10-09 15:15:00,104.75,84.615,54.571000000000005,31.349 +2020-10-09 15:30:00,101.76,84.139,54.571000000000005,31.349 +2020-10-09 15:45:00,104.43,84.66,54.571000000000005,31.349 +2020-10-09 16:00:00,106.95,83.49,58.662,31.349 +2020-10-09 16:15:00,108.48,83.429,58.662,31.349 +2020-10-09 16:30:00,109.76,84.04,58.662,31.349 +2020-10-09 16:45:00,111.36,82.041,58.662,31.349 +2020-10-09 17:00:00,113.98,83.177,65.941,31.349 +2020-10-09 17:15:00,116.0,84.243,65.941,31.349 +2020-10-09 17:30:00,116.08,83.93700000000001,65.941,31.349 +2020-10-09 17:45:00,120.05,84.536,65.941,31.349 +2020-10-09 18:00:00,122.43,84.505,65.628,31.349 +2020-10-09 18:15:00,118.95,83.552,65.628,31.349 +2020-10-09 18:30:00,120.14,82.779,65.628,31.349 +2020-10-09 18:45:00,116.46,86.61200000000001,65.628,31.349 +2020-10-09 19:00:00,110.08,87.984,63.662,31.349 +2020-10-09 19:15:00,107.3,87.711,63.662,31.349 +2020-10-09 19:30:00,102.67,86.975,63.662,31.349 +2020-10-09 19:45:00,102.52,86.115,63.662,31.349 +2020-10-09 20:00:00,94.57,85.47399999999999,61.945,31.349 +2020-10-09 20:15:00,92.41,83.66,61.945,31.349 +2020-10-09 20:30:00,93.56,82.355,61.945,31.349 +2020-10-09 20:45:00,88.21,80.892,61.945,31.349 +2020-10-09 21:00:00,83.31,79.843,53.903,31.349 +2020-10-09 21:15:00,83.13,81.453,53.903,31.349 +2020-10-09 21:30:00,83.95,80.569,53.903,31.349 +2020-10-09 21:45:00,86.18,79.085,53.903,31.349 +2020-10-09 22:00:00,80.94,76.436,48.403999999999996,31.349 +2020-10-09 22:15:00,79.92,73.9,48.403999999999996,31.349 +2020-10-09 22:30:00,73.84,68.861,48.403999999999996,31.349 +2020-10-09 22:45:00,69.3,65.65100000000001,48.403999999999996,31.349 +2020-10-09 23:00:00,66.03,61.873999999999995,41.07,31.349 +2020-10-09 23:15:00,66.63,59.684,41.07,31.349 +2020-10-09 23:30:00,71.33,57.535,41.07,31.349 +2020-10-09 23:45:00,72.04,57.123999999999995,41.07,31.349 +2020-10-10 00:00:00,68.05,48.924,38.989000000000004,31.177 +2020-10-10 00:15:00,66.03,48.18899999999999,38.989000000000004,31.177 +2020-10-10 00:30:00,63.47,48.287,38.989000000000004,31.177 +2020-10-10 00:45:00,70.67,48.302,38.989000000000004,31.177 +2020-10-10 01:00:00,68.97,48.708,35.275,31.177 +2020-10-10 01:15:00,68.98,48.229,35.275,31.177 +2020-10-10 01:30:00,62.64,47.24100000000001,35.275,31.177 +2020-10-10 01:45:00,63.19,47.28,35.275,31.177 +2020-10-10 02:00:00,60.49,48.051,32.838,31.177 +2020-10-10 02:15:00,67.91,47.53,32.838,31.177 +2020-10-10 02:30:00,68.09,47.768,32.838,31.177 +2020-10-10 02:45:00,61.18,48.528999999999996,32.838,31.177 +2020-10-10 03:00:00,60.57,50.135,32.418,31.177 +2020-10-10 03:15:00,60.96,49.832,32.418,31.177 +2020-10-10 03:30:00,60.75,49.342,32.418,31.177 +2020-10-10 03:45:00,61.33,51.481,32.418,31.177 +2020-10-10 04:00:00,63.26,57.9,32.099000000000004,31.177 +2020-10-10 04:15:00,62.9,64.293,32.099000000000004,31.177 +2020-10-10 04:30:00,62.36,62.413000000000004,32.099000000000004,31.177 +2020-10-10 04:45:00,64.21,63.248000000000005,32.099000000000004,31.177 +2020-10-10 05:00:00,67.64,77.72,32.926,31.177 +2020-10-10 05:15:00,68.34,87.98899999999999,32.926,31.177 +2020-10-10 05:30:00,67.96,83.508,32.926,31.177 +2020-10-10 05:45:00,69.98,79.889,32.926,31.177 +2020-10-10 06:00:00,70.7,93.516,35.069,31.177 +2020-10-10 06:15:00,73.22,107.594,35.069,31.177 +2020-10-10 06:30:00,74.31,101.949,35.069,31.177 +2020-10-10 06:45:00,77.52,97.90799999999999,35.069,31.177 +2020-10-10 07:00:00,79.57,95.572,40.906,31.177 +2020-10-10 07:15:00,80.64,97.601,40.906,31.177 +2020-10-10 07:30:00,80.5,98.264,40.906,31.177 +2020-10-10 07:45:00,81.73,100.09,40.906,31.177 +2020-10-10 08:00:00,80.79,100.335,46.603,31.177 +2020-10-10 08:15:00,80.92,101.959,46.603,31.177 +2020-10-10 08:30:00,80.69,100.809,46.603,31.177 +2020-10-10 08:45:00,79.3,100.93,46.603,31.177 +2020-10-10 09:00:00,80.93,97.544,49.935,31.177 +2020-10-10 09:15:00,78.7,97.215,49.935,31.177 +2020-10-10 09:30:00,77.39,97.61399999999999,49.935,31.177 +2020-10-10 09:45:00,77.01,97.289,49.935,31.177 +2020-10-10 10:00:00,75.49,93.726,47.585,31.177 +2020-10-10 10:15:00,76.27,93.39399999999999,47.585,31.177 +2020-10-10 10:30:00,75.48,92.43299999999999,47.585,31.177 +2020-10-10 10:45:00,75.32,92.353,47.585,31.177 +2020-10-10 11:00:00,73.13,89.22200000000001,43.376999999999995,31.177 +2020-10-10 11:15:00,72.55,88.932,43.376999999999995,31.177 +2020-10-10 11:30:00,69.49,89.382,43.376999999999995,31.177 +2020-10-10 11:45:00,71.73,88.456,43.376999999999995,31.177 +2020-10-10 12:00:00,67.21,85.07799999999999,40.855,31.177 +2020-10-10 12:15:00,65.23,84.057,40.855,31.177 +2020-10-10 12:30:00,64.52,83.74700000000001,40.855,31.177 +2020-10-10 12:45:00,64.43,83.87899999999999,40.855,31.177 +2020-10-10 13:00:00,62.06,83.459,37.251,31.177 +2020-10-10 13:15:00,63.54,82.611,37.251,31.177 +2020-10-10 13:30:00,64.24,82.051,37.251,31.177 +2020-10-10 13:45:00,65.9,81.27199999999999,37.251,31.177 +2020-10-10 14:00:00,65.04,80.149,38.548,31.177 +2020-10-10 14:15:00,65.98,79.81,38.548,31.177 +2020-10-10 14:30:00,67.11,79.18,38.548,31.177 +2020-10-10 14:45:00,68.17,79.163,38.548,31.177 +2020-10-10 15:00:00,68.93,79.266,42.883,31.177 +2020-10-10 15:15:00,68.68,79.398,42.883,31.177 +2020-10-10 15:30:00,70.26,79.771,42.883,31.177 +2020-10-10 15:45:00,73.92,79.848,42.883,31.177 +2020-10-10 16:00:00,76.34,79.433,48.143,31.177 +2020-10-10 16:15:00,80.43,79.315,48.143,31.177 +2020-10-10 16:30:00,82.57,80.018,48.143,31.177 +2020-10-10 16:45:00,81.96,78.45,48.143,31.177 +2020-10-10 17:00:00,87.07,78.757,55.25,31.177 +2020-10-10 17:15:00,85.61,79.41,55.25,31.177 +2020-10-10 17:30:00,93.1,78.993,55.25,31.177 +2020-10-10 17:45:00,93.78,79.703,55.25,31.177 +2020-10-10 18:00:00,94.81,80.311,57.506,31.177 +2020-10-10 18:15:00,93.4,81.016,57.506,31.177 +2020-10-10 18:30:00,95.37,81.557,57.506,31.177 +2020-10-10 18:45:00,92.73,82.074,57.506,31.177 +2020-10-10 19:00:00,86.72,83.03299999999999,55.528999999999996,31.177 +2020-10-10 19:15:00,83.84,81.98700000000001,55.528999999999996,31.177 +2020-10-10 19:30:00,81.98,81.98100000000001,55.528999999999996,31.177 +2020-10-10 19:45:00,80.54,82.01799999999999,55.528999999999996,31.177 +2020-10-10 20:00:00,77.04,82.738,46.166000000000004,31.177 +2020-10-10 20:15:00,76.13,81.439,46.166000000000004,31.177 +2020-10-10 20:30:00,75.51,79.47399999999999,46.166000000000004,31.177 +2020-10-10 20:45:00,73.93,78.848,46.166000000000004,31.177 +2020-10-10 21:00:00,71.35,77.916,40.406,31.177 +2020-10-10 21:15:00,70.87,79.498,40.406,31.177 +2020-10-10 21:30:00,66.41,79.23100000000001,40.406,31.177 +2020-10-10 21:45:00,65.71,77.248,40.406,31.177 +2020-10-10 22:00:00,61.42,75.122,39.616,31.177 +2020-10-10 22:15:00,62.07,73.71600000000001,39.616,31.177 +2020-10-10 22:30:00,59.56,71.013,39.616,31.177 +2020-10-10 22:45:00,58.47,68.78,39.616,31.177 +2020-10-10 23:00:00,54.74,65.638,32.205,31.177 +2020-10-10 23:15:00,54.08,62.986999999999995,32.205,31.177 +2020-10-10 23:30:00,55.19,61.508,32.205,31.177 +2020-10-10 23:45:00,53.38,60.303999999999995,32.205,31.177 +2020-10-11 00:00:00,50.93,50.198,28.229,31.177 +2020-10-11 00:15:00,51.94,48.662,28.229,31.177 +2020-10-11 00:30:00,50.97,48.536,28.229,31.177 +2020-10-11 00:45:00,51.87,48.757,28.229,31.177 +2020-10-11 01:00:00,49.67,49.263999999999996,25.669,31.177 +2020-10-11 01:15:00,51.23,49.141000000000005,25.669,31.177 +2020-10-11 01:30:00,50.66,48.294,25.669,31.177 +2020-10-11 01:45:00,51.05,47.988,25.669,31.177 +2020-10-11 02:00:00,51.26,48.52,24.948,31.177 +2020-10-11 02:15:00,50.24,48.092,24.948,31.177 +2020-10-11 02:30:00,49.75,48.754,24.948,31.177 +2020-10-11 02:45:00,49.74,49.521,24.948,31.177 +2020-10-11 03:00:00,49.25,51.614,24.445,31.177 +2020-10-11 03:15:00,50.7,51.281000000000006,24.445,31.177 +2020-10-11 03:30:00,51.34,50.907,24.445,31.177 +2020-10-11 03:45:00,51.61,52.536,24.445,31.177 +2020-10-11 04:00:00,52.83,58.86600000000001,25.839000000000002,31.177 +2020-10-11 04:15:00,53.93,64.65,25.839000000000002,31.177 +2020-10-11 04:30:00,53.84,63.577,25.839000000000002,31.177 +2020-10-11 04:45:00,54.0,64.253,25.839000000000002,31.177 +2020-10-11 05:00:00,56.26,77.597,26.803,31.177 +2020-10-11 05:15:00,56.25,86.6,26.803,31.177 +2020-10-11 05:30:00,56.59,81.8,26.803,31.177 +2020-10-11 05:45:00,54.83,78.065,26.803,31.177 +2020-10-11 06:00:00,60.32,90.211,28.147,31.177 +2020-10-11 06:15:00,59.82,104.145,28.147,31.177 +2020-10-11 06:30:00,62.29,97.684,28.147,31.177 +2020-10-11 06:45:00,63.34,92.662,28.147,31.177 +2020-10-11 07:00:00,65.28,91.40100000000001,31.116,31.177 +2020-10-11 07:15:00,66.84,92.166,31.116,31.177 +2020-10-11 07:30:00,67.06,93.11200000000001,31.116,31.177 +2020-10-11 07:45:00,67.82,94.62100000000001,31.116,31.177 +2020-10-11 08:00:00,66.65,95.993,35.739000000000004,31.177 +2020-10-11 08:15:00,66.05,98.213,35.739000000000004,31.177 +2020-10-11 08:30:00,64.97,98.24600000000001,35.739000000000004,31.177 +2020-10-11 08:45:00,64.71,99.119,35.739000000000004,31.177 +2020-10-11 09:00:00,62.3,95.48899999999999,39.455999999999996,31.177 +2020-10-11 09:15:00,61.87,95.111,39.455999999999996,31.177 +2020-10-11 09:30:00,61.94,95.667,39.455999999999996,31.177 +2020-10-11 09:45:00,63.26,95.81700000000001,39.455999999999996,31.177 +2020-10-11 10:00:00,63.21,93.579,41.343999999999994,31.177 +2020-10-11 10:15:00,63.71,93.508,41.343999999999994,31.177 +2020-10-11 10:30:00,64.26,92.906,41.343999999999994,31.177 +2020-10-11 10:45:00,63.77,92.535,41.343999999999994,31.177 +2020-10-11 11:00:00,60.7,89.61200000000001,43.645,31.177 +2020-10-11 11:15:00,59.98,89.12700000000001,43.645,31.177 +2020-10-11 11:30:00,57.95,89.469,43.645,31.177 +2020-10-11 11:45:00,60.21,88.926,43.645,31.177 +2020-10-11 12:00:00,56.47,85.79,39.796,31.177 +2020-10-11 12:15:00,53.94,85.294,39.796,31.177 +2020-10-11 12:30:00,52.59,84.475,39.796,31.177 +2020-10-11 12:45:00,54.32,83.84200000000001,39.796,31.177 +2020-10-11 13:00:00,49.62,82.914,36.343,31.177 +2020-10-11 13:15:00,51.08,83.12700000000001,36.343,31.177 +2020-10-11 13:30:00,51.24,81.898,36.343,31.177 +2020-10-11 13:45:00,52.47,81.398,36.343,31.177 +2020-10-11 14:00:00,52.86,80.949,33.162,31.177 +2020-10-11 14:15:00,54.18,81.385,33.162,31.177 +2020-10-11 14:30:00,54.72,80.714,33.162,31.177 +2020-10-11 14:45:00,57.08,79.959,33.162,31.177 +2020-10-11 15:00:00,61.5,79.414,33.215,31.177 +2020-10-11 15:15:00,61.01,79.492,33.215,31.177 +2020-10-11 15:30:00,61.52,80.07300000000001,33.215,31.177 +2020-10-11 15:45:00,64.89,80.623,33.215,31.177 +2020-10-11 16:00:00,69.86,80.05,37.385999999999996,31.177 +2020-10-11 16:15:00,73.5,79.68,37.385999999999996,31.177 +2020-10-11 16:30:00,72.54,81.023,37.385999999999996,31.177 +2020-10-11 16:45:00,77.12,79.60300000000001,37.385999999999996,31.177 +2020-10-11 17:00:00,81.3,80.021,46.618,31.177 +2020-10-11 17:15:00,82.78,81.369,46.618,31.177 +2020-10-11 17:30:00,89.15,81.513,46.618,31.177 +2020-10-11 17:45:00,88.39,83.434,46.618,31.177 +2020-10-11 18:00:00,91.86,84.07700000000001,50.111000000000004,31.177 +2020-10-11 18:15:00,89.54,85.089,50.111000000000004,31.177 +2020-10-11 18:30:00,91.48,84.645,50.111000000000004,31.177 +2020-10-11 18:45:00,86.46,85.98299999999999,50.111000000000004,31.177 +2020-10-11 19:00:00,81.58,87.99,50.25,31.177 +2020-10-11 19:15:00,87.47,86.554,50.25,31.177 +2020-10-11 19:30:00,87.65,86.333,50.25,31.177 +2020-10-11 19:45:00,84.98,86.675,50.25,31.177 +2020-10-11 20:00:00,82.24,87.521,44.265,31.177 +2020-10-11 20:15:00,88.55,86.537,44.265,31.177 +2020-10-11 20:30:00,87.01,85.499,44.265,31.177 +2020-10-11 20:45:00,88.49,83.41799999999999,44.265,31.177 +2020-10-11 21:00:00,79.77,81.342,39.717,31.177 +2020-10-11 21:15:00,77.18,82.494,39.717,31.177 +2020-10-11 21:30:00,75.52,81.98899999999999,39.717,31.177 +2020-10-11 21:45:00,74.79,80.236,39.717,31.177 +2020-10-11 22:00:00,70.87,78.767,39.224000000000004,31.177 +2020-10-11 22:15:00,77.05,76.087,39.224000000000004,31.177 +2020-10-11 22:30:00,76.79,71.846,39.224000000000004,31.177 +2020-10-11 22:45:00,77.47,68.562,39.224000000000004,31.177 +2020-10-11 23:00:00,67.29,64.157,33.518,31.177 +2020-10-11 23:15:00,67.19,62.933,33.518,31.177 +2020-10-11 23:30:00,72.95,61.476000000000006,33.518,31.177 +2020-10-11 23:45:00,73.21,60.711999999999996,33.518,31.177 +2020-10-12 00:00:00,70.94,53.006,34.301,31.349 +2020-10-12 00:15:00,68.42,53.067,34.301,31.349 +2020-10-12 00:30:00,68.11,52.773999999999994,34.301,31.349 +2020-10-12 00:45:00,72.11,52.558,34.301,31.349 +2020-10-12 01:00:00,70.36,53.288999999999994,34.143,31.349 +2020-10-12 01:15:00,68.12,52.99100000000001,34.143,31.349 +2020-10-12 01:30:00,65.37,52.393,34.143,31.349 +2020-10-12 01:45:00,69.82,52.073,34.143,31.349 +2020-10-12 02:00:00,69.15,52.861999999999995,33.650999999999996,31.349 +2020-10-12 02:15:00,70.12,52.422,33.650999999999996,31.349 +2020-10-12 02:30:00,66.23,53.29,33.650999999999996,31.349 +2020-10-12 02:45:00,70.36,53.729,33.650999999999996,31.349 +2020-10-12 03:00:00,72.15,56.57899999999999,32.599000000000004,31.349 +2020-10-12 03:15:00,72.16,57.313,32.599000000000004,31.349 +2020-10-12 03:30:00,69.17,57.236000000000004,32.599000000000004,31.349 +2020-10-12 03:45:00,76.19,58.367,32.599000000000004,31.349 +2020-10-12 04:00:00,82.36,68.183,33.785,31.349 +2020-10-12 04:15:00,85.74,77.285,33.785,31.349 +2020-10-12 04:30:00,84.49,76.767,33.785,31.349 +2020-10-12 04:45:00,89.3,77.73,33.785,31.349 +2020-10-12 05:00:00,101.52,101.051,41.285,31.349 +2020-10-12 05:15:00,106.64,123.691,41.285,31.349 +2020-10-12 05:30:00,106.95,118.056,41.285,31.349 +2020-10-12 05:45:00,106.85,110.37,41.285,31.349 +2020-10-12 06:00:00,115.15,109.96799999999999,60.486000000000004,31.349 +2020-10-12 06:15:00,115.95,113.00200000000001,60.486000000000004,31.349 +2020-10-12 06:30:00,115.89,111.90100000000001,60.486000000000004,31.349 +2020-10-12 06:45:00,118.2,113.29,60.486000000000004,31.349 +2020-10-12 07:00:00,121.53,113.51899999999999,74.012,31.349 +2020-10-12 07:15:00,122.2,116.09200000000001,74.012,31.349 +2020-10-12 07:30:00,118.03,116.274,74.012,31.349 +2020-10-12 07:45:00,116.52,116.95,74.012,31.349 +2020-10-12 08:00:00,114.57,115.436,69.569,31.349 +2020-10-12 08:15:00,114.1,116.088,69.569,31.349 +2020-10-12 08:30:00,114.31,113.955,69.569,31.349 +2020-10-12 08:45:00,116.52,113.573,69.569,31.349 +2020-10-12 09:00:00,111.0,109.07,66.152,31.349 +2020-10-12 09:15:00,110.6,106.264,66.152,31.349 +2020-10-12 09:30:00,110.4,105.881,66.152,31.349 +2020-10-12 09:45:00,110.21,104.867,66.152,31.349 +2020-10-12 10:00:00,109.5,102.59200000000001,62.923,31.349 +2020-10-12 10:15:00,116.29,102.26299999999999,62.923,31.349 +2020-10-12 10:30:00,112.61,101.021,62.923,31.349 +2020-10-12 10:45:00,114.5,100.115,62.923,31.349 +2020-10-12 11:00:00,111.52,96.26700000000001,61.522,31.349 +2020-10-12 11:15:00,115.75,96.70100000000001,61.522,31.349 +2020-10-12 11:30:00,110.56,98.014,61.522,31.349 +2020-10-12 11:45:00,105.29,97.54,61.522,31.349 +2020-10-12 12:00:00,103.06,94.429,58.632,31.349 +2020-10-12 12:15:00,103.27,93.98200000000001,58.632,31.349 +2020-10-12 12:30:00,99.5,92.764,58.632,31.349 +2020-10-12 12:45:00,102.59,92.81299999999999,58.632,31.349 +2020-10-12 13:00:00,103.02,92.575,59.06,31.349 +2020-10-12 13:15:00,104.16,91.713,59.06,31.349 +2020-10-12 13:30:00,101.81,90.331,59.06,31.349 +2020-10-12 13:45:00,102.12,90.304,59.06,31.349 +2020-10-12 14:00:00,104.85,89.085,59.791000000000004,31.349 +2020-10-12 14:15:00,111.72,89.54899999999999,59.791000000000004,31.349 +2020-10-12 14:30:00,108.5,88.586,59.791000000000004,31.349 +2020-10-12 14:45:00,107.1,88.87,59.791000000000004,31.349 +2020-10-12 15:00:00,107.33,88.921,61.148,31.349 +2020-10-12 15:15:00,107.86,88.06700000000001,61.148,31.349 +2020-10-12 15:30:00,108.23,88.7,61.148,31.349 +2020-10-12 15:45:00,110.3,88.81700000000001,61.148,31.349 +2020-10-12 16:00:00,111.96,88.654,66.009,31.349 +2020-10-12 16:15:00,114.55,88.006,66.009,31.349 +2020-10-12 16:30:00,114.39,88.575,66.009,31.349 +2020-10-12 16:45:00,116.45,86.70299999999999,66.009,31.349 +2020-10-12 17:00:00,117.5,86.335,73.683,31.349 +2020-10-12 17:15:00,116.77,87.499,73.683,31.349 +2020-10-12 17:30:00,118.94,87.204,73.683,31.349 +2020-10-12 17:45:00,119.82,88.29700000000001,73.683,31.349 +2020-10-12 18:00:00,121.77,88.435,72.848,31.349 +2020-10-12 18:15:00,121.95,87.51100000000001,72.848,31.349 +2020-10-12 18:30:00,119.5,86.93299999999999,72.848,31.349 +2020-10-12 18:45:00,116.12,90.29799999999999,72.848,31.349 +2020-10-12 19:00:00,110.92,91.43799999999999,71.139,31.349 +2020-10-12 19:15:00,106.85,90.26799999999999,71.139,31.349 +2020-10-12 19:30:00,103.85,90.03200000000001,71.139,31.349 +2020-10-12 19:45:00,107.99,89.679,71.139,31.349 +2020-10-12 20:00:00,105.84,88.89,69.667,31.349 +2020-10-12 20:15:00,105.89,87.679,69.667,31.349 +2020-10-12 20:30:00,98.58,86.147,69.667,31.349 +2020-10-12 20:45:00,96.33,84.819,69.667,31.349 +2020-10-12 21:00:00,91.25,82.61399999999999,61.166000000000004,31.349 +2020-10-12 21:15:00,90.87,83.53299999999999,61.166000000000004,31.349 +2020-10-12 21:30:00,91.81,82.939,61.166000000000004,31.349 +2020-10-12 21:45:00,92.26,80.832,61.166000000000004,31.349 +2020-10-12 22:00:00,88.19,77.041,52.772,31.349 +2020-10-12 22:15:00,87.73,74.929,52.772,31.349 +2020-10-12 22:30:00,87.11,64.431,52.772,31.349 +2020-10-12 22:45:00,87.25,59.202,52.772,31.349 +2020-10-12 23:00:00,79.67,55.16,45.136,31.349 +2020-10-12 23:15:00,78.21,54.04,45.136,31.349 +2020-10-12 23:30:00,80.76,53.67,45.136,31.349 +2020-10-12 23:45:00,81.41,53.657,45.136,31.349 +2020-10-13 00:00:00,73.81,51.739,47.35,31.349 +2020-10-13 00:15:00,72.3,52.842,47.35,31.349 +2020-10-13 00:30:00,74.12,52.604,47.35,31.349 +2020-10-13 00:45:00,77.97,52.465,47.35,31.349 +2020-10-13 01:00:00,75.1,52.812,43.424,31.349 +2020-10-13 01:15:00,74.15,52.428000000000004,43.424,31.349 +2020-10-13 01:30:00,75.85,51.838,43.424,31.349 +2020-10-13 01:45:00,77.72,51.335,43.424,31.349 +2020-10-13 02:00:00,77.58,51.851000000000006,41.778999999999996,31.349 +2020-10-13 02:15:00,75.84,52.056000000000004,41.778999999999996,31.349 +2020-10-13 02:30:00,75.85,52.452,41.778999999999996,31.349 +2020-10-13 02:45:00,75.3,53.08,41.778999999999996,31.349 +2020-10-13 03:00:00,79.74,55.13399999999999,40.771,31.349 +2020-10-13 03:15:00,78.15,56.108000000000004,40.771,31.349 +2020-10-13 03:30:00,75.52,56.201,40.771,31.349 +2020-10-13 03:45:00,75.48,56.739,40.771,31.349 +2020-10-13 04:00:00,77.47,65.77199999999999,41.816,31.349 +2020-10-13 04:15:00,82.06,74.781,41.816,31.349 +2020-10-13 04:30:00,90.51,74.07600000000001,41.816,31.349 +2020-10-13 04:45:00,97.73,75.794,41.816,31.349 +2020-10-13 05:00:00,105.85,102.00200000000001,45.842,31.349 +2020-10-13 05:15:00,102.75,125.023,45.842,31.349 +2020-10-13 05:30:00,106.37,118.916,45.842,31.349 +2020-10-13 05:45:00,110.98,110.75399999999999,45.842,31.349 +2020-10-13 06:00:00,119.85,110.51,59.12,31.349 +2020-10-13 06:15:00,119.21,114.292,59.12,31.349 +2020-10-13 06:30:00,118.8,112.76899999999999,59.12,31.349 +2020-10-13 06:45:00,121.82,113.495,59.12,31.349 +2020-10-13 07:00:00,123.79,113.73899999999999,70.33,31.349 +2020-10-13 07:15:00,123.79,116.11399999999999,70.33,31.349 +2020-10-13 07:30:00,123.33,116.124,70.33,31.349 +2020-10-13 07:45:00,123.65,116.325,70.33,31.349 +2020-10-13 08:00:00,123.2,114.829,67.788,31.349 +2020-10-13 08:15:00,123.26,114.786,67.788,31.349 +2020-10-13 08:30:00,124.04,112.699,67.788,31.349 +2020-10-13 08:45:00,124.33,111.66799999999999,67.788,31.349 +2020-10-13 09:00:00,122.91,107.008,62.622,31.349 +2020-10-13 09:15:00,121.43,104.785,62.622,31.349 +2020-10-13 09:30:00,119.64,105.04799999999999,62.622,31.349 +2020-10-13 09:45:00,119.0,104.652,62.622,31.349 +2020-10-13 10:00:00,118.75,101.374,60.887,31.349 +2020-10-13 10:15:00,118.55,100.47,60.887,31.349 +2020-10-13 10:30:00,116.94,99.303,60.887,31.349 +2020-10-13 10:45:00,119.41,99.036,60.887,31.349 +2020-10-13 11:00:00,113.99,95.845,59.812,31.349 +2020-10-13 11:15:00,112.18,96.32600000000001,59.812,31.349 +2020-10-13 11:30:00,114.67,96.523,59.812,31.349 +2020-10-13 11:45:00,109.79,96.10600000000001,59.812,31.349 +2020-10-13 12:00:00,105.65,92.26,56.614,31.349 +2020-10-13 12:15:00,104.47,91.789,56.614,31.349 +2020-10-13 12:30:00,103.49,91.37299999999999,56.614,31.349 +2020-10-13 12:45:00,102.45,91.63799999999999,56.614,31.349 +2020-10-13 13:00:00,102.46,91.0,56.824,31.349 +2020-10-13 13:15:00,105.71,90.90700000000001,56.824,31.349 +2020-10-13 13:30:00,106.9,90.038,56.824,31.349 +2020-10-13 13:45:00,109.76,89.601,56.824,31.349 +2020-10-13 14:00:00,110.65,88.68700000000001,57.623999999999995,31.349 +2020-10-13 14:15:00,111.37,89.135,57.623999999999995,31.349 +2020-10-13 14:30:00,111.33,88.661,57.623999999999995,31.349 +2020-10-13 14:45:00,111.96,88.50200000000001,57.623999999999995,31.349 +2020-10-13 15:00:00,111.31,88.243,59.724,31.349 +2020-10-13 15:15:00,107.85,88.03,59.724,31.349 +2020-10-13 15:30:00,108.03,88.694,59.724,31.349 +2020-10-13 15:45:00,111.58,88.821,59.724,31.349 +2020-10-13 16:00:00,111.54,88.443,61.64,31.349 +2020-10-13 16:15:00,111.16,88.069,61.64,31.349 +2020-10-13 16:30:00,113.67,88.71700000000001,61.64,31.349 +2020-10-13 16:45:00,116.95,87.404,61.64,31.349 +2020-10-13 17:00:00,118.92,87.344,68.962,31.349 +2020-10-13 17:15:00,119.99,88.766,68.962,31.349 +2020-10-13 17:30:00,122.98,88.524,68.962,31.349 +2020-10-13 17:45:00,123.02,89.40299999999999,68.962,31.349 +2020-10-13 18:00:00,123.95,88.95,69.149,31.349 +2020-10-13 18:15:00,123.41,88.626,69.149,31.349 +2020-10-13 18:30:00,120.16,87.787,69.149,31.349 +2020-10-13 18:45:00,119.43,91.367,69.149,31.349 +2020-10-13 19:00:00,114.42,91.887,68.832,31.349 +2020-10-13 19:15:00,112.4,90.699,68.832,31.349 +2020-10-13 19:30:00,115.3,90.065,68.832,31.349 +2020-10-13 19:45:00,110.17,89.897,68.832,31.349 +2020-10-13 20:00:00,100.53,89.419,66.403,31.349 +2020-10-13 20:15:00,101.98,87.134,66.403,31.349 +2020-10-13 20:30:00,100.14,86.008,66.403,31.349 +2020-10-13 20:45:00,100.17,84.654,66.403,31.349 +2020-10-13 21:00:00,98.31,82.654,57.352,31.349 +2020-10-13 21:15:00,99.67,83.098,57.352,31.349 +2020-10-13 21:30:00,94.62,82.321,57.352,31.349 +2020-10-13 21:45:00,94.12,80.389,57.352,31.349 +2020-10-13 22:00:00,86.01,77.366,51.148999999999994,31.349 +2020-10-13 22:15:00,88.4,74.939,51.148999999999994,31.349 +2020-10-13 22:30:00,85.44,64.658,51.148999999999994,31.349 +2020-10-13 22:45:00,86.13,59.544,51.148999999999994,31.349 +2020-10-13 23:00:00,77.28,55.103,41.8,31.349 +2020-10-13 23:15:00,80.43,54.472,41.8,31.349 +2020-10-13 23:30:00,81.88,53.957,41.8,31.349 +2020-10-13 23:45:00,80.49,53.865,41.8,31.349 +2020-10-14 00:00:00,73.56,52.053000000000004,42.269,31.349 +2020-10-14 00:15:00,77.84,53.146,42.269,31.349 +2020-10-14 00:30:00,78.77,52.915,42.269,31.349 +2020-10-14 00:45:00,79.64,52.773999999999994,42.269,31.349 +2020-10-14 01:00:00,73.44,53.123999999999995,38.527,31.349 +2020-10-14 01:15:00,70.94,52.76,38.527,31.349 +2020-10-14 01:30:00,68.57,52.188,38.527,31.349 +2020-10-14 01:45:00,76.68,51.684,38.527,31.349 +2020-10-14 02:00:00,78.54,52.208,36.393,31.349 +2020-10-14 02:15:00,78.33,52.428000000000004,36.393,31.349 +2020-10-14 02:30:00,74.42,52.806000000000004,36.393,31.349 +2020-10-14 02:45:00,75.3,53.431000000000004,36.393,31.349 +2020-10-14 03:00:00,80.29,55.471000000000004,36.167,31.349 +2020-10-14 03:15:00,82.02,56.465,36.167,31.349 +2020-10-14 03:30:00,80.68,56.562,36.167,31.349 +2020-10-14 03:45:00,79.09,57.08,36.167,31.349 +2020-10-14 04:00:00,83.44,66.143,38.092,31.349 +2020-10-14 04:15:00,90.73,75.185,38.092,31.349 +2020-10-14 04:30:00,92.79,74.482,38.092,31.349 +2020-10-14 04:45:00,97.44,76.208,38.092,31.349 +2020-10-14 05:00:00,100.81,102.50299999999999,42.268,31.349 +2020-10-14 05:15:00,104.2,125.611,42.268,31.349 +2020-10-14 05:30:00,107.8,119.486,42.268,31.349 +2020-10-14 05:45:00,107.29,111.281,42.268,31.349 +2020-10-14 06:00:00,119.76,111.015,60.158,31.349 +2020-10-14 06:15:00,120.14,114.814,60.158,31.349 +2020-10-14 06:30:00,119.69,113.304,60.158,31.349 +2020-10-14 06:45:00,121.56,114.03200000000001,60.158,31.349 +2020-10-14 07:00:00,125.08,114.275,74.792,31.349 +2020-10-14 07:15:00,123.17,116.663,74.792,31.349 +2020-10-14 07:30:00,121.58,116.706,74.792,31.349 +2020-10-14 07:45:00,120.8,116.906,74.792,31.349 +2020-10-14 08:00:00,118.68,115.421,70.499,31.349 +2020-10-14 08:15:00,118.45,115.34899999999999,70.499,31.349 +2020-10-14 08:30:00,117.08,113.28399999999999,70.499,31.349 +2020-10-14 08:45:00,117.25,112.23,70.499,31.349 +2020-10-14 09:00:00,115.59,107.568,68.892,31.349 +2020-10-14 09:15:00,114.95,105.34,68.892,31.349 +2020-10-14 09:30:00,114.97,105.587,68.892,31.349 +2020-10-14 09:45:00,113.19,105.165,68.892,31.349 +2020-10-14 10:00:00,112.69,101.88,66.88600000000001,31.349 +2020-10-14 10:15:00,113.46,100.93700000000001,66.88600000000001,31.349 +2020-10-14 10:30:00,111.52,99.751,66.88600000000001,31.349 +2020-10-14 10:45:00,111.35,99.469,66.88600000000001,31.349 +2020-10-14 11:00:00,109.19,96.285,66.187,31.349 +2020-10-14 11:15:00,108.97,96.74799999999999,66.187,31.349 +2020-10-14 11:30:00,109.66,96.945,66.187,31.349 +2020-10-14 11:45:00,107.61,96.51100000000001,66.187,31.349 +2020-10-14 12:00:00,105.62,92.641,62.18,31.349 +2020-10-14 12:15:00,107.74,92.162,62.18,31.349 +2020-10-14 12:30:00,102.93,91.781,62.18,31.349 +2020-10-14 12:45:00,105.05,92.044,62.18,31.349 +2020-10-14 13:00:00,103.94,91.37299999999999,62.23,31.349 +2020-10-14 13:15:00,104.45,91.285,62.23,31.349 +2020-10-14 13:30:00,103.05,90.414,62.23,31.349 +2020-10-14 13:45:00,104.96,89.976,62.23,31.349 +2020-10-14 14:00:00,105.25,89.01100000000001,63.721000000000004,31.349 +2020-10-14 14:15:00,105.9,89.475,63.721000000000004,31.349 +2020-10-14 14:30:00,106.58,89.037,63.721000000000004,31.349 +2020-10-14 14:45:00,106.09,88.876,63.721000000000004,31.349 +2020-10-14 15:00:00,106.28,88.58200000000001,66.523,31.349 +2020-10-14 15:15:00,106.7,88.389,66.523,31.349 +2020-10-14 15:30:00,107.34,89.088,66.523,31.349 +2020-10-14 15:45:00,109.23,89.23,66.523,31.349 +2020-10-14 16:00:00,110.31,88.816,69.679,31.349 +2020-10-14 16:15:00,111.71,88.462,69.679,31.349 +2020-10-14 16:30:00,113.73,89.105,69.679,31.349 +2020-10-14 16:45:00,116.27,87.84700000000001,69.679,31.349 +2020-10-14 17:00:00,121.1,87.74600000000001,75.04,31.349 +2020-10-14 17:15:00,119.15,89.189,75.04,31.349 +2020-10-14 17:30:00,125.29,88.95,75.04,31.349 +2020-10-14 17:45:00,126.05,89.851,75.04,31.349 +2020-10-14 18:00:00,126.68,89.385,75.915,31.349 +2020-10-14 18:15:00,123.93,89.046,75.915,31.349 +2020-10-14 18:30:00,123.69,88.21799999999999,75.915,31.349 +2020-10-14 18:45:00,120.41,91.794,75.915,31.349 +2020-10-14 19:00:00,120.5,92.323,74.66,31.349 +2020-10-14 19:15:00,119.72,91.131,74.66,31.349 +2020-10-14 19:30:00,116.45,90.488,74.66,31.349 +2020-10-14 19:45:00,107.96,90.303,74.66,31.349 +2020-10-14 20:00:00,107.34,89.848,71.204,31.349 +2020-10-14 20:15:00,109.49,87.55799999999999,71.204,31.349 +2020-10-14 20:30:00,100.41,86.404,71.204,31.349 +2020-10-14 20:45:00,104.83,85.022,71.204,31.349 +2020-10-14 21:00:00,93.46,83.021,61.052,31.349 +2020-10-14 21:15:00,92.85,83.454,61.052,31.349 +2020-10-14 21:30:00,93.83,82.68700000000001,61.052,31.349 +2020-10-14 21:45:00,95.35,80.72800000000001,61.052,31.349 +2020-10-14 22:00:00,90.21,77.693,54.691,31.349 +2020-10-14 22:15:00,86.19,75.244,54.691,31.349 +2020-10-14 22:30:00,79.18,64.979,54.691,31.349 +2020-10-14 22:45:00,83.68,59.871,54.691,31.349 +2020-10-14 23:00:00,81.26,55.456,45.18,31.349 +2020-10-14 23:15:00,82.45,54.794,45.18,31.349 +2020-10-14 23:30:00,76.29,54.28,45.18,31.349 +2020-10-14 23:45:00,79.49,54.183,45.18,31.349 +2020-10-15 00:00:00,78.17,52.367,42.746,31.349 +2020-10-15 00:15:00,79.69,53.452,42.746,31.349 +2020-10-15 00:30:00,76.7,53.227,42.746,31.349 +2020-10-15 00:45:00,80.0,53.082,42.746,31.349 +2020-10-15 01:00:00,77.04,53.437,40.025999999999996,31.349 +2020-10-15 01:15:00,76.62,53.093999999999994,40.025999999999996,31.349 +2020-10-15 01:30:00,72.38,52.538999999999994,40.025999999999996,31.349 +2020-10-15 01:45:00,72.39,52.033,40.025999999999996,31.349 +2020-10-15 02:00:00,75.25,52.565,38.154,31.349 +2020-10-15 02:15:00,79.25,52.799,38.154,31.349 +2020-10-15 02:30:00,80.42,53.161,38.154,31.349 +2020-10-15 02:45:00,76.0,53.781000000000006,38.154,31.349 +2020-10-15 03:00:00,78.42,55.809,37.575,31.349 +2020-10-15 03:15:00,81.16,56.821000000000005,37.575,31.349 +2020-10-15 03:30:00,82.38,56.92100000000001,37.575,31.349 +2020-10-15 03:45:00,80.84,57.42100000000001,37.575,31.349 +2020-10-15 04:00:00,86.29,66.514,39.154,31.349 +2020-10-15 04:15:00,91.49,75.59,39.154,31.349 +2020-10-15 04:30:00,94.3,74.887,39.154,31.349 +2020-10-15 04:45:00,97.08,76.622,39.154,31.349 +2020-10-15 05:00:00,98.74,103.00299999999999,44.085,31.349 +2020-10-15 05:15:00,105.55,126.2,44.085,31.349 +2020-10-15 05:30:00,107.13,120.055,44.085,31.349 +2020-10-15 05:45:00,111.73,111.80799999999999,44.085,31.349 +2020-10-15 06:00:00,119.93,111.52,57.49,31.349 +2020-10-15 06:15:00,118.79,115.337,57.49,31.349 +2020-10-15 06:30:00,120.03,113.837,57.49,31.349 +2020-10-15 06:45:00,122.37,114.57,57.49,31.349 +2020-10-15 07:00:00,124.75,114.81200000000001,73.617,31.349 +2020-10-15 07:15:00,124.91,117.212,73.617,31.349 +2020-10-15 07:30:00,125.12,117.286,73.617,31.349 +2020-10-15 07:45:00,121.11,117.48700000000001,73.617,31.349 +2020-10-15 08:00:00,118.19,116.01,69.281,31.349 +2020-10-15 08:15:00,117.96,115.90899999999999,69.281,31.349 +2020-10-15 08:30:00,122.14,113.867,69.281,31.349 +2020-10-15 08:45:00,122.14,112.792,69.281,31.349 +2020-10-15 09:00:00,120.75,108.12799999999999,63.926,31.349 +2020-10-15 09:15:00,121.83,105.895,63.926,31.349 +2020-10-15 09:30:00,123.0,106.12700000000001,63.926,31.349 +2020-10-15 09:45:00,123.77,105.677,63.926,31.349 +2020-10-15 10:00:00,117.9,102.385,59.442,31.349 +2020-10-15 10:15:00,119.2,101.40299999999999,59.442,31.349 +2020-10-15 10:30:00,115.6,100.199,59.442,31.349 +2020-10-15 10:45:00,114.68,99.9,59.442,31.349 +2020-10-15 11:00:00,111.42,96.72399999999999,56.771,31.349 +2020-10-15 11:15:00,110.71,97.169,56.771,31.349 +2020-10-15 11:30:00,111.45,97.36399999999999,56.771,31.349 +2020-10-15 11:45:00,110.73,96.916,56.771,31.349 +2020-10-15 12:00:00,108.08,93.021,53.701,31.349 +2020-10-15 12:15:00,108.8,92.53399999999999,53.701,31.349 +2020-10-15 12:30:00,106.76,92.189,53.701,31.349 +2020-10-15 12:45:00,106.52,92.447,53.701,31.349 +2020-10-15 13:00:00,106.01,91.74600000000001,52.364,31.349 +2020-10-15 13:15:00,105.75,91.661,52.364,31.349 +2020-10-15 13:30:00,107.45,90.788,52.364,31.349 +2020-10-15 13:45:00,110.96,90.34899999999999,52.364,31.349 +2020-10-15 14:00:00,110.78,89.334,53.419,31.349 +2020-10-15 14:15:00,109.56,89.81299999999999,53.419,31.349 +2020-10-15 14:30:00,111.28,89.411,53.419,31.349 +2020-10-15 14:45:00,109.87,89.24700000000001,53.419,31.349 +2020-10-15 15:00:00,111.22,88.921,56.744,31.349 +2020-10-15 15:15:00,111.54,88.74600000000001,56.744,31.349 +2020-10-15 15:30:00,111.77,89.48200000000001,56.744,31.349 +2020-10-15 15:45:00,113.05,89.63799999999999,56.744,31.349 +2020-10-15 16:00:00,116.56,89.189,60.458,31.349 +2020-10-15 16:15:00,120.2,88.854,60.458,31.349 +2020-10-15 16:30:00,122.91,89.492,60.458,31.349 +2020-10-15 16:45:00,122.73,88.289,60.458,31.349 +2020-10-15 17:00:00,126.75,88.146,66.295,31.349 +2020-10-15 17:15:00,126.46,89.611,66.295,31.349 +2020-10-15 17:30:00,128.48,89.376,66.295,31.349 +2020-10-15 17:45:00,124.9,90.3,66.295,31.349 +2020-10-15 18:00:00,127.14,89.82,68.468,31.349 +2020-10-15 18:15:00,123.0,89.46700000000001,68.468,31.349 +2020-10-15 18:30:00,124.51,88.649,68.468,31.349 +2020-10-15 18:45:00,120.83,92.22,68.468,31.349 +2020-10-15 19:00:00,114.09,92.759,66.39399999999999,31.349 +2020-10-15 19:15:00,112.02,91.56299999999999,66.39399999999999,31.349 +2020-10-15 19:30:00,107.14,90.911,66.39399999999999,31.349 +2020-10-15 19:45:00,113.12,90.709,66.39399999999999,31.349 +2020-10-15 20:00:00,108.37,90.277,63.183,31.349 +2020-10-15 20:15:00,108.7,87.98200000000001,63.183,31.349 +2020-10-15 20:30:00,99.66,86.8,63.183,31.349 +2020-10-15 20:45:00,101.56,85.389,63.183,31.349 +2020-10-15 21:00:00,93.27,83.387,55.133,31.349 +2020-10-15 21:15:00,93.41,83.809,55.133,31.349 +2020-10-15 21:30:00,93.15,83.053,55.133,31.349 +2020-10-15 21:45:00,94.83,81.067,55.133,31.349 +2020-10-15 22:00:00,90.93,78.02,50.111999999999995,31.349 +2020-10-15 22:15:00,84.87,75.54899999999999,50.111999999999995,31.349 +2020-10-15 22:30:00,80.83,65.3,50.111999999999995,31.349 +2020-10-15 22:45:00,85.9,60.198,50.111999999999995,31.349 +2020-10-15 23:00:00,83.4,55.809,44.536,31.349 +2020-10-15 23:15:00,83.04,55.11600000000001,44.536,31.349 +2020-10-15 23:30:00,78.5,54.603,44.536,31.349 +2020-10-15 23:45:00,78.94,54.5,44.536,31.349 +2020-10-16 00:00:00,78.53,56.543,42.291000000000004,31.349 +2020-10-16 00:15:00,80.34,58.228,42.291000000000004,31.349 +2020-10-16 00:30:00,75.11,57.871,42.291000000000004,31.349 +2020-10-16 00:45:00,78.3,57.95399999999999,42.291000000000004,31.349 +2020-10-16 01:00:00,77.43,58.34,41.008,31.349 +2020-10-16 01:15:00,79.28,57.986000000000004,41.008,31.349 +2020-10-16 01:30:00,77.29,57.566,41.008,31.349 +2020-10-16 01:45:00,76.13,57.258,41.008,31.349 +2020-10-16 02:00:00,78.43,58.57,39.521,31.349 +2020-10-16 02:15:00,78.48,58.61600000000001,39.521,31.349 +2020-10-16 02:30:00,76.3,59.912,39.521,31.349 +2020-10-16 02:45:00,72.93,60.25899999999999,39.521,31.349 +2020-10-16 03:00:00,75.99,62.902,39.812,31.349 +2020-10-16 03:15:00,78.35,63.992,39.812,31.349 +2020-10-16 03:30:00,83.32,64.1,39.812,31.349 +2020-10-16 03:45:00,87.67,65.527,39.812,31.349 +2020-10-16 04:00:00,86.71,75.56,41.22,31.349 +2020-10-16 04:15:00,87.83,84.14,41.22,31.349 +2020-10-16 04:30:00,93.37,84.585,41.22,31.349 +2020-10-16 04:45:00,97.81,85.63799999999999,41.22,31.349 +2020-10-16 05:00:00,105.43,113.375,45.115,31.349 +2020-10-16 05:15:00,108.97,139.797,45.115,31.349 +2020-10-16 05:30:00,110.44,134.631,45.115,31.349 +2020-10-16 05:45:00,116.33,125.77,45.115,31.349 +2020-10-16 06:00:00,121.67,125.60799999999999,59.06100000000001,31.349 +2020-10-16 06:15:00,121.67,129.158,59.06100000000001,31.349 +2020-10-16 06:30:00,123.2,127.425,59.06100000000001,31.349 +2020-10-16 06:45:00,125.24,128.667,59.06100000000001,31.349 +2020-10-16 07:00:00,129.04,129.357,71.874,31.349 +2020-10-16 07:15:00,129.43,132.971,71.874,31.349 +2020-10-16 07:30:00,129.86,132.234,71.874,31.349 +2020-10-16 07:45:00,129.09,131.789,71.874,31.349 +2020-10-16 08:00:00,129.38,131.43,68.439,31.349 +2020-10-16 08:15:00,130.59,131.24200000000002,68.439,31.349 +2020-10-16 08:30:00,135.1,129.144,68.439,31.349 +2020-10-16 08:45:00,135.29,126.51,68.439,31.349 +2020-10-16 09:00:00,133.1,121.61399999999999,65.523,31.349 +2020-10-16 09:15:00,134.37,120.37200000000001,65.523,31.349 +2020-10-16 09:30:00,133.1,119.85799999999999,65.523,31.349 +2020-10-16 09:45:00,136.75,119.541,65.523,31.349 +2020-10-16 10:00:00,133.64,115.68799999999999,62.005,31.349 +2020-10-16 10:15:00,134.38,115.005,62.005,31.349 +2020-10-16 10:30:00,133.95,113.708,62.005,31.349 +2020-10-16 10:45:00,134.02,113.04,62.005,31.349 +2020-10-16 11:00:00,132.18,109.76799999999999,60.351000000000006,31.349 +2020-10-16 11:15:00,131.6,109.25299999999999,60.351000000000006,31.349 +2020-10-16 11:30:00,132.82,110.32799999999999,60.351000000000006,31.349 +2020-10-16 11:45:00,132.96,110.382,60.351000000000006,31.349 +2020-10-16 12:00:00,127.23,107.589,55.331,31.349 +2020-10-16 12:15:00,125.71,105.316,55.331,31.349 +2020-10-16 12:30:00,122.7,105.105,55.331,31.349 +2020-10-16 12:45:00,120.88,105.475,55.331,31.349 +2020-10-16 13:00:00,119.13,106.29700000000001,53.361999999999995,31.349 +2020-10-16 13:15:00,118.48,106.384,53.361999999999995,31.349 +2020-10-16 13:30:00,117.76,105.999,53.361999999999995,31.349 +2020-10-16 13:45:00,117.81,105.741,53.361999999999995,31.349 +2020-10-16 14:00:00,116.71,104.645,51.708,31.349 +2020-10-16 14:15:00,115.79,104.56,51.708,31.349 +2020-10-16 14:30:00,116.75,104.74799999999999,51.708,31.349 +2020-10-16 14:45:00,114.09,104.55,51.708,31.349 +2020-10-16 15:00:00,111.63,103.62700000000001,54.571000000000005,31.349 +2020-10-16 15:15:00,111.74,103.186,54.571000000000005,31.349 +2020-10-16 15:30:00,111.8,102.095,54.571000000000005,31.349 +2020-10-16 15:45:00,112.85,102.43700000000001,54.571000000000005,31.349 +2020-10-16 16:00:00,116.73,102.41799999999999,58.662,31.349 +2020-10-16 16:15:00,115.35,103.176,58.662,31.349 +2020-10-16 16:30:00,120.57,103.56,58.662,31.349 +2020-10-16 16:45:00,121.41,101.693,58.662,31.349 +2020-10-16 17:00:00,125.43,103.272,65.941,31.349 +2020-10-16 17:15:00,124.15,103.835,65.941,31.349 +2020-10-16 17:30:00,126.8,103.678,65.941,31.349 +2020-10-16 17:45:00,124.08,103.572,65.941,31.349 +2020-10-16 18:00:00,125.15,105.102,65.628,31.349 +2020-10-16 18:15:00,120.47,104.225,65.628,31.349 +2020-10-16 18:30:00,119.06,103.037,65.628,31.349 +2020-10-16 18:45:00,118.12,106.52799999999999,65.628,31.349 +2020-10-16 19:00:00,111.76,108.321,63.662,31.349 +2020-10-16 19:15:00,108.6,107.913,63.662,31.349 +2020-10-16 19:30:00,107.6,106.984,63.662,31.349 +2020-10-16 19:45:00,105.8,105.885,63.662,31.349 +2020-10-16 20:00:00,105.65,102.874,61.945,31.349 +2020-10-16 20:15:00,104.08,100.425,61.945,31.349 +2020-10-16 20:30:00,97.94,99.93700000000001,61.945,31.349 +2020-10-16 20:45:00,91.95,98.415,61.945,31.349 +2020-10-16 21:00:00,89.11,95.71700000000001,53.903,31.349 +2020-10-16 21:15:00,85.85,96.61399999999999,53.903,31.349 +2020-10-16 21:30:00,82.94,95.831,53.903,31.349 +2020-10-16 21:45:00,80.74,93.97200000000001,53.903,31.349 +2020-10-16 22:00:00,77.43,89.986,48.403999999999996,31.349 +2020-10-16 22:15:00,76.58,86.50399999999999,48.403999999999996,31.349 +2020-10-16 22:30:00,73.82,80.395,48.403999999999996,31.349 +2020-10-16 22:45:00,73.45,76.143,48.403999999999996,31.349 +2020-10-16 23:00:00,75.53,70.91199999999999,41.07,31.349 +2020-10-16 23:15:00,75.35,69.029,41.07,31.349 +2020-10-16 23:30:00,75.46,66.437,41.07,31.349 +2020-10-16 23:45:00,67.11,66.324,41.07,31.349 +2020-10-17 00:00:00,64.47,56.047,38.989000000000004,31.177 +2020-10-17 00:15:00,67.85,55.284,38.989000000000004,31.177 +2020-10-17 00:30:00,69.98,55.373000000000005,38.989000000000004,31.177 +2020-10-17 00:45:00,71.35,55.461000000000006,38.989000000000004,31.177 +2020-10-17 01:00:00,65.24,56.316,35.275,31.177 +2020-10-17 01:15:00,64.34,55.773999999999994,35.275,31.177 +2020-10-17 01:30:00,64.87,54.731,35.275,31.177 +2020-10-17 01:45:00,69.92,54.907,35.275,31.177 +2020-10-17 02:00:00,67.08,56.114,32.838,31.177 +2020-10-17 02:15:00,64.62,55.611999999999995,32.838,31.177 +2020-10-17 02:30:00,62.67,55.968,32.838,31.177 +2020-10-17 02:45:00,62.07,56.736999999999995,32.838,31.177 +2020-10-17 03:00:00,61.8,59.028999999999996,32.418,31.177 +2020-10-17 03:15:00,61.68,59.211999999999996,32.418,31.177 +2020-10-17 03:30:00,62.25,58.705,32.418,31.177 +2020-10-17 03:45:00,62.17,60.88399999999999,32.418,31.177 +2020-10-17 04:00:00,63.27,68.046,32.099000000000004,31.177 +2020-10-17 04:15:00,63.3,74.992,32.099000000000004,31.177 +2020-10-17 04:30:00,63.92,73.575,32.099000000000004,31.177 +2020-10-17 04:45:00,65.08,74.53,32.099000000000004,31.177 +2020-10-17 05:00:00,68.03,90.771,32.926,31.177 +2020-10-17 05:15:00,68.43,102.831,32.926,31.177 +2020-10-17 05:30:00,69.72,98.57600000000001,32.926,31.177 +2020-10-17 05:45:00,72.43,94.47,32.926,31.177 +2020-10-17 06:00:00,75.18,108.464,35.069,31.177 +2020-10-17 06:15:00,75.93,123.736,35.069,31.177 +2020-10-17 06:30:00,77.23,118.042,35.069,31.177 +2020-10-17 06:45:00,80.22,113.37700000000001,35.069,31.177 +2020-10-17 07:00:00,83.39,111.169,40.906,31.177 +2020-10-17 07:15:00,84.09,113.566,40.906,31.177 +2020-10-17 07:30:00,85.78,114.92299999999999,40.906,31.177 +2020-10-17 07:45:00,87.06,116.913,40.906,31.177 +2020-10-17 08:00:00,87.85,118.645,46.603,31.177 +2020-10-17 08:15:00,88.48,120.234,46.603,31.177 +2020-10-17 08:30:00,88.17,118.999,46.603,31.177 +2020-10-17 08:45:00,89.51,118.406,46.603,31.177 +2020-10-17 09:00:00,88.81,115.679,49.935,31.177 +2020-10-17 09:15:00,91.01,115.045,49.935,31.177 +2020-10-17 09:30:00,92.06,115.214,49.935,31.177 +2020-10-17 09:45:00,91.72,114.764,49.935,31.177 +2020-10-17 10:00:00,86.24,111.156,47.585,31.177 +2020-10-17 10:15:00,83.34,110.669,47.585,31.177 +2020-10-17 10:30:00,82.45,109.304,47.585,31.177 +2020-10-17 10:45:00,81.32,109.196,47.585,31.177 +2020-10-17 11:00:00,79.02,105.931,43.376999999999995,31.177 +2020-10-17 11:15:00,79.49,105.35600000000001,43.376999999999995,31.177 +2020-10-17 11:30:00,76.81,105.913,43.376999999999995,31.177 +2020-10-17 11:45:00,81.02,105.679,43.376999999999995,31.177 +2020-10-17 12:00:00,75.69,102.375,40.855,31.177 +2020-10-17 12:15:00,78.67,100.795,40.855,31.177 +2020-10-17 12:30:00,77.84,100.75200000000001,40.855,31.177 +2020-10-17 12:45:00,75.43,100.959,40.855,31.177 +2020-10-17 13:00:00,74.06,101.12700000000001,37.251,31.177 +2020-10-17 13:15:00,75.51,99.822,37.251,31.177 +2020-10-17 13:30:00,76.5,99.24700000000001,37.251,31.177 +2020-10-17 13:45:00,76.49,98.74799999999999,37.251,31.177 +2020-10-17 14:00:00,73.63,98.32,38.548,31.177 +2020-10-17 14:15:00,76.38,97.464,38.548,31.177 +2020-10-17 14:30:00,77.84,96.46700000000001,38.548,31.177 +2020-10-17 14:45:00,80.65,96.57700000000001,38.548,31.177 +2020-10-17 15:00:00,81.45,96.179,42.883,31.177 +2020-10-17 15:15:00,81.41,96.49600000000001,42.883,31.177 +2020-10-17 15:30:00,83.3,96.42,42.883,31.177 +2020-10-17 15:45:00,84.57,96.435,42.883,31.177 +2020-10-17 16:00:00,85.25,96.794,48.143,31.177 +2020-10-17 16:15:00,84.79,97.71600000000001,48.143,31.177 +2020-10-17 16:30:00,86.19,98.15799999999999,48.143,31.177 +2020-10-17 16:45:00,89.26,96.83,48.143,31.177 +2020-10-17 17:00:00,95.62,97.73299999999999,55.25,31.177 +2020-10-17 17:15:00,96.78,98.31700000000001,55.25,31.177 +2020-10-17 17:30:00,101.35,98.053,55.25,31.177 +2020-10-17 17:45:00,99.12,97.947,55.25,31.177 +2020-10-17 18:00:00,101.19,99.876,57.506,31.177 +2020-10-17 18:15:00,98.31,100.652,57.506,31.177 +2020-10-17 18:30:00,99.49,100.771,57.506,31.177 +2020-10-17 18:45:00,95.84,100.971,57.506,31.177 +2020-10-17 19:00:00,89.41,102.62799999999999,55.528999999999996,31.177 +2020-10-17 19:15:00,87.46,101.51,55.528999999999996,31.177 +2020-10-17 19:30:00,85.9,101.3,55.528999999999996,31.177 +2020-10-17 19:45:00,84.72,100.851,55.528999999999996,31.177 +2020-10-17 20:00:00,80.41,99.40299999999999,46.166000000000004,31.177 +2020-10-17 20:15:00,80.37,97.781,46.166000000000004,31.177 +2020-10-17 20:30:00,79.26,96.693,46.166000000000004,31.177 +2020-10-17 20:45:00,77.14,95.757,46.166000000000004,31.177 +2020-10-17 21:00:00,73.18,93.601,40.406,31.177 +2020-10-17 21:15:00,72.29,94.561,40.406,31.177 +2020-10-17 21:30:00,70.67,94.51,40.406,31.177 +2020-10-17 21:45:00,69.52,92.175,40.406,31.177 +2020-10-17 22:00:00,66.37,88.87700000000001,39.616,31.177 +2020-10-17 22:15:00,65.35,86.779,39.616,31.177 +2020-10-17 22:30:00,63.44,83.772,39.616,31.177 +2020-10-17 22:45:00,62.35,80.665,39.616,31.177 +2020-10-17 23:00:00,58.53,76.434,32.205,31.177 +2020-10-17 23:15:00,56.67,73.855,32.205,31.177 +2020-10-17 23:30:00,56.68,71.44,32.205,31.177 +2020-10-17 23:45:00,56.38,70.208,32.205,31.177 +2020-10-18 00:00:00,52.9,57.243,28.229,31.177 +2020-10-18 00:15:00,53.22,55.765,28.229,31.177 +2020-10-18 00:30:00,52.88,55.606,28.229,31.177 +2020-10-18 00:45:00,53.61,55.98,28.229,31.177 +2020-10-18 01:00:00,51.32,56.903999999999996,25.669,31.177 +2020-10-18 01:15:00,52.64,56.831,25.669,31.177 +2020-10-18 01:30:00,51.62,55.996,25.669,31.177 +2020-10-18 01:45:00,51.85,55.832,25.669,31.177 +2020-10-18 02:00:00,51.91,56.717,24.948,31.177 +2020-10-18 02:15:00,52.54,56.151,24.948,31.177 +2020-10-18 02:30:00,52.65,56.998000000000005,24.948,31.177 +2020-10-18 02:45:00,52.06,57.849,24.948,31.177 +2020-10-18 03:00:00,51.53,60.59,24.445,31.177 +2020-10-18 03:15:00,52.25,60.667,24.445,31.177 +2020-10-18 03:30:00,52.4,60.486999999999995,24.445,31.177 +2020-10-18 03:45:00,52.91,62.228,24.445,31.177 +2020-10-18 04:00:00,54.69,69.267,25.839000000000002,31.177 +2020-10-18 04:15:00,55.62,75.545,25.839000000000002,31.177 +2020-10-18 04:30:00,56.31,74.807,25.839000000000002,31.177 +2020-10-18 04:45:00,56.9,75.675,25.839000000000002,31.177 +2020-10-18 05:00:00,58.65,90.402,26.803,31.177 +2020-10-18 05:15:00,58.63,101.00399999999999,26.803,31.177 +2020-10-18 05:30:00,59.14,96.463,26.803,31.177 +2020-10-18 05:45:00,57.88,92.30799999999999,26.803,31.177 +2020-10-18 06:00:00,62.76,105.06200000000001,28.147,31.177 +2020-10-18 06:15:00,63.62,119.914,28.147,31.177 +2020-10-18 06:30:00,63.69,113.374,28.147,31.177 +2020-10-18 06:45:00,65.7,107.73,28.147,31.177 +2020-10-18 07:00:00,70.03,106.82,31.116,31.177 +2020-10-18 07:15:00,71.46,108.045,31.116,31.177 +2020-10-18 07:30:00,71.58,109.411,31.116,31.177 +2020-10-18 07:45:00,72.74,111.01,31.116,31.177 +2020-10-18 08:00:00,71.27,113.97399999999999,35.739000000000004,31.177 +2020-10-18 08:15:00,71.83,116.021,35.739000000000004,31.177 +2020-10-18 08:30:00,71.64,116.059,35.739000000000004,31.177 +2020-10-18 08:45:00,73.24,116.448,35.739000000000004,31.177 +2020-10-18 09:00:00,73.13,113.434,39.455999999999996,31.177 +2020-10-18 09:15:00,75.2,112.875,39.455999999999996,31.177 +2020-10-18 09:30:00,75.81,113.135,39.455999999999996,31.177 +2020-10-18 09:45:00,78.43,113.03299999999999,39.455999999999996,31.177 +2020-10-18 10:00:00,74.81,110.97200000000001,41.343999999999994,31.177 +2020-10-18 10:15:00,75.06,110.79299999999999,41.343999999999994,31.177 +2020-10-18 10:30:00,75.48,109.82700000000001,41.343999999999994,31.177 +2020-10-18 10:45:00,76.1,109.087,41.343999999999994,31.177 +2020-10-18 11:00:00,75.4,106.171,43.645,31.177 +2020-10-18 11:15:00,70.54,105.47,43.645,31.177 +2020-10-18 11:30:00,68.91,105.75200000000001,43.645,31.177 +2020-10-18 11:45:00,69.28,105.943,43.645,31.177 +2020-10-18 12:00:00,69.75,102.689,39.796,31.177 +2020-10-18 12:15:00,67.11,101.93700000000001,39.796,31.177 +2020-10-18 12:30:00,67.36,101.18799999999999,39.796,31.177 +2020-10-18 12:45:00,69.13,100.594,39.796,31.177 +2020-10-18 13:00:00,64.24,100.186,36.343,31.177 +2020-10-18 13:15:00,62.42,100.39,36.343,31.177 +2020-10-18 13:30:00,63.26,99.26299999999999,36.343,31.177 +2020-10-18 13:45:00,63.85,98.81700000000001,36.343,31.177 +2020-10-18 14:00:00,61.41,98.94,33.162,31.177 +2020-10-18 14:15:00,61.03,98.955,33.162,31.177 +2020-10-18 14:30:00,62.69,98.22399999999999,33.162,31.177 +2020-10-18 14:45:00,64.11,97.681,33.162,31.177 +2020-10-18 15:00:00,66.4,96.44,33.215,31.177 +2020-10-18 15:15:00,66.52,96.898,33.215,31.177 +2020-10-18 15:30:00,68.51,97.12799999999999,33.215,31.177 +2020-10-18 15:45:00,71.34,97.664,33.215,31.177 +2020-10-18 16:00:00,75.08,98.287,37.385999999999996,31.177 +2020-10-18 16:15:00,76.81,98.823,37.385999999999996,31.177 +2020-10-18 16:30:00,79.34,99.815,37.385999999999996,31.177 +2020-10-18 16:45:00,83.18,98.639,37.385999999999996,31.177 +2020-10-18 17:00:00,89.98,99.574,46.618,31.177 +2020-10-18 17:15:00,92.24,100.639,46.618,31.177 +2020-10-18 17:30:00,94.57,100.874,46.618,31.177 +2020-10-18 17:45:00,94.81,102.181,46.618,31.177 +2020-10-18 18:00:00,98.0,104.02799999999999,50.111000000000004,31.177 +2020-10-18 18:15:00,94.97,105.304,50.111000000000004,31.177 +2020-10-18 18:30:00,93.21,104.22200000000001,50.111000000000004,31.177 +2020-10-18 18:45:00,91.12,105.446,50.111000000000004,31.177 +2020-10-18 19:00:00,88.83,107.86,50.25,31.177 +2020-10-18 19:15:00,92.4,106.557,50.25,31.177 +2020-10-18 19:30:00,93.45,106.14,50.25,31.177 +2020-10-18 19:45:00,92.2,106.221,50.25,31.177 +2020-10-18 20:00:00,82.79,104.83200000000001,44.265,31.177 +2020-10-18 20:15:00,85.44,103.654,44.265,31.177 +2020-10-18 20:30:00,87.59,103.538,44.265,31.177 +2020-10-18 20:45:00,91.02,101.215,44.265,31.177 +2020-10-18 21:00:00,88.07,97.631,39.717,31.177 +2020-10-18 21:15:00,83.42,98.124,39.717,31.177 +2020-10-18 21:30:00,80.07,97.94200000000001,39.717,31.177 +2020-10-18 21:45:00,83.72,95.816,39.717,31.177 +2020-10-18 22:00:00,81.94,92.81299999999999,39.224000000000004,31.177 +2020-10-18 22:15:00,82.38,89.552,39.224000000000004,31.177 +2020-10-18 22:30:00,77.0,84.719,39.224000000000004,31.177 +2020-10-18 22:45:00,77.52,80.611,39.224000000000004,31.177 +2020-10-18 23:00:00,75.69,74.82,33.518,31.177 +2020-10-18 23:15:00,75.04,73.737,33.518,31.177 +2020-10-18 23:30:00,72.22,71.502,33.518,31.177 +2020-10-18 23:45:00,70.95,70.789,33.518,31.177 +2020-10-19 00:00:00,72.57,60.35,34.301,31.349 +2020-10-19 00:15:00,72.05,60.687,34.301,31.349 +2020-10-19 00:30:00,71.7,60.415,34.301,31.349 +2020-10-19 00:45:00,68.05,60.333999999999996,34.301,31.349 +2020-10-19 01:00:00,63.81,61.445,34.143,31.349 +2020-10-19 01:15:00,70.57,61.14,34.143,31.349 +2020-10-19 01:30:00,73.46,60.521,34.143,31.349 +2020-10-19 01:45:00,73.96,60.363,34.143,31.349 +2020-10-19 02:00:00,69.68,61.452,33.650999999999996,31.349 +2020-10-19 02:15:00,71.16,61.118,33.650999999999996,31.349 +2020-10-19 02:30:00,74.31,62.191,33.650999999999996,31.349 +2020-10-19 02:45:00,75.73,62.668,33.650999999999996,31.349 +2020-10-19 03:00:00,74.19,66.23100000000001,32.599000000000004,31.349 +2020-10-19 03:15:00,76.4,67.46600000000001,32.599000000000004,31.349 +2020-10-19 03:30:00,80.44,67.487,32.599000000000004,31.349 +2020-10-19 03:45:00,82.75,68.72399999999999,32.599000000000004,31.349 +2020-10-19 04:00:00,83.01,79.33800000000001,33.785,31.349 +2020-10-19 04:15:00,86.46,89.04,33.785,31.349 +2020-10-19 04:30:00,93.16,89.11200000000001,33.785,31.349 +2020-10-19 04:45:00,99.61,90.245,33.785,31.349 +2020-10-19 05:00:00,103.97,115.891,41.285,31.349 +2020-10-19 05:15:00,106.86,141.102,41.285,31.349 +2020-10-19 05:30:00,111.68,135.92700000000002,41.285,31.349 +2020-10-19 05:45:00,123.4,127.571,41.285,31.349 +2020-10-19 06:00:00,130.7,127.085,60.486000000000004,31.349 +2020-10-19 06:15:00,123.9,130.441,60.486000000000004,31.349 +2020-10-19 06:30:00,124.3,129.692,60.486000000000004,31.349 +2020-10-19 06:45:00,125.37,130.83,60.486000000000004,31.349 +2020-10-19 07:00:00,128.39,131.643,74.012,31.349 +2020-10-19 07:15:00,126.92,134.558,74.012,31.349 +2020-10-19 07:30:00,124.7,135.17,74.012,31.349 +2020-10-19 07:45:00,123.89,135.63299999999998,74.012,31.349 +2020-10-19 08:00:00,122.1,135.503,69.569,31.349 +2020-10-19 08:15:00,120.91,135.888,69.569,31.349 +2020-10-19 08:30:00,123.72,133.418,69.569,31.349 +2020-10-19 08:45:00,126.6,132.155,69.569,31.349 +2020-10-19 09:00:00,123.28,128.308,66.152,31.349 +2020-10-19 09:15:00,122.26,125.132,66.152,31.349 +2020-10-19 09:30:00,120.9,124.45,66.152,31.349 +2020-10-19 09:45:00,122.7,123.491,66.152,31.349 +2020-10-19 10:00:00,123.26,121.242,62.923,31.349 +2020-10-19 10:15:00,123.92,120.78200000000001,62.923,31.349 +2020-10-19 10:30:00,124.65,119.12799999999999,62.923,31.349 +2020-10-19 10:45:00,121.74,118.13,62.923,31.349 +2020-10-19 11:00:00,111.45,113.977,61.522,31.349 +2020-10-19 11:15:00,111.72,114.376,61.522,31.349 +2020-10-19 11:30:00,111.64,115.708,61.522,31.349 +2020-10-19 11:45:00,114.51,115.85799999999999,61.522,31.349 +2020-10-19 12:00:00,109.18,113.042,58.632,31.349 +2020-10-19 12:15:00,109.27,112.32799999999999,58.632,31.349 +2020-10-19 12:30:00,119.09,111.34,58.632,31.349 +2020-10-19 12:45:00,120.49,111.61399999999999,58.632,31.349 +2020-10-19 13:00:00,121.81,111.93299999999999,59.06,31.349 +2020-10-19 13:15:00,123.36,111.008,59.06,31.349 +2020-10-19 13:30:00,124.18,109.641,59.06,31.349 +2020-10-19 13:45:00,123.73,109.555,59.06,31.349 +2020-10-19 14:00:00,128.78,108.941,59.791000000000004,31.349 +2020-10-19 14:15:00,127.05,108.83,59.791000000000004,31.349 +2020-10-19 14:30:00,122.62,107.762,59.791000000000004,31.349 +2020-10-19 14:45:00,122.32,107.991,59.791000000000004,31.349 +2020-10-19 15:00:00,121.8,107.624,61.148,31.349 +2020-10-19 15:15:00,124.23,107.045,61.148,31.349 +2020-10-19 15:30:00,120.17,107.131,61.148,31.349 +2020-10-19 15:45:00,122.07,107.24,61.148,31.349 +2020-10-19 16:00:00,125.4,108.13600000000001,66.009,31.349 +2020-10-19 16:15:00,122.23,108.296,66.009,31.349 +2020-10-19 16:30:00,123.96,108.48,66.009,31.349 +2020-10-19 16:45:00,124.66,106.705,66.009,31.349 +2020-10-19 17:00:00,132.19,106.90799999999999,73.683,31.349 +2020-10-19 17:15:00,128.75,107.635,73.683,31.349 +2020-10-19 17:30:00,131.94,107.412,73.683,31.349 +2020-10-19 17:45:00,128.27,107.76,73.683,31.349 +2020-10-19 18:00:00,132.72,109.30799999999999,72.848,31.349 +2020-10-19 18:15:00,126.09,108.596,72.848,31.349 +2020-10-19 18:30:00,127.58,107.54700000000001,72.848,31.349 +2020-10-19 18:45:00,123.85,110.50200000000001,72.848,31.349 +2020-10-19 19:00:00,120.43,111.916,71.139,31.349 +2020-10-19 19:15:00,112.13,110.569,71.139,31.349 +2020-10-19 19:30:00,108.57,110.238,71.139,31.349 +2020-10-19 19:45:00,108.74,109.59,71.139,31.349 +2020-10-19 20:00:00,106.01,106.426,69.667,31.349 +2020-10-19 20:15:00,109.28,104.56700000000001,69.667,31.349 +2020-10-19 20:30:00,106.18,103.68299999999999,69.667,31.349 +2020-10-19 20:45:00,100.16,102.28200000000001,69.667,31.349 +2020-10-19 21:00:00,94.21,98.705,61.166000000000004,31.349 +2020-10-19 21:15:00,95.43,98.78,61.166000000000004,31.349 +2020-10-19 21:30:00,96.14,98.366,61.166000000000004,31.349 +2020-10-19 21:45:00,94.59,95.859,61.166000000000004,31.349 +2020-10-19 22:00:00,89.3,90.444,52.772,31.349 +2020-10-19 22:15:00,84.72,87.365,52.772,31.349 +2020-10-19 22:30:00,86.82,75.70100000000001,52.772,31.349 +2020-10-19 22:45:00,86.53,69.094,52.772,31.349 +2020-10-19 23:00:00,81.21,63.718,45.136,31.349 +2020-10-19 23:15:00,79.44,63.248999999999995,45.136,31.349 +2020-10-19 23:30:00,81.27,62.427,45.136,31.349 +2020-10-19 23:45:00,78.67,62.832,45.136,31.349 +2020-10-20 00:00:00,75.94,59.277,47.35,31.349 +2020-10-20 00:15:00,72.26,60.708999999999996,47.35,31.349 +2020-10-20 00:30:00,77.13,60.317,47.35,31.349 +2020-10-20 00:45:00,79.34,60.123999999999995,47.35,31.349 +2020-10-20 01:00:00,76.83,60.9,43.424,31.349 +2020-10-20 01:15:00,74.74,60.449,43.424,31.349 +2020-10-20 01:30:00,77.46,59.869,43.424,31.349 +2020-10-20 01:45:00,79.29,59.611999999999995,43.424,31.349 +2020-10-20 02:00:00,74.21,60.479,41.778999999999996,31.349 +2020-10-20 02:15:00,72.87,60.657,41.778999999999996,31.349 +2020-10-20 02:30:00,80.67,61.238,41.778999999999996,31.349 +2020-10-20 02:45:00,80.69,61.875,41.778999999999996,31.349 +2020-10-20 03:00:00,84.16,64.58,40.771,31.349 +2020-10-20 03:15:00,76.84,65.876,40.771,31.349 +2020-10-20 03:30:00,81.98,66.119,40.771,31.349 +2020-10-20 03:45:00,85.52,66.896,40.771,31.349 +2020-10-20 04:00:00,88.83,76.83,41.816,31.349 +2020-10-20 04:15:00,86.61,86.4,41.816,31.349 +2020-10-20 04:30:00,89.0,86.262,41.816,31.349 +2020-10-20 04:45:00,93.57,88.22,41.816,31.349 +2020-10-20 05:00:00,103.8,117.08200000000001,45.842,31.349 +2020-10-20 05:15:00,114.96,142.561,45.842,31.349 +2020-10-20 05:30:00,119.33,136.741,45.842,31.349 +2020-10-20 05:45:00,118.64,127.999,45.842,31.349 +2020-10-20 06:00:00,120.99,127.436,59.12,31.349 +2020-10-20 06:15:00,123.0,131.688,59.12,31.349 +2020-10-20 06:30:00,124.14,130.493,59.12,31.349 +2020-10-20 06:45:00,126.4,131.034,59.12,31.349 +2020-10-20 07:00:00,128.62,131.822,70.33,31.349 +2020-10-20 07:15:00,126.71,134.554,70.33,31.349 +2020-10-20 07:30:00,126.14,134.931,70.33,31.349 +2020-10-20 07:45:00,125.14,135.055,70.33,31.349 +2020-10-20 08:00:00,124.17,134.97,67.788,31.349 +2020-10-20 08:15:00,128.23,134.60299999999998,67.788,31.349 +2020-10-20 08:30:00,129.26,132.143,67.788,31.349 +2020-10-20 08:45:00,129.38,130.308,67.788,31.349 +2020-10-20 09:00:00,127.86,126.161,62.622,31.349 +2020-10-20 09:15:00,129.76,123.774,62.622,31.349 +2020-10-20 09:30:00,128.95,123.736,62.622,31.349 +2020-10-20 09:45:00,129.62,123.212,62.622,31.349 +2020-10-20 10:00:00,130.0,120.056,60.887,31.349 +2020-10-20 10:15:00,130.9,118.914,60.887,31.349 +2020-10-20 10:30:00,130.71,117.34899999999999,60.887,31.349 +2020-10-20 10:45:00,129.56,116.9,60.887,31.349 +2020-10-20 11:00:00,126.57,113.57799999999999,59.812,31.349 +2020-10-20 11:15:00,127.65,113.931,59.812,31.349 +2020-10-20 11:30:00,129.33,114.149,59.812,31.349 +2020-10-20 11:45:00,128.71,114.49700000000001,59.812,31.349 +2020-10-20 12:00:00,126.55,110.807,56.614,31.349 +2020-10-20 12:15:00,128.41,109.98,56.614,31.349 +2020-10-20 12:30:00,126.5,109.773,56.614,31.349 +2020-10-20 12:45:00,126.79,110.145,56.614,31.349 +2020-10-20 13:00:00,125.27,110.044,56.824,31.349 +2020-10-20 13:15:00,127.5,109.61200000000001,56.824,31.349 +2020-10-20 13:30:00,127.16,108.911,56.824,31.349 +2020-10-20 13:45:00,125.66,108.56200000000001,56.824,31.349 +2020-10-20 14:00:00,126.58,108.228,57.623999999999995,31.349 +2020-10-20 14:15:00,125.58,108.148,57.623999999999995,31.349 +2020-10-20 14:30:00,125.54,107.60600000000001,57.623999999999995,31.349 +2020-10-20 14:45:00,124.61,107.48700000000001,57.623999999999995,31.349 +2020-10-20 15:00:00,124.48,106.787,59.724,31.349 +2020-10-20 15:15:00,125.53,106.771,59.724,31.349 +2020-10-20 15:30:00,122.51,106.945,59.724,31.349 +2020-10-20 15:45:00,123.47,106.96700000000001,59.724,31.349 +2020-10-20 16:00:00,124.86,107.792,61.64,31.349 +2020-10-20 16:15:00,126.88,108.274,61.64,31.349 +2020-10-20 16:30:00,127.79,108.666,61.64,31.349 +2020-10-20 16:45:00,130.62,107.39299999999999,61.64,31.349 +2020-10-20 17:00:00,132.63,107.946,68.962,31.349 +2020-10-20 17:15:00,134.24,108.882,68.962,31.349 +2020-10-20 17:30:00,132.03,108.838,68.962,31.349 +2020-10-20 17:45:00,129.1,108.991,68.962,31.349 +2020-10-20 18:00:00,128.24,110.06,69.149,31.349 +2020-10-20 18:15:00,127.87,109.694,69.149,31.349 +2020-10-20 18:30:00,123.22,108.37899999999999,69.149,31.349 +2020-10-20 18:45:00,124.82,111.66799999999999,69.149,31.349 +2020-10-20 19:00:00,115.2,112.62100000000001,68.832,31.349 +2020-10-20 19:15:00,110.22,111.197,68.832,31.349 +2020-10-20 19:30:00,108.41,110.40899999999999,68.832,31.349 +2020-10-20 19:45:00,104.68,109.90299999999999,68.832,31.349 +2020-10-20 20:00:00,101.56,107.01299999999999,66.403,31.349 +2020-10-20 20:15:00,101.65,104.178,66.403,31.349 +2020-10-20 20:30:00,100.36,103.81,66.403,31.349 +2020-10-20 20:45:00,100.6,102.27600000000001,66.403,31.349 +2020-10-20 21:00:00,91.23,98.71700000000001,57.352,31.349 +2020-10-20 21:15:00,90.92,98.59700000000001,57.352,31.349 +2020-10-20 21:30:00,88.27,97.89299999999999,57.352,31.349 +2020-10-20 21:45:00,86.17,95.569,57.352,31.349 +2020-10-20 22:00:00,81.6,91.111,51.148999999999994,31.349 +2020-10-20 22:15:00,80.06,87.729,51.148999999999994,31.349 +2020-10-20 22:30:00,78.33,76.27,51.148999999999994,31.349 +2020-10-20 22:45:00,77.72,69.81,51.148999999999994,31.349 +2020-10-20 23:00:00,75.14,64.126,41.8,31.349 +2020-10-20 23:15:00,72.31,63.852,41.8,31.349 +2020-10-20 23:30:00,72.2,62.843,41.8,31.349 +2020-10-20 23:45:00,70.69,63.091,41.8,31.349 +2020-10-21 00:00:00,69.76,59.614,42.269,31.349 +2020-10-21 00:15:00,70.51,61.033,42.269,31.349 +2020-10-21 00:30:00,68.91,60.648,42.269,31.349 +2020-10-21 00:45:00,70.39,60.449,42.269,31.349 +2020-10-21 01:00:00,68.96,61.236999999999995,38.527,31.349 +2020-10-21 01:15:00,69.7,60.805,38.527,31.349 +2020-10-21 01:30:00,66.77,60.242,38.527,31.349 +2020-10-21 01:45:00,69.61,59.981,38.527,31.349 +2020-10-21 02:00:00,69.44,60.858000000000004,36.393,31.349 +2020-10-21 02:15:00,70.57,61.048,36.393,31.349 +2020-10-21 02:30:00,70.25,61.61600000000001,36.393,31.349 +2020-10-21 02:45:00,70.99,62.246,36.393,31.349 +2020-10-21 03:00:00,72.78,64.939,36.167,31.349 +2020-10-21 03:15:00,73.7,66.257,36.167,31.349 +2020-10-21 03:30:00,74.4,66.502,36.167,31.349 +2020-10-21 03:45:00,74.06,67.263,36.167,31.349 +2020-10-21 04:00:00,80.73,77.21600000000001,38.092,31.349 +2020-10-21 04:15:00,83.23,86.816,38.092,31.349 +2020-10-21 04:30:00,94.46,86.675,38.092,31.349 +2020-10-21 04:45:00,98.59,88.64200000000001,38.092,31.349 +2020-10-21 05:00:00,105.32,117.571,42.268,31.349 +2020-10-21 05:15:00,106.7,143.113,42.268,31.349 +2020-10-21 05:30:00,113.14,137.283,42.268,31.349 +2020-10-21 05:45:00,116.18,128.509,42.268,31.349 +2020-10-21 06:00:00,122.3,127.931,60.158,31.349 +2020-10-21 06:15:00,125.24,132.197,60.158,31.349 +2020-10-21 06:30:00,128.18,131.024,60.158,31.349 +2020-10-21 06:45:00,129.46,131.575,60.158,31.349 +2020-10-21 07:00:00,132.09,132.361,74.792,31.349 +2020-10-21 07:15:00,133.19,135.106,74.792,31.349 +2020-10-21 07:30:00,135.44,135.514,74.792,31.349 +2020-10-21 07:45:00,133.65,135.642,74.792,31.349 +2020-10-21 08:00:00,132.41,135.57,70.499,31.349 +2020-10-21 08:15:00,134.22,135.18200000000002,70.499,31.349 +2020-10-21 08:30:00,134.75,132.749,70.499,31.349 +2020-10-21 08:45:00,134.77,130.888,70.499,31.349 +2020-10-21 09:00:00,135.22,126.735,68.892,31.349 +2020-10-21 09:15:00,136.3,124.346,68.892,31.349 +2020-10-21 09:30:00,138.43,124.294,68.892,31.349 +2020-10-21 09:45:00,138.85,123.74600000000001,68.892,31.349 +2020-10-21 10:00:00,138.15,120.583,66.88600000000001,31.349 +2020-10-21 10:15:00,138.15,119.40100000000001,66.88600000000001,31.349 +2020-10-21 10:30:00,137.96,117.815,66.88600000000001,31.349 +2020-10-21 10:45:00,135.02,117.351,66.88600000000001,31.349 +2020-10-21 11:00:00,131.3,114.03200000000001,66.187,31.349 +2020-10-21 11:15:00,131.26,114.366,66.187,31.349 +2020-10-21 11:30:00,131.11,114.583,66.187,31.349 +2020-10-21 11:45:00,132.59,114.916,66.187,31.349 +2020-10-21 12:00:00,129.61,111.206,62.18,31.349 +2020-10-21 12:15:00,129.87,110.37299999999999,62.18,31.349 +2020-10-21 12:30:00,128.91,110.20200000000001,62.18,31.349 +2020-10-21 12:45:00,128.14,110.572,62.18,31.349 +2020-10-21 13:00:00,124.86,110.43700000000001,62.23,31.349 +2020-10-21 13:15:00,121.86,110.012,62.23,31.349 +2020-10-21 13:30:00,123.13,109.31,62.23,31.349 +2020-10-21 13:45:00,120.42,108.958,62.23,31.349 +2020-10-21 14:00:00,118.39,108.573,63.721000000000004,31.349 +2020-10-21 14:15:00,120.77,108.508,63.721000000000004,31.349 +2020-10-21 14:30:00,121.85,108.00299999999999,63.721000000000004,31.349 +2020-10-21 14:45:00,121.34,107.882,63.721000000000004,31.349 +2020-10-21 15:00:00,118.71,107.161,66.523,31.349 +2020-10-21 15:15:00,118.61,107.163,66.523,31.349 +2020-10-21 15:30:00,118.37,107.37700000000001,66.523,31.349 +2020-10-21 15:45:00,117.66,107.412,66.523,31.349 +2020-10-21 16:00:00,117.97,108.20700000000001,69.679,31.349 +2020-10-21 16:15:00,119.99,108.711,69.679,31.349 +2020-10-21 16:30:00,122.09,109.101,69.679,31.349 +2020-10-21 16:45:00,123.57,107.87899999999999,69.679,31.349 +2020-10-21 17:00:00,130.15,108.39,75.04,31.349 +2020-10-21 17:15:00,128.12,109.345,75.04,31.349 +2020-10-21 17:30:00,132.36,109.304,75.04,31.349 +2020-10-21 17:45:00,129.08,109.47200000000001,75.04,31.349 +2020-10-21 18:00:00,130.24,110.53299999999999,75.915,31.349 +2020-10-21 18:15:00,127.7,110.141,75.915,31.349 +2020-10-21 18:30:00,127.89,108.836,75.915,31.349 +2020-10-21 18:45:00,123.32,112.119,75.915,31.349 +2020-10-21 19:00:00,118.23,113.083,74.66,31.349 +2020-10-21 19:15:00,113.94,111.652,74.66,31.349 +2020-10-21 19:30:00,110.24,110.85,74.66,31.349 +2020-10-21 19:45:00,109.15,110.32,74.66,31.349 +2020-10-21 20:00:00,103.44,107.45299999999999,71.204,31.349 +2020-10-21 20:15:00,104.11,104.609,71.204,31.349 +2020-10-21 20:30:00,104.78,104.212,71.204,31.349 +2020-10-21 20:45:00,105.75,102.661,71.204,31.349 +2020-10-21 21:00:00,100.72,99.09899999999999,61.052,31.349 +2020-10-21 21:15:00,95.64,98.96700000000001,61.052,31.349 +2020-10-21 21:30:00,91.76,98.273,61.052,31.349 +2020-10-21 21:45:00,95.11,95.929,61.052,31.349 +2020-10-21 22:00:00,91.39,91.464,54.691,31.349 +2020-10-21 22:15:00,87.48,88.06200000000001,54.691,31.349 +2020-10-21 22:30:00,84.33,76.632,54.691,31.349 +2020-10-21 22:45:00,85.24,70.179,54.691,31.349 +2020-10-21 23:00:00,81.86,64.51,45.18,31.349 +2020-10-21 23:15:00,83.34,64.208,45.18,31.349 +2020-10-21 23:30:00,79.08,63.201,45.18,31.349 +2020-10-21 23:45:00,78.07,63.438,45.18,31.349 +2020-10-22 00:00:00,78.96,59.951,42.746,31.349 +2020-10-22 00:15:00,80.34,61.357,42.746,31.349 +2020-10-22 00:30:00,76.75,60.979,42.746,31.349 +2020-10-22 00:45:00,74.47,60.773,42.746,31.349 +2020-10-22 01:00:00,74.08,61.575,40.025999999999996,31.349 +2020-10-22 01:15:00,78.96,61.161,40.025999999999996,31.349 +2020-10-22 01:30:00,78.92,60.614,40.025999999999996,31.349 +2020-10-22 01:45:00,72.66,60.349,40.025999999999996,31.349 +2020-10-22 02:00:00,72.85,61.236000000000004,38.154,31.349 +2020-10-22 02:15:00,72.18,61.438,38.154,31.349 +2020-10-22 02:30:00,80.12,61.99100000000001,38.154,31.349 +2020-10-22 02:45:00,80.24,62.618,38.154,31.349 +2020-10-22 03:00:00,77.98,65.297,37.575,31.349 +2020-10-22 03:15:00,77.21,66.63600000000001,37.575,31.349 +2020-10-22 03:30:00,76.58,66.88600000000001,37.575,31.349 +2020-10-22 03:45:00,81.48,67.62899999999999,37.575,31.349 +2020-10-22 04:00:00,90.01,77.601,39.154,31.349 +2020-10-22 04:15:00,92.44,87.23,39.154,31.349 +2020-10-22 04:30:00,87.34,87.086,39.154,31.349 +2020-10-22 04:45:00,93.79,89.06200000000001,39.154,31.349 +2020-10-22 05:00:00,105.64,118.059,44.085,31.349 +2020-10-22 05:15:00,114.87,143.665,44.085,31.349 +2020-10-22 05:30:00,114.7,137.82399999999998,44.085,31.349 +2020-10-22 05:45:00,113.54,129.018,44.085,31.349 +2020-10-22 06:00:00,122.12,128.42600000000002,57.49,31.349 +2020-10-22 06:15:00,124.14,132.705,57.49,31.349 +2020-10-22 06:30:00,127.55,131.553,57.49,31.349 +2020-10-22 06:45:00,128.59,132.114,57.49,31.349 +2020-10-22 07:00:00,132.08,132.9,73.617,31.349 +2020-10-22 07:15:00,132.26,135.658,73.617,31.349 +2020-10-22 07:30:00,133.23,136.096,73.617,31.349 +2020-10-22 07:45:00,133.61,136.227,73.617,31.349 +2020-10-22 08:00:00,133.59,136.168,69.281,31.349 +2020-10-22 08:15:00,133.94,135.75799999999998,69.281,31.349 +2020-10-22 08:30:00,133.56,133.351,69.281,31.349 +2020-10-22 08:45:00,134.29,131.466,69.281,31.349 +2020-10-22 09:00:00,132.11,127.307,63.926,31.349 +2020-10-22 09:15:00,134.27,124.915,63.926,31.349 +2020-10-22 09:30:00,133.85,124.851,63.926,31.349 +2020-10-22 09:45:00,133.96,124.27799999999999,63.926,31.349 +2020-10-22 10:00:00,131.38,121.10799999999999,59.442,31.349 +2020-10-22 10:15:00,133.21,119.88600000000001,59.442,31.349 +2020-10-22 10:30:00,132.9,118.28,59.442,31.349 +2020-10-22 10:45:00,132.11,117.79899999999999,59.442,31.349 +2020-10-22 11:00:00,127.4,114.485,56.771,31.349 +2020-10-22 11:15:00,129.2,114.801,56.771,31.349 +2020-10-22 11:30:00,127.65,115.016,56.771,31.349 +2020-10-22 11:45:00,125.62,115.334,56.771,31.349 +2020-10-22 12:00:00,123.46,111.602,53.701,31.349 +2020-10-22 12:15:00,124.88,110.765,53.701,31.349 +2020-10-22 12:30:00,119.09,110.62799999999999,53.701,31.349 +2020-10-22 12:45:00,120.14,110.99799999999999,53.701,31.349 +2020-10-22 13:00:00,122.1,110.82799999999999,52.364,31.349 +2020-10-22 13:15:00,123.26,110.411,52.364,31.349 +2020-10-22 13:30:00,124.34,109.709,52.364,31.349 +2020-10-22 13:45:00,124.93,109.353,52.364,31.349 +2020-10-22 14:00:00,128.1,108.916,53.419,31.349 +2020-10-22 14:15:00,124.78,108.868,53.419,31.349 +2020-10-22 14:30:00,123.07,108.398,53.419,31.349 +2020-10-22 14:45:00,119.7,108.27600000000001,53.419,31.349 +2020-10-22 15:00:00,121.74,107.53399999999999,56.744,31.349 +2020-10-22 15:15:00,119.68,107.554,56.744,31.349 +2020-10-22 15:30:00,119.77,107.806,56.744,31.349 +2020-10-22 15:45:00,118.01,107.85600000000001,56.744,31.349 +2020-10-22 16:00:00,123.58,108.62,60.458,31.349 +2020-10-22 16:15:00,123.95,109.146,60.458,31.349 +2020-10-22 16:30:00,124.24,109.53299999999999,60.458,31.349 +2020-10-22 16:45:00,126.61,108.363,60.458,31.349 +2020-10-22 17:00:00,132.29,108.83200000000001,66.295,31.349 +2020-10-22 17:15:00,131.4,109.807,66.295,31.349 +2020-10-22 17:30:00,130.67,109.76799999999999,66.295,31.349 +2020-10-22 17:45:00,130.55,109.95200000000001,66.295,31.349 +2020-10-22 18:00:00,130.07,111.006,68.468,31.349 +2020-10-22 18:15:00,127.24,110.588,68.468,31.349 +2020-10-22 18:30:00,126.24,109.292,68.468,31.349 +2020-10-22 18:45:00,124.94,112.571,68.468,31.349 +2020-10-22 19:00:00,116.69,113.544,66.39399999999999,31.349 +2020-10-22 19:15:00,113.13,112.10600000000001,66.39399999999999,31.349 +2020-10-22 19:30:00,109.94,111.291,66.39399999999999,31.349 +2020-10-22 19:45:00,109.01,110.73700000000001,66.39399999999999,31.349 +2020-10-22 20:00:00,106.55,107.891,63.183,31.349 +2020-10-22 20:15:00,110.78,105.039,63.183,31.349 +2020-10-22 20:30:00,109.45,104.61399999999999,63.183,31.349 +2020-10-22 20:45:00,102.93,103.04299999999999,63.183,31.349 +2020-10-22 21:00:00,95.75,99.479,55.133,31.349 +2020-10-22 21:15:00,94.53,99.337,55.133,31.349 +2020-10-22 21:30:00,91.45,98.652,55.133,31.349 +2020-10-22 21:45:00,96.05,96.286,55.133,31.349 +2020-10-22 22:00:00,89.93,91.816,50.111999999999995,31.349 +2020-10-22 22:15:00,88.13,88.395,50.111999999999995,31.349 +2020-10-22 22:30:00,89.13,76.995,50.111999999999995,31.349 +2020-10-22 22:45:00,89.9,70.547,50.111999999999995,31.349 +2020-10-22 23:00:00,83.75,64.893,44.536,31.349 +2020-10-22 23:15:00,78.07,64.564,44.536,31.349 +2020-10-22 23:30:00,80.56,63.558,44.536,31.349 +2020-10-22 23:45:00,82.63,63.785,44.536,31.349 +2020-10-23 00:00:00,78.83,58.907,42.291000000000004,31.349 +2020-10-23 00:15:00,76.14,60.5,42.291000000000004,31.349 +2020-10-23 00:30:00,70.38,60.188,42.291000000000004,31.349 +2020-10-23 00:45:00,78.33,60.231,42.291000000000004,31.349 +2020-10-23 01:00:00,77.49,60.708999999999996,41.008,31.349 +2020-10-23 01:15:00,78.38,60.483000000000004,41.008,31.349 +2020-10-23 01:30:00,72.93,60.181999999999995,41.008,31.349 +2020-10-23 01:45:00,73.02,59.847,41.008,31.349 +2020-10-23 02:00:00,71.03,61.23,39.521,31.349 +2020-10-23 02:15:00,73.65,61.361000000000004,39.521,31.349 +2020-10-23 02:30:00,79.52,62.555,39.521,31.349 +2020-10-23 02:45:00,80.33,62.872,39.521,31.349 +2020-10-23 03:00:00,79.49,65.42,39.812,31.349 +2020-10-23 03:15:00,77.2,66.66199999999999,39.812,31.349 +2020-10-23 03:30:00,77.66,66.79,39.812,31.349 +2020-10-23 03:45:00,80.08,68.09899999999999,39.812,31.349 +2020-10-23 04:00:00,93.02,78.267,41.22,31.349 +2020-10-23 04:15:00,92.04,87.04899999999999,41.22,31.349 +2020-10-23 04:30:00,95.88,87.476,41.22,31.349 +2020-10-23 04:45:00,103.64,88.589,41.22,31.349 +2020-10-23 05:00:00,110.56,116.802,45.115,31.349 +2020-10-23 05:15:00,109.64,143.666,45.115,31.349 +2020-10-23 05:30:00,112.58,138.431,45.115,31.349 +2020-10-23 05:45:00,115.81,129.345,45.115,31.349 +2020-10-23 06:00:00,127.62,129.082,59.06100000000001,31.349 +2020-10-23 06:15:00,125.7,132.726,59.06100000000001,31.349 +2020-10-23 06:30:00,129.36,131.142,59.06100000000001,31.349 +2020-10-23 06:45:00,130.72,132.45600000000002,59.06100000000001,31.349 +2020-10-23 07:00:00,132.87,133.134,71.874,31.349 +2020-10-23 07:15:00,132.91,136.845,71.874,31.349 +2020-10-23 07:30:00,132.75,136.32399999999998,71.874,31.349 +2020-10-23 07:45:00,132.39,135.908,71.874,31.349 +2020-10-23 08:00:00,135.33,135.643,68.439,31.349 +2020-10-23 08:15:00,133.18,135.3,68.439,31.349 +2020-10-23 08:30:00,131.71,133.393,68.439,31.349 +2020-10-23 08:45:00,132.74,130.588,68.439,31.349 +2020-10-23 09:00:00,130.86,125.649,65.523,31.349 +2020-10-23 09:15:00,129.44,124.387,65.523,31.349 +2020-10-23 09:30:00,130.21,123.78299999999999,65.523,31.349 +2020-10-23 09:45:00,126.99,123.292,65.523,31.349 +2020-10-23 10:00:00,126.29,119.389,62.005,31.349 +2020-10-23 10:15:00,126.97,118.429,62.005,31.349 +2020-10-23 10:30:00,127.62,116.988,62.005,31.349 +2020-10-23 10:45:00,128.81,116.204,62.005,31.349 +2020-10-23 11:00:00,125.12,112.964,60.351000000000006,31.349 +2020-10-23 11:15:00,124.04,112.315,60.351000000000006,31.349 +2020-10-23 11:30:00,124.64,113.381,60.351000000000006,31.349 +2020-10-23 11:45:00,128.31,113.32799999999999,60.351000000000006,31.349 +2020-10-23 12:00:00,121.36,110.387,55.331,31.349 +2020-10-23 12:15:00,123.71,108.07700000000001,55.331,31.349 +2020-10-23 12:30:00,121.4,108.113,55.331,31.349 +2020-10-23 12:45:00,120.71,108.477,55.331,31.349 +2020-10-23 13:00:00,114.05,109.057,53.361999999999995,31.349 +2020-10-23 13:15:00,112.96,109.195,53.361999999999995,31.349 +2020-10-23 13:30:00,114.06,108.807,53.361999999999995,31.349 +2020-10-23 13:45:00,109.64,108.527,53.361999999999995,31.349 +2020-10-23 14:00:00,109.29,107.06299999999999,51.708,31.349 +2020-10-23 14:15:00,107.91,107.09200000000001,51.708,31.349 +2020-10-23 14:30:00,109.64,107.539,51.708,31.349 +2020-10-23 14:45:00,108.55,107.329,51.708,31.349 +2020-10-23 15:00:00,108.97,106.251,54.571000000000005,31.349 +2020-10-23 15:15:00,110.24,105.94,54.571000000000005,31.349 +2020-10-23 15:30:00,110.25,105.12200000000001,54.571000000000005,31.349 +2020-10-23 15:45:00,111.4,105.56299999999999,54.571000000000005,31.349 +2020-10-23 16:00:00,111.51,105.333,58.662,31.349 +2020-10-23 16:15:00,113.23,106.244,58.662,31.349 +2020-10-23 16:30:00,114.42,106.60799999999999,58.662,31.349 +2020-10-23 16:45:00,118.53,105.10600000000001,58.662,31.349 +2020-10-23 17:00:00,122.49,106.39399999999999,65.941,31.349 +2020-10-23 17:15:00,122.56,107.088,65.941,31.349 +2020-10-23 17:30:00,123.72,106.946,65.941,31.349 +2020-10-23 17:45:00,123.59,106.948,65.941,31.349 +2020-10-23 18:00:00,121.84,108.42299999999999,65.628,31.349 +2020-10-23 18:15:00,121.3,107.361,65.628,31.349 +2020-10-23 18:30:00,117.05,106.244,65.628,31.349 +2020-10-23 18:45:00,113.81,109.695,65.628,31.349 +2020-10-23 19:00:00,107.92,111.56299999999999,63.662,31.349 +2020-10-23 19:15:00,105.21,111.102,63.662,31.349 +2020-10-23 19:30:00,103.36,110.083,63.662,31.349 +2020-10-23 19:45:00,100.53,108.814,63.662,31.349 +2020-10-23 20:00:00,96.19,105.954,61.945,31.349 +2020-10-23 20:15:00,96.91,103.45,61.945,31.349 +2020-10-23 20:30:00,98.96,102.758,61.945,31.349 +2020-10-23 20:45:00,98.21,101.104,61.945,31.349 +2020-10-23 21:00:00,88.95,98.39200000000001,53.903,31.349 +2020-10-23 21:15:00,85.03,99.21700000000001,53.903,31.349 +2020-10-23 21:30:00,86.27,98.49700000000001,53.903,31.349 +2020-10-23 21:45:00,86.36,96.485,53.903,31.349 +2020-10-23 22:00:00,82.11,92.458,48.403999999999996,31.349 +2020-10-23 22:15:00,79.76,88.836,48.403999999999996,31.349 +2020-10-23 22:30:00,78.87,82.929,48.403999999999996,31.349 +2020-10-23 22:45:00,77.66,78.721,48.403999999999996,31.349 +2020-10-23 23:00:00,72.45,73.60300000000001,41.07,31.349 +2020-10-23 23:15:00,66.27,71.525,41.07,31.349 +2020-10-23 23:30:00,71.85,68.943,41.07,31.349 +2020-10-23 23:45:00,72.89,68.757,41.07,31.349 +2020-10-24 00:00:00,69.15,58.409,38.989000000000004,31.177 +2020-10-24 00:15:00,66.21,57.553999999999995,38.989000000000004,31.177 +2020-10-24 00:30:00,66.17,57.687,38.989000000000004,31.177 +2020-10-24 00:45:00,68.56,57.732,38.989000000000004,31.177 +2020-10-24 01:00:00,67.79,58.681000000000004,35.275,31.177 +2020-10-24 01:15:00,63.68,58.266000000000005,35.275,31.177 +2020-10-24 01:30:00,65.18,57.342,35.275,31.177 +2020-10-24 01:45:00,67.86,57.486999999999995,35.275,31.177 +2020-10-24 02:00:00,66.58,58.766999999999996,32.838,31.177 +2020-10-24 02:15:00,64.36,58.35,32.838,31.177 +2020-10-24 02:30:00,61.86,58.606,32.838,31.177 +2020-10-24 02:45:00,66.88,59.343999999999994,32.838,31.177 +2020-10-24 03:00:00,65.58,61.542,32.418,31.177 +2020-10-24 03:15:00,60.76,61.876000000000005,32.418,31.177 +2020-10-24 03:30:00,61.45,61.39,32.418,31.177 +2020-10-24 03:45:00,60.33,63.449,32.418,31.177 +2020-10-24 04:00:00,61.63,70.747,32.099000000000004,31.177 +2020-10-24 04:15:00,61.62,77.895,32.099000000000004,31.177 +2020-10-24 04:30:00,61.95,76.46,32.099000000000004,31.177 +2020-10-24 04:45:00,64.96,77.475,32.099000000000004,31.177 +2020-10-24 05:00:00,66.95,94.19200000000001,32.926,31.177 +2020-10-24 05:15:00,68.94,106.695,32.926,31.177 +2020-10-24 05:30:00,70.32,102.368,32.926,31.177 +2020-10-24 05:45:00,72.01,98.037,32.926,31.177 +2020-10-24 06:00:00,75.35,111.932,35.069,31.177 +2020-10-24 06:15:00,77.38,127.29899999999999,35.069,31.177 +2020-10-24 06:30:00,80.27,121.75399999999999,35.069,31.177 +2020-10-24 06:45:00,82.52,117.161,35.069,31.177 +2020-10-24 07:00:00,84.52,114.94,40.906,31.177 +2020-10-24 07:15:00,86.47,117.434,40.906,31.177 +2020-10-24 07:30:00,88.55,119.00399999999999,40.906,31.177 +2020-10-24 07:45:00,90.46,121.02,40.906,31.177 +2020-10-24 08:00:00,93.09,122.844,46.603,31.177 +2020-10-24 08:15:00,92.39,124.279,46.603,31.177 +2020-10-24 08:30:00,92.69,123.23299999999999,46.603,31.177 +2020-10-24 08:45:00,92.18,122.46799999999999,46.603,31.177 +2020-10-24 09:00:00,92.61,119.697,49.935,31.177 +2020-10-24 09:15:00,95.38,119.044,49.935,31.177 +2020-10-24 09:30:00,92.79,119.12299999999999,49.935,31.177 +2020-10-24 09:45:00,98.37,118.501,49.935,31.177 +2020-10-24 10:00:00,95.5,114.84299999999999,47.585,31.177 +2020-10-24 10:15:00,96.58,114.081,47.585,31.177 +2020-10-24 10:30:00,97.94,112.571,47.585,31.177 +2020-10-24 10:45:00,98.26,112.348,47.585,31.177 +2020-10-24 11:00:00,98.27,109.11399999999999,43.376999999999995,31.177 +2020-10-24 11:15:00,98.66,108.404,43.376999999999995,31.177 +2020-10-24 11:30:00,99.06,108.95299999999999,43.376999999999995,31.177 +2020-10-24 11:45:00,96.15,108.61399999999999,43.376999999999995,31.177 +2020-10-24 12:00:00,91.6,105.161,40.855,31.177 +2020-10-24 12:15:00,91.87,103.545,40.855,31.177 +2020-10-24 12:30:00,93.38,103.74799999999999,40.855,31.177 +2020-10-24 12:45:00,90.21,103.949,40.855,31.177 +2020-10-24 13:00:00,86.6,103.87899999999999,37.251,31.177 +2020-10-24 13:15:00,86.51,102.62299999999999,37.251,31.177 +2020-10-24 13:30:00,85.95,102.045,37.251,31.177 +2020-10-24 13:45:00,85.82,101.522,37.251,31.177 +2020-10-24 14:00:00,86.06,100.728,38.548,31.177 +2020-10-24 14:15:00,86.43,99.986,38.548,31.177 +2020-10-24 14:30:00,86.03,99.24700000000001,38.548,31.177 +2020-10-24 14:45:00,85.75,99.345,38.548,31.177 +2020-10-24 15:00:00,84.72,98.795,42.883,31.177 +2020-10-24 15:15:00,85.56,99.241,42.883,31.177 +2020-10-24 15:30:00,85.6,99.435,42.883,31.177 +2020-10-24 15:45:00,86.3,99.54899999999999,42.883,31.177 +2020-10-24 16:00:00,89.01,99.697,48.143,31.177 +2020-10-24 16:15:00,89.51,100.771,48.143,31.177 +2020-10-24 16:30:00,92.55,101.194,48.143,31.177 +2020-10-24 16:45:00,96.25,100.23100000000001,48.143,31.177 +2020-10-24 17:00:00,102.45,100.84299999999999,55.25,31.177 +2020-10-24 17:15:00,102.68,101.559,55.25,31.177 +2020-10-24 17:30:00,105.3,101.311,55.25,31.177 +2020-10-24 17:45:00,102.74,101.31299999999999,55.25,31.177 +2020-10-24 18:00:00,102.08,103.189,57.506,31.177 +2020-10-24 18:15:00,99.7,103.78200000000001,57.506,31.177 +2020-10-24 18:30:00,98.42,103.971,57.506,31.177 +2020-10-24 18:45:00,96.26,104.133,57.506,31.177 +2020-10-24 19:00:00,91.99,105.861,55.528999999999996,31.177 +2020-10-24 19:15:00,88.78,104.691,55.528999999999996,31.177 +2020-10-24 19:30:00,86.53,104.39200000000001,55.528999999999996,31.177 +2020-10-24 19:45:00,85.01,103.775,55.528999999999996,31.177 +2020-10-24 20:00:00,81.07,102.477,46.166000000000004,31.177 +2020-10-24 20:15:00,81.19,100.8,46.166000000000004,31.177 +2020-10-24 20:30:00,79.06,99.508,46.166000000000004,31.177 +2020-10-24 20:45:00,78.71,98.44,46.166000000000004,31.177 +2020-10-24 21:00:00,74.27,96.27,40.406,31.177 +2020-10-24 21:15:00,73.82,97.156,40.406,31.177 +2020-10-24 21:30:00,69.74,97.17,40.406,31.177 +2020-10-24 21:45:00,70.03,94.685,40.406,31.177 +2020-10-24 22:00:00,66.57,91.34200000000001,39.616,31.177 +2020-10-24 22:15:00,66.52,89.10799999999999,39.616,31.177 +2020-10-24 22:30:00,63.76,86.304,39.616,31.177 +2020-10-24 22:45:00,63.35,83.242,39.616,31.177 +2020-10-24 23:00:00,59.13,79.12100000000001,32.205,31.177 +2020-10-24 23:15:00,57.91,76.347,32.205,31.177 +2020-10-24 23:30:00,58.49,73.942,32.205,31.177 +2020-10-24 23:45:00,57.43,72.638,32.205,31.177 +2020-10-25 00:00:00,54.37,59.602,28.229,31.177 +2020-10-25 00:15:00,54.85,58.032,28.229,31.177 +2020-10-25 00:30:00,53.15,57.915,28.229,31.177 +2020-10-25 00:45:00,53.46,58.245,28.229,31.177 +2020-10-25 01:00:00,52.45,59.261,25.669,31.177 +2020-10-25 01:15:00,53.12,59.316,25.669,31.177 +2020-10-25 01:30:00,52.62,58.598,25.669,31.177 +2020-10-25 01:45:00,53.42,58.406000000000006,25.669,31.177 +2020-10-25 02:00:00,53.47,59.361999999999995,24.948,31.177 +2020-10-25 02:15:00,52.07,58.88,24.948,31.177 +2020-10-25 02:30:00,53.29,59.628,24.948,31.177 +2020-10-25 02:45:00,50.45,60.449,24.948,31.177 +2020-10-25 02:00:00,53.47,59.361999999999995,24.948,31.177 +2020-10-25 02:15:00,52.07,58.88,24.948,31.177 +2020-10-25 02:30:00,53.29,59.628,24.948,31.177 +2020-10-25 02:45:00,50.45,60.449,24.948,31.177 +2020-10-25 03:00:00,51.47,63.097,25.839000000000002,31.177 +2020-10-25 03:15:00,53.26,63.324,25.839000000000002,31.177 +2020-10-25 03:30:00,53.34,63.165,25.839000000000002,31.177 +2020-10-25 03:45:00,54.51,64.78699999999999,25.839000000000002,31.177 +2020-10-25 04:00:00,53.29,71.962,26.803,31.177 +2020-10-25 04:15:00,53.34,78.443,26.803,31.177 +2020-10-25 04:30:00,53.8,77.687,26.803,31.177 +2020-10-25 04:45:00,54.16,78.613,26.803,31.177 +2020-10-25 05:00:00,55.1,93.816,28.147,31.177 +2020-10-25 05:15:00,57.45,104.86200000000001,28.147,31.177 +2020-10-25 05:30:00,57.71,100.24700000000001,28.147,31.177 +2020-10-25 05:45:00,58.69,95.866,28.147,31.177 +2020-10-25 06:00:00,62.27,108.524,31.116,31.177 +2020-10-25 06:15:00,63.1,123.47,31.116,31.177 +2020-10-25 06:30:00,62.46,117.07799999999999,31.116,31.177 +2020-10-25 06:45:00,62.7,111.507,31.116,31.177 +2020-10-25 07:00:00,63.83,110.587,35.739000000000004,31.177 +2020-10-25 07:15:00,68.03,111.905,35.739000000000004,31.177 +2020-10-25 07:30:00,70.45,113.48200000000001,35.739000000000004,31.177 +2020-10-25 07:45:00,73.32,115.104,35.739000000000004,31.177 +2020-10-25 08:00:00,74.3,118.15899999999999,39.455999999999996,31.177 +2020-10-25 08:15:00,75.67,120.051,39.455999999999996,31.177 +2020-10-25 08:30:00,77.85,120.27600000000001,39.455999999999996,31.177 +2020-10-25 08:45:00,79.31,120.493,39.455999999999996,31.177 +2020-10-25 09:00:00,79.61,117.434,41.343999999999994,31.177 +2020-10-25 09:15:00,84.58,116.85700000000001,41.343999999999994,31.177 +2020-10-25 09:30:00,85.64,117.029,41.343999999999994,31.177 +2020-10-25 09:45:00,82.94,116.75399999999999,41.343999999999994,31.177 +2020-10-25 10:00:00,85.77,114.64299999999999,43.645,31.177 +2020-10-25 10:15:00,85.99,114.189,43.645,31.177 +2020-10-25 10:30:00,87.54,113.08,43.645,31.177 +2020-10-25 10:45:00,90.19,112.226,43.645,31.177 +2020-10-25 11:00:00,91.19,109.338,39.796,31.177 +2020-10-25 11:15:00,94.15,108.50299999999999,39.796,31.177 +2020-10-25 11:30:00,95.37,108.779,39.796,31.177 +2020-10-25 11:45:00,98.86,108.865,39.796,31.177 +2020-10-25 12:00:00,91.98,105.462,36.343,31.177 +2020-10-25 12:15:00,91.68,104.677,36.343,31.177 +2020-10-25 12:30:00,88.9,104.17200000000001,36.343,31.177 +2020-10-25 12:45:00,90.13,103.572,36.343,31.177 +2020-10-25 13:00:00,82.09,102.927,33.162,31.177 +2020-10-25 13:15:00,81.4,103.18,33.162,31.177 +2020-10-25 13:30:00,81.38,102.04700000000001,33.162,31.177 +2020-10-25 13:45:00,81.84,101.579,33.162,31.177 +2020-10-25 14:00:00,81.77,101.337,33.215,31.177 +2020-10-25 14:15:00,79.73,101.465,33.215,31.177 +2020-10-25 14:30:00,79.1,100.992,33.215,31.177 +2020-10-25 14:45:00,79.01,100.43799999999999,33.215,31.177 +2020-10-25 15:00:00,79.17,99.04700000000001,37.385999999999996,31.177 +2020-10-25 15:15:00,80.91,99.631,37.385999999999996,31.177 +2020-10-25 15:30:00,81.52,100.13,37.385999999999996,31.177 +2020-10-25 15:45:00,82.75,100.765,37.385999999999996,31.177 +2020-10-25 16:00:00,82.73,101.178,46.618,31.177 +2020-10-25 16:15:00,84.13,101.86399999999999,46.618,31.177 +2020-10-25 16:30:00,88.98,102.837,46.618,31.177 +2020-10-25 16:45:00,90.57,102.025,46.618,31.177 +2020-10-25 17:00:00,95.12,102.66799999999999,50.111000000000004,31.177 +2020-10-25 17:15:00,97.69,103.867,50.111000000000004,31.177 +2020-10-25 17:30:00,96.42,104.118,50.111000000000004,31.177 +2020-10-25 17:45:00,98.46,105.537,50.111000000000004,31.177 +2020-10-25 18:00:00,103.58,107.33200000000001,50.25,31.177 +2020-10-25 18:15:00,101.93,108.426,50.25,31.177 +2020-10-25 18:30:00,102.54,107.414,50.25,31.177 +2020-10-25 18:45:00,100.8,108.6,50.25,31.177 +2020-10-25 19:00:00,96.36,111.086,44.265,31.177 +2020-10-25 19:15:00,94.85,109.73100000000001,44.265,31.177 +2020-10-25 19:30:00,95.47,109.225,44.265,31.177 +2020-10-25 19:45:00,92.72,109.13799999999999,44.265,31.177 +2020-10-25 20:00:00,95.91,107.9,39.717,31.177 +2020-10-25 20:15:00,98.51,106.665,39.717,31.177 +2020-10-25 20:30:00,93.22,106.344,39.717,31.177 +2020-10-25 20:45:00,87.29,103.89200000000001,39.717,31.177 +2020-10-25 21:00:00,89.17,100.29299999999999,39.224000000000004,31.177 +2020-10-25 21:15:00,87.17,100.712,39.224000000000004,31.177 +2020-10-25 21:30:00,90.1,100.595,39.224000000000004,31.177 +2020-10-25 21:45:00,88.38,98.32,39.224000000000004,31.177 +2020-10-25 22:00:00,85.43,95.274,33.518,31.177 +2020-10-25 22:15:00,83.95,91.87799999999999,33.518,31.177 +2020-10-25 22:30:00,78.09,87.24700000000001,33.518,31.177 +2020-10-25 22:45:00,78.18,83.186,33.518,31.177 +2020-10-25 23:00:00,78.0,77.503,33.518,31.177 +2020-10-25 23:15:00,83.24,76.22399999999999,33.518,31.177 +2020-10-25 23:30:00,81.26,73.999,33.518,31.177 +2020-10-25 23:45:00,79.19,73.21600000000001,33.518,31.177 +2020-10-26 00:00:00,70.49,62.706,34.301,31.349 +2020-10-26 00:15:00,74.87,62.949,34.301,31.349 +2020-10-26 00:30:00,78.69,62.718999999999994,34.301,31.349 +2020-10-26 00:45:00,81.84,62.592,34.301,31.349 +2020-10-26 01:00:00,75.6,63.79600000000001,34.143,31.349 +2020-10-26 01:15:00,71.72,63.619,34.143,31.349 +2020-10-26 01:30:00,75.5,63.11600000000001,34.143,31.349 +2020-10-26 01:45:00,77.08,62.928999999999995,34.143,31.349 +2020-10-26 02:00:00,76.26,64.08800000000001,33.650999999999996,31.349 +2020-10-26 02:15:00,72.33,63.838,33.650999999999996,31.349 +2020-10-26 02:30:00,72.88,64.815,33.650999999999996,31.349 +2020-10-26 02:45:00,75.86,65.26100000000001,33.650999999999996,31.349 +2020-10-26 03:00:00,73.71,68.73,32.599000000000004,31.349 +2020-10-26 03:15:00,76.24,70.116,32.599000000000004,31.349 +2020-10-26 03:30:00,71.02,70.158,32.599000000000004,31.349 +2020-10-26 03:45:00,78.2,71.275,32.599000000000004,31.349 +2020-10-26 04:00:00,79.63,82.025,33.785,31.349 +2020-10-26 04:15:00,78.32,91.931,33.785,31.349 +2020-10-26 04:30:00,77.2,91.984,33.785,31.349 +2020-10-26 04:45:00,76.44,93.176,33.785,31.349 +2020-10-26 05:00:00,81.4,119.296,41.285,31.349 +2020-10-26 05:15:00,86.72,144.952,41.285,31.349 +2020-10-26 05:30:00,94.41,139.7,41.285,31.349 +2020-10-26 05:45:00,100.77,131.119,41.285,31.349 +2020-10-26 06:00:00,111.1,130.539,60.486000000000004,31.349 +2020-10-26 06:15:00,109.54,133.99,60.486000000000004,31.349 +2020-10-26 06:30:00,116.42,133.387,60.486000000000004,31.349 +2020-10-26 06:45:00,125.54,134.597,60.486000000000004,31.349 +2020-10-26 07:00:00,128.36,135.40200000000002,74.012,31.349 +2020-10-26 07:15:00,127.02,138.408,74.012,31.349 +2020-10-26 07:30:00,129.02,139.22899999999998,74.012,31.349 +2020-10-26 07:45:00,130.69,139.71200000000002,74.012,31.349 +2020-10-26 08:00:00,133.0,139.673,69.569,31.349 +2020-10-26 08:15:00,130.26,139.901,69.569,31.349 +2020-10-26 08:30:00,131.6,137.61700000000002,69.569,31.349 +2020-10-26 08:45:00,131.65,136.181,69.569,31.349 +2020-10-26 09:00:00,132.1,132.289,66.152,31.349 +2020-10-26 09:15:00,133.34,129.095,66.152,31.349 +2020-10-26 09:30:00,133.68,128.327,66.152,31.349 +2020-10-26 09:45:00,134.8,127.196,66.152,31.349 +2020-10-26 10:00:00,135.34,124.896,62.923,31.349 +2020-10-26 10:15:00,134.92,124.164,62.923,31.349 +2020-10-26 10:30:00,135.66,122.366,62.923,31.349 +2020-10-26 10:45:00,136.84,121.25299999999999,62.923,31.349 +2020-10-26 11:00:00,136.57,117.12899999999999,61.522,31.349 +2020-10-26 11:15:00,138.31,117.39399999999999,61.522,31.349 +2020-10-26 11:30:00,137.5,118.719,61.522,31.349 +2020-10-26 11:45:00,136.55,118.765,61.522,31.349 +2020-10-26 12:00:00,132.39,115.801,58.632,31.349 +2020-10-26 12:15:00,133.44,115.056,58.632,31.349 +2020-10-26 12:30:00,136.05,114.31200000000001,58.632,31.349 +2020-10-26 12:45:00,136.12,114.579,58.632,31.349 +2020-10-26 13:00:00,131.73,114.663,59.06,31.349 +2020-10-26 13:15:00,133.41,113.785,59.06,31.349 +2020-10-26 13:30:00,132.28,112.412,59.06,31.349 +2020-10-26 13:45:00,129.34,112.303,59.06,31.349 +2020-10-26 14:00:00,128.88,111.329,59.791000000000004,31.349 +2020-10-26 14:15:00,130.35,111.32799999999999,59.791000000000004,31.349 +2020-10-26 14:30:00,130.63,110.51799999999999,59.791000000000004,31.349 +2020-10-26 14:45:00,129.71,110.73700000000001,59.791000000000004,31.349 +2020-10-26 15:00:00,133.57,110.221,61.148,31.349 +2020-10-26 15:15:00,130.84,109.76799999999999,61.148,31.349 +2020-10-26 15:30:00,129.48,110.12,61.148,31.349 +2020-10-26 15:45:00,127.89,110.32600000000001,61.148,31.349 +2020-10-26 16:00:00,129.63,111.01299999999999,66.009,31.349 +2020-10-26 16:15:00,130.73,111.325,66.009,31.349 +2020-10-26 16:30:00,128.87,111.488,66.009,31.349 +2020-10-26 16:45:00,131.75,110.07600000000001,66.009,31.349 +2020-10-26 17:00:00,135.6,109.988,73.683,31.349 +2020-10-26 17:15:00,135.29,110.84899999999999,73.683,31.349 +2020-10-26 17:30:00,138.5,110.64399999999999,73.683,31.349 +2020-10-26 17:45:00,138.2,111.103,73.683,31.349 +2020-10-26 18:00:00,139.43,112.59899999999999,72.848,31.349 +2020-10-26 18:15:00,137.29,111.71,72.848,31.349 +2020-10-26 18:30:00,135.93,110.73100000000001,72.848,31.349 +2020-10-26 18:45:00,133.94,113.649,72.848,31.349 +2020-10-26 19:00:00,131.63,115.131,71.139,31.349 +2020-10-26 19:15:00,128.86,113.73299999999999,71.139,31.349 +2020-10-26 19:30:00,126.98,113.314,71.139,31.349 +2020-10-26 19:45:00,125.67,112.5,71.139,31.349 +2020-10-26 20:00:00,117.03,109.485,69.667,31.349 +2020-10-26 20:15:00,114.91,107.57,69.667,31.349 +2020-10-26 20:30:00,114.37,106.48299999999999,69.667,31.349 +2020-10-26 20:45:00,109.01,104.954,69.667,31.349 +2020-10-26 21:00:00,112.26,101.359,61.166000000000004,31.349 +2020-10-26 21:15:00,112.02,101.35799999999999,61.166000000000004,31.349 +2020-10-26 21:30:00,107.75,101.01100000000001,61.166000000000004,31.349 +2020-10-26 21:45:00,102.71,98.35700000000001,61.166000000000004,31.349 +2020-10-26 22:00:00,96.59,92.899,52.772,31.349 +2020-10-26 22:15:00,95.82,89.686,52.772,31.349 +2020-10-26 22:30:00,95.29,78.227,52.772,31.349 +2020-10-26 22:45:00,98.09,71.666,52.772,31.349 +2020-10-26 23:00:00,93.54,66.396,45.136,31.349 +2020-10-26 23:15:00,86.6,65.73100000000001,45.136,31.349 +2020-10-26 23:30:00,84.41,64.919,45.136,31.349 +2020-10-26 23:45:00,88.99,65.25399999999999,45.136,31.349 +2020-10-27 00:00:00,81.74,61.629,47.35,31.349 +2020-10-27 00:15:00,82.4,62.966,47.35,31.349 +2020-10-27 00:30:00,77.99,62.614,47.35,31.349 +2020-10-27 00:45:00,77.23,62.376000000000005,47.35,31.349 +2020-10-27 01:00:00,80.95,63.243,43.424,31.349 +2020-10-27 01:15:00,82.6,62.918,43.424,31.349 +2020-10-27 01:30:00,79.87,62.45399999999999,43.424,31.349 +2020-10-27 01:45:00,72.99,62.169,43.424,31.349 +2020-10-27 02:00:00,72.38,63.107,41.778999999999996,31.349 +2020-10-27 02:15:00,71.66,63.367,41.778999999999996,31.349 +2020-10-27 02:30:00,79.74,63.854,41.778999999999996,31.349 +2020-10-27 02:45:00,81.55,64.459,41.778999999999996,31.349 +2020-10-27 03:00:00,82.6,67.071,40.771,31.349 +2020-10-27 03:15:00,75.83,68.518,40.771,31.349 +2020-10-27 03:30:00,74.84,68.78,40.771,31.349 +2020-10-27 03:45:00,75.64,69.439,40.771,31.349 +2020-10-27 04:00:00,78.97,79.508,41.816,31.349 +2020-10-27 04:15:00,82.85,89.28299999999999,41.816,31.349 +2020-10-27 04:30:00,84.37,89.12799999999999,41.816,31.349 +2020-10-27 04:45:00,85.9,91.14399999999999,41.816,31.349 +2020-10-27 05:00:00,85.7,120.477,45.842,31.349 +2020-10-27 05:15:00,86.54,146.40200000000002,45.842,31.349 +2020-10-27 05:30:00,89.88,140.503,45.842,31.349 +2020-10-27 05:45:00,92.99,131.537,45.842,31.349 +2020-10-27 06:00:00,102.93,130.881,59.12,31.349 +2020-10-27 06:15:00,108.45,135.22899999999998,59.12,31.349 +2020-10-27 06:30:00,113.1,134.179,59.12,31.349 +2020-10-27 06:45:00,115.04,134.792,59.12,31.349 +2020-10-27 07:00:00,122.18,135.57399999999998,70.33,31.349 +2020-10-27 07:15:00,119.35,138.393,70.33,31.349 +2020-10-27 07:30:00,121.17,138.977,70.33,31.349 +2020-10-27 07:45:00,122.32,139.118,70.33,31.349 +2020-10-27 08:00:00,123.86,139.123,67.788,31.349 +2020-10-27 08:15:00,121.9,138.599,67.788,31.349 +2020-10-27 08:30:00,120.05,136.322,67.788,31.349 +2020-10-27 08:45:00,119.09,134.313,67.788,31.349 +2020-10-27 09:00:00,117.83,130.122,62.622,31.349 +2020-10-27 09:15:00,117.56,127.71700000000001,62.622,31.349 +2020-10-27 09:30:00,117.58,127.595,62.622,31.349 +2020-10-27 09:45:00,116.75,126.899,62.622,31.349 +2020-10-27 10:00:00,115.99,123.69200000000001,60.887,31.349 +2020-10-27 10:15:00,115.96,122.279,60.887,31.349 +2020-10-27 10:30:00,114.65,120.57,60.887,31.349 +2020-10-27 10:45:00,115.67,120.009,60.887,31.349 +2020-10-27 11:00:00,114.34,116.712,59.812,31.349 +2020-10-27 11:15:00,114.91,116.93299999999999,59.812,31.349 +2020-10-27 11:30:00,116.28,117.145,59.812,31.349 +2020-10-27 11:45:00,115.75,117.391,59.812,31.349 +2020-10-27 12:00:00,113.14,113.553,56.614,31.349 +2020-10-27 12:15:00,114.82,112.695,56.614,31.349 +2020-10-27 12:30:00,122.4,112.73200000000001,56.614,31.349 +2020-10-27 12:45:00,118.13,113.09700000000001,56.614,31.349 +2020-10-27 13:00:00,120.98,112.76,56.824,31.349 +2020-10-27 13:15:00,123.1,112.37700000000001,56.824,31.349 +2020-10-27 13:30:00,120.06,111.669,56.824,31.349 +2020-10-27 13:45:00,116.95,111.295,56.824,31.349 +2020-10-27 14:00:00,111.93,110.604,57.623999999999995,31.349 +2020-10-27 14:15:00,110.15,110.633,57.623999999999995,31.349 +2020-10-27 14:30:00,114.67,110.348,57.623999999999995,31.349 +2020-10-27 14:45:00,116.3,110.22,57.623999999999995,31.349 +2020-10-27 15:00:00,118.01,109.37299999999999,59.724,31.349 +2020-10-27 15:15:00,119.23,109.48100000000001,59.724,31.349 +2020-10-27 15:30:00,118.98,109.921,59.724,31.349 +2020-10-27 15:45:00,123.51,110.039,59.724,31.349 +2020-10-27 16:00:00,124.34,110.655,61.64,31.349 +2020-10-27 16:15:00,123.37,111.288,61.64,31.349 +2020-10-27 16:30:00,126.21,111.66,61.64,31.349 +2020-10-27 16:45:00,127.32,110.74799999999999,61.64,31.349 +2020-10-27 17:00:00,132.95,111.009,68.962,31.349 +2020-10-27 17:15:00,130.54,112.081,68.962,31.349 +2020-10-27 17:30:00,133.6,112.05799999999999,68.962,31.349 +2020-10-27 17:45:00,132.78,112.322,68.962,31.349 +2020-10-27 18:00:00,133.9,113.34,69.149,31.349 +2020-10-27 18:15:00,131.39,112.79799999999999,69.149,31.349 +2020-10-27 18:30:00,130.49,111.552,69.149,31.349 +2020-10-27 18:45:00,129.9,114.807,69.149,31.349 +2020-10-27 19:00:00,126.61,115.82600000000001,68.832,31.349 +2020-10-27 19:15:00,124.87,114.351,68.832,31.349 +2020-10-27 19:30:00,122.2,113.476,68.832,31.349 +2020-10-27 19:45:00,121.07,112.803,68.832,31.349 +2020-10-27 20:00:00,114.52,110.06200000000001,66.403,31.349 +2020-10-27 20:15:00,112.08,107.171,66.403,31.349 +2020-10-27 20:30:00,108.46,106.601,66.403,31.349 +2020-10-27 20:45:00,105.79,104.941,66.403,31.349 +2020-10-27 21:00:00,98.15,101.363,57.352,31.349 +2020-10-27 21:15:00,94.41,101.166,57.352,31.349 +2020-10-27 21:30:00,92.13,100.529,57.352,31.349 +2020-10-27 21:45:00,91.04,98.061,57.352,31.349 +2020-10-27 22:00:00,84.86,93.559,51.148999999999994,31.349 +2020-10-27 22:15:00,81.56,90.044,51.148999999999994,31.349 +2020-10-27 22:30:00,78.97,78.791,51.148999999999994,31.349 +2020-10-27 22:45:00,75.04,72.378,51.148999999999994,31.349 +2020-10-27 23:00:00,69.33,66.797,41.8,31.349 +2020-10-27 23:15:00,72.04,66.328,41.8,31.349 +2020-10-27 23:30:00,67.62,65.33,41.8,31.349 +2020-10-27 23:45:00,70.34,65.508,41.8,31.349 +2020-10-28 00:00:00,66.64,61.961999999999996,42.269,31.349 +2020-10-28 00:15:00,63.47,63.285,42.269,31.349 +2020-10-28 00:30:00,62.17,62.938,42.269,31.349 +2020-10-28 00:45:00,59.22,62.693000000000005,42.269,31.349 +2020-10-28 01:00:00,59.46,63.573,38.527,31.349 +2020-10-28 01:15:00,59.73,63.266000000000005,38.527,31.349 +2020-10-28 01:30:00,57.69,62.818000000000005,38.527,31.349 +2020-10-28 01:45:00,59.75,62.528,38.527,31.349 +2020-10-28 02:00:00,58.9,63.477,36.393,31.349 +2020-10-28 02:15:00,57.99,63.747,36.393,31.349 +2020-10-28 02:30:00,58.66,64.222,36.393,31.349 +2020-10-28 02:45:00,59.76,64.822,36.393,31.349 +2020-10-28 03:00:00,58.64,67.422,36.167,31.349 +2020-10-28 03:15:00,59.18,68.89,36.167,31.349 +2020-10-28 03:30:00,59.1,69.155,36.167,31.349 +2020-10-28 03:45:00,59.8,69.797,36.167,31.349 +2020-10-28 04:00:00,57.35,79.88600000000001,38.092,31.349 +2020-10-28 04:15:00,60.36,89.689,38.092,31.349 +2020-10-28 04:30:00,61.45,89.531,38.092,31.349 +2020-10-28 04:45:00,62.49,91.555,38.092,31.349 +2020-10-28 05:00:00,64.56,120.95700000000001,42.268,31.349 +2020-10-28 05:15:00,64.41,146.945,42.268,31.349 +2020-10-28 05:30:00,63.43,141.032,42.268,31.349 +2020-10-28 05:45:00,60.47,132.036,42.268,31.349 +2020-10-28 06:00:00,64.37,131.36700000000002,60.158,31.349 +2020-10-28 06:15:00,65.72,135.72799999999998,60.158,31.349 +2020-10-28 06:30:00,65.45,134.7,60.158,31.349 +2020-10-28 06:45:00,65.86,135.322,60.158,31.349 +2020-10-28 07:00:00,68.96,136.10399999999998,74.792,31.349 +2020-10-28 07:15:00,70.01,138.934,74.792,31.349 +2020-10-28 07:30:00,71.67,139.547,74.792,31.349 +2020-10-28 07:45:00,73.31,139.689,74.792,31.349 +2020-10-28 08:00:00,76.29,139.70600000000002,70.499,31.349 +2020-10-28 08:15:00,75.92,139.159,70.499,31.349 +2020-10-28 08:30:00,76.39,136.907,70.499,31.349 +2020-10-28 08:45:00,76.44,134.873,70.499,31.349 +2020-10-28 09:00:00,75.16,130.675,68.892,31.349 +2020-10-28 09:15:00,75.44,128.268,68.892,31.349 +2020-10-28 09:30:00,72.53,128.134,68.892,31.349 +2020-10-28 09:45:00,73.66,127.415,68.892,31.349 +2020-10-28 10:00:00,72.88,124.2,66.88600000000001,31.349 +2020-10-28 10:15:00,69.24,122.749,66.88600000000001,31.349 +2020-10-28 10:30:00,71.4,121.02,66.88600000000001,31.349 +2020-10-28 10:45:00,72.71,120.443,66.88600000000001,31.349 +2020-10-28 11:00:00,72.82,117.149,66.187,31.349 +2020-10-28 11:15:00,74.56,117.351,66.187,31.349 +2020-10-28 11:30:00,75.37,117.56200000000001,66.187,31.349 +2020-10-28 11:45:00,76.16,117.794,66.187,31.349 +2020-10-28 12:00:00,73.91,113.936,62.18,31.349 +2020-10-28 12:15:00,72.6,113.075,62.18,31.349 +2020-10-28 12:30:00,71.55,113.145,62.18,31.349 +2020-10-28 12:45:00,70.09,113.51,62.18,31.349 +2020-10-28 13:00:00,70.36,113.141,62.23,31.349 +2020-10-28 13:15:00,67.51,112.76299999999999,62.23,31.349 +2020-10-28 13:30:00,65.99,112.054,62.23,31.349 +2020-10-28 13:45:00,66.82,111.676,62.23,31.349 +2020-10-28 14:00:00,67.12,110.936,63.721000000000004,31.349 +2020-10-28 14:15:00,67.37,110.98,63.721000000000004,31.349 +2020-10-28 14:30:00,69.07,110.73200000000001,63.721000000000004,31.349 +2020-10-28 14:45:00,72.24,110.602,63.721000000000004,31.349 +2020-10-28 15:00:00,74.87,109.736,66.523,31.349 +2020-10-28 15:15:00,75.32,109.86,66.523,31.349 +2020-10-28 15:30:00,79.14,110.337,66.523,31.349 +2020-10-28 15:45:00,80.15,110.46799999999999,66.523,31.349 +2020-10-28 16:00:00,83.7,111.055,69.679,31.349 +2020-10-28 16:15:00,83.65,111.71,69.679,31.349 +2020-10-28 16:30:00,85.79,112.07799999999999,69.679,31.349 +2020-10-28 16:45:00,87.82,111.21600000000001,69.679,31.349 +2020-10-28 17:00:00,95.0,111.43700000000001,75.04,31.349 +2020-10-28 17:15:00,96.91,112.52799999999999,75.04,31.349 +2020-10-28 17:30:00,100.33,112.508,75.04,31.349 +2020-10-28 17:45:00,101.95,112.79,75.04,31.349 +2020-10-28 18:00:00,102.58,113.801,75.915,31.349 +2020-10-28 18:15:00,101.4,113.235,75.915,31.349 +2020-10-28 18:30:00,100.78,111.999,75.915,31.349 +2020-10-28 18:45:00,99.5,115.25,75.915,31.349 +2020-10-28 19:00:00,96.55,116.27600000000001,74.66,31.349 +2020-10-28 19:15:00,96.95,114.795,74.66,31.349 +2020-10-28 19:30:00,94.44,113.90700000000001,74.66,31.349 +2020-10-28 19:45:00,93.81,113.212,74.66,31.349 +2020-10-28 20:00:00,97.85,110.491,71.204,31.349 +2020-10-28 20:15:00,98.96,107.59299999999999,71.204,31.349 +2020-10-28 20:30:00,93.72,106.994,71.204,31.349 +2020-10-28 20:45:00,96.99,105.316,71.204,31.349 +2020-10-28 21:00:00,88.01,101.735,61.052,31.349 +2020-10-28 21:15:00,91.89,101.527,61.052,31.349 +2020-10-28 21:30:00,86.26,100.899,61.052,31.349 +2020-10-28 21:45:00,85.22,98.413,61.052,31.349 +2020-10-28 22:00:00,82.78,93.904,54.691,31.349 +2020-10-28 22:15:00,84.41,90.37200000000001,54.691,31.349 +2020-10-28 22:30:00,80.28,79.148,54.691,31.349 +2020-10-28 22:45:00,82.44,72.742,54.691,31.349 +2020-10-28 23:00:00,82.46,67.175,45.18,31.349 +2020-10-28 23:15:00,86.27,66.678,45.18,31.349 +2020-10-28 23:30:00,83.78,65.682,45.18,31.349 +2020-10-28 23:45:00,82.95,65.851,45.18,31.349 +2020-10-29 00:00:00,77.65,62.294,42.746,31.349 +2020-10-29 00:15:00,79.71,63.602,42.746,31.349 +2020-10-29 00:30:00,79.87,63.261,42.746,31.349 +2020-10-29 00:45:00,70.04,63.008,42.746,31.349 +2020-10-29 01:00:00,74.52,63.9,40.025999999999996,31.349 +2020-10-29 01:15:00,77.14,63.611000000000004,40.025999999999996,31.349 +2020-10-29 01:30:00,78.1,63.178999999999995,40.025999999999996,31.349 +2020-10-29 01:45:00,74.52,62.885,40.025999999999996,31.349 +2020-10-29 02:00:00,74.6,63.843999999999994,38.154,31.349 +2020-10-29 02:15:00,78.3,64.126,38.154,31.349 +2020-10-29 02:30:00,77.12,64.58800000000001,38.154,31.349 +2020-10-29 02:45:00,73.83,65.185,38.154,31.349 +2020-10-29 03:00:00,77.13,67.771,37.575,31.349 +2020-10-29 03:15:00,78.81,69.26100000000001,37.575,31.349 +2020-10-29 03:30:00,79.06,69.528,37.575,31.349 +2020-10-29 03:45:00,73.2,70.152,37.575,31.349 +2020-10-29 04:00:00,76.0,80.26,39.154,31.349 +2020-10-29 04:15:00,81.59,90.094,39.154,31.349 +2020-10-29 04:30:00,84.79,89.934,39.154,31.349 +2020-10-29 04:45:00,83.0,91.965,39.154,31.349 +2020-10-29 05:00:00,83.0,121.43299999999999,44.085,31.349 +2020-10-29 05:15:00,86.43,147.484,44.085,31.349 +2020-10-29 05:30:00,90.66,141.559,44.085,31.349 +2020-10-29 05:45:00,100.19,132.532,44.085,31.349 +2020-10-29 06:00:00,111.83,131.852,57.49,31.349 +2020-10-29 06:15:00,117.74,136.226,57.49,31.349 +2020-10-29 06:30:00,119.59,135.217,57.49,31.349 +2020-10-29 06:45:00,115.34,135.85,57.49,31.349 +2020-10-29 07:00:00,122.6,136.632,73.617,31.349 +2020-10-29 07:15:00,123.1,139.474,73.617,31.349 +2020-10-29 07:30:00,124.09,140.114,73.617,31.349 +2020-10-29 07:45:00,126.48,140.257,73.617,31.349 +2020-10-29 08:00:00,126.46,140.285,69.281,31.349 +2020-10-29 08:15:00,123.29,139.715,69.281,31.349 +2020-10-29 08:30:00,119.21,137.487,69.281,31.349 +2020-10-29 08:45:00,118.47,135.429,69.281,31.349 +2020-10-29 09:00:00,118.0,131.224,63.926,31.349 +2020-10-29 09:15:00,122.76,128.815,63.926,31.349 +2020-10-29 09:30:00,119.63,128.67,63.926,31.349 +2020-10-29 09:45:00,122.87,127.926,63.926,31.349 +2020-10-29 10:00:00,122.23,124.705,59.442,31.349 +2020-10-29 10:15:00,119.89,123.21600000000001,59.442,31.349 +2020-10-29 10:30:00,124.6,121.46700000000001,59.442,31.349 +2020-10-29 10:45:00,131.0,120.874,59.442,31.349 +2020-10-29 11:00:00,127.83,117.583,56.771,31.349 +2020-10-29 11:15:00,128.86,117.766,56.771,31.349 +2020-10-29 11:30:00,130.16,117.978,56.771,31.349 +2020-10-29 11:45:00,131.01,118.196,56.771,31.349 +2020-10-29 12:00:00,130.57,114.31700000000001,53.701,31.349 +2020-10-29 12:15:00,130.91,113.45200000000001,53.701,31.349 +2020-10-29 12:30:00,133.3,113.556,53.701,31.349 +2020-10-29 12:45:00,130.48,113.921,53.701,31.349 +2020-10-29 13:00:00,133.45,113.51899999999999,52.364,31.349 +2020-10-29 13:15:00,130.71,113.147,52.364,31.349 +2020-10-29 13:30:00,128.95,112.436,52.364,31.349 +2020-10-29 13:45:00,129.8,112.055,52.364,31.349 +2020-10-29 14:00:00,130.29,111.265,53.419,31.349 +2020-10-29 14:15:00,129.67,111.324,53.419,31.349 +2020-10-29 14:30:00,128.02,111.11200000000001,53.419,31.349 +2020-10-29 14:45:00,130.31,110.98299999999999,53.419,31.349 +2020-10-29 15:00:00,127.71,110.096,56.744,31.349 +2020-10-29 15:15:00,126.9,110.236,56.744,31.349 +2020-10-29 15:30:00,126.36,110.75,56.744,31.349 +2020-10-29 15:45:00,125.0,110.89399999999999,56.744,31.349 +2020-10-29 16:00:00,125.03,111.45200000000001,60.458,31.349 +2020-10-29 16:15:00,124.56,112.12799999999999,60.458,31.349 +2020-10-29 16:30:00,124.0,112.494,60.458,31.349 +2020-10-29 16:45:00,128.25,111.682,60.458,31.349 +2020-10-29 17:00:00,134.32,111.861,66.295,31.349 +2020-10-29 17:15:00,137.45,112.973,66.295,31.349 +2020-10-29 17:30:00,135.07,112.95700000000001,66.295,31.349 +2020-10-29 17:45:00,135.2,113.255,66.295,31.349 +2020-10-29 18:00:00,134.97,114.259,68.468,31.349 +2020-10-29 18:15:00,133.97,113.67,68.468,31.349 +2020-10-29 18:30:00,134.41,112.444,68.468,31.349 +2020-10-29 18:45:00,130.82,115.691,68.468,31.349 +2020-10-29 19:00:00,127.46,116.72399999999999,66.39399999999999,31.349 +2020-10-29 19:15:00,126.41,115.23700000000001,66.39399999999999,31.349 +2020-10-29 19:30:00,124.92,114.338,66.39399999999999,31.349 +2020-10-29 19:45:00,123.49,113.619,66.39399999999999,31.349 +2020-10-29 20:00:00,123.79,110.91799999999999,63.183,31.349 +2020-10-29 20:15:00,121.13,108.01299999999999,63.183,31.349 +2020-10-29 20:30:00,115.55,107.385,63.183,31.349 +2020-10-29 20:45:00,110.22,105.69,63.183,31.349 +2020-10-29 21:00:00,106.12,102.105,55.133,31.349 +2020-10-29 21:15:00,108.93,101.885,55.133,31.349 +2020-10-29 21:30:00,108.46,101.26799999999999,55.133,31.349 +2020-10-29 21:45:00,104.99,98.762,55.133,31.349 +2020-10-29 22:00:00,95.45,94.24700000000001,50.111999999999995,31.349 +2020-10-29 22:15:00,94.84,90.696,50.111999999999995,31.349 +2020-10-29 22:30:00,95.34,79.503,50.111999999999995,31.349 +2020-10-29 22:45:00,96.16,73.104,50.111999999999995,31.349 +2020-10-29 23:00:00,90.51,67.551,44.536,31.349 +2020-10-29 23:15:00,87.23,67.025,44.536,31.349 +2020-10-29 23:30:00,86.68,66.032,44.536,31.349 +2020-10-29 23:45:00,87.36,66.191,44.536,31.349 +2020-10-30 00:00:00,85.77,61.242,42.291000000000004,31.349 +2020-10-30 00:15:00,82.09,62.739,42.291000000000004,31.349 +2020-10-30 00:30:00,77.43,62.463,42.291000000000004,31.349 +2020-10-30 00:45:00,82.66,62.458,42.291000000000004,31.349 +2020-10-30 01:00:00,80.41,63.026,41.008,31.349 +2020-10-30 01:15:00,82.12,62.924,41.008,31.349 +2020-10-30 01:30:00,75.47,62.736000000000004,41.008,31.349 +2020-10-30 01:45:00,77.24,62.371,41.008,31.349 +2020-10-30 02:00:00,74.28,63.827,39.521,31.349 +2020-10-30 02:15:00,74.78,64.03699999999999,39.521,31.349 +2020-10-30 02:30:00,79.52,65.142,39.521,31.349 +2020-10-30 02:45:00,81.93,65.428,39.521,31.349 +2020-10-30 03:00:00,80.63,67.885,39.812,31.349 +2020-10-30 03:15:00,75.97,69.27600000000001,39.812,31.349 +2020-10-30 03:30:00,78.26,69.422,39.812,31.349 +2020-10-30 03:45:00,82.39,70.611,39.812,31.349 +2020-10-30 04:00:00,82.18,80.916,41.22,31.349 +2020-10-30 04:15:00,78.3,89.902,41.22,31.349 +2020-10-30 04:30:00,84.06,90.31200000000001,41.22,31.349 +2020-10-30 04:45:00,88.56,91.48200000000001,41.22,31.349 +2020-10-30 05:00:00,92.62,120.163,45.115,31.349 +2020-10-30 05:15:00,89.69,147.474,45.115,31.349 +2020-10-30 05:30:00,95.75,142.15200000000002,45.115,31.349 +2020-10-30 05:45:00,102.23,132.844,45.115,31.349 +2020-10-30 06:00:00,113.59,132.496,59.06100000000001,31.349 +2020-10-30 06:15:00,111.04,136.235,59.06100000000001,31.349 +2020-10-30 06:30:00,113.71,134.79399999999998,59.06100000000001,31.349 +2020-10-30 06:45:00,116.04,136.179,59.06100000000001,31.349 +2020-10-30 07:00:00,119.06,136.855,71.874,31.349 +2020-10-30 07:15:00,120.84,140.64700000000002,71.874,31.349 +2020-10-30 07:30:00,122.72,140.326,71.874,31.349 +2020-10-30 07:45:00,122.52,139.918,71.874,31.349 +2020-10-30 08:00:00,125.5,139.739,68.439,31.349 +2020-10-30 08:15:00,124.47,139.237,68.439,31.349 +2020-10-30 08:30:00,127.07,137.506,68.439,31.349 +2020-10-30 08:45:00,127.1,134.52700000000002,68.439,31.349 +2020-10-30 09:00:00,128.83,129.542,65.523,31.349 +2020-10-30 09:15:00,129.4,128.264,65.523,31.349 +2020-10-30 09:30:00,128.5,127.581,65.523,31.349 +2020-10-30 09:45:00,127.31,126.919,65.523,31.349 +2020-10-30 10:00:00,126.26,122.965,62.005,31.349 +2020-10-30 10:15:00,124.62,121.74,62.005,31.349 +2020-10-30 10:30:00,123.49,120.156,62.005,31.349 +2020-10-30 10:45:00,124.03,119.26,62.005,31.349 +2020-10-30 11:00:00,124.17,116.04299999999999,60.351000000000006,31.349 +2020-10-30 11:15:00,126.28,115.26100000000001,60.351000000000006,31.349 +2020-10-30 11:30:00,123.52,116.325,60.351000000000006,31.349 +2020-10-30 11:45:00,123.86,116.17299999999999,60.351000000000006,31.349 +2020-10-30 12:00:00,121.54,113.084,55.331,31.349 +2020-10-30 12:15:00,125.28,110.75,55.331,31.349 +2020-10-30 12:30:00,124.06,111.024,55.331,31.349 +2020-10-30 12:45:00,121.96,111.383,55.331,31.349 +2020-10-30 13:00:00,118.71,111.73299999999999,53.361999999999995,31.349 +2020-10-30 13:15:00,120.56,111.917,53.361999999999995,31.349 +2020-10-30 13:30:00,118.3,111.51799999999999,53.361999999999995,31.349 +2020-10-30 13:45:00,120.01,111.213,53.361999999999995,31.349 +2020-10-30 14:00:00,117.73,109.399,51.708,31.349 +2020-10-30 14:15:00,118.43,109.535,51.708,31.349 +2020-10-30 14:30:00,118.52,110.236,51.708,31.349 +2020-10-30 14:45:00,119.61,110.02,51.708,31.349 +2020-10-30 15:00:00,120.23,108.8,54.571000000000005,31.349 +2020-10-30 15:15:00,118.83,108.60799999999999,54.571000000000005,31.349 +2020-10-30 15:30:00,118.31,108.04899999999999,54.571000000000005,31.349 +2020-10-30 15:45:00,119.66,108.585,54.571000000000005,31.349 +2020-10-30 16:00:00,120.97,108.147,58.662,31.349 +2020-10-30 16:15:00,120.45,109.209,58.662,31.349 +2020-10-30 16:30:00,122.73,109.552,58.662,31.349 +2020-10-30 16:45:00,126.37,108.40799999999999,58.662,31.349 +2020-10-30 17:00:00,132.24,109.404,65.941,31.349 +2020-10-30 17:15:00,131.66,110.236,65.941,31.349 +2020-10-30 17:30:00,135.32,110.119,65.941,31.349 +2020-10-30 17:45:00,131.92,110.234,65.941,31.349 +2020-10-30 18:00:00,131.8,111.662,65.628,31.349 +2020-10-30 18:15:00,129.88,110.432,65.628,31.349 +2020-10-30 18:30:00,128.86,109.383,65.628,31.349 +2020-10-30 18:45:00,128.89,112.803,65.628,31.349 +2020-10-30 19:00:00,127.22,114.73,63.662,31.349 +2020-10-30 19:15:00,123.33,114.22,63.662,31.349 +2020-10-30 19:30:00,121.1,113.117,63.662,31.349 +2020-10-30 19:45:00,121.23,111.685,63.662,31.349 +2020-10-30 20:00:00,115.04,108.969,61.945,31.349 +2020-10-30 20:15:00,110.74,106.413,61.945,31.349 +2020-10-30 20:30:00,109.01,105.51799999999999,61.945,31.349 +2020-10-30 20:45:00,106.5,103.742,61.945,31.349 +2020-10-30 21:00:00,100.01,101.007,53.903,31.349 +2020-10-30 21:15:00,102.44,101.75200000000001,53.903,31.349 +2020-10-30 21:30:00,100.98,101.102,53.903,31.349 +2020-10-30 21:45:00,94.15,98.95299999999999,53.903,31.349 +2020-10-30 22:00:00,87.77,94.88,48.403999999999996,31.349 +2020-10-30 22:15:00,85.26,91.131,48.403999999999996,31.349 +2020-10-30 22:30:00,84.58,85.432,48.403999999999996,31.349 +2020-10-30 22:45:00,83.7,81.27199999999999,48.403999999999996,31.349 +2020-10-30 23:00:00,82.16,76.253,41.07,31.349 +2020-10-30 23:15:00,81.37,73.979,41.07,31.349 +2020-10-30 23:30:00,73.71,71.40899999999999,41.07,31.349 +2020-10-30 23:45:00,71.13,71.156,41.07,31.349 +2020-10-31 00:00:00,72.45,61.597,38.989000000000004,31.177 +2020-10-31 00:15:00,73.37,59.941,38.989000000000004,31.177 +2020-10-31 00:30:00,73.61,59.854,38.989000000000004,31.177 +2020-10-31 00:45:00,71.83,60.141000000000005,38.989000000000004,31.177 +2020-10-31 01:00:00,68.78,61.233000000000004,35.275,31.177 +2020-10-31 01:15:00,71.23,61.393,35.275,31.177 +2020-10-31 01:30:00,69.97,60.773,35.275,31.177 +2020-10-31 01:45:00,65.93,60.555,35.275,31.177 +2020-10-31 02:00:00,64.99,61.573,32.838,31.177 +2020-10-31 02:15:00,69.96,61.156000000000006,32.838,31.177 +2020-10-31 02:30:00,68.68,61.831,32.838,31.177 +2020-10-31 02:45:00,66.64,62.625,32.838,31.177 +2020-10-31 03:00:00,65.79,65.196,32.418,31.177 +2020-10-31 03:15:00,67.38,65.55199999999999,32.418,31.177 +2020-10-31 03:30:00,67.91,65.406,32.418,31.177 +2020-10-31 03:45:00,63.45,66.92699999999999,32.418,31.177 +2020-10-31 04:00:00,60.12,74.21600000000001,32.099000000000004,31.177 +2020-10-31 04:15:00,61.24,80.874,32.099000000000004,31.177 +2020-10-31 04:30:00,62.55,80.10300000000001,32.099000000000004,31.177 +2020-10-31 04:45:00,62.68,81.078,32.099000000000004,31.177 +2020-10-31 05:00:00,63.12,96.679,32.926,31.177 +2020-10-31 05:15:00,62.57,108.109,32.926,31.177 +2020-10-31 05:30:00,63.2,103.416,32.926,31.177 +2020-10-31 05:45:00,65.37,98.848,32.926,31.177 +2020-10-31 06:00:00,68.19,111.434,35.069,31.177 +2020-10-31 06:15:00,69.79,126.461,35.069,31.177 +2020-10-31 06:30:00,71.11,120.191,35.069,31.177 +2020-10-31 06:45:00,73.82,114.679,35.069,31.177 +2020-10-31 07:00:00,75.79,113.76,40.906,31.177 +2020-10-31 07:15:00,77.22,115.145,40.906,31.177 +2020-10-31 07:30:00,78.86,116.89,40.906,31.177 +2020-10-31 07:45:00,81.1,118.516,40.906,31.177 +2020-10-31 08:00:00,83.23,121.64399999999999,46.603,31.177 +2020-10-31 08:15:00,84.49,123.396,46.603,31.177 +2020-10-31 08:30:00,86.25,123.77,46.603,31.177 +2020-10-31 08:45:00,88.76,123.838,46.603,31.177 +2020-10-31 09:00:00,92.45,120.73899999999999,49.935,31.177 +2020-10-31 09:15:00,93.68,120.148,49.935,31.177 +2020-10-31 09:30:00,94.02,120.255,49.935,31.177 +2020-10-31 09:45:00,94.02,119.835,49.935,31.177 +2020-10-31 10:00:00,92.45,117.68,47.585,31.177 +2020-10-31 10:15:00,93.24,117.001,47.585,31.177 +2020-10-31 10:30:00,92.64,115.77,47.585,31.177 +2020-10-31 10:45:00,92.15,114.821,47.585,31.177 +2020-10-31 11:00:00,93.34,111.95100000000001,43.376999999999995,31.177 +2020-10-31 11:15:00,96.08,111.00299999999999,43.376999999999995,31.177 +2020-10-31 11:30:00,95.83,111.277,43.376999999999995,31.177 +2020-10-31 11:45:00,96.13,111.28,43.376999999999995,31.177 +2020-10-31 12:00:00,95.48,107.751,40.855,31.177 +2020-10-31 12:15:00,95.57,106.947,40.855,31.177 +2020-10-31 12:30:00,92.12,106.645,40.855,31.177 +2020-10-31 12:45:00,91.78,106.042,40.855,31.177 +2020-10-31 13:00:00,88.92,105.20100000000001,37.251,31.177 +2020-10-31 13:15:00,89.25,105.492,37.251,31.177 +2020-10-31 13:30:00,88.99,104.34899999999999,37.251,31.177 +2020-10-31 13:45:00,89.08,103.85700000000001,37.251,31.177 +2020-10-31 14:00:00,86.2,103.321,38.548,31.177 +2020-10-31 14:15:00,87.18,103.538,38.548,31.177 +2020-10-31 14:30:00,86.99,103.285,38.548,31.177 +2020-10-31 14:45:00,87.31,102.726,38.548,31.177 +2020-10-31 15:00:00,87.48,101.215,42.883,31.177 +2020-10-31 15:15:00,87.93,101.897,42.883,31.177 +2020-10-31 15:30:00,87.72,102.617,42.883,31.177 +2020-10-31 15:45:00,88.54,103.33200000000001,42.883,31.177 +2020-10-31 16:00:00,90.83,103.56700000000001,48.143,31.177 +2020-10-31 16:15:00,90.48,104.383,48.143,31.177 +2020-10-31 16:30:00,92.56,105.337,48.143,31.177 +2020-10-31 16:45:00,97.53,104.829,48.143,31.177 +2020-10-31 17:00:00,102.04,105.22399999999999,55.25,31.177 +2020-10-31 17:15:00,100.68,106.541,55.25,31.177 +2020-10-31 17:30:00,102.51,106.81700000000001,55.25,31.177 +2020-10-31 17:45:00,102.66,108.331,55.25,31.177 +2020-10-31 18:00:00,104.1,110.088,57.506,31.177 +2020-10-31 18:15:00,107.0,111.04,57.506,31.177 +2020-10-31 18:30:00,103.91,110.087,57.506,31.177 +2020-10-31 18:45:00,104.55,111.25,57.506,31.177 +2020-10-31 19:00:00,100.55,113.781,55.528999999999996,31.177 +2020-10-31 19:15:00,99.24,112.38600000000001,55.528999999999996,31.177 +2020-10-31 19:30:00,97.68,111.81,55.528999999999996,31.177 +2020-10-31 19:45:00,96.75,111.583,55.528999999999996,31.177 +2020-10-31 20:00:00,91.74,110.46700000000001,46.166000000000004,31.177 +2020-10-31 20:15:00,88.35,109.18799999999999,46.166000000000004,31.177 +2020-10-31 20:30:00,86.31,108.696,46.166000000000004,31.177 +2020-10-31 20:45:00,83.91,106.139,46.166000000000004,31.177 +2020-10-31 21:00:00,80.56,102.51899999999999,40.406,31.177 +2020-10-31 21:15:00,80.4,102.87,40.406,31.177 +2020-10-31 21:30:00,79.05,102.81200000000001,40.406,31.177 +2020-10-31 21:45:00,77.96,100.42200000000001,40.406,31.177 +2020-10-31 22:00:00,75.29,97.337,39.616,31.177 +2020-10-31 22:15:00,74.6,93.835,39.616,31.177 +2020-10-31 22:30:00,71.03,89.384,39.616,31.177 +2020-10-31 22:45:00,70.49,85.363,39.616,31.177 +2020-10-31 23:00:00,67.0,79.763,32.205,31.177 +2020-10-31 23:15:00,66.8,78.317,32.205,31.177 +2020-10-31 23:30:00,64.91,76.102,32.205,31.177 +2020-10-31 23:45:00,63.18,75.262,32.205,31.177 +2020-11-01 00:00:00,58.91,73.844,36.376,32.047 +2020-11-01 00:15:00,57.64,71.598,36.376,32.047 +2020-11-01 00:30:00,58.09,71.771,36.376,32.047 +2020-11-01 00:45:00,56.8,72.655,36.376,32.047 +2020-11-01 01:00:00,53.61,74.10300000000001,32.992,32.047 +2020-11-01 01:15:00,55.48,74.691,32.992,32.047 +2020-11-01 01:30:00,54.86,74.157,32.992,32.047 +2020-11-01 01:45:00,55.56,74.169,32.992,32.047 +2020-11-01 02:00:00,53.33,75.35600000000001,32.327,32.047 +2020-11-01 02:15:00,53.19,74.95,32.327,32.047 +2020-11-01 02:30:00,53.38,75.499,32.327,32.047 +2020-11-01 02:45:00,53.54,76.763,32.327,32.047 +2020-11-01 03:00:00,52.29,79.542,31.169,32.047 +2020-11-01 03:15:00,53.27,79.608,31.169,32.047 +2020-11-01 03:30:00,53.38,80.009,31.169,32.047 +2020-11-01 03:45:00,53.67,81.561,31.169,32.047 +2020-11-01 04:00:00,53.04,90.10600000000001,30.796,32.047 +2020-11-01 04:15:00,53.69,97.82600000000001,30.796,32.047 +2020-11-01 04:30:00,54.71,97.304,30.796,32.047 +2020-11-01 04:45:00,54.7,98.21600000000001,30.796,32.047 +2020-11-01 05:00:00,56.28,114.69200000000001,30.848000000000003,32.047 +2020-11-01 05:15:00,56.84,126.919,30.848000000000003,32.047 +2020-11-01 05:30:00,56.12,122.334,30.848000000000003,32.047 +2020-11-01 05:45:00,57.2,117.79299999999999,30.848000000000003,32.047 +2020-11-01 06:00:00,58.93,131.375,31.166,32.047 +2020-11-01 06:15:00,60.16,148.164,31.166,32.047 +2020-11-01 06:30:00,60.09,141.499,31.166,32.047 +2020-11-01 06:45:00,61.33,135.033,31.166,32.047 +2020-11-01 07:00:00,62.62,134.209,33.527,32.047 +2020-11-01 07:15:00,64.53,135.998,33.527,32.047 +2020-11-01 07:30:00,66.19,138.07299999999998,33.527,32.047 +2020-11-01 07:45:00,69.32,140.029,33.527,32.047 +2020-11-01 08:00:00,72.18,143.476,36.616,32.047 +2020-11-01 08:15:00,73.05,145.618,36.616,32.047 +2020-11-01 08:30:00,75.6,146.314,36.616,32.047 +2020-11-01 08:45:00,76.81,146.583,36.616,32.047 +2020-11-01 09:00:00,78.39,143.313,37.857,32.047 +2020-11-01 09:15:00,79.58,142.33100000000002,37.857,32.047 +2020-11-01 09:30:00,79.36,141.849,37.857,32.047 +2020-11-01 09:45:00,80.15,141.034,37.857,32.047 +2020-11-01 10:00:00,78.79,139.36,36.319,32.047 +2020-11-01 10:15:00,81.18,138.553,36.319,32.047 +2020-11-01 10:30:00,82.06,137.161,36.319,32.047 +2020-11-01 10:45:00,84.37,135.914,36.319,32.047 +2020-11-01 11:00:00,87.44,132.654,37.236999999999995,32.047 +2020-11-01 11:15:00,90.16,131.516,37.236999999999995,32.047 +2020-11-01 11:30:00,92.79,131.694,37.236999999999995,32.047 +2020-11-01 11:45:00,94.47,132.332,37.236999999999995,32.047 +2020-11-01 12:00:00,93.72,128.334,34.871,32.047 +2020-11-01 12:15:00,91.05,127.323,34.871,32.047 +2020-11-01 12:30:00,88.73,126.853,34.871,32.047 +2020-11-01 12:45:00,87.98,126.25399999999999,34.871,32.047 +2020-11-01 13:00:00,81.86,125.57600000000001,29.738000000000003,32.047 +2020-11-01 13:15:00,79.48,125.912,29.738000000000003,32.047 +2020-11-01 13:30:00,78.24,124.734,29.738000000000003,32.047 +2020-11-01 13:45:00,77.77,124.292,29.738000000000003,32.047 +2020-11-01 14:00:00,78.82,124.43,27.333000000000002,32.047 +2020-11-01 14:15:00,77.97,124.331,27.333000000000002,32.047 +2020-11-01 14:30:00,78.18,123.743,27.333000000000002,32.047 +2020-11-01 14:45:00,79.25,123.29,27.333000000000002,32.047 +2020-11-01 15:00:00,79.18,121.579,28.232,32.047 +2020-11-01 15:15:00,79.37,122.45100000000001,28.232,32.047 +2020-11-01 15:30:00,79.55,122.805,28.232,32.047 +2020-11-01 15:45:00,81.75,123.443,28.232,32.047 +2020-11-01 16:00:00,83.82,125.545,32.815,32.047 +2020-11-01 16:15:00,83.89,127.041,32.815,32.047 +2020-11-01 16:30:00,84.8,127.854,32.815,32.047 +2020-11-01 16:45:00,89.87,127.169,32.815,32.047 +2020-11-01 17:00:00,96.6,129.106,43.068999999999996,32.047 +2020-11-01 17:15:00,96.91,130.164,43.068999999999996,32.047 +2020-11-01 17:30:00,99.73,130.485,43.068999999999996,32.047 +2020-11-01 17:45:00,101.95,131.416,43.068999999999996,32.047 +2020-11-01 18:00:00,100.66,134.56799999999998,50.498999999999995,32.047 +2020-11-01 18:15:00,100.99,135.72299999999998,50.498999999999995,32.047 +2020-11-01 18:30:00,97.5,134.084,50.498999999999995,32.047 +2020-11-01 18:45:00,96.13,135.263,50.498999999999995,32.047 +2020-11-01 19:00:00,94.03,137.83700000000002,53.481,32.047 +2020-11-01 19:15:00,94.0,136.382,53.481,32.047 +2020-11-01 19:30:00,92.13,135.606,53.481,32.047 +2020-11-01 19:45:00,92.89,135.071,53.481,32.047 +2020-11-01 20:00:00,90.69,132.186,51.687,32.047 +2020-11-01 20:15:00,94.58,130.61700000000002,51.687,32.047 +2020-11-01 20:30:00,92.24,130.408,51.687,32.047 +2020-11-01 20:45:00,86.66,127.39,51.687,32.047 +2020-11-01 21:00:00,83.37,123.02,47.674,32.047 +2020-11-01 21:15:00,84.21,122.93,47.674,32.047 +2020-11-01 21:30:00,88.7,122.751,47.674,32.047 +2020-11-01 21:45:00,90.47,120.36399999999999,47.674,32.047 +2020-11-01 22:00:00,90.97,116.14,48.178000000000004,32.047 +2020-11-01 22:15:00,86.98,112.27799999999999,48.178000000000004,32.047 +2020-11-01 22:30:00,83.62,106.661,48.178000000000004,32.047 +2020-11-01 22:45:00,82.66,102.23,48.178000000000004,32.047 +2020-11-01 23:00:00,81.93,96.52,42.553999999999995,32.047 +2020-11-01 23:15:00,85.7,94.42200000000001,42.553999999999995,32.047 +2020-11-01 23:30:00,82.06,92.15700000000001,42.553999999999995,32.047 +2020-11-01 23:45:00,78.61,90.99799999999999,42.553999999999995,32.047 +2020-11-02 00:00:00,77.71,77.483,37.177,32.225 +2020-11-02 00:15:00,78.04,77.413,37.177,32.225 +2020-11-02 00:30:00,75.0,77.514,37.177,32.225 +2020-11-02 00:45:00,71.86,77.888,37.177,32.225 +2020-11-02 01:00:00,73.26,79.508,35.358000000000004,32.225 +2020-11-02 01:15:00,75.19,79.788,35.358000000000004,32.225 +2020-11-02 01:30:00,74.29,79.453,35.358000000000004,32.225 +2020-11-02 01:45:00,71.36,79.48899999999999,35.358000000000004,32.225 +2020-11-02 02:00:00,74.46,80.84899999999999,35.03,32.225 +2020-11-02 02:15:00,76.19,80.941,35.03,32.225 +2020-11-02 02:30:00,73.58,81.756,35.03,32.225 +2020-11-02 02:45:00,70.49,82.568,35.03,32.225 +2020-11-02 03:00:00,71.48,86.30799999999999,34.394,32.225 +2020-11-02 03:15:00,76.54,87.72,34.394,32.225 +2020-11-02 03:30:00,78.06,88.242,34.394,32.225 +2020-11-02 03:45:00,72.8,89.243,34.394,32.225 +2020-11-02 04:00:00,77.28,101.759,34.421,32.225 +2020-11-02 04:15:00,80.21,113.29299999999999,34.421,32.225 +2020-11-02 04:30:00,81.33,113.90799999999999,34.421,32.225 +2020-11-02 04:45:00,78.06,115.085,34.421,32.225 +2020-11-02 05:00:00,85.36,144.252,39.435,32.225 +2020-11-02 05:15:00,91.98,173.24200000000002,39.435,32.225 +2020-11-02 05:30:00,96.04,168.166,39.435,32.225 +2020-11-02 05:45:00,95.92,158.836,39.435,32.225 +2020-11-02 06:00:00,104.79,157.326,55.685,32.225 +2020-11-02 06:15:00,114.4,161.115,55.685,32.225 +2020-11-02 06:30:00,121.39,161.122,55.685,32.225 +2020-11-02 06:45:00,125.87,162.344,55.685,32.225 +2020-11-02 07:00:00,126.76,163.525,66.837,32.225 +2020-11-02 07:15:00,126.88,167.03400000000002,66.837,32.225 +2020-11-02 07:30:00,127.09,168.299,66.837,32.225 +2020-11-02 07:45:00,125.37,168.74099999999999,66.837,32.225 +2020-11-02 08:00:00,127.99,168.55700000000002,72.217,32.225 +2020-11-02 08:15:00,126.32,168.81599999999997,72.217,32.225 +2020-11-02 08:30:00,126.89,166.49400000000003,72.217,32.225 +2020-11-02 08:45:00,124.03,164.62900000000002,72.217,32.225 +2020-11-02 09:00:00,123.27,160.44799999999998,66.117,32.225 +2020-11-02 09:15:00,123.92,156.477,66.117,32.225 +2020-11-02 09:30:00,125.15,154.977,66.117,32.225 +2020-11-02 09:45:00,128.93,153.493,66.117,32.225 +2020-11-02 10:00:00,124.84,151.434,62.1,32.225 +2020-11-02 10:15:00,123.92,150.312,62.1,32.225 +2020-11-02 10:30:00,124.83,148.13299999999998,62.1,32.225 +2020-11-02 10:45:00,125.47,146.829,62.1,32.225 +2020-11-02 11:00:00,125.02,141.958,60.021,32.225 +2020-11-02 11:15:00,116.55,142.15,60.021,32.225 +2020-11-02 11:30:00,119.3,143.525,60.021,32.225 +2020-11-02 11:45:00,122.68,144.034,60.021,32.225 +2020-11-02 12:00:00,116.26,140.8,56.75899999999999,32.225 +2020-11-02 12:15:00,116.59,139.82399999999998,56.75899999999999,32.225 +2020-11-02 12:30:00,124.27,139.215,56.75899999999999,32.225 +2020-11-02 12:45:00,119.06,139.694,56.75899999999999,32.225 +2020-11-02 13:00:00,118.02,139.783,56.04600000000001,32.225 +2020-11-02 13:15:00,119.4,138.85399999999998,56.04600000000001,32.225 +2020-11-02 13:30:00,122.02,137.35399999999998,56.04600000000001,32.225 +2020-11-02 13:45:00,119.1,137.218,56.04600000000001,32.225 +2020-11-02 14:00:00,122.77,136.59,55.475,32.225 +2020-11-02 14:15:00,120.69,136.239,55.475,32.225 +2020-11-02 14:30:00,120.41,135.251,55.475,32.225 +2020-11-02 14:45:00,124.04,135.44,55.475,32.225 +2020-11-02 15:00:00,120.94,134.887,57.048,32.225 +2020-11-02 15:15:00,120.7,134.558,57.048,32.225 +2020-11-02 15:30:00,120.66,134.607,57.048,32.225 +2020-11-02 15:45:00,121.41,134.784,57.048,32.225 +2020-11-02 16:00:00,122.05,137.142,59.06,32.225 +2020-11-02 16:15:00,124.08,138.155,59.06,32.225 +2020-11-02 16:30:00,124.36,138.067,59.06,32.225 +2020-11-02 16:45:00,129.23,136.614,59.06,32.225 +2020-11-02 17:00:00,134.05,137.868,65.419,32.225 +2020-11-02 17:15:00,132.55,138.431,65.419,32.225 +2020-11-02 17:30:00,137.22,138.244,65.419,32.225 +2020-11-02 17:45:00,135.22,138.03,65.419,32.225 +2020-11-02 18:00:00,135.07,141.039,69.345,32.225 +2020-11-02 18:15:00,132.86,140.007,69.345,32.225 +2020-11-02 18:30:00,131.02,138.54399999999998,69.345,32.225 +2020-11-02 18:45:00,131.86,141.345,69.345,32.225 +2020-11-02 19:00:00,128.07,142.715,73.825,32.225 +2020-11-02 19:15:00,126.32,140.951,73.825,32.225 +2020-11-02 19:30:00,127.01,140.357,73.825,32.225 +2020-11-02 19:45:00,122.74,139.009,73.825,32.225 +2020-11-02 20:00:00,119.02,134.085,64.027,32.225 +2020-11-02 20:15:00,113.3,131.38299999999998,64.027,32.225 +2020-11-02 20:30:00,108.72,130.102,64.027,32.225 +2020-11-02 20:45:00,106.21,128.234,64.027,32.225 +2020-11-02 21:00:00,107.92,123.986,57.952,32.225 +2020-11-02 21:15:00,109.71,123.275,57.952,32.225 +2020-11-02 21:30:00,108.09,122.71700000000001,57.952,32.225 +2020-11-02 21:45:00,100.41,119.898,57.952,32.225 +2020-11-02 22:00:00,93.26,112.988,53.031000000000006,32.225 +2020-11-02 22:15:00,93.34,108.98299999999999,53.031000000000006,32.225 +2020-11-02 22:30:00,98.42,95.46700000000001,53.031000000000006,32.225 +2020-11-02 22:45:00,95.12,87.84,53.031000000000006,32.225 +2020-11-02 23:00:00,89.39,82.635,45.085,32.225 +2020-11-02 23:15:00,85.21,81.652,45.085,32.225 +2020-11-02 23:30:00,86.99,81.206,45.085,32.225 +2020-11-02 23:45:00,87.54,81.581,45.085,32.225 +2020-11-03 00:00:00,84.2,76.499,42.843,32.225 +2020-11-03 00:15:00,78.55,77.664,42.843,32.225 +2020-11-03 00:30:00,76.57,77.46300000000001,42.843,32.225 +2020-11-03 00:45:00,82.07,77.531,42.843,32.225 +2020-11-03 01:00:00,80.46,78.831,41.542,32.225 +2020-11-03 01:15:00,79.44,78.893,41.542,32.225 +2020-11-03 01:30:00,74.87,78.63,41.542,32.225 +2020-11-03 01:45:00,72.5,78.641,41.542,32.225 +2020-11-03 02:00:00,78.57,79.813,40.19,32.225 +2020-11-03 02:15:00,80.48,80.32300000000001,40.19,32.225 +2020-11-03 02:30:00,80.8,80.593,40.19,32.225 +2020-11-03 02:45:00,74.67,81.544,40.19,32.225 +2020-11-03 03:00:00,71.93,84.294,39.626,32.225 +2020-11-03 03:15:00,79.33,85.59299999999999,39.626,32.225 +2020-11-03 03:30:00,82.08,86.404,39.626,32.225 +2020-11-03 03:45:00,82.13,87.045,39.626,32.225 +2020-11-03 04:00:00,77.08,98.926,40.196999999999996,32.225 +2020-11-03 04:15:00,77.12,110.274,40.196999999999996,32.225 +2020-11-03 04:30:00,82.35,110.641,40.196999999999996,32.225 +2020-11-03 04:45:00,87.06,112.781,40.196999999999996,32.225 +2020-11-03 05:00:00,91.11,145.749,43.378,32.225 +2020-11-03 05:15:00,88.11,174.916,43.378,32.225 +2020-11-03 05:30:00,88.09,168.97,43.378,32.225 +2020-11-03 05:45:00,96.86,159.315,43.378,32.225 +2020-11-03 06:00:00,106.9,157.497,55.691,32.225 +2020-11-03 06:15:00,109.83,162.39700000000002,55.691,32.225 +2020-11-03 06:30:00,115.64,161.89700000000002,55.691,32.225 +2020-11-03 06:45:00,121.32,162.537,55.691,32.225 +2020-11-03 07:00:00,121.08,163.66,65.567,32.225 +2020-11-03 07:15:00,122.41,166.97799999999998,65.567,32.225 +2020-11-03 07:30:00,122.07,167.926,65.567,32.225 +2020-11-03 07:45:00,122.18,168.12400000000002,65.567,32.225 +2020-11-03 08:00:00,123.78,168.005,73.001,32.225 +2020-11-03 08:15:00,123.3,167.40099999999998,73.001,32.225 +2020-11-03 08:30:00,121.01,165.054,73.001,32.225 +2020-11-03 08:45:00,120.18,162.636,73.001,32.225 +2020-11-03 09:00:00,119.27,158.009,67.08800000000001,32.225 +2020-11-03 09:15:00,120.13,155.065,67.08800000000001,32.225 +2020-11-03 09:30:00,119.45,154.263,67.08800000000001,32.225 +2020-11-03 09:45:00,119.38,153.096,67.08800000000001,32.225 +2020-11-03 10:00:00,117.17,150.135,62.803000000000004,32.225 +2020-11-03 10:15:00,116.37,148.19,62.803000000000004,32.225 +2020-11-03 10:30:00,114.86,146.122,62.803000000000004,32.225 +2020-11-03 10:45:00,114.46,145.343,62.803000000000004,32.225 +2020-11-03 11:00:00,115.53,141.503,60.155,32.225 +2020-11-03 11:15:00,116.0,141.577,60.155,32.225 +2020-11-03 11:30:00,114.96,141.749,60.155,32.225 +2020-11-03 11:45:00,115.15,142.586,60.155,32.225 +2020-11-03 12:00:00,114.54,138.3,56.845,32.225 +2020-11-03 12:15:00,113.41,137.136,56.845,32.225 +2020-11-03 12:30:00,116.09,137.35399999999998,56.845,32.225 +2020-11-03 12:45:00,113.87,137.849,56.845,32.225 +2020-11-03 13:00:00,112.05,137.47799999999998,56.163000000000004,32.225 +2020-11-03 13:15:00,113.4,136.88,56.163000000000004,32.225 +2020-11-03 13:30:00,111.98,136.209,56.163000000000004,32.225 +2020-11-03 13:45:00,112.78,135.895,56.163000000000004,32.225 +2020-11-03 14:00:00,114.01,135.56,55.934,32.225 +2020-11-03 14:15:00,114.62,135.273,55.934,32.225 +2020-11-03 14:30:00,114.22,134.879,55.934,32.225 +2020-11-03 14:45:00,116.32,134.763,55.934,32.225 +2020-11-03 15:00:00,118.75,133.835,57.43899999999999,32.225 +2020-11-03 15:15:00,121.12,134.054,57.43899999999999,32.225 +2020-11-03 15:30:00,118.99,134.238,57.43899999999999,32.225 +2020-11-03 15:45:00,120.3,134.248,57.43899999999999,32.225 +2020-11-03 16:00:00,121.23,136.631,59.968999999999994,32.225 +2020-11-03 16:15:00,121.12,138.031,59.968999999999994,32.225 +2020-11-03 16:30:00,124.29,138.269,59.968999999999994,32.225 +2020-11-03 16:45:00,128.38,137.31,59.968999999999994,32.225 +2020-11-03 17:00:00,134.36,138.976,67.428,32.225 +2020-11-03 17:15:00,135.38,139.727,67.428,32.225 +2020-11-03 17:30:00,136.98,139.846,67.428,32.225 +2020-11-03 17:45:00,136.7,139.438,67.428,32.225 +2020-11-03 18:00:00,135.03,142.02700000000002,71.533,32.225 +2020-11-03 18:15:00,133.99,141.156,71.533,32.225 +2020-11-03 18:30:00,132.72,139.4,71.533,32.225 +2020-11-03 18:45:00,132.44,142.667,71.533,32.225 +2020-11-03 19:00:00,129.7,143.67,73.32300000000001,32.225 +2020-11-03 19:15:00,127.73,141.774,73.32300000000001,32.225 +2020-11-03 19:30:00,125.23,140.636,73.32300000000001,32.225 +2020-11-03 19:45:00,124.06,139.406,73.32300000000001,32.225 +2020-11-03 20:00:00,116.4,134.749,64.166,32.225 +2020-11-03 20:15:00,113.06,131.075,64.166,32.225 +2020-11-03 20:30:00,111.89,130.452,64.166,32.225 +2020-11-03 20:45:00,108.23,128.349,64.166,32.225 +2020-11-03 21:00:00,111.71,123.95299999999999,57.891999999999996,32.225 +2020-11-03 21:15:00,112.07,123.279,57.891999999999996,32.225 +2020-11-03 21:30:00,109.48,122.31299999999999,57.891999999999996,32.225 +2020-11-03 21:45:00,100.8,119.70200000000001,57.891999999999996,32.225 +2020-11-03 22:00:00,96.06,113.992,53.242,32.225 +2020-11-03 22:15:00,98.84,109.67299999999999,53.242,32.225 +2020-11-03 22:30:00,98.89,96.368,53.242,32.225 +2020-11-03 22:45:00,97.23,88.929,53.242,32.225 +2020-11-03 23:00:00,87.29,83.465,46.665,32.225 +2020-11-03 23:15:00,90.27,82.43700000000001,46.665,32.225 +2020-11-03 23:30:00,89.3,81.75399999999999,46.665,32.225 +2020-11-03 23:45:00,90.61,81.89,46.665,32.225 +2020-11-04 00:00:00,81.4,76.873,43.16,32.225 +2020-11-04 00:15:00,84.07,78.01899999999999,43.16,32.225 +2020-11-04 00:30:00,85.61,77.822,43.16,32.225 +2020-11-04 00:45:00,83.69,77.87899999999999,43.16,32.225 +2020-11-04 01:00:00,75.16,79.202,40.972,32.225 +2020-11-04 01:15:00,82.28,79.28,40.972,32.225 +2020-11-04 01:30:00,82.05,79.033,40.972,32.225 +2020-11-04 01:45:00,82.13,79.03699999999999,40.972,32.225 +2020-11-04 02:00:00,77.14,80.223,39.749,32.225 +2020-11-04 02:15:00,83.18,80.742,39.749,32.225 +2020-11-04 02:30:00,81.85,81.001,39.749,32.225 +2020-11-04 02:45:00,79.64,81.949,39.749,32.225 +2020-11-04 03:00:00,77.05,84.684,39.422,32.225 +2020-11-04 03:15:00,82.52,86.007,39.422,32.225 +2020-11-04 03:30:00,83.16,86.821,39.422,32.225 +2020-11-04 03:45:00,81.55,87.447,39.422,32.225 +2020-11-04 04:00:00,77.98,99.337,40.505,32.225 +2020-11-04 04:15:00,84.43,110.712,40.505,32.225 +2020-11-04 04:30:00,87.82,111.073,40.505,32.225 +2020-11-04 04:45:00,88.51,113.22,40.505,32.225 +2020-11-04 05:00:00,88.07,146.239,43.397,32.225 +2020-11-04 05:15:00,92.6,175.447,43.397,32.225 +2020-11-04 05:30:00,99.67,169.497,43.397,32.225 +2020-11-04 05:45:00,101.58,159.82,43.397,32.225 +2020-11-04 06:00:00,103.46,157.997,55.218,32.225 +2020-11-04 06:15:00,112.23,162.908,55.218,32.225 +2020-11-04 06:30:00,114.12,162.44,55.218,32.225 +2020-11-04 06:45:00,117.78,163.09799999999998,55.218,32.225 +2020-11-04 07:00:00,121.0,164.22,67.39,32.225 +2020-11-04 07:15:00,123.07,167.551,67.39,32.225 +2020-11-04 07:30:00,121.54,168.52599999999998,67.39,32.225 +2020-11-04 07:45:00,123.46,168.72799999999998,67.39,32.225 +2020-11-04 08:00:00,126.53,168.62400000000002,74.345,32.225 +2020-11-04 08:15:00,124.13,168.00099999999998,74.345,32.225 +2020-11-04 08:30:00,124.39,165.68,74.345,32.225 +2020-11-04 08:45:00,125.06,163.233,74.345,32.225 +2020-11-04 09:00:00,120.85,158.594,69.336,32.225 +2020-11-04 09:15:00,122.69,155.651,69.336,32.225 +2020-11-04 09:30:00,127.17,154.84,69.336,32.225 +2020-11-04 09:45:00,119.89,153.649,69.336,32.225 +2020-11-04 10:00:00,117.85,150.679,64.291,32.225 +2020-11-04 10:15:00,119.47,148.695,64.291,32.225 +2020-11-04 10:30:00,118.23,146.60299999999998,64.291,32.225 +2020-11-04 10:45:00,117.75,145.809,64.291,32.225 +2020-11-04 11:00:00,117.87,141.967,62.20399999999999,32.225 +2020-11-04 11:15:00,117.63,142.02100000000002,62.20399999999999,32.225 +2020-11-04 11:30:00,117.7,142.192,62.20399999999999,32.225 +2020-11-04 11:45:00,119.73,143.015,62.20399999999999,32.225 +2020-11-04 12:00:00,117.89,138.71,59.042,32.225 +2020-11-04 12:15:00,116.62,137.545,59.042,32.225 +2020-11-04 12:30:00,119.41,137.797,59.042,32.225 +2020-11-04 12:45:00,114.95,138.29399999999998,59.042,32.225 +2020-11-04 13:00:00,118.41,137.885,57.907,32.225 +2020-11-04 13:15:00,117.16,137.295,57.907,32.225 +2020-11-04 13:30:00,115.27,136.624,57.907,32.225 +2020-11-04 13:45:00,116.77,136.303,57.907,32.225 +2020-11-04 14:00:00,114.41,135.916,58.358000000000004,32.225 +2020-11-04 14:15:00,113.25,135.64700000000002,58.358000000000004,32.225 +2020-11-04 14:30:00,113.2,135.291,58.358000000000004,32.225 +2020-11-04 14:45:00,116.71,135.17600000000002,58.358000000000004,32.225 +2020-11-04 15:00:00,118.53,134.237,59.348,32.225 +2020-11-04 15:15:00,117.22,134.471,59.348,32.225 +2020-11-04 15:30:00,115.9,134.694,59.348,32.225 +2020-11-04 15:45:00,117.62,134.718,59.348,32.225 +2020-11-04 16:00:00,119.43,137.075,61.413999999999994,32.225 +2020-11-04 16:15:00,119.61,138.499,61.413999999999994,32.225 +2020-11-04 16:30:00,124.61,138.737,61.413999999999994,32.225 +2020-11-04 16:45:00,127.54,137.829,61.413999999999994,32.225 +2020-11-04 17:00:00,135.41,139.454,67.107,32.225 +2020-11-04 17:15:00,134.88,140.226,67.107,32.225 +2020-11-04 17:30:00,134.97,140.349,67.107,32.225 +2020-11-04 17:45:00,134.76,139.954,67.107,32.225 +2020-11-04 18:00:00,134.73,142.542,71.92,32.225 +2020-11-04 18:15:00,133.15,141.637,71.92,32.225 +2020-11-04 18:30:00,132.19,139.892,71.92,32.225 +2020-11-04 18:45:00,131.93,143.155,71.92,32.225 +2020-11-04 19:00:00,128.13,144.165,75.09,32.225 +2020-11-04 19:15:00,126.83,142.259,75.09,32.225 +2020-11-04 19:30:00,125.21,141.106,75.09,32.225 +2020-11-04 19:45:00,126.42,139.845,75.09,32.225 +2020-11-04 20:00:00,120.08,135.208,65.977,32.225 +2020-11-04 20:15:00,113.28,131.525,65.977,32.225 +2020-11-04 20:30:00,111.02,130.87,65.977,32.225 +2020-11-04 20:45:00,112.13,128.757,65.977,32.225 +2020-11-04 21:00:00,108.11,124.35700000000001,58.798,32.225 +2020-11-04 21:15:00,107.66,123.671,58.798,32.225 +2020-11-04 21:30:00,109.28,122.713,58.798,32.225 +2020-11-04 21:45:00,103.93,120.087,58.798,32.225 +2020-11-04 22:00:00,97.92,114.374,54.486000000000004,32.225 +2020-11-04 22:15:00,93.55,110.039,54.486000000000004,32.225 +2020-11-04 22:30:00,95.7,96.777,54.486000000000004,32.225 +2020-11-04 22:45:00,98.22,89.345,54.486000000000004,32.225 +2020-11-04 23:00:00,93.29,83.88600000000001,47.783,32.225 +2020-11-04 23:15:00,92.59,82.83200000000001,47.783,32.225 +2020-11-04 23:30:00,89.1,82.152,47.783,32.225 +2020-11-04 23:45:00,89.25,82.274,47.783,32.225 +2020-11-05 00:00:00,85.13,77.245,43.88,32.225 +2020-11-05 00:15:00,81.55,78.372,43.88,32.225 +2020-11-05 00:30:00,84.52,78.18,43.88,32.225 +2020-11-05 00:45:00,83.95,78.225,43.88,32.225 +2020-11-05 01:00:00,78.92,79.571,42.242,32.225 +2020-11-05 01:15:00,78.1,79.663,42.242,32.225 +2020-11-05 01:30:00,81.46,79.433,42.242,32.225 +2020-11-05 01:45:00,82.78,79.43,42.242,32.225 +2020-11-05 02:00:00,79.05,80.63,40.918,32.225 +2020-11-05 02:15:00,78.82,81.15899999999999,40.918,32.225 +2020-11-05 02:30:00,81.15,81.407,40.918,32.225 +2020-11-05 02:45:00,85.55,82.351,40.918,32.225 +2020-11-05 03:00:00,79.23,85.072,40.411,32.225 +2020-11-05 03:15:00,74.81,86.42,40.411,32.225 +2020-11-05 03:30:00,82.28,87.23700000000001,40.411,32.225 +2020-11-05 03:45:00,82.7,87.84700000000001,40.411,32.225 +2020-11-05 04:00:00,84.49,99.74700000000001,41.246,32.225 +2020-11-05 04:15:00,80.91,111.145,41.246,32.225 +2020-11-05 04:30:00,84.02,111.501,41.246,32.225 +2020-11-05 04:45:00,88.63,113.65700000000001,41.246,32.225 +2020-11-05 05:00:00,89.89,146.725,44.533,32.225 +2020-11-05 05:15:00,85.19,175.975,44.533,32.225 +2020-11-05 05:30:00,88.1,170.021,44.533,32.225 +2020-11-05 05:45:00,93.43,160.321,44.533,32.225 +2020-11-05 06:00:00,102.82,158.494,55.005,32.225 +2020-11-05 06:15:00,110.75,163.417,55.005,32.225 +2020-11-05 06:30:00,115.53,162.97799999999998,55.005,32.225 +2020-11-05 06:45:00,126.41,163.656,55.005,32.225 +2020-11-05 07:00:00,128.69,164.77700000000002,64.597,32.225 +2020-11-05 07:15:00,123.86,168.12,64.597,32.225 +2020-11-05 07:30:00,127.33,169.122,64.597,32.225 +2020-11-05 07:45:00,126.84,169.326,64.597,32.225 +2020-11-05 08:00:00,128.0,169.237,71.71600000000001,32.225 +2020-11-05 08:15:00,129.17,168.595,71.71600000000001,32.225 +2020-11-05 08:30:00,128.65,166.301,71.71600000000001,32.225 +2020-11-05 08:45:00,128.37,163.826,71.71600000000001,32.225 +2020-11-05 09:00:00,127.46,159.174,66.51899999999999,32.225 +2020-11-05 09:15:00,128.89,156.231,66.51899999999999,32.225 +2020-11-05 09:30:00,127.36,155.411,66.51899999999999,32.225 +2020-11-05 09:45:00,127.69,154.197,66.51899999999999,32.225 +2020-11-05 10:00:00,124.33,151.218,63.04,32.225 +2020-11-05 10:15:00,121.71,149.195,63.04,32.225 +2020-11-05 10:30:00,118.24,147.08,63.04,32.225 +2020-11-05 10:45:00,119.63,146.269,63.04,32.225 +2020-11-05 11:00:00,119.67,142.42700000000002,60.998000000000005,32.225 +2020-11-05 11:15:00,125.95,142.46,60.998000000000005,32.225 +2020-11-05 11:30:00,128.57,142.63,60.998000000000005,32.225 +2020-11-05 11:45:00,128.14,143.44,60.998000000000005,32.225 +2020-11-05 12:00:00,125.75,139.116,58.27,32.225 +2020-11-05 12:15:00,124.99,137.951,58.27,32.225 +2020-11-05 12:30:00,126.45,138.237,58.27,32.225 +2020-11-05 12:45:00,127.2,138.736,58.27,32.225 +2020-11-05 13:00:00,125.78,138.29,57.196000000000005,32.225 +2020-11-05 13:15:00,126.04,137.708,57.196000000000005,32.225 +2020-11-05 13:30:00,123.81,137.035,57.196000000000005,32.225 +2020-11-05 13:45:00,124.92,136.709,57.196000000000005,32.225 +2020-11-05 14:00:00,127.64,136.27,57.38399999999999,32.225 +2020-11-05 14:15:00,128.62,136.016,57.38399999999999,32.225 +2020-11-05 14:30:00,130.34,135.69899999999998,57.38399999999999,32.225 +2020-11-05 14:45:00,127.29,135.585,57.38399999999999,32.225 +2020-11-05 15:00:00,129.48,134.634,58.647,32.225 +2020-11-05 15:15:00,129.34,134.884,58.647,32.225 +2020-11-05 15:30:00,127.64,135.14600000000002,58.647,32.225 +2020-11-05 15:45:00,127.96,135.18200000000002,58.647,32.225 +2020-11-05 16:00:00,131.92,137.515,60.083999999999996,32.225 +2020-11-05 16:15:00,131.35,138.964,60.083999999999996,32.225 +2020-11-05 16:30:00,131.61,139.2,60.083999999999996,32.225 +2020-11-05 16:45:00,134.19,138.344,60.083999999999996,32.225 +2020-11-05 17:00:00,137.92,139.92700000000002,65.85600000000001,32.225 +2020-11-05 17:15:00,136.96,140.72,65.85600000000001,32.225 +2020-11-05 17:30:00,137.47,140.84799999999998,65.85600000000001,32.225 +2020-11-05 17:45:00,138.29,140.466,65.85600000000001,32.225 +2020-11-05 18:00:00,139.48,143.053,69.855,32.225 +2020-11-05 18:15:00,134.15,142.115,69.855,32.225 +2020-11-05 18:30:00,133.51,140.38,69.855,32.225 +2020-11-05 18:45:00,132.8,143.64,69.855,32.225 +2020-11-05 19:00:00,131.09,144.657,74.015,32.225 +2020-11-05 19:15:00,127.89,142.741,74.015,32.225 +2020-11-05 19:30:00,128.05,141.57299999999998,74.015,32.225 +2020-11-05 19:45:00,123.88,140.283,74.015,32.225 +2020-11-05 20:00:00,116.5,135.664,65.316,32.225 +2020-11-05 20:15:00,113.48,131.972,65.316,32.225 +2020-11-05 20:30:00,112.84,131.285,65.316,32.225 +2020-11-05 20:45:00,114.31,129.164,65.316,32.225 +2020-11-05 21:00:00,103.83,124.757,58.403999999999996,32.225 +2020-11-05 21:15:00,105.69,124.059,58.403999999999996,32.225 +2020-11-05 21:30:00,103.3,123.11,58.403999999999996,32.225 +2020-11-05 21:45:00,102.93,120.469,58.403999999999996,32.225 +2020-11-05 22:00:00,97.89,114.755,54.092,32.225 +2020-11-05 22:15:00,97.8,110.404,54.092,32.225 +2020-11-05 22:30:00,92.88,97.184,54.092,32.225 +2020-11-05 22:45:00,97.46,89.76,54.092,32.225 +2020-11-05 23:00:00,92.45,84.305,48.18600000000001,32.225 +2020-11-05 23:15:00,91.31,83.226,48.18600000000001,32.225 +2020-11-05 23:30:00,83.3,82.54899999999999,48.18600000000001,32.225 +2020-11-05 23:45:00,84.68,82.65700000000001,48.18600000000001,32.225 +2020-11-06 00:00:00,84.7,76.2,45.18899999999999,32.225 +2020-11-06 00:15:00,85.23,77.523,45.18899999999999,32.225 +2020-11-06 00:30:00,83.88,77.358,45.18899999999999,32.225 +2020-11-06 00:45:00,80.5,77.637,45.18899999999999,32.225 +2020-11-06 01:00:00,80.34,78.648,43.256,32.225 +2020-11-06 01:15:00,81.37,79.095,43.256,32.225 +2020-11-06 01:30:00,77.99,79.031,43.256,32.225 +2020-11-06 01:45:00,77.75,78.985,43.256,32.225 +2020-11-06 02:00:00,79.46,80.635,42.312,32.225 +2020-11-06 02:15:00,81.87,81.078,42.312,32.225 +2020-11-06 02:30:00,75.94,81.99,42.312,32.225 +2020-11-06 02:45:00,73.82,82.675,42.312,32.225 +2020-11-06 03:00:00,72.73,85.07600000000001,41.833,32.225 +2020-11-06 03:15:00,81.36,86.537,41.833,32.225 +2020-11-06 03:30:00,82.16,87.24600000000001,41.833,32.225 +2020-11-06 03:45:00,82.46,88.412,41.833,32.225 +2020-11-06 04:00:00,78.14,100.52,42.732,32.225 +2020-11-06 04:15:00,80.31,111.135,42.732,32.225 +2020-11-06 04:30:00,83.97,112.02799999999999,42.732,32.225 +2020-11-06 04:45:00,89.09,113.204,42.732,32.225 +2020-11-06 05:00:00,92.15,145.32399999999998,46.254,32.225 +2020-11-06 05:15:00,86.95,175.96,46.254,32.225 +2020-11-06 05:30:00,90.34,170.75799999999998,46.254,32.225 +2020-11-06 05:45:00,99.94,160.809,46.254,32.225 +2020-11-06 06:00:00,111.88,159.36700000000002,56.76,32.225 +2020-11-06 06:15:00,114.2,163.43200000000002,56.76,32.225 +2020-11-06 06:30:00,122.58,162.452,56.76,32.225 +2020-11-06 06:45:00,120.37,164.13,56.76,32.225 +2020-11-06 07:00:00,125.85,164.983,66.029,32.225 +2020-11-06 07:15:00,126.83,169.363,66.029,32.225 +2020-11-06 07:30:00,126.69,169.503,66.029,32.225 +2020-11-06 07:45:00,128.85,169.049,66.029,32.225 +2020-11-06 08:00:00,132.52,168.55,73.128,32.225 +2020-11-06 08:15:00,131.81,167.88299999999998,73.128,32.225 +2020-11-06 08:30:00,131.12,166.232,73.128,32.225 +2020-11-06 08:45:00,131.33,162.619,73.128,32.225 +2020-11-06 09:00:00,132.45,157.394,68.23100000000001,32.225 +2020-11-06 09:15:00,134.02,155.541,68.23100000000001,32.225 +2020-11-06 09:30:00,135.36,154.16899999999998,68.23100000000001,32.225 +2020-11-06 09:45:00,135.72,152.999,68.23100000000001,32.225 +2020-11-06 10:00:00,135.71,149.14,64.733,32.225 +2020-11-06 10:15:00,136.19,147.489,64.733,32.225 +2020-11-06 10:30:00,134.9,145.493,64.733,32.225 +2020-11-06 10:45:00,134.66,144.32399999999998,64.733,32.225 +2020-11-06 11:00:00,134.81,140.532,62.0,32.225 +2020-11-06 11:15:00,135.06,139.54399999999998,62.0,32.225 +2020-11-06 11:30:00,134.53,140.829,62.0,32.225 +2020-11-06 11:45:00,137.14,141.335,62.0,32.225 +2020-11-06 12:00:00,134.12,137.924,57.876999999999995,32.225 +2020-11-06 12:15:00,133.37,135.04399999999998,57.876999999999995,32.225 +2020-11-06 12:30:00,132.61,135.516,57.876999999999995,32.225 +2020-11-06 12:45:00,131.3,136.129,57.876999999999995,32.225 +2020-11-06 13:00:00,128.69,136.524,55.585,32.225 +2020-11-06 13:15:00,127.97,136.601,55.585,32.225 +2020-11-06 13:30:00,124.2,136.194,55.585,32.225 +2020-11-06 13:45:00,123.92,135.91299999999998,55.585,32.225 +2020-11-06 14:00:00,118.95,134.342,54.5,32.225 +2020-11-06 14:15:00,120.9,134.113,54.5,32.225 +2020-11-06 14:30:00,121.51,134.691,54.5,32.225 +2020-11-06 14:45:00,120.58,134.575,54.5,32.225 +2020-11-06 15:00:00,122.67,133.238,55.131,32.225 +2020-11-06 15:15:00,118.7,133.108,55.131,32.225 +2020-11-06 15:30:00,121.37,132.126,55.131,32.225 +2020-11-06 15:45:00,120.79,132.526,55.131,32.225 +2020-11-06 16:00:00,122.13,133.756,56.8,32.225 +2020-11-06 16:15:00,122.5,135.602,56.8,32.225 +2020-11-06 16:30:00,127.43,135.842,56.8,32.225 +2020-11-06 16:45:00,129.4,134.688,56.8,32.225 +2020-11-06 17:00:00,133.06,137.007,63.428999999999995,32.225 +2020-11-06 17:15:00,134.26,137.476,63.428999999999995,32.225 +2020-11-06 17:30:00,132.78,137.447,63.428999999999995,32.225 +2020-11-06 17:45:00,131.33,136.857,63.428999999999995,32.225 +2020-11-06 18:00:00,134.08,139.959,67.915,32.225 +2020-11-06 18:15:00,130.5,138.378,67.915,32.225 +2020-11-06 18:30:00,129.8,136.88299999999998,67.915,32.225 +2020-11-06 18:45:00,133.03,140.289,67.915,32.225 +2020-11-06 19:00:00,126.8,142.261,69.428,32.225 +2020-11-06 19:15:00,125.27,141.47899999999998,69.428,32.225 +2020-11-06 19:30:00,122.86,140.039,69.428,32.225 +2020-11-06 19:45:00,124.09,138.034,69.428,32.225 +2020-11-06 20:00:00,114.98,133.422,60.56100000000001,32.225 +2020-11-06 20:15:00,113.0,130.02100000000002,60.56100000000001,32.225 +2020-11-06 20:30:00,109.07,129.093,60.56100000000001,32.225 +2020-11-06 20:45:00,109.58,127.03299999999999,60.56100000000001,32.225 +2020-11-06 21:00:00,102.28,123.459,55.18600000000001,32.225 +2020-11-06 21:15:00,106.27,123.67200000000001,55.18600000000001,32.225 +2020-11-06 21:30:00,104.99,122.706,55.18600000000001,32.225 +2020-11-06 21:45:00,96.99,120.491,55.18600000000001,32.225 +2020-11-06 22:00:00,88.19,115.375,51.433,32.225 +2020-11-06 22:15:00,86.68,110.825,51.433,32.225 +2020-11-06 22:30:00,82.9,103.704,51.433,32.225 +2020-11-06 22:45:00,82.11,98.971,51.433,32.225 +2020-11-06 23:00:00,77.89,93.86200000000001,46.201,32.225 +2020-11-06 23:15:00,81.36,90.863,46.201,32.225 +2020-11-06 23:30:00,75.34,88.525,46.201,32.225 +2020-11-06 23:45:00,74.67,88.12899999999999,46.201,32.225 +2020-11-07 00:00:00,72.4,75.25399999999999,42.576,32.047 +2020-11-07 00:15:00,76.3,73.567,42.576,32.047 +2020-11-07 00:30:00,76.56,74.063,42.576,32.047 +2020-11-07 00:45:00,74.82,74.486,42.576,32.047 +2020-11-07 01:00:00,70.47,76.05,39.34,32.047 +2020-11-07 01:15:00,73.1,76.13,39.34,32.047 +2020-11-07 01:30:00,72.65,75.42,39.34,32.047 +2020-11-07 01:45:00,66.71,75.745,39.34,32.047 +2020-11-07 02:00:00,66.31,77.455,37.582,32.047 +2020-11-07 02:15:00,71.52,77.346,37.582,32.047 +2020-11-07 02:30:00,68.13,77.218,37.582,32.047 +2020-11-07 02:45:00,68.12,78.291,37.582,32.047 +2020-11-07 03:00:00,64.49,80.518,36.523,32.047 +2020-11-07 03:15:00,70.44,80.949,36.523,32.047 +2020-11-07 03:30:00,71.27,80.803,36.523,32.047 +2020-11-07 03:45:00,70.23,82.641,36.523,32.047 +2020-11-07 04:00:00,62.21,91.421,36.347,32.047 +2020-11-07 04:15:00,62.81,100.102,36.347,32.047 +2020-11-07 04:30:00,63.23,98.935,36.347,32.047 +2020-11-07 04:45:00,63.56,99.926,36.347,32.047 +2020-11-07 05:00:00,64.36,118.78299999999999,36.407,32.047 +2020-11-07 05:15:00,65.17,133.07,36.407,32.047 +2020-11-07 05:30:00,64.94,128.732,36.407,32.047 +2020-11-07 05:45:00,64.25,124.027,36.407,32.047 +2020-11-07 06:00:00,70.3,138.67,38.228,32.047 +2020-11-07 06:15:00,70.95,156.255,38.228,32.047 +2020-11-07 06:30:00,68.59,150.749,38.228,32.047 +2020-11-07 06:45:00,70.43,145.471,38.228,32.047 +2020-11-07 07:00:00,75.46,143.015,41.905,32.047 +2020-11-07 07:15:00,76.33,146.082,41.905,32.047 +2020-11-07 07:30:00,78.63,148.593,41.905,32.047 +2020-11-07 07:45:00,81.45,151.06799999999998,41.905,32.047 +2020-11-07 08:00:00,83.9,153.178,46.051,32.047 +2020-11-07 08:15:00,83.65,154.811,46.051,32.047 +2020-11-07 08:30:00,84.05,154.248,46.051,32.047 +2020-11-07 08:45:00,85.96,153.055,46.051,32.047 +2020-11-07 09:00:00,87.34,150.039,46.683,32.047 +2020-11-07 09:15:00,87.81,148.872,46.683,32.047 +2020-11-07 09:30:00,86.74,148.283,46.683,32.047 +2020-11-07 09:45:00,86.17,147.033,46.683,32.047 +2020-11-07 10:00:00,86.08,143.433,44.425,32.047 +2020-11-07 10:15:00,85.25,141.98,44.425,32.047 +2020-11-07 10:30:00,85.52,139.95600000000002,44.425,32.047 +2020-11-07 10:45:00,85.84,139.55,44.425,32.047 +2020-11-07 11:00:00,86.36,135.795,42.148999999999994,32.047 +2020-11-07 11:15:00,87.42,134.597,42.148999999999994,32.047 +2020-11-07 11:30:00,87.9,135.195,42.148999999999994,32.047 +2020-11-07 11:45:00,89.42,135.241,42.148999999999994,32.047 +2020-11-07 12:00:00,86.24,131.194,39.683,32.047 +2020-11-07 12:15:00,84.54,129.049,39.683,32.047 +2020-11-07 12:30:00,83.03,129.739,39.683,32.047 +2020-11-07 12:45:00,82.28,130.041,39.683,32.047 +2020-11-07 13:00:00,80.02,129.766,37.154,32.047 +2020-11-07 13:15:00,79.68,128.201,37.154,32.047 +2020-11-07 13:30:00,78.82,127.529,37.154,32.047 +2020-11-07 13:45:00,78.57,127.15,37.154,32.047 +2020-11-07 14:00:00,77.79,126.416,36.457,32.047 +2020-11-07 14:15:00,78.58,125.419,36.457,32.047 +2020-11-07 14:30:00,78.89,124.588,36.457,32.047 +2020-11-07 14:45:00,80.87,124.78399999999999,36.457,32.047 +2020-11-07 15:00:00,80.77,124.04299999999999,38.257,32.047 +2020-11-07 15:15:00,82.29,124.734,38.257,32.047 +2020-11-07 15:30:00,83.92,124.965,38.257,32.047 +2020-11-07 15:45:00,86.04,125.09299999999999,38.257,32.047 +2020-11-07 16:00:00,87.33,126.383,41.181000000000004,32.047 +2020-11-07 16:15:00,88.36,128.577,41.181000000000004,32.047 +2020-11-07 16:30:00,90.62,128.856,41.181000000000004,32.047 +2020-11-07 16:45:00,95.89,128.364,41.181000000000004,32.047 +2020-11-07 17:00:00,101.97,130.005,46.806000000000004,32.047 +2020-11-07 17:15:00,101.76,130.86700000000002,46.806000000000004,32.047 +2020-11-07 17:30:00,104.34,130.73,46.806000000000004,32.047 +2020-11-07 17:45:00,104.83,130.05,46.806000000000004,32.047 +2020-11-07 18:00:00,105.8,133.376,52.073,32.047 +2020-11-07 18:15:00,105.2,133.58700000000002,52.073,32.047 +2020-11-07 18:30:00,103.89,133.498,52.073,32.047 +2020-11-07 18:45:00,101.96,133.372,52.073,32.047 +2020-11-07 19:00:00,100.94,135.424,53.608000000000004,32.047 +2020-11-07 19:15:00,98.9,133.929,53.608000000000004,32.047 +2020-11-07 19:30:00,97.79,133.262,53.608000000000004,32.047 +2020-11-07 19:45:00,96.56,131.755,53.608000000000004,32.047 +2020-11-07 20:00:00,91.1,128.975,50.265,32.047 +2020-11-07 20:15:00,85.93,126.74799999999999,50.265,32.047 +2020-11-07 20:30:00,85.7,125.228,50.265,32.047 +2020-11-07 20:45:00,83.77,123.581,50.265,32.047 +2020-11-07 21:00:00,79.67,120.964,45.766000000000005,32.047 +2020-11-07 21:15:00,79.69,121.325,45.766000000000005,32.047 +2020-11-07 21:30:00,78.88,121.25399999999999,45.766000000000005,32.047 +2020-11-07 21:45:00,77.61,118.55,45.766000000000005,32.047 +2020-11-07 22:00:00,75.18,114.319,45.97,32.047 +2020-11-07 22:15:00,76.21,111.5,45.97,32.047 +2020-11-07 22:30:00,73.1,108.414,45.97,32.047 +2020-11-07 22:45:00,72.11,105.071,45.97,32.047 +2020-11-07 23:00:00,68.65,101.348,40.415,32.047 +2020-11-07 23:15:00,68.16,97.39200000000001,40.415,32.047 +2020-11-07 23:30:00,64.73,94.814,40.415,32.047 +2020-11-07 23:45:00,63.74,92.92399999999999,40.415,32.047 +2020-11-08 00:00:00,56.78,76.44800000000001,36.376,32.047 +2020-11-08 00:15:00,59.59,74.069,36.376,32.047 +2020-11-08 00:30:00,59.04,74.27,36.376,32.047 +2020-11-08 00:45:00,58.74,75.078,36.376,32.047 +2020-11-08 01:00:00,56.0,76.681,32.992,32.047 +2020-11-08 01:15:00,56.15,77.38,32.992,32.047 +2020-11-08 01:30:00,53.94,76.958,32.992,32.047 +2020-11-08 01:45:00,55.96,76.921,32.992,32.047 +2020-11-08 02:00:00,53.18,78.202,32.327,32.047 +2020-11-08 02:15:00,54.85,77.861,32.327,32.047 +2020-11-08 02:30:00,53.77,78.33800000000001,32.327,32.047 +2020-11-08 02:45:00,54.28,79.574,32.327,32.047 +2020-11-08 03:00:00,54.05,82.25399999999999,31.169,32.047 +2020-11-08 03:15:00,54.15,82.494,31.169,32.047 +2020-11-08 03:30:00,54.49,82.912,31.169,32.047 +2020-11-08 03:45:00,52.1,84.354,31.169,32.047 +2020-11-08 04:00:00,54.3,92.965,30.796,32.047 +2020-11-08 04:15:00,54.34,100.863,30.796,32.047 +2020-11-08 04:30:00,55.34,100.29799999999999,30.796,32.047 +2020-11-08 04:45:00,52.7,101.26799999999999,30.796,32.047 +2020-11-08 05:00:00,55.15,118.09200000000001,30.848000000000003,32.047 +2020-11-08 05:15:00,56.75,130.61,30.848000000000003,32.047 +2020-11-08 05:30:00,56.68,125.994,30.848000000000003,32.047 +2020-11-08 05:45:00,57.37,121.301,30.848000000000003,32.047 +2020-11-08 06:00:00,58.77,134.852,31.166,32.047 +2020-11-08 06:15:00,56.79,151.719,31.166,32.047 +2020-11-08 06:30:00,59.58,145.264,31.166,32.047 +2020-11-08 06:45:00,61.46,138.931,31.166,32.047 +2020-11-08 07:00:00,63.74,138.10299999999998,33.527,32.047 +2020-11-08 07:15:00,64.26,139.97799999999998,33.527,32.047 +2020-11-08 07:30:00,66.51,142.24200000000002,33.527,32.047 +2020-11-08 07:45:00,65.6,144.219,33.527,32.047 +2020-11-08 08:00:00,71.16,147.767,36.616,32.047 +2020-11-08 08:15:00,72.66,149.776,36.616,32.047 +2020-11-08 08:30:00,73.14,150.658,36.616,32.047 +2020-11-08 08:45:00,75.05,150.73,36.616,32.047 +2020-11-08 09:00:00,75.61,147.37,37.857,32.047 +2020-11-08 09:15:00,73.91,146.389,37.857,32.047 +2020-11-08 09:30:00,74.91,145.84799999999998,37.857,32.047 +2020-11-08 09:45:00,74.9,144.869,37.857,32.047 +2020-11-08 10:00:00,75.28,143.132,36.319,32.047 +2020-11-08 10:15:00,81.36,142.055,36.319,32.047 +2020-11-08 10:30:00,83.52,140.497,36.319,32.047 +2020-11-08 10:45:00,84.81,139.138,36.319,32.047 +2020-11-08 11:00:00,89.21,135.871,37.236999999999995,32.047 +2020-11-08 11:15:00,90.66,134.592,37.236999999999995,32.047 +2020-11-08 11:30:00,90.88,134.764,37.236999999999995,32.047 +2020-11-08 11:45:00,89.95,135.306,37.236999999999995,32.047 +2020-11-08 12:00:00,87.53,131.175,34.871,32.047 +2020-11-08 12:15:00,85.32,130.161,34.871,32.047 +2020-11-08 12:30:00,83.34,129.931,34.871,32.047 +2020-11-08 12:45:00,78.63,129.341,34.871,32.047 +2020-11-08 13:00:00,69.57,128.405,29.738000000000003,32.047 +2020-11-08 13:15:00,69.16,128.797,29.738000000000003,32.047 +2020-11-08 13:30:00,66.72,127.609,29.738000000000003,32.047 +2020-11-08 13:45:00,67.62,127.12299999999999,29.738000000000003,32.047 +2020-11-08 14:00:00,66.83,126.906,27.333000000000002,32.047 +2020-11-08 14:15:00,66.82,126.916,27.333000000000002,32.047 +2020-11-08 14:30:00,67.73,126.59700000000001,27.333000000000002,32.047 +2020-11-08 14:45:00,69.57,126.15100000000001,27.333000000000002,32.047 +2020-11-08 15:00:00,71.36,124.36,28.232,32.047 +2020-11-08 15:15:00,70.85,125.339,28.232,32.047 +2020-11-08 15:30:00,72.33,125.96799999999999,28.232,32.047 +2020-11-08 15:45:00,74.18,126.693,28.232,32.047 +2020-11-08 16:00:00,77.95,128.625,32.815,32.047 +2020-11-08 16:15:00,78.12,130.292,32.815,32.047 +2020-11-08 16:30:00,81.11,131.095,32.815,32.047 +2020-11-08 16:45:00,86.31,130.768,32.815,32.047 +2020-11-08 17:00:00,91.61,132.41899999999998,43.068999999999996,32.047 +2020-11-08 17:15:00,92.71,133.623,43.068999999999996,32.047 +2020-11-08 17:30:00,94.77,133.976,43.068999999999996,32.047 +2020-11-08 17:45:00,96.36,135.0,43.068999999999996,32.047 +2020-11-08 18:00:00,97.41,138.14700000000002,50.498999999999995,32.047 +2020-11-08 18:15:00,96.11,139.069,50.498999999999995,32.047 +2020-11-08 18:30:00,96.01,137.498,50.498999999999995,32.047 +2020-11-08 18:45:00,94.28,138.656,50.498999999999995,32.047 +2020-11-08 19:00:00,92.22,141.273,53.481,32.047 +2020-11-08 19:15:00,90.52,139.755,53.481,32.047 +2020-11-08 19:30:00,89.88,138.872,53.481,32.047 +2020-11-08 19:45:00,88.47,138.131,53.481,32.047 +2020-11-08 20:00:00,86.19,135.379,51.687,32.047 +2020-11-08 20:15:00,84.49,133.74200000000002,51.687,32.047 +2020-11-08 20:30:00,83.73,133.315,51.687,32.047 +2020-11-08 20:45:00,83.27,130.233,51.687,32.047 +2020-11-08 21:00:00,79.79,125.823,47.674,32.047 +2020-11-08 21:15:00,80.23,125.645,47.674,32.047 +2020-11-08 21:30:00,79.81,125.53,47.674,32.047 +2020-11-08 21:45:00,80.44,123.037,47.674,32.047 +2020-11-08 22:00:00,78.11,118.802,48.178000000000004,32.047 +2020-11-08 22:15:00,78.45,114.829,48.178000000000004,32.047 +2020-11-08 22:30:00,76.78,109.508,48.178000000000004,32.047 +2020-11-08 22:45:00,77.39,105.12899999999999,48.178000000000004,32.047 +2020-11-08 23:00:00,74.59,99.45,42.553999999999995,32.047 +2020-11-08 23:15:00,73.28,97.175,42.553999999999995,32.047 +2020-11-08 23:30:00,72.41,94.932,42.553999999999995,32.047 +2020-11-08 23:45:00,72.06,93.675,42.553999999999995,32.047 +2020-11-09 00:00:00,68.05,80.07300000000001,37.177,32.225 +2020-11-09 00:15:00,68.1,79.87,37.177,32.225 +2020-11-09 00:30:00,68.17,79.997,37.177,32.225 +2020-11-09 00:45:00,67.31,80.294,37.177,32.225 +2020-11-09 01:00:00,65.98,82.068,35.358000000000004,32.225 +2020-11-09 01:15:00,65.25,82.456,35.358000000000004,32.225 +2020-11-09 01:30:00,64.89,82.23299999999999,35.358000000000004,32.225 +2020-11-09 01:45:00,65.37,82.22,35.358000000000004,32.225 +2020-11-09 02:00:00,63.02,83.67399999999999,35.03,32.225 +2020-11-09 02:15:00,64.11,83.83,35.03,32.225 +2020-11-09 02:30:00,64.54,84.574,35.03,32.225 +2020-11-09 02:45:00,64.76,85.359,35.03,32.225 +2020-11-09 03:00:00,64.28,89.001,34.394,32.225 +2020-11-09 03:15:00,66.01,90.586,34.394,32.225 +2020-11-09 03:30:00,66.41,91.126,34.394,32.225 +2020-11-09 03:45:00,67.37,92.016,34.394,32.225 +2020-11-09 04:00:00,69.97,104.598,34.421,32.225 +2020-11-09 04:15:00,71.09,116.309,34.421,32.225 +2020-11-09 04:30:00,71.91,116.883,34.421,32.225 +2020-11-09 04:45:00,79.32,118.117,34.421,32.225 +2020-11-09 05:00:00,82.71,147.628,39.435,32.225 +2020-11-09 05:15:00,87.16,176.90900000000002,39.435,32.225 +2020-11-09 05:30:00,87.32,171.798,39.435,32.225 +2020-11-09 05:45:00,101.72,162.31799999999998,39.435,32.225 +2020-11-09 06:00:00,110.45,160.78,55.685,32.225 +2020-11-09 06:15:00,113.86,164.645,55.685,32.225 +2020-11-09 06:30:00,115.39,164.861,55.685,32.225 +2020-11-09 06:45:00,118.64,166.217,55.685,32.225 +2020-11-09 07:00:00,122.58,167.396,66.837,32.225 +2020-11-09 07:15:00,122.82,170.988,66.837,32.225 +2020-11-09 07:30:00,126.22,172.438,66.837,32.225 +2020-11-09 07:45:00,125.54,172.898,66.837,32.225 +2020-11-09 08:00:00,127.34,172.813,72.217,32.225 +2020-11-09 08:15:00,127.18,172.938,72.217,32.225 +2020-11-09 08:30:00,126.88,170.8,72.217,32.225 +2020-11-09 08:45:00,127.51,168.736,72.217,32.225 +2020-11-09 09:00:00,128.25,164.46599999999998,66.117,32.225 +2020-11-09 09:15:00,130.74,160.498,66.117,32.225 +2020-11-09 09:30:00,135.41,158.94,66.117,32.225 +2020-11-09 09:45:00,128.44,157.295,66.117,32.225 +2020-11-09 10:00:00,121.64,155.171,62.1,32.225 +2020-11-09 10:15:00,122.64,153.78,62.1,32.225 +2020-11-09 10:30:00,121.37,151.439,62.1,32.225 +2020-11-09 10:45:00,120.12,150.023,62.1,32.225 +2020-11-09 11:00:00,119.17,145.142,60.021,32.225 +2020-11-09 11:15:00,118.05,145.196,60.021,32.225 +2020-11-09 11:30:00,120.45,146.566,60.021,32.225 +2020-11-09 11:45:00,127.24,146.98,60.021,32.225 +2020-11-09 12:00:00,123.12,143.612,56.75899999999999,32.225 +2020-11-09 12:15:00,119.35,142.637,56.75899999999999,32.225 +2020-11-09 12:30:00,119.39,142.264,56.75899999999999,32.225 +2020-11-09 12:45:00,119.84,142.754,56.75899999999999,32.225 +2020-11-09 13:00:00,116.9,142.586,56.04600000000001,32.225 +2020-11-09 13:15:00,116.79,141.71200000000002,56.04600000000001,32.225 +2020-11-09 13:30:00,113.73,140.2,56.04600000000001,32.225 +2020-11-09 13:45:00,114.61,140.019,56.04600000000001,32.225 +2020-11-09 14:00:00,115.9,139.042,55.475,32.225 +2020-11-09 14:15:00,116.58,138.798,55.475,32.225 +2020-11-09 14:30:00,120.75,138.077,55.475,32.225 +2020-11-09 14:45:00,120.18,138.275,55.475,32.225 +2020-11-09 15:00:00,123.17,137.644,57.048,32.225 +2020-11-09 15:15:00,122.33,137.42,57.048,32.225 +2020-11-09 15:30:00,124.39,137.74,57.048,32.225 +2020-11-09 15:45:00,125.72,138.004,57.048,32.225 +2020-11-09 16:00:00,126.17,140.192,59.06,32.225 +2020-11-09 16:15:00,125.85,141.375,59.06,32.225 +2020-11-09 16:30:00,127.02,141.27700000000002,59.06,32.225 +2020-11-09 16:45:00,131.92,140.181,59.06,32.225 +2020-11-09 17:00:00,136.09,141.148,65.419,32.225 +2020-11-09 17:15:00,136.48,141.859,65.419,32.225 +2020-11-09 17:30:00,137.83,141.708,65.419,32.225 +2020-11-09 17:45:00,137.56,141.585,65.419,32.225 +2020-11-09 18:00:00,135.48,144.59,69.345,32.225 +2020-11-09 18:15:00,133.71,143.328,69.345,32.225 +2020-11-09 18:30:00,132.59,141.934,69.345,32.225 +2020-11-09 18:45:00,132.3,144.716,69.345,32.225 +2020-11-09 19:00:00,127.93,146.127,73.825,32.225 +2020-11-09 19:15:00,126.34,144.3,73.825,32.225 +2020-11-09 19:30:00,123.57,143.6,73.825,32.225 +2020-11-09 19:45:00,121.6,142.05,73.825,32.225 +2020-11-09 20:00:00,115.11,137.256,64.027,32.225 +2020-11-09 20:15:00,112.04,134.487,64.027,32.225 +2020-11-09 20:30:00,108.08,132.988,64.027,32.225 +2020-11-09 20:45:00,107.81,131.05700000000002,64.027,32.225 +2020-11-09 21:00:00,107.92,126.76799999999999,57.952,32.225 +2020-11-09 21:15:00,109.06,125.969,57.952,32.225 +2020-11-09 21:30:00,106.65,125.475,57.952,32.225 +2020-11-09 21:45:00,100.41,122.554,57.952,32.225 +2020-11-09 22:00:00,92.6,115.631,53.031000000000006,32.225 +2020-11-09 22:15:00,93.12,111.51899999999999,53.031000000000006,32.225 +2020-11-09 22:30:00,95.32,98.29700000000001,53.031000000000006,32.225 +2020-11-09 22:45:00,94.64,90.72200000000001,53.031000000000006,32.225 +2020-11-09 23:00:00,90.53,85.54799999999999,45.085,32.225 +2020-11-09 23:15:00,89.52,84.38799999999999,45.085,32.225 +2020-11-09 23:30:00,88.09,83.963,45.085,32.225 +2020-11-09 23:45:00,87.29,84.242,45.085,32.225 +2020-11-10 00:00:00,79.63,79.074,42.843,32.225 +2020-11-10 00:15:00,76.93,80.104,42.843,32.225 +2020-11-10 00:30:00,82.65,79.929,42.843,32.225 +2020-11-10 00:45:00,81.56,79.919,42.843,32.225 +2020-11-10 01:00:00,80.25,81.372,41.542,32.225 +2020-11-10 01:15:00,76.77,81.541,41.542,32.225 +2020-11-10 01:30:00,79.55,81.388,41.542,32.225 +2020-11-10 01:45:00,79.66,81.351,41.542,32.225 +2020-11-10 02:00:00,78.05,82.617,40.19,32.225 +2020-11-10 02:15:00,73.84,83.189,40.19,32.225 +2020-11-10 02:30:00,77.2,83.39,40.19,32.225 +2020-11-10 02:45:00,79.47,84.315,40.19,32.225 +2020-11-10 03:00:00,79.27,86.96600000000001,39.626,32.225 +2020-11-10 03:15:00,78.58,88.43700000000001,39.626,32.225 +2020-11-10 03:30:00,80.16,89.265,39.626,32.225 +2020-11-10 03:45:00,80.07,89.79799999999999,39.626,32.225 +2020-11-10 04:00:00,78.72,101.744,40.196999999999996,32.225 +2020-11-10 04:15:00,81.21,113.26899999999999,40.196999999999996,32.225 +2020-11-10 04:30:00,83.65,113.595,40.196999999999996,32.225 +2020-11-10 04:45:00,86.52,115.79,40.196999999999996,32.225 +2020-11-10 05:00:00,87.82,149.101,43.378,32.225 +2020-11-10 05:15:00,90.74,178.55599999999998,43.378,32.225 +2020-11-10 05:30:00,99.8,172.574,43.378,32.225 +2020-11-10 05:45:00,101.83,162.769,43.378,32.225 +2020-11-10 06:00:00,103.85,160.92600000000002,55.691,32.225 +2020-11-10 06:15:00,109.52,165.903,55.691,32.225 +2020-11-10 06:30:00,115.89,165.612,55.691,32.225 +2020-11-10 06:45:00,121.67,166.38299999999998,55.691,32.225 +2020-11-10 07:00:00,125.99,167.50599999999997,65.567,32.225 +2020-11-10 07:15:00,125.23,170.90400000000002,65.567,32.225 +2020-11-10 07:30:00,125.48,172.033,65.567,32.225 +2020-11-10 07:45:00,127.15,172.24599999999998,65.567,32.225 +2020-11-10 08:00:00,130.42,172.225,73.001,32.225 +2020-11-10 08:15:00,129.33,171.486,73.001,32.225 +2020-11-10 08:30:00,128.62,169.31900000000002,73.001,32.225 +2020-11-10 08:45:00,130.72,166.703,73.001,32.225 +2020-11-10 09:00:00,130.86,161.987,67.08800000000001,32.225 +2020-11-10 09:15:00,133.46,159.045,67.08800000000001,32.225 +2020-11-10 09:30:00,134.07,158.188,67.08800000000001,32.225 +2020-11-10 09:45:00,134.92,156.86,67.08800000000001,32.225 +2020-11-10 10:00:00,138.69,153.835,62.803000000000004,32.225 +2020-11-10 10:15:00,136.4,151.625,62.803000000000004,32.225 +2020-11-10 10:30:00,138.3,149.394,62.803000000000004,32.225 +2020-11-10 10:45:00,138.45,148.505,62.803000000000004,32.225 +2020-11-10 11:00:00,138.8,144.653,60.155,32.225 +2020-11-10 11:15:00,140.26,144.589,60.155,32.225 +2020-11-10 11:30:00,138.98,144.757,60.155,32.225 +2020-11-10 11:45:00,138.44,145.502,60.155,32.225 +2020-11-10 12:00:00,136.24,141.084,56.845,32.225 +2020-11-10 12:15:00,132.78,139.921,56.845,32.225 +2020-11-10 12:30:00,132.82,140.374,56.845,32.225 +2020-11-10 12:45:00,133.55,140.88,56.845,32.225 +2020-11-10 13:00:00,133.51,140.255,56.163000000000004,32.225 +2020-11-10 13:15:00,133.18,139.71,56.163000000000004,32.225 +2020-11-10 13:30:00,132.48,139.026,56.163000000000004,32.225 +2020-11-10 13:45:00,135.29,138.667,56.163000000000004,32.225 +2020-11-10 14:00:00,132.64,137.986,55.934,32.225 +2020-11-10 14:15:00,133.0,137.806,55.934,32.225 +2020-11-10 14:30:00,130.66,137.678,55.934,32.225 +2020-11-10 14:45:00,129.66,137.57,55.934,32.225 +2020-11-10 15:00:00,129.09,136.569,57.43899999999999,32.225 +2020-11-10 15:15:00,128.92,136.889,57.43899999999999,32.225 +2020-11-10 15:30:00,125.2,137.341,57.43899999999999,32.225 +2020-11-10 15:45:00,127.27,137.435,57.43899999999999,32.225 +2020-11-10 16:00:00,129.73,139.649,59.968999999999994,32.225 +2020-11-10 16:15:00,128.3,141.219,59.968999999999994,32.225 +2020-11-10 16:30:00,129.86,141.44799999999998,59.968999999999994,32.225 +2020-11-10 16:45:00,133.6,140.843,59.968999999999994,32.225 +2020-11-10 17:00:00,138.8,142.224,67.428,32.225 +2020-11-10 17:15:00,136.91,143.123,67.428,32.225 +2020-11-10 17:30:00,136.54,143.278,67.428,32.225 +2020-11-10 17:45:00,137.56,142.963,67.428,32.225 +2020-11-10 18:00:00,136.51,145.55,71.533,32.225 +2020-11-10 18:15:00,134.26,144.453,71.533,32.225 +2020-11-10 18:30:00,133.81,142.766,71.533,32.225 +2020-11-10 18:45:00,133.71,146.015,71.533,32.225 +2020-11-10 19:00:00,131.4,147.056,73.32300000000001,32.225 +2020-11-10 19:15:00,128.61,145.097,73.32300000000001,32.225 +2020-11-10 19:30:00,126.59,143.855,73.32300000000001,32.225 +2020-11-10 19:45:00,125.18,142.424,73.32300000000001,32.225 +2020-11-10 20:00:00,121.52,137.89600000000002,64.166,32.225 +2020-11-10 20:15:00,115.1,134.157,64.166,32.225 +2020-11-10 20:30:00,113.81,133.317,64.166,32.225 +2020-11-10 20:45:00,112.32,131.153,64.166,32.225 +2020-11-10 21:00:00,107.06,126.714,57.891999999999996,32.225 +2020-11-10 21:15:00,105.3,125.95200000000001,57.891999999999996,32.225 +2020-11-10 21:30:00,102.67,125.04799999999999,57.891999999999996,32.225 +2020-11-10 21:45:00,101.94,122.339,57.891999999999996,32.225 +2020-11-10 22:00:00,98.35,116.615,53.242,32.225 +2020-11-10 22:15:00,100.16,112.191,53.242,32.225 +2020-11-10 22:30:00,98.28,99.18,53.242,32.225 +2020-11-10 22:45:00,96.68,91.795,53.242,32.225 +2020-11-10 23:00:00,88.01,86.35799999999999,46.665,32.225 +2020-11-10 23:15:00,84.04,85.154,46.665,32.225 +2020-11-10 23:30:00,84.59,84.492,46.665,32.225 +2020-11-10 23:45:00,81.05,84.535,46.665,32.225 +2020-11-11 00:00:00,83.4,79.433,43.16,32.225 +2020-11-11 00:15:00,83.26,80.444,43.16,32.225 +2020-11-11 00:30:00,84.67,80.27,43.16,32.225 +2020-11-11 00:45:00,81.96,80.248,43.16,32.225 +2020-11-11 01:00:00,78.35,81.723,40.972,32.225 +2020-11-11 01:15:00,81.15,81.906,40.972,32.225 +2020-11-11 01:30:00,81.08,81.768,40.972,32.225 +2020-11-11 01:45:00,81.16,81.72399999999999,40.972,32.225 +2020-11-11 02:00:00,78.61,83.00299999999999,39.749,32.225 +2020-11-11 02:15:00,80.73,83.584,39.749,32.225 +2020-11-11 02:30:00,81.45,83.777,39.749,32.225 +2020-11-11 02:45:00,79.88,84.698,39.749,32.225 +2020-11-11 03:00:00,77.2,87.337,39.422,32.225 +2020-11-11 03:15:00,82.68,88.831,39.422,32.225 +2020-11-11 03:30:00,82.22,89.661,39.422,32.225 +2020-11-11 03:45:00,83.11,90.179,39.422,32.225 +2020-11-11 04:00:00,83.29,102.134,40.505,32.225 +2020-11-11 04:15:00,85.37,113.684,40.505,32.225 +2020-11-11 04:30:00,86.75,114.00299999999999,40.505,32.225 +2020-11-11 04:45:00,86.34,116.20700000000001,40.505,32.225 +2020-11-11 05:00:00,93.0,149.563,43.397,32.225 +2020-11-11 05:15:00,96.5,179.05900000000003,43.397,32.225 +2020-11-11 05:30:00,96.82,173.071,43.397,32.225 +2020-11-11 05:45:00,95.16,163.246,43.397,32.225 +2020-11-11 06:00:00,104.31,161.401,55.218,32.225 +2020-11-11 06:15:00,112.72,166.389,55.218,32.225 +2020-11-11 06:30:00,122.08,166.125,55.218,32.225 +2020-11-11 06:45:00,124.33,166.916,55.218,32.225 +2020-11-11 07:00:00,126.72,168.04,67.39,32.225 +2020-11-11 07:15:00,123.92,171.447,67.39,32.225 +2020-11-11 07:30:00,127.19,172.601,67.39,32.225 +2020-11-11 07:45:00,125.8,172.813,67.39,32.225 +2020-11-11 08:00:00,126.17,172.80700000000002,74.345,32.225 +2020-11-11 08:15:00,124.97,172.047,74.345,32.225 +2020-11-11 08:30:00,126.2,169.903,74.345,32.225 +2020-11-11 08:45:00,125.23,167.25900000000001,74.345,32.225 +2020-11-11 09:00:00,124.4,162.53,69.336,32.225 +2020-11-11 09:15:00,125.54,159.589,69.336,32.225 +2020-11-11 09:30:00,123.59,158.725,69.336,32.225 +2020-11-11 09:45:00,121.93,157.375,69.336,32.225 +2020-11-11 10:00:00,121.82,154.341,64.291,32.225 +2020-11-11 10:15:00,120.73,152.095,64.291,32.225 +2020-11-11 10:30:00,118.92,149.842,64.291,32.225 +2020-11-11 10:45:00,121.37,148.938,64.291,32.225 +2020-11-11 11:00:00,120.95,145.084,62.20399999999999,32.225 +2020-11-11 11:15:00,122.46,145.001,62.20399999999999,32.225 +2020-11-11 11:30:00,121.56,145.16899999999998,62.20399999999999,32.225 +2020-11-11 11:45:00,121.47,145.9,62.20399999999999,32.225 +2020-11-11 12:00:00,120.37,141.465,59.042,32.225 +2020-11-11 12:15:00,119.79,140.303,59.042,32.225 +2020-11-11 12:30:00,119.63,140.78799999999998,59.042,32.225 +2020-11-11 12:45:00,121.96,141.296,59.042,32.225 +2020-11-11 13:00:00,120.59,140.635,57.907,32.225 +2020-11-11 13:15:00,122.95,140.09799999999998,57.907,32.225 +2020-11-11 13:30:00,118.81,139.411,57.907,32.225 +2020-11-11 13:45:00,117.0,139.046,57.907,32.225 +2020-11-11 14:00:00,116.01,138.31799999999998,58.358000000000004,32.225 +2020-11-11 14:15:00,115.36,138.151,58.358000000000004,32.225 +2020-11-11 14:30:00,116.38,138.061,58.358000000000004,32.225 +2020-11-11 14:45:00,119.55,137.955,58.358000000000004,32.225 +2020-11-11 15:00:00,123.96,136.944,59.348,32.225 +2020-11-11 15:15:00,123.92,137.278,59.348,32.225 +2020-11-11 15:30:00,125.52,137.766,59.348,32.225 +2020-11-11 15:45:00,123.32,137.872,59.348,32.225 +2020-11-11 16:00:00,125.75,140.063,61.413999999999994,32.225 +2020-11-11 16:15:00,126.76,141.656,61.413999999999994,32.225 +2020-11-11 16:30:00,128.31,141.88299999999998,61.413999999999994,32.225 +2020-11-11 16:45:00,133.13,141.327,61.413999999999994,32.225 +2020-11-11 17:00:00,138.86,142.668,67.107,32.225 +2020-11-11 17:15:00,136.54,143.589,67.107,32.225 +2020-11-11 17:30:00,137.81,143.75,67.107,32.225 +2020-11-11 17:45:00,137.09,143.44899999999998,67.107,32.225 +2020-11-11 18:00:00,136.75,146.037,71.92,32.225 +2020-11-11 18:15:00,134.87,144.91,71.92,32.225 +2020-11-11 18:30:00,134.79,143.232,71.92,32.225 +2020-11-11 18:45:00,135.01,146.47899999999998,71.92,32.225 +2020-11-11 19:00:00,130.77,147.524,75.09,32.225 +2020-11-11 19:15:00,128.61,145.55700000000002,75.09,32.225 +2020-11-11 19:30:00,126.23,144.30100000000002,75.09,32.225 +2020-11-11 19:45:00,129.72,142.842,75.09,32.225 +2020-11-11 20:00:00,120.35,138.33,65.977,32.225 +2020-11-11 20:15:00,114.91,134.583,65.977,32.225 +2020-11-11 20:30:00,111.86,133.71200000000002,65.977,32.225 +2020-11-11 20:45:00,111.47,131.542,65.977,32.225 +2020-11-11 21:00:00,106.24,127.095,58.798,32.225 +2020-11-11 21:15:00,111.59,126.32,58.798,32.225 +2020-11-11 21:30:00,109.24,125.426,58.798,32.225 +2020-11-11 21:45:00,110.46,122.704,58.798,32.225 +2020-11-11 22:00:00,100.23,116.978,54.486000000000004,32.225 +2020-11-11 22:15:00,95.43,112.539,54.486000000000004,32.225 +2020-11-11 22:30:00,93.35,99.57,54.486000000000004,32.225 +2020-11-11 22:45:00,97.79,92.194,54.486000000000004,32.225 +2020-11-11 23:00:00,94.38,86.758,47.783,32.225 +2020-11-11 23:15:00,93.93,85.53,47.783,32.225 +2020-11-11 23:30:00,85.88,84.87299999999999,47.783,32.225 +2020-11-11 23:45:00,84.95,84.90299999999999,47.783,32.225 +2020-11-12 00:00:00,85.17,79.789,43.88,32.225 +2020-11-12 00:15:00,87.34,80.78,43.88,32.225 +2020-11-12 00:30:00,85.98,80.609,43.88,32.225 +2020-11-12 00:45:00,80.22,80.575,43.88,32.225 +2020-11-12 01:00:00,83.59,82.071,42.242,32.225 +2020-11-12 01:15:00,84.86,82.26899999999999,42.242,32.225 +2020-11-12 01:30:00,83.06,82.146,42.242,32.225 +2020-11-12 01:45:00,81.74,82.094,42.242,32.225 +2020-11-12 02:00:00,83.52,83.387,40.918,32.225 +2020-11-12 02:15:00,83.31,83.976,40.918,32.225 +2020-11-12 02:30:00,78.25,84.161,40.918,32.225 +2020-11-12 02:45:00,78.19,85.07700000000001,40.918,32.225 +2020-11-12 03:00:00,75.8,87.70299999999999,40.411,32.225 +2020-11-12 03:15:00,78.38,89.221,40.411,32.225 +2020-11-12 03:30:00,82.84,90.053,40.411,32.225 +2020-11-12 03:45:00,85.06,90.555,40.411,32.225 +2020-11-12 04:00:00,83.04,102.51899999999999,41.246,32.225 +2020-11-12 04:15:00,79.36,114.094,41.246,32.225 +2020-11-12 04:30:00,80.43,114.40899999999999,41.246,32.225 +2020-11-12 04:45:00,81.3,116.619,41.246,32.225 +2020-11-12 05:00:00,85.8,150.023,44.533,32.225 +2020-11-12 05:15:00,88.26,179.558,44.533,32.225 +2020-11-12 05:30:00,92.04,173.56400000000002,44.533,32.225 +2020-11-12 05:45:00,97.64,163.719,44.533,32.225 +2020-11-12 06:00:00,113.62,161.872,55.005,32.225 +2020-11-12 06:15:00,116.6,166.87,55.005,32.225 +2020-11-12 06:30:00,120.04,166.635,55.005,32.225 +2020-11-12 06:45:00,120.88,167.44400000000002,55.005,32.225 +2020-11-12 07:00:00,127.95,168.56900000000002,64.597,32.225 +2020-11-12 07:15:00,124.59,171.987,64.597,32.225 +2020-11-12 07:30:00,125.29,173.16299999999998,64.597,32.225 +2020-11-12 07:45:00,127.13,173.375,64.597,32.225 +2020-11-12 08:00:00,129.63,173.38099999999997,71.71600000000001,32.225 +2020-11-12 08:15:00,127.3,172.602,71.71600000000001,32.225 +2020-11-12 08:30:00,127.82,170.482,71.71600000000001,32.225 +2020-11-12 08:45:00,127.07,167.80900000000003,71.71600000000001,32.225 +2020-11-12 09:00:00,123.74,163.067,66.51899999999999,32.225 +2020-11-12 09:15:00,124.7,160.127,66.51899999999999,32.225 +2020-11-12 09:30:00,127.83,159.257,66.51899999999999,32.225 +2020-11-12 09:45:00,129.16,157.885,66.51899999999999,32.225 +2020-11-12 10:00:00,129.07,154.842,63.04,32.225 +2020-11-12 10:15:00,128.54,152.56,63.04,32.225 +2020-11-12 10:30:00,127.42,150.284,63.04,32.225 +2020-11-12 10:45:00,124.95,149.365,63.04,32.225 +2020-11-12 11:00:00,124.62,145.50799999999998,60.998000000000005,32.225 +2020-11-12 11:15:00,124.2,145.406,60.998000000000005,32.225 +2020-11-12 11:30:00,123.72,145.57399999999998,60.998000000000005,32.225 +2020-11-12 11:45:00,125.51,146.29399999999998,60.998000000000005,32.225 +2020-11-12 12:00:00,126.91,141.84,58.27,32.225 +2020-11-12 12:15:00,122.96,140.68,58.27,32.225 +2020-11-12 12:30:00,121.82,141.197,58.27,32.225 +2020-11-12 12:45:00,116.29,141.707,58.27,32.225 +2020-11-12 13:00:00,117.07,141.012,57.196000000000005,32.225 +2020-11-12 13:15:00,119.85,140.481,57.196000000000005,32.225 +2020-11-12 13:30:00,119.33,139.791,57.196000000000005,32.225 +2020-11-12 13:45:00,120.26,139.41899999999998,57.196000000000005,32.225 +2020-11-12 14:00:00,118.83,138.64600000000002,57.38399999999999,32.225 +2020-11-12 14:15:00,120.08,138.493,57.38399999999999,32.225 +2020-11-12 14:30:00,121.28,138.439,57.38399999999999,32.225 +2020-11-12 14:45:00,124.13,138.335,57.38399999999999,32.225 +2020-11-12 15:00:00,125.14,137.315,58.647,32.225 +2020-11-12 15:15:00,123.4,137.661,58.647,32.225 +2020-11-12 15:30:00,122.12,138.186,58.647,32.225 +2020-11-12 15:45:00,123.51,138.30200000000002,58.647,32.225 +2020-11-12 16:00:00,126.72,140.471,60.083999999999996,32.225 +2020-11-12 16:15:00,125.87,142.08700000000002,60.083999999999996,32.225 +2020-11-12 16:30:00,129.87,142.313,60.083999999999996,32.225 +2020-11-12 16:45:00,133.87,141.805,60.083999999999996,32.225 +2020-11-12 17:00:00,137.7,143.105,65.85600000000001,32.225 +2020-11-12 17:15:00,137.13,144.049,65.85600000000001,32.225 +2020-11-12 17:30:00,139.61,144.218,65.85600000000001,32.225 +2020-11-12 17:45:00,138.78,143.93,65.85600000000001,32.225 +2020-11-12 18:00:00,137.47,146.519,69.855,32.225 +2020-11-12 18:15:00,136.57,145.361,69.855,32.225 +2020-11-12 18:30:00,135.22,143.692,69.855,32.225 +2020-11-12 18:45:00,135.89,146.94,69.855,32.225 +2020-11-12 19:00:00,131.65,147.986,74.015,32.225 +2020-11-12 19:15:00,131.85,146.012,74.015,32.225 +2020-11-12 19:30:00,129.37,144.74200000000002,74.015,32.225 +2020-11-12 19:45:00,126.94,143.257,74.015,32.225 +2020-11-12 20:00:00,119.74,138.762,65.316,32.225 +2020-11-12 20:15:00,117.13,135.005,65.316,32.225 +2020-11-12 20:30:00,115.09,134.105,65.316,32.225 +2020-11-12 20:45:00,111.94,131.92600000000002,65.316,32.225 +2020-11-12 21:00:00,107.77,127.473,58.403999999999996,32.225 +2020-11-12 21:15:00,113.2,126.684,58.403999999999996,32.225 +2020-11-12 21:30:00,112.61,125.8,58.403999999999996,32.225 +2020-11-12 21:45:00,110.3,123.066,58.403999999999996,32.225 +2020-11-12 22:00:00,100.12,117.338,54.092,32.225 +2020-11-12 22:15:00,96.26,112.885,54.092,32.225 +2020-11-12 22:30:00,99.58,99.958,54.092,32.225 +2020-11-12 22:45:00,99.58,92.59,54.092,32.225 +2020-11-12 23:00:00,93.92,87.156,48.18600000000001,32.225 +2020-11-12 23:15:00,90.66,85.905,48.18600000000001,32.225 +2020-11-12 23:30:00,82.77,85.25,48.18600000000001,32.225 +2020-11-12 23:45:00,93.39,85.26700000000001,48.18600000000001,32.225 +2020-11-13 00:00:00,88.54,78.727,45.18899999999999,32.225 +2020-11-13 00:15:00,87.54,79.914,45.18899999999999,32.225 +2020-11-13 00:30:00,83.28,79.76899999999999,45.18899999999999,32.225 +2020-11-13 00:45:00,75.68,79.967,45.18899999999999,32.225 +2020-11-13 01:00:00,83.0,81.128,43.256,32.225 +2020-11-13 01:15:00,83.41,81.67699999999999,43.256,32.225 +2020-11-13 01:30:00,80.05,81.719,43.256,32.225 +2020-11-13 01:45:00,78.52,81.625,43.256,32.225 +2020-11-13 02:00:00,74.26,83.368,42.312,32.225 +2020-11-13 02:15:00,81.05,83.87,42.312,32.225 +2020-11-13 02:30:00,84.7,84.721,42.312,32.225 +2020-11-13 02:45:00,85.03,85.37799999999999,42.312,32.225 +2020-11-13 03:00:00,79.15,87.684,41.833,32.225 +2020-11-13 03:15:00,80.84,89.316,41.833,32.225 +2020-11-13 03:30:00,82.96,90.04,41.833,32.225 +2020-11-13 03:45:00,87.43,91.09899999999999,41.833,32.225 +2020-11-13 04:00:00,85.9,103.27,42.732,32.225 +2020-11-13 04:15:00,84.02,114.059,42.732,32.225 +2020-11-13 04:30:00,88.32,114.913,42.732,32.225 +2020-11-13 04:45:00,91.69,116.14200000000001,42.732,32.225 +2020-11-13 05:00:00,90.57,148.593,46.254,32.225 +2020-11-13 05:15:00,89.24,179.515,46.254,32.225 +2020-11-13 05:30:00,100.06,174.269,46.254,32.225 +2020-11-13 05:45:00,105.63,164.176,46.254,32.225 +2020-11-13 06:00:00,111.79,162.717,56.76,32.225 +2020-11-13 06:15:00,111.89,166.858,56.76,32.225 +2020-11-13 06:30:00,118.4,166.08,56.76,32.225 +2020-11-13 06:45:00,122.39,167.889,56.76,32.225 +2020-11-13 07:00:00,129.36,168.747,66.029,32.225 +2020-11-13 07:15:00,127.21,173.19799999999998,66.029,32.225 +2020-11-13 07:30:00,127.28,173.50900000000001,66.029,32.225 +2020-11-13 07:45:00,128.94,173.06,66.029,32.225 +2020-11-13 08:00:00,131.25,172.65400000000002,73.128,32.225 +2020-11-13 08:15:00,129.89,171.84900000000002,73.128,32.225 +2020-11-13 08:30:00,128.17,170.36700000000002,73.128,32.225 +2020-11-13 08:45:00,128.29,166.558,73.128,32.225 +2020-11-13 09:00:00,123.97,161.243,68.23100000000001,32.225 +2020-11-13 09:15:00,128.09,159.393,68.23100000000001,32.225 +2020-11-13 09:30:00,127.96,157.974,68.23100000000001,32.225 +2020-11-13 09:45:00,126.9,156.645,68.23100000000001,32.225 +2020-11-13 10:00:00,123.72,152.725,64.733,32.225 +2020-11-13 10:15:00,124.12,150.817,64.733,32.225 +2020-11-13 10:30:00,121.64,148.661,64.733,32.225 +2020-11-13 10:45:00,122.3,147.386,64.733,32.225 +2020-11-13 11:00:00,119.98,143.577,62.0,32.225 +2020-11-13 11:15:00,121.51,142.455,62.0,32.225 +2020-11-13 11:30:00,120.54,143.74,62.0,32.225 +2020-11-13 11:45:00,120.13,144.156,62.0,32.225 +2020-11-13 12:00:00,119.43,140.618,57.876999999999995,32.225 +2020-11-13 12:15:00,119.97,137.744,57.876999999999995,32.225 +2020-11-13 12:30:00,120.64,138.444,57.876999999999995,32.225 +2020-11-13 12:45:00,119.97,139.069,57.876999999999995,32.225 +2020-11-13 13:00:00,117.26,139.217,55.585,32.225 +2020-11-13 13:15:00,116.98,139.343,55.585,32.225 +2020-11-13 13:30:00,114.0,138.91899999999998,55.585,32.225 +2020-11-13 13:45:00,115.51,138.592,55.585,32.225 +2020-11-13 14:00:00,113.7,136.691,54.5,32.225 +2020-11-13 14:15:00,116.76,136.561,54.5,32.225 +2020-11-13 14:30:00,115.06,137.4,54.5,32.225 +2020-11-13 14:45:00,117.33,137.297,54.5,32.225 +2020-11-13 15:00:00,118.57,135.893,55.131,32.225 +2020-11-13 15:15:00,121.83,135.856,55.131,32.225 +2020-11-13 15:30:00,118.63,135.135,55.131,32.225 +2020-11-13 15:45:00,120.04,135.612,55.131,32.225 +2020-11-13 16:00:00,123.01,136.678,56.8,32.225 +2020-11-13 16:15:00,123.05,138.69,56.8,32.225 +2020-11-13 16:30:00,127.02,138.92,56.8,32.225 +2020-11-13 16:45:00,132.91,138.112,56.8,32.225 +2020-11-13 17:00:00,136.31,140.149,63.428999999999995,32.225 +2020-11-13 17:15:00,134.14,140.769,63.428999999999995,32.225 +2020-11-13 17:30:00,135.6,140.783,63.428999999999995,32.225 +2020-11-13 17:45:00,136.85,140.28799999999998,63.428999999999995,32.225 +2020-11-13 18:00:00,132.99,143.393,67.915,32.225 +2020-11-13 18:15:00,132.55,141.596,67.915,32.225 +2020-11-13 18:30:00,131.85,140.16899999999998,67.915,32.225 +2020-11-13 18:45:00,131.77,143.563,67.915,32.225 +2020-11-13 19:00:00,127.72,145.562,69.428,32.225 +2020-11-13 19:15:00,126.33,144.722,69.428,32.225 +2020-11-13 19:30:00,123.37,143.181,69.428,32.225 +2020-11-13 19:45:00,122.12,140.983,69.428,32.225 +2020-11-13 20:00:00,116.08,136.49200000000002,60.56100000000001,32.225 +2020-11-13 20:15:00,112.51,133.029,60.56100000000001,32.225 +2020-11-13 20:30:00,109.7,131.888,60.56100000000001,32.225 +2020-11-13 20:45:00,109.08,129.773,60.56100000000001,32.225 +2020-11-13 21:00:00,102.38,126.15,55.18600000000001,32.225 +2020-11-13 21:15:00,102.35,126.273,55.18600000000001,32.225 +2020-11-13 21:30:00,103.54,125.37200000000001,55.18600000000001,32.225 +2020-11-13 21:45:00,102.4,123.066,55.18600000000001,32.225 +2020-11-13 22:00:00,94.88,117.936,51.433,32.225 +2020-11-13 22:15:00,90.57,113.288,51.433,32.225 +2020-11-13 22:30:00,83.68,106.458,51.433,32.225 +2020-11-13 22:45:00,88.07,101.781,51.433,32.225 +2020-11-13 23:00:00,87.2,96.691,46.201,32.225 +2020-11-13 23:15:00,86.93,93.52,46.201,32.225 +2020-11-13 23:30:00,81.06,91.205,46.201,32.225 +2020-11-13 23:45:00,75.58,90.72,46.201,32.225 +2020-11-14 00:00:00,73.58,77.764,42.576,32.047 +2020-11-14 00:15:00,74.26,75.94,42.576,32.047 +2020-11-14 00:30:00,76.41,76.453,42.576,32.047 +2020-11-14 00:45:00,75.17,76.795,42.576,32.047 +2020-11-14 01:00:00,67.85,78.506,39.34,32.047 +2020-11-14 01:15:00,73.52,78.689,39.34,32.047 +2020-11-14 01:30:00,72.25,78.084,39.34,32.047 +2020-11-14 01:45:00,73.31,78.359,39.34,32.047 +2020-11-14 02:00:00,65.19,80.163,37.582,32.047 +2020-11-14 02:15:00,71.47,80.111,37.582,32.047 +2020-11-14 02:30:00,72.68,79.926,37.582,32.047 +2020-11-14 02:45:00,72.1,80.97,37.582,32.047 +2020-11-14 03:00:00,65.86,83.103,36.523,32.047 +2020-11-14 03:15:00,72.14,83.70299999999999,36.523,32.047 +2020-11-14 03:30:00,71.64,83.572,36.523,32.047 +2020-11-14 03:45:00,67.64,85.304,36.523,32.047 +2020-11-14 04:00:00,64.63,94.146,36.347,32.047 +2020-11-14 04:15:00,66.11,103.001,36.347,32.047 +2020-11-14 04:30:00,66.46,101.794,36.347,32.047 +2020-11-14 04:45:00,67.22,102.839,36.347,32.047 +2020-11-14 05:00:00,66.75,122.023,36.407,32.047 +2020-11-14 05:15:00,66.43,136.593,36.407,32.047 +2020-11-14 05:30:00,66.52,132.209,36.407,32.047 +2020-11-14 05:45:00,69.84,127.36399999999999,36.407,32.047 +2020-11-14 06:00:00,68.77,141.99200000000002,38.228,32.047 +2020-11-14 06:15:00,71.68,159.651,38.228,32.047 +2020-11-14 06:30:00,74.01,154.347,38.228,32.047 +2020-11-14 06:45:00,75.87,149.19799999999998,38.228,32.047 +2020-11-14 07:00:00,77.55,146.75,41.905,32.047 +2020-11-14 07:15:00,78.66,149.886,41.905,32.047 +2020-11-14 07:30:00,84.59,152.562,41.905,32.047 +2020-11-14 07:45:00,85.21,155.03799999999998,41.905,32.047 +2020-11-14 08:00:00,88.67,157.241,46.051,32.047 +2020-11-14 08:15:00,88.43,158.736,46.051,32.047 +2020-11-14 08:30:00,88.2,158.338,46.051,32.047 +2020-11-14 08:45:00,89.95,156.94899999999998,46.051,32.047 +2020-11-14 09:00:00,90.45,153.843,46.683,32.047 +2020-11-14 09:15:00,92.7,152.679,46.683,32.047 +2020-11-14 09:30:00,89.36,152.045,46.683,32.047 +2020-11-14 09:45:00,91.08,150.639,46.683,32.047 +2020-11-14 10:00:00,92.47,146.975,44.425,32.047 +2020-11-14 10:15:00,89.41,145.27,44.425,32.047 +2020-11-14 10:30:00,89.74,143.088,44.425,32.047 +2020-11-14 10:45:00,89.93,142.576,44.425,32.047 +2020-11-14 11:00:00,88.64,138.803,42.148999999999994,32.047 +2020-11-14 11:15:00,89.78,137.474,42.148999999999994,32.047 +2020-11-14 11:30:00,87.93,138.07,42.148999999999994,32.047 +2020-11-14 11:45:00,90.55,138.03,42.148999999999994,32.047 +2020-11-14 12:00:00,88.09,133.856,39.683,32.047 +2020-11-14 12:15:00,84.91,131.719,39.683,32.047 +2020-11-14 12:30:00,85.92,132.635,39.683,32.047 +2020-11-14 12:45:00,81.63,132.94799999999998,39.683,32.047 +2020-11-14 13:00:00,83.08,132.429,37.154,32.047 +2020-11-14 13:15:00,79.91,130.911,37.154,32.047 +2020-11-14 13:30:00,78.46,130.222,37.154,32.047 +2020-11-14 13:45:00,81.0,129.796,37.154,32.047 +2020-11-14 14:00:00,78.11,128.738,36.457,32.047 +2020-11-14 14:15:00,80.05,127.838,36.457,32.047 +2020-11-14 14:30:00,79.25,127.26700000000001,36.457,32.047 +2020-11-14 14:45:00,81.47,127.476,36.457,32.047 +2020-11-14 15:00:00,82.81,126.669,38.257,32.047 +2020-11-14 15:15:00,83.68,127.45100000000001,38.257,32.047 +2020-11-14 15:30:00,86.67,127.93799999999999,38.257,32.047 +2020-11-14 15:45:00,89.46,128.145,38.257,32.047 +2020-11-14 16:00:00,93.0,129.27100000000002,41.181000000000004,32.047 +2020-11-14 16:15:00,94.17,131.631,41.181000000000004,32.047 +2020-11-14 16:30:00,97.49,131.899,41.181000000000004,32.047 +2020-11-14 16:45:00,98.74,131.75,41.181000000000004,32.047 +2020-11-14 17:00:00,103.86,133.11,46.806000000000004,32.047 +2020-11-14 17:15:00,102.08,134.124,46.806000000000004,32.047 +2020-11-14 17:30:00,106.38,134.031,46.806000000000004,32.047 +2020-11-14 17:45:00,104.65,133.447,46.806000000000004,32.047 +2020-11-14 18:00:00,105.57,136.77700000000002,52.073,32.047 +2020-11-14 18:15:00,107.67,136.77700000000002,52.073,32.047 +2020-11-14 18:30:00,107.18,136.754,52.073,32.047 +2020-11-14 18:45:00,104.68,136.619,52.073,32.047 +2020-11-14 19:00:00,106.23,138.694,53.608000000000004,32.047 +2020-11-14 19:15:00,102.11,137.142,53.608000000000004,32.047 +2020-11-14 19:30:00,101.48,136.377,53.608000000000004,32.047 +2020-11-14 19:45:00,102.58,134.679,53.608000000000004,32.047 +2020-11-14 20:00:00,93.26,132.019,50.265,32.047 +2020-11-14 20:15:00,91.95,129.72899999999998,50.265,32.047 +2020-11-14 20:30:00,89.67,127.99700000000001,50.265,32.047 +2020-11-14 20:45:00,87.38,126.29799999999999,50.265,32.047 +2020-11-14 21:00:00,82.46,123.631,45.766000000000005,32.047 +2020-11-14 21:15:00,82.49,123.90100000000001,45.766000000000005,32.047 +2020-11-14 21:30:00,81.16,123.895,45.766000000000005,32.047 +2020-11-14 21:45:00,81.79,121.103,45.766000000000005,32.047 +2020-11-14 22:00:00,76.57,116.85799999999999,45.97,32.047 +2020-11-14 22:15:00,78.47,113.943,45.97,32.047 +2020-11-14 22:30:00,73.69,111.147,45.97,32.047 +2020-11-14 22:45:00,74.23,107.86,45.97,32.047 +2020-11-14 23:00:00,71.23,104.15299999999999,40.415,32.047 +2020-11-14 23:15:00,73.31,100.02799999999999,40.415,32.047 +2020-11-14 23:30:00,67.49,97.47399999999999,40.415,32.047 +2020-11-14 23:45:00,69.47,95.495,40.415,32.047 +2020-11-15 00:00:00,64.33,78.939,36.376,32.047 +2020-11-15 00:15:00,64.14,76.423,36.376,32.047 +2020-11-15 00:30:00,62.75,76.64,36.376,32.047 +2020-11-15 00:45:00,61.39,77.367,36.376,32.047 +2020-11-15 01:00:00,59.62,79.115,32.992,32.047 +2020-11-15 01:15:00,59.48,79.914,32.992,32.047 +2020-11-15 01:30:00,59.29,79.596,32.992,32.047 +2020-11-15 01:45:00,58.73,79.51,32.992,32.047 +2020-11-15 02:00:00,57.48,80.884,32.327,32.047 +2020-11-15 02:15:00,58.06,80.6,32.327,32.047 +2020-11-15 02:30:00,56.99,81.02,32.327,32.047 +2020-11-15 02:45:00,57.41,82.229,32.327,32.047 +2020-11-15 03:00:00,56.71,84.815,31.169,32.047 +2020-11-15 03:15:00,58.22,85.223,31.169,32.047 +2020-11-15 03:30:00,57.82,85.655,31.169,32.047 +2020-11-15 03:45:00,60.95,86.992,31.169,32.047 +2020-11-15 04:00:00,58.41,95.665,30.796,32.047 +2020-11-15 04:15:00,58.67,103.736,30.796,32.047 +2020-11-15 04:30:00,59.85,103.132,30.796,32.047 +2020-11-15 04:45:00,60.47,104.154,30.796,32.047 +2020-11-15 05:00:00,61.1,121.301,30.848000000000003,32.047 +2020-11-15 05:15:00,62.05,134.10299999999998,30.848000000000003,32.047 +2020-11-15 05:30:00,61.78,129.438,30.848000000000003,32.047 +2020-11-15 05:45:00,62.1,124.605,30.848000000000003,32.047 +2020-11-15 06:00:00,63.73,138.144,31.166,32.047 +2020-11-15 06:15:00,64.63,155.086,31.166,32.047 +2020-11-15 06:30:00,63.71,148.829,31.166,32.047 +2020-11-15 06:45:00,66.0,142.626,31.166,32.047 +2020-11-15 07:00:00,69.28,141.808,33.527,32.047 +2020-11-15 07:15:00,69.23,143.747,33.527,32.047 +2020-11-15 07:30:00,70.66,146.174,33.527,32.047 +2020-11-15 07:45:00,72.74,148.149,33.527,32.047 +2020-11-15 08:00:00,76.18,151.78799999999998,36.616,32.047 +2020-11-15 08:15:00,75.92,153.657,36.616,32.047 +2020-11-15 08:30:00,76.26,154.701,36.616,32.047 +2020-11-15 08:45:00,76.69,154.578,36.616,32.047 +2020-11-15 09:00:00,75.86,151.127,37.857,32.047 +2020-11-15 09:15:00,76.01,150.151,37.857,32.047 +2020-11-15 09:30:00,76.34,149.567,37.857,32.047 +2020-11-15 09:45:00,75.23,148.433,37.857,32.047 +2020-11-15 10:00:00,74.72,146.63299999999998,36.319,32.047 +2020-11-15 10:15:00,76.47,145.306,36.319,32.047 +2020-11-15 10:30:00,77.15,143.592,36.319,32.047 +2020-11-15 10:45:00,81.23,142.128,36.319,32.047 +2020-11-15 11:00:00,80.21,138.842,37.236999999999995,32.047 +2020-11-15 11:15:00,83.25,137.43200000000002,37.236999999999995,32.047 +2020-11-15 11:30:00,84.16,137.60299999999998,37.236999999999995,32.047 +2020-11-15 11:45:00,84.67,138.061,37.236999999999995,32.047 +2020-11-15 12:00:00,81.09,133.804,34.871,32.047 +2020-11-15 12:15:00,79.37,132.8,34.871,32.047 +2020-11-15 12:30:00,75.92,132.793,34.871,32.047 +2020-11-15 12:45:00,80.89,132.215,34.871,32.047 +2020-11-15 13:00:00,77.87,131.03799999999998,29.738000000000003,32.047 +2020-11-15 13:15:00,77.45,131.476,29.738000000000003,32.047 +2020-11-15 13:30:00,80.57,130.268,29.738000000000003,32.047 +2020-11-15 13:45:00,80.75,129.736,29.738000000000003,32.047 +2020-11-15 14:00:00,77.13,129.19799999999998,27.333000000000002,32.047 +2020-11-15 14:15:00,77.57,129.305,27.333000000000002,32.047 +2020-11-15 14:30:00,76.34,129.243,27.333000000000002,32.047 +2020-11-15 14:45:00,81.67,128.811,27.333000000000002,32.047 +2020-11-15 15:00:00,80.78,126.95700000000001,28.232,32.047 +2020-11-15 15:15:00,82.26,128.025,28.232,32.047 +2020-11-15 15:30:00,82.54,128.907,28.232,32.047 +2020-11-15 15:45:00,83.8,129.707,28.232,32.047 +2020-11-15 16:00:00,84.97,131.477,32.815,32.047 +2020-11-15 16:15:00,87.01,133.31,32.815,32.047 +2020-11-15 16:30:00,90.92,134.102,32.815,32.047 +2020-11-15 16:45:00,94.28,134.114,32.815,32.047 +2020-11-15 17:00:00,99.69,135.486,43.068999999999996,32.047 +2020-11-15 17:15:00,99.2,136.842,43.068999999999996,32.047 +2020-11-15 17:30:00,101.41,137.244,43.068999999999996,32.047 +2020-11-15 17:45:00,102.52,138.362,43.068999999999996,32.047 +2020-11-15 18:00:00,103.76,141.516,50.498999999999995,32.047 +2020-11-15 18:15:00,103.31,142.23,50.498999999999995,32.047 +2020-11-15 18:30:00,103.12,140.725,50.498999999999995,32.047 +2020-11-15 18:45:00,100.94,141.876,50.498999999999995,32.047 +2020-11-15 19:00:00,98.74,144.512,53.481,32.047 +2020-11-15 19:15:00,97.83,142.938,53.481,32.047 +2020-11-15 19:30:00,95.48,141.958,53.481,32.047 +2020-11-15 19:45:00,95.27,141.03,53.481,32.047 +2020-11-15 20:00:00,97.93,138.394,51.687,32.047 +2020-11-15 20:15:00,98.82,136.695,51.687,32.047 +2020-11-15 20:30:00,95.61,136.059,51.687,32.047 +2020-11-15 20:45:00,88.72,132.92600000000002,51.687,32.047 +2020-11-15 21:00:00,85.57,128.464,47.674,32.047 +2020-11-15 21:15:00,87.2,128.194,47.674,32.047 +2020-11-15 21:30:00,86.41,128.145,47.674,32.047 +2020-11-15 21:45:00,91.7,125.568,47.674,32.047 +2020-11-15 22:00:00,90.91,121.318,48.178000000000004,32.047 +2020-11-15 22:15:00,94.56,117.251,48.178000000000004,32.047 +2020-11-15 22:30:00,86.94,112.219,48.178000000000004,32.047 +2020-11-15 22:45:00,86.52,107.897,48.178000000000004,32.047 +2020-11-15 23:00:00,86.59,102.23200000000001,42.553999999999995,32.047 +2020-11-15 23:15:00,89.06,99.788,42.553999999999995,32.047 +2020-11-15 23:30:00,87.84,97.57,42.553999999999995,32.047 +2020-11-15 23:45:00,83.38,96.226,42.553999999999995,32.047 +2020-11-16 00:00:00,77.1,99.905,37.177,32.225 +2020-11-16 00:15:00,77.33,98.662,37.177,32.225 +2020-11-16 00:30:00,82.22,99.729,37.177,32.225 +2020-11-16 00:45:00,82.47,101.445,37.177,32.225 +2020-11-16 01:00:00,81.36,102.945,35.358000000000004,32.225 +2020-11-16 01:15:00,75.91,104.265,35.358000000000004,32.225 +2020-11-16 01:30:00,77.02,104.413,35.358000000000004,32.225 +2020-11-16 01:45:00,82.03,104.87100000000001,35.358000000000004,32.225 +2020-11-16 02:00:00,81.18,106.01299999999999,35.03,32.225 +2020-11-16 02:15:00,77.98,106.837,35.03,32.225 +2020-11-16 02:30:00,75.5,107.139,35.03,32.225 +2020-11-16 02:45:00,80.51,108.699,35.03,32.225 +2020-11-16 03:00:00,81.9,112.40299999999999,34.394,32.225 +2020-11-16 03:15:00,83.19,113.646,34.394,32.225 +2020-11-16 03:30:00,82.04,115.22200000000001,34.394,32.225 +2020-11-16 03:45:00,82.89,115.84,34.394,32.225 +2020-11-16 04:00:00,84.77,130.126,34.421,32.225 +2020-11-16 04:15:00,83.73,143.125,34.421,32.225 +2020-11-16 04:30:00,79.15,144.792,34.421,32.225 +2020-11-16 04:45:00,87.58,145.659,34.421,32.225 +2020-11-16 05:00:00,93.5,175.607,39.435,32.225 +2020-11-16 05:15:00,96.13,205.88400000000001,39.435,32.225 +2020-11-16 05:30:00,95.49,202.571,39.435,32.225 +2020-11-16 05:45:00,96.86,193.637,39.435,32.225 +2020-11-16 06:00:00,106.96,190.03,55.685,32.225 +2020-11-16 06:15:00,117.57,193.713,55.685,32.225 +2020-11-16 06:30:00,124.3,195.49400000000003,55.685,32.225 +2020-11-16 06:45:00,128.75,196.998,55.685,32.225 +2020-11-16 07:00:00,127.88,198.408,66.837,32.225 +2020-11-16 07:15:00,130.09,202.535,66.837,32.225 +2020-11-16 07:30:00,131.28,204.43900000000002,66.837,32.225 +2020-11-16 07:45:00,132.13,204.878,66.837,32.225 +2020-11-16 08:00:00,134.59,203.71,72.217,32.225 +2020-11-16 08:15:00,135.13,204.238,72.217,32.225 +2020-11-16 08:30:00,134.93,202.28799999999998,72.217,32.225 +2020-11-16 08:45:00,135.48,199.72,72.217,32.225 +2020-11-16 09:00:00,137.04,194.734,66.117,32.225 +2020-11-16 09:15:00,137.72,189.67,66.117,32.225 +2020-11-16 09:30:00,139.23,186.65099999999998,66.117,32.225 +2020-11-16 09:45:00,140.89,184.546,66.117,32.225 +2020-11-16 10:00:00,140.75,182.533,62.1,32.225 +2020-11-16 10:15:00,140.27,180.21200000000002,62.1,32.225 +2020-11-16 10:30:00,139.75,178.195,62.1,32.225 +2020-11-16 10:45:00,141.87,176.75400000000002,62.1,32.225 +2020-11-16 11:00:00,139.36,173.687,60.021,32.225 +2020-11-16 11:15:00,138.76,173.46599999999998,60.021,32.225 +2020-11-16 11:30:00,136.87,174.088,60.021,32.225 +2020-11-16 11:45:00,137.88,173.037,60.021,32.225 +2020-11-16 12:00:00,136.95,168.87,56.75899999999999,32.225 +2020-11-16 12:15:00,136.17,167.93599999999998,56.75899999999999,32.225 +2020-11-16 12:30:00,138.07,167.206,56.75899999999999,32.225 +2020-11-16 12:45:00,139.07,167.998,56.75899999999999,32.225 +2020-11-16 13:00:00,135.65,167.93099999999998,56.04600000000001,32.225 +2020-11-16 13:15:00,138.73,167.377,56.04600000000001,32.225 +2020-11-16 13:30:00,134.79,165.9,56.04600000000001,32.225 +2020-11-16 13:45:00,133.83,165.859,56.04600000000001,32.225 +2020-11-16 14:00:00,130.83,165.37900000000002,55.475,32.225 +2020-11-16 14:15:00,130.65,165.18099999999998,55.475,32.225 +2020-11-16 14:30:00,129.75,164.22,55.475,32.225 +2020-11-16 14:45:00,129.95,164.2,55.475,32.225 +2020-11-16 15:00:00,130.58,164.608,57.048,32.225 +2020-11-16 15:15:00,129.71,164.543,57.048,32.225 +2020-11-16 15:30:00,129.12,165.127,57.048,32.225 +2020-11-16 15:45:00,129.23,166.125,57.048,32.225 +2020-11-16 16:00:00,130.93,169.43,59.06,32.225 +2020-11-16 16:15:00,132.04,170.313,59.06,32.225 +2020-11-16 16:30:00,136.67,171.088,59.06,32.225 +2020-11-16 16:45:00,138.56,170.58900000000003,59.06,32.225 +2020-11-16 17:00:00,139.38,173.562,65.419,32.225 +2020-11-16 17:15:00,139.96,173.8,65.419,32.225 +2020-11-16 17:30:00,140.06,173.597,65.419,32.225 +2020-11-16 17:45:00,139.57,173.247,65.419,32.225 +2020-11-16 18:00:00,137.5,175.109,69.345,32.225 +2020-11-16 18:15:00,136.2,174.597,69.345,32.225 +2020-11-16 18:30:00,135.18,173.084,69.345,32.225 +2020-11-16 18:45:00,135.88,173.261,69.345,32.225 +2020-11-16 19:00:00,133.06,174.851,73.825,32.225 +2020-11-16 19:15:00,129.71,172.138,73.825,32.225 +2020-11-16 19:30:00,128.36,171.513,73.825,32.225 +2020-11-16 19:45:00,126.9,168.456,73.825,32.225 +2020-11-16 20:00:00,121.58,164.43200000000002,64.027,32.225 +2020-11-16 20:15:00,117.62,160.344,64.027,32.225 +2020-11-16 20:30:00,113.7,156.911,64.027,32.225 +2020-11-16 20:45:00,112.48,154.009,64.027,32.225 +2020-11-16 21:00:00,108.97,152.451,57.952,32.225 +2020-11-16 21:15:00,107.75,150.361,57.952,32.225 +2020-11-16 21:30:00,110.85,148.789,57.952,32.225 +2020-11-16 21:45:00,111.19,146.843,57.952,32.225 +2020-11-16 22:00:00,106.86,138.77200000000002,53.031000000000006,32.225 +2020-11-16 22:15:00,98.64,134.02100000000002,53.031000000000006,32.225 +2020-11-16 22:30:00,94.58,118.351,53.031000000000006,32.225 +2020-11-16 22:45:00,92.11,110.148,53.031000000000006,32.225 +2020-11-16 23:00:00,89.04,105.603,45.085,32.225 +2020-11-16 23:15:00,88.11,104.111,45.085,32.225 +2020-11-16 23:30:00,84.69,104.76700000000001,45.085,32.225 +2020-11-16 23:45:00,83.57,104.964,45.085,32.225 +2020-11-17 00:00:00,84.58,99.30799999999999,42.843,32.225 +2020-11-17 00:15:00,87.16,99.41,42.843,32.225 +2020-11-17 00:30:00,88.47,99.79700000000001,42.843,32.225 +2020-11-17 00:45:00,83.12,100.8,42.843,32.225 +2020-11-17 01:00:00,78.7,102.07799999999999,41.542,32.225 +2020-11-17 01:15:00,84.09,103.046,41.542,32.225 +2020-11-17 01:30:00,84.23,103.331,41.542,32.225 +2020-11-17 01:45:00,83.62,103.946,41.542,32.225 +2020-11-17 02:00:00,76.91,105.01700000000001,40.19,32.225 +2020-11-17 02:15:00,80.46,105.959,40.19,32.225 +2020-11-17 02:30:00,83.38,105.678,40.19,32.225 +2020-11-17 02:45:00,84.48,107.306,40.19,32.225 +2020-11-17 03:00:00,82.24,109.87200000000001,39.626,32.225 +2020-11-17 03:15:00,84.6,110.59299999999999,39.626,32.225 +2020-11-17 03:30:00,86.1,112.57600000000001,39.626,32.225 +2020-11-17 03:45:00,86.14,113.147,39.626,32.225 +2020-11-17 04:00:00,82.44,127.023,40.196999999999996,32.225 +2020-11-17 04:15:00,86.13,139.739,40.196999999999996,32.225 +2020-11-17 04:30:00,89.79,141.108,40.196999999999996,32.225 +2020-11-17 04:45:00,90.47,143.09799999999998,40.196999999999996,32.225 +2020-11-17 05:00:00,88.89,177.57299999999998,43.378,32.225 +2020-11-17 05:15:00,91.86,207.77200000000002,43.378,32.225 +2020-11-17 05:30:00,94.62,203.21400000000003,43.378,32.225 +2020-11-17 05:45:00,98.85,194.155,43.378,32.225 +2020-11-17 06:00:00,105.56,189.748,55.691,32.225 +2020-11-17 06:15:00,112.04,194.85299999999998,55.691,32.225 +2020-11-17 06:30:00,118.42,196.07299999999998,55.691,32.225 +2020-11-17 06:45:00,121.99,197.13400000000001,55.691,32.225 +2020-11-17 07:00:00,126.36,198.41299999999998,65.567,32.225 +2020-11-17 07:15:00,128.77,202.37,65.567,32.225 +2020-11-17 07:30:00,129.41,203.832,65.567,32.225 +2020-11-17 07:45:00,131.36,204.28900000000002,65.567,32.225 +2020-11-17 08:00:00,134.03,203.22099999999998,73.001,32.225 +2020-11-17 08:15:00,133.22,202.782,73.001,32.225 +2020-11-17 08:30:00,137.27,200.732,73.001,32.225 +2020-11-17 08:45:00,136.45,197.748,73.001,32.225 +2020-11-17 09:00:00,137.66,192.072,67.08800000000001,32.225 +2020-11-17 09:15:00,138.23,188.37599999999998,67.08800000000001,32.225 +2020-11-17 09:30:00,141.61,186.05599999999998,67.08800000000001,32.225 +2020-11-17 09:45:00,141.87,183.963,67.08800000000001,32.225 +2020-11-17 10:00:00,142.21,181.206,62.803000000000004,32.225 +2020-11-17 10:15:00,140.92,177.903,62.803000000000004,32.225 +2020-11-17 10:30:00,143.35,176.02200000000002,62.803000000000004,32.225 +2020-11-17 10:45:00,142.09,174.975,62.803000000000004,32.225 +2020-11-17 11:00:00,140.39,173.19299999999998,60.155,32.225 +2020-11-17 11:15:00,139.74,172.726,60.155,32.225 +2020-11-17 11:30:00,140.21,172.148,60.155,32.225 +2020-11-17 11:45:00,140.97,171.627,60.155,32.225 +2020-11-17 12:00:00,139.79,166.22099999999998,56.845,32.225 +2020-11-17 12:15:00,138.99,164.982,56.845,32.225 +2020-11-17 12:30:00,143.01,165.047,56.845,32.225 +2020-11-17 12:45:00,146.71,165.69400000000002,56.845,32.225 +2020-11-17 13:00:00,144.04,165.172,56.163000000000004,32.225 +2020-11-17 13:15:00,138.69,164.601,56.163000000000004,32.225 +2020-11-17 13:30:00,137.01,164.142,56.163000000000004,32.225 +2020-11-17 13:45:00,136.33,164.106,56.163000000000004,32.225 +2020-11-17 14:00:00,136.4,163.898,55.934,32.225 +2020-11-17 14:15:00,130.93,163.82,55.934,32.225 +2020-11-17 14:30:00,127.86,163.495,55.934,32.225 +2020-11-17 14:45:00,127.46,163.289,55.934,32.225 +2020-11-17 15:00:00,129.68,163.296,57.43899999999999,32.225 +2020-11-17 15:15:00,129.91,163.672,57.43899999999999,32.225 +2020-11-17 15:30:00,130.07,164.459,57.43899999999999,32.225 +2020-11-17 15:45:00,132.74,165.16299999999998,57.43899999999999,32.225 +2020-11-17 16:00:00,135.56,168.65099999999998,59.968999999999994,32.225 +2020-11-17 16:15:00,137.0,169.99099999999999,59.968999999999994,32.225 +2020-11-17 16:30:00,137.55,171.27900000000002,59.968999999999994,32.225 +2020-11-17 16:45:00,137.39,171.187,59.968999999999994,32.225 +2020-11-17 17:00:00,139.11,174.64,67.428,32.225 +2020-11-17 17:15:00,139.71,174.99,67.428,32.225 +2020-11-17 17:30:00,139.17,175.31,67.428,32.225 +2020-11-17 17:45:00,137.63,174.801,67.428,32.225 +2020-11-17 18:00:00,135.8,176.44,71.533,32.225 +2020-11-17 18:15:00,134.06,175.666,71.533,32.225 +2020-11-17 18:30:00,133.05,173.852,71.533,32.225 +2020-11-17 18:45:00,133.67,174.7,71.533,32.225 +2020-11-17 19:00:00,130.58,176.188,73.32300000000001,32.225 +2020-11-17 19:15:00,128.02,173.24200000000002,73.32300000000001,32.225 +2020-11-17 19:30:00,125.82,171.97799999999998,73.32300000000001,32.225 +2020-11-17 19:45:00,124.09,168.967,73.32300000000001,32.225 +2020-11-17 20:00:00,119.92,165.153,64.166,32.225 +2020-11-17 20:15:00,115.35,160.27200000000002,64.166,32.225 +2020-11-17 20:30:00,112.08,157.69899999999998,64.166,32.225 +2020-11-17 20:45:00,109.65,154.369,64.166,32.225 +2020-11-17 21:00:00,106.35,152.325,57.891999999999996,32.225 +2020-11-17 21:15:00,101.33,150.787,57.891999999999996,32.225 +2020-11-17 21:30:00,97.94,148.607,57.891999999999996,32.225 +2020-11-17 21:45:00,94.87,146.888,57.891999999999996,32.225 +2020-11-17 22:00:00,90.4,140.35,53.242,32.225 +2020-11-17 22:15:00,87.25,135.31,53.242,32.225 +2020-11-17 22:30:00,83.17,119.822,53.242,32.225 +2020-11-17 22:45:00,81.13,111.868,53.242,32.225 +2020-11-17 23:00:00,77.54,107.215,46.665,32.225 +2020-11-17 23:15:00,75.31,105.156,46.665,32.225 +2020-11-17 23:30:00,73.28,105.50200000000001,46.665,32.225 +2020-11-17 23:45:00,71.49,105.323,46.665,32.225 +2020-11-18 00:00:00,68.06,96.84,43.16,32.225 +2020-11-18 00:15:00,66.65,92.867,43.16,32.225 +2020-11-18 00:30:00,66.05,93.897,43.16,32.225 +2020-11-18 00:45:00,65.38,96.11,43.16,32.225 +2020-11-18 01:00:00,63.81,97.618,40.972,32.225 +2020-11-18 01:15:00,65.53,99.4,40.972,32.225 +2020-11-18 01:30:00,61.92,99.46,40.972,32.225 +2020-11-18 01:45:00,62.1,99.815,40.972,32.225 +2020-11-18 02:00:00,61.67,100.939,39.749,32.225 +2020-11-18 02:15:00,62.09,100.72399999999999,39.749,32.225 +2020-11-18 02:30:00,61.4,100.70299999999999,39.749,32.225 +2020-11-18 02:45:00,61.82,102.81200000000001,39.749,32.225 +2020-11-18 03:00:00,61.2,105.334,39.422,32.225 +2020-11-18 03:15:00,61.6,105.109,39.422,32.225 +2020-11-18 03:30:00,61.32,106.791,39.422,32.225 +2020-11-18 03:45:00,62.03,107.945,39.422,32.225 +2020-11-18 04:00:00,62.95,117.961,40.505,32.225 +2020-11-18 04:15:00,62.82,126.931,40.505,32.225 +2020-11-18 04:30:00,63.09,126.81700000000001,40.505,32.225 +2020-11-18 04:45:00,63.58,127.492,40.505,32.225 +2020-11-18 05:00:00,64.61,142.859,43.397,32.225 +2020-11-18 05:15:00,64.56,154.186,43.397,32.225 +2020-11-18 05:30:00,64.42,150.976,43.397,32.225 +2020-11-18 05:45:00,65.18,147.387,43.397,32.225 +2020-11-18 06:00:00,65.99,160.76,55.218,32.225 +2020-11-18 06:15:00,66.23,178.679,55.218,32.225 +2020-11-18 06:30:00,67.7,173.043,55.218,32.225 +2020-11-18 06:45:00,69.63,166.206,55.218,32.225 +2020-11-18 07:00:00,72.15,165.429,67.39,32.225 +2020-11-18 07:15:00,73.16,168.10299999999998,67.39,32.225 +2020-11-18 07:30:00,73.58,170.863,67.39,32.225 +2020-11-18 07:45:00,76.05,173.437,67.39,32.225 +2020-11-18 08:00:00,79.97,176.707,74.345,32.225 +2020-11-18 08:15:00,81.61,179.26,74.345,32.225 +2020-11-18 08:30:00,82.38,181.017,74.345,32.225 +2020-11-18 08:45:00,84.44,181.19799999999998,74.345,32.225 +2020-11-18 09:00:00,86.73,177.138,69.336,32.225 +2020-11-18 09:15:00,88.15,175.387,69.336,32.225 +2020-11-18 09:30:00,88.01,173.38099999999997,69.336,32.225 +2020-11-18 09:45:00,88.77,171.382,69.336,32.225 +2020-11-18 10:00:00,88.6,170.173,64.291,32.225 +2020-11-18 10:15:00,89.69,168.09,64.291,32.225 +2020-11-18 10:30:00,91.83,166.854,64.291,32.225 +2020-11-18 10:45:00,93.65,165.011,64.291,32.225 +2020-11-18 11:00:00,93.82,164.09099999999998,62.20399999999999,32.225 +2020-11-18 11:15:00,96.88,162.225,62.20399999999999,32.225 +2020-11-18 11:30:00,97.62,161.532,62.20399999999999,32.225 +2020-11-18 11:45:00,98.81,160.72899999999998,62.20399999999999,32.225 +2020-11-18 12:00:00,98.43,155.30100000000002,59.042,32.225 +2020-11-18 12:15:00,96.83,154.36,59.042,32.225 +2020-11-18 12:30:00,94.69,153.64600000000002,59.042,32.225 +2020-11-18 12:45:00,92.52,153.125,59.042,32.225 +2020-11-18 13:00:00,91.17,152.308,57.907,32.225 +2020-11-18 13:15:00,90.28,153.11700000000002,57.907,32.225 +2020-11-18 13:30:00,89.49,152.067,57.907,32.225 +2020-11-18 13:45:00,89.41,151.834,57.907,32.225 +2020-11-18 14:00:00,88.17,151.933,58.358000000000004,32.225 +2020-11-18 14:15:00,87.97,152.22299999999998,58.358000000000004,32.225 +2020-11-18 14:30:00,88.15,151.829,58.358000000000004,32.225 +2020-11-18 14:45:00,89.22,151.511,58.358000000000004,32.225 +2020-11-18 15:00:00,91.04,150.407,59.348,32.225 +2020-11-18 15:15:00,90.98,151.707,59.348,32.225 +2020-11-18 15:30:00,90.33,152.963,59.348,32.225 +2020-11-18 15:45:00,91.75,154.442,59.348,32.225 +2020-11-18 16:00:00,93.83,157.414,61.413999999999994,32.225 +2020-11-18 16:15:00,96.36,159.002,61.413999999999994,32.225 +2020-11-18 16:30:00,97.49,160.732,61.413999999999994,32.225 +2020-11-18 16:45:00,98.72,161.352,61.413999999999994,32.225 +2020-11-18 17:00:00,101.46,164.60299999999998,67.107,32.225 +2020-11-18 17:15:00,103.06,165.655,67.107,32.225 +2020-11-18 17:30:00,107.61,166.00599999999997,67.107,32.225 +2020-11-18 17:45:00,105.53,167.03400000000002,67.107,32.225 +2020-11-18 18:00:00,105.8,168.706,71.92,32.225 +2020-11-18 18:15:00,104.59,170.32,71.92,32.225 +2020-11-18 18:30:00,103.28,168.372,71.92,32.225 +2020-11-18 18:45:00,104.04,167.43,71.92,32.225 +2020-11-18 19:00:00,102.53,170.468,75.09,32.225 +2020-11-18 19:15:00,100.99,168.55,75.09,32.225 +2020-11-18 19:30:00,99.54,167.50900000000001,75.09,32.225 +2020-11-18 19:45:00,98.32,165.204,75.09,32.225 +2020-11-18 20:00:00,101.42,163.47799999999998,65.977,32.225 +2020-11-18 20:15:00,101.77,161.30200000000002,65.977,32.225 +2020-11-18 20:30:00,98.91,159.345,65.977,32.225 +2020-11-18 20:45:00,94.0,155.009,65.977,32.225 +2020-11-18 21:00:00,91.32,153.06799999999998,58.798,32.225 +2020-11-18 21:15:00,94.27,151.904,58.798,32.225 +2020-11-18 21:30:00,94.17,150.993,58.798,32.225 +2020-11-18 21:45:00,90.94,149.516,58.798,32.225 +2020-11-18 22:00:00,87.11,144.303,54.486000000000004,32.225 +2020-11-18 22:15:00,86.53,140.368,54.486000000000004,32.225 +2020-11-18 22:30:00,87.31,133.83,54.486000000000004,32.225 +2020-11-18 22:45:00,83.89,129.859,54.486000000000004,32.225 +2020-11-18 23:00:00,79.96,124.615,47.783,32.225 +2020-11-18 23:15:00,80.17,121.041,47.783,32.225 +2020-11-18 23:30:00,78.39,119.30799999999999,47.783,32.225 +2020-11-18 23:45:00,77.89,117.245,47.783,32.225 +2020-11-19 00:00:00,74.83,100.085,43.88,32.225 +2020-11-19 00:15:00,74.27,100.132,43.88,32.225 +2020-11-19 00:30:00,72.66,100.522,43.88,32.225 +2020-11-19 00:45:00,72.09,101.492,43.88,32.225 +2020-11-19 01:00:00,71.02,102.845,42.242,32.225 +2020-11-19 01:15:00,71.4,103.829,42.242,32.225 +2020-11-19 01:30:00,70.62,104.14,42.242,32.225 +2020-11-19 01:45:00,71.65,104.73,42.242,32.225 +2020-11-19 02:00:00,74.0,105.839,40.918,32.225 +2020-11-19 02:15:00,71.51,106.79,40.918,32.225 +2020-11-19 02:30:00,71.25,106.499,40.918,32.225 +2020-11-19 02:45:00,72.0,108.12299999999999,40.918,32.225 +2020-11-19 03:00:00,71.53,110.661,40.411,32.225 +2020-11-19 03:15:00,71.33,111.44,40.411,32.225 +2020-11-19 03:30:00,71.54,113.429,40.411,32.225 +2020-11-19 03:45:00,73.18,113.98200000000001,40.411,32.225 +2020-11-19 04:00:00,74.7,127.82600000000001,41.246,32.225 +2020-11-19 04:15:00,75.98,140.563,41.246,32.225 +2020-11-19 04:30:00,77.0,141.905,41.246,32.225 +2020-11-19 04:45:00,79.12,143.909,41.246,32.225 +2020-11-19 05:00:00,84.14,178.382,44.533,32.225 +2020-11-19 05:15:00,87.47,208.544,44.533,32.225 +2020-11-19 05:30:00,90.75,204.02200000000002,44.533,32.225 +2020-11-19 05:45:00,96.44,194.972,44.533,32.225 +2020-11-19 06:00:00,104.01,190.593,55.005,32.225 +2020-11-19 06:15:00,109.76,195.706,55.005,32.225 +2020-11-19 06:30:00,116.54,197.018,55.005,32.225 +2020-11-19 06:45:00,120.52,198.15200000000002,55.005,32.225 +2020-11-19 07:00:00,124.39,199.429,64.597,32.225 +2020-11-19 07:15:00,126.36,203.41299999999998,64.597,32.225 +2020-11-19 07:30:00,127.26,204.91,64.597,32.225 +2020-11-19 07:45:00,130.79,205.38299999999998,64.597,32.225 +2020-11-19 08:00:00,133.32,204.34599999999998,71.71600000000001,32.225 +2020-11-19 08:15:00,133.27,203.889,71.71600000000001,32.225 +2020-11-19 08:30:00,133.0,201.88400000000001,71.71600000000001,32.225 +2020-11-19 08:45:00,133.02,198.83599999999998,71.71600000000001,32.225 +2020-11-19 09:00:00,137.0,193.114,66.51899999999999,32.225 +2020-11-19 09:15:00,138.81,189.428,66.51899999999999,32.225 +2020-11-19 09:30:00,139.87,187.102,66.51899999999999,32.225 +2020-11-19 09:45:00,136.56,184.975,66.51899999999999,32.225 +2020-11-19 10:00:00,136.84,182.19400000000002,63.04,32.225 +2020-11-19 10:15:00,136.24,178.824,63.04,32.225 +2020-11-19 10:30:00,136.58,176.892,63.04,32.225 +2020-11-19 10:45:00,138.2,175.81799999999998,63.04,32.225 +2020-11-19 11:00:00,143.48,174.016,60.998000000000005,32.225 +2020-11-19 11:15:00,145.08,173.512,60.998000000000005,32.225 +2020-11-19 11:30:00,141.99,172.93099999999998,60.998000000000005,32.225 +2020-11-19 11:45:00,139.15,172.388,60.998000000000005,32.225 +2020-11-19 12:00:00,137.73,166.959,58.27,32.225 +2020-11-19 12:15:00,136.88,165.732,58.27,32.225 +2020-11-19 12:30:00,137.43,165.851,58.27,32.225 +2020-11-19 12:45:00,136.49,166.507,58.27,32.225 +2020-11-19 13:00:00,134.8,165.90599999999998,57.196000000000005,32.225 +2020-11-19 13:15:00,134.79,165.35,57.196000000000005,32.225 +2020-11-19 13:30:00,132.52,164.889,57.196000000000005,32.225 +2020-11-19 13:45:00,131.77,164.835,57.196000000000005,32.225 +2020-11-19 14:00:00,133.0,164.542,57.38399999999999,32.225 +2020-11-19 14:15:00,133.06,164.49,57.38399999999999,32.225 +2020-11-19 14:30:00,131.21,164.235,57.38399999999999,32.225 +2020-11-19 14:45:00,131.57,164.03900000000002,57.38399999999999,32.225 +2020-11-19 15:00:00,132.6,164.05700000000002,58.647,32.225 +2020-11-19 15:15:00,131.83,164.451,58.647,32.225 +2020-11-19 15:30:00,132.4,165.30900000000003,58.647,32.225 +2020-11-19 15:45:00,133.34,166.02900000000002,58.647,32.225 +2020-11-19 16:00:00,136.61,169.5,60.083999999999996,32.225 +2020-11-19 16:15:00,135.17,170.892,60.083999999999996,32.225 +2020-11-19 16:30:00,137.53,172.18599999999998,60.083999999999996,32.225 +2020-11-19 16:45:00,137.22,172.18200000000002,60.083999999999996,32.225 +2020-11-19 17:00:00,138.52,175.574,65.85600000000001,32.225 +2020-11-19 17:15:00,138.89,175.96900000000002,65.85600000000001,32.225 +2020-11-19 17:30:00,140.31,176.305,65.85600000000001,32.225 +2020-11-19 17:45:00,138.58,175.81,65.85600000000001,32.225 +2020-11-19 18:00:00,136.88,177.47400000000002,69.855,32.225 +2020-11-19 18:15:00,135.08,176.61,69.855,32.225 +2020-11-19 18:30:00,134.94,174.812,69.855,32.225 +2020-11-19 18:45:00,135.05,175.66299999999998,69.855,32.225 +2020-11-19 19:00:00,132.41,177.15200000000002,74.015,32.225 +2020-11-19 19:15:00,129.97,174.18200000000002,74.015,32.225 +2020-11-19 19:30:00,128.17,172.87900000000002,74.015,32.225 +2020-11-19 19:45:00,126.77,169.799,74.015,32.225 +2020-11-19 20:00:00,121.56,166.00799999999998,65.316,32.225 +2020-11-19 20:15:00,116.74,161.10299999999998,65.316,32.225 +2020-11-19 20:30:00,113.64,158.468,65.316,32.225 +2020-11-19 20:45:00,117.52,155.156,65.316,32.225 +2020-11-19 21:00:00,113.74,153.093,58.403999999999996,32.225 +2020-11-19 21:15:00,113.82,151.528,58.403999999999996,32.225 +2020-11-19 21:30:00,108.94,149.357,58.403999999999996,32.225 +2020-11-19 21:45:00,105.09,147.631,58.403999999999996,32.225 +2020-11-19 22:00:00,99.66,141.107,54.092,32.225 +2020-11-19 22:15:00,102.85,136.05100000000002,54.092,32.225 +2020-11-19 22:30:00,100.78,120.677,54.092,32.225 +2020-11-19 22:45:00,99.23,112.73700000000001,54.092,32.225 +2020-11-19 23:00:00,91.21,108.055,48.18600000000001,32.225 +2020-11-19 23:15:00,88.51,105.96700000000001,48.18600000000001,32.225 +2020-11-19 23:30:00,92.47,106.329,48.18600000000001,32.225 +2020-11-19 23:45:00,91.07,106.109,48.18600000000001,32.225 +2020-11-20 00:00:00,87.75,99.23,45.18899999999999,32.225 +2020-11-20 00:15:00,81.05,99.459,45.18899999999999,32.225 +2020-11-20 00:30:00,85.97,99.78200000000001,45.18899999999999,32.225 +2020-11-20 00:45:00,85.53,100.905,45.18899999999999,32.225 +2020-11-20 01:00:00,83.27,101.958,43.256,32.225 +2020-11-20 01:15:00,80.61,103.631,43.256,32.225 +2020-11-20 01:30:00,82.75,103.89,43.256,32.225 +2020-11-20 01:45:00,83.03,104.514,43.256,32.225 +2020-11-20 02:00:00,78.63,105.882,42.312,32.225 +2020-11-20 02:15:00,77.14,106.729,42.312,32.225 +2020-11-20 02:30:00,77.65,107.031,42.312,32.225 +2020-11-20 02:45:00,82.12,108.568,42.312,32.225 +2020-11-20 03:00:00,83.0,110.37899999999999,41.833,32.225 +2020-11-20 03:15:00,81.44,111.771,41.833,32.225 +2020-11-20 03:30:00,78.04,113.704,41.833,32.225 +2020-11-20 03:45:00,81.83,114.689,41.833,32.225 +2020-11-20 04:00:00,85.52,128.736,42.732,32.225 +2020-11-20 04:15:00,86.71,140.985,42.732,32.225 +2020-11-20 04:30:00,82.74,142.685,42.732,32.225 +2020-11-20 04:45:00,85.24,143.602,42.732,32.225 +2020-11-20 05:00:00,87.34,176.903,46.254,32.225 +2020-11-20 05:15:00,95.1,208.50799999999998,46.254,32.225 +2020-11-20 05:30:00,100.38,204.958,46.254,32.225 +2020-11-20 05:45:00,104.5,195.774,46.254,32.225 +2020-11-20 06:00:00,105.66,191.827,56.76,32.225 +2020-11-20 06:15:00,110.32,195.717,56.76,32.225 +2020-11-20 06:30:00,114.29,196.326,56.76,32.225 +2020-11-20 06:45:00,119.4,198.864,56.76,32.225 +2020-11-20 07:00:00,126.14,199.542,66.029,32.225 +2020-11-20 07:15:00,127.73,204.579,66.029,32.225 +2020-11-20 07:30:00,127.93,205.59099999999998,66.029,32.225 +2020-11-20 07:45:00,128.79,205.271,66.029,32.225 +2020-11-20 08:00:00,131.62,203.435,73.128,32.225 +2020-11-20 08:15:00,131.62,202.748,73.128,32.225 +2020-11-20 08:30:00,131.81,201.59400000000002,73.128,32.225 +2020-11-20 08:45:00,132.09,197.12599999999998,73.128,32.225 +2020-11-20 09:00:00,132.08,191.378,68.23100000000001,32.225 +2020-11-20 09:15:00,132.7,188.517,68.23100000000001,32.225 +2020-11-20 09:30:00,130.51,185.7,68.23100000000001,32.225 +2020-11-20 09:45:00,130.25,183.52700000000002,68.23100000000001,32.225 +2020-11-20 10:00:00,127.82,179.71400000000003,64.733,32.225 +2020-11-20 10:15:00,126.85,176.882,64.733,32.225 +2020-11-20 10:30:00,125.62,174.955,64.733,32.225 +2020-11-20 10:45:00,125.03,173.46599999999998,64.733,32.225 +2020-11-20 11:00:00,126.8,171.67,62.0,32.225 +2020-11-20 11:15:00,126.91,170.18599999999998,62.0,32.225 +2020-11-20 11:30:00,124.26,171.074,62.0,32.225 +2020-11-20 11:45:00,122.85,170.40400000000002,62.0,32.225 +2020-11-20 12:00:00,121.61,165.986,57.876999999999995,32.225 +2020-11-20 12:15:00,121.21,162.827,57.876999999999995,32.225 +2020-11-20 12:30:00,121.35,163.131,57.876999999999995,32.225 +2020-11-20 12:45:00,122.36,164.113,57.876999999999995,32.225 +2020-11-20 13:00:00,119.72,164.393,55.585,32.225 +2020-11-20 13:15:00,118.77,164.592,55.585,32.225 +2020-11-20 13:30:00,118.89,164.27200000000002,55.585,32.225 +2020-11-20 13:45:00,117.82,164.204,55.585,32.225 +2020-11-20 14:00:00,119.04,162.74,54.5,32.225 +2020-11-20 14:15:00,118.42,162.615,54.5,32.225 +2020-11-20 14:30:00,118.28,163.089,54.5,32.225 +2020-11-20 14:45:00,119.7,163.05,54.5,32.225 +2020-11-20 15:00:00,119.39,162.639,55.131,32.225 +2020-11-20 15:15:00,120.02,162.61700000000002,55.131,32.225 +2020-11-20 15:30:00,120.71,162.084,55.131,32.225 +2020-11-20 15:45:00,122.19,163.062,55.131,32.225 +2020-11-20 16:00:00,124.56,165.365,56.8,32.225 +2020-11-20 16:15:00,127.81,167.11599999999999,56.8,32.225 +2020-11-20 16:30:00,132.47,168.468,56.8,32.225 +2020-11-20 16:45:00,134.58,168.27900000000002,56.8,32.225 +2020-11-20 17:00:00,135.53,172.09799999999998,63.428999999999995,32.225 +2020-11-20 17:15:00,135.41,172.127,63.428999999999995,32.225 +2020-11-20 17:30:00,135.86,172.21599999999998,63.428999999999995,32.225 +2020-11-20 17:45:00,135.03,171.495,63.428999999999995,32.225 +2020-11-20 18:00:00,133.0,173.782,67.915,32.225 +2020-11-20 18:15:00,132.6,172.382,67.915,32.225 +2020-11-20 18:30:00,132.16,170.923,67.915,32.225 +2020-11-20 18:45:00,133.09,171.83599999999998,67.915,32.225 +2020-11-20 19:00:00,129.97,174.26,69.428,32.225 +2020-11-20 19:15:00,127.95,172.574,69.428,32.225 +2020-11-20 19:30:00,125.39,170.898,69.428,32.225 +2020-11-20 19:45:00,122.11,167.21900000000002,69.428,32.225 +2020-11-20 20:00:00,117.37,163.468,60.56100000000001,32.225 +2020-11-20 20:15:00,113.89,158.686,60.56100000000001,32.225 +2020-11-20 20:30:00,110.74,155.905,60.56100000000001,32.225 +2020-11-20 20:45:00,109.52,152.961,60.56100000000001,32.225 +2020-11-20 21:00:00,106.9,151.546,55.18600000000001,32.225 +2020-11-20 21:15:00,105.87,150.619,55.18600000000001,32.225 +2020-11-20 21:30:00,106.31,148.468,55.18600000000001,32.225 +2020-11-20 21:45:00,103.66,147.249,55.18600000000001,32.225 +2020-11-20 22:00:00,92.56,141.57,51.433,32.225 +2020-11-20 22:15:00,90.8,136.34799999999998,51.433,32.225 +2020-11-20 22:30:00,84.72,127.389,51.433,32.225 +2020-11-20 22:45:00,86.32,122.689,51.433,32.225 +2020-11-20 23:00:00,87.62,117.875,46.201,32.225 +2020-11-20 23:15:00,86.93,113.809,46.201,32.225 +2020-11-20 23:30:00,82.7,112.605,46.201,32.225 +2020-11-20 23:45:00,78.82,111.764,46.201,32.225 +2020-11-21 00:00:00,79.22,97.382,42.576,32.047 +2020-11-21 00:15:00,78.96,93.809,42.576,32.047 +2020-11-21 00:30:00,73.94,95.18799999999999,42.576,32.047 +2020-11-21 00:45:00,70.84,96.777,42.576,32.047 +2020-11-21 01:00:00,71.67,98.469,39.34,32.047 +2020-11-21 01:15:00,74.73,99.413,39.34,32.047 +2020-11-21 01:30:00,74.22,99.094,39.34,32.047 +2020-11-21 01:45:00,71.33,99.74799999999999,39.34,32.047 +2020-11-21 02:00:00,71.97,101.554,37.582,32.047 +2020-11-21 02:15:00,73.0,101.944,37.582,32.047 +2020-11-21 02:30:00,71.44,101.148,37.582,32.047 +2020-11-21 02:45:00,69.8,102.914,37.582,32.047 +2020-11-21 03:00:00,70.35,105.005,36.523,32.047 +2020-11-21 03:15:00,71.12,105.26100000000001,36.523,32.047 +2020-11-21 03:30:00,68.69,105.90799999999999,36.523,32.047 +2020-11-21 03:45:00,65.43,107.25299999999999,36.523,32.047 +2020-11-21 04:00:00,63.81,117.439,36.347,32.047 +2020-11-21 04:15:00,64.05,127.375,36.347,32.047 +2020-11-21 04:30:00,65.13,126.90899999999999,36.347,32.047 +2020-11-21 04:45:00,65.92,127.463,36.347,32.047 +2020-11-21 05:00:00,66.64,145.74,36.407,32.047 +2020-11-21 05:15:00,66.61,159.19,36.407,32.047 +2020-11-21 05:30:00,66.7,156.248,36.407,32.047 +2020-11-21 05:45:00,68.33,152.523,36.407,32.047 +2020-11-21 06:00:00,70.41,166.495,38.228,32.047 +2020-11-21 06:15:00,71.52,185.717,38.228,32.047 +2020-11-21 06:30:00,73.49,181.28099999999998,38.228,32.047 +2020-11-21 06:45:00,75.76,175.635,38.228,32.047 +2020-11-21 07:00:00,79.69,172.74200000000002,41.905,32.047 +2020-11-21 07:15:00,81.93,176.484,41.905,32.047 +2020-11-21 07:30:00,82.59,180.097,41.905,32.047 +2020-11-21 07:45:00,86.02,183.34099999999998,41.905,32.047 +2020-11-21 08:00:00,89.63,184.977,46.051,32.047 +2020-11-21 08:15:00,90.83,187.362,46.051,32.047 +2020-11-21 08:30:00,93.03,187.613,46.051,32.047 +2020-11-21 08:45:00,96.12,185.99200000000002,46.051,32.047 +2020-11-21 09:00:00,100.89,182.237,46.683,32.047 +2020-11-21 09:15:00,98.64,180.114,46.683,32.047 +2020-11-21 09:30:00,100.25,178.16,46.683,32.047 +2020-11-21 09:45:00,100.87,176.032,46.683,32.047 +2020-11-21 10:00:00,100.67,172.535,44.425,32.047 +2020-11-21 10:15:00,99.74,169.87400000000002,44.425,32.047 +2020-11-21 10:30:00,99.32,168.007,44.425,32.047 +2020-11-21 10:45:00,99.73,167.572,44.425,32.047 +2020-11-21 11:00:00,100.59,165.90200000000002,42.148999999999994,32.047 +2020-11-21 11:15:00,101.81,163.94400000000002,42.148999999999994,32.047 +2020-11-21 11:30:00,104.19,163.908,42.148999999999994,32.047 +2020-11-21 11:45:00,103.4,162.507,42.148999999999994,32.047 +2020-11-21 12:00:00,103.31,157.35299999999998,39.683,32.047 +2020-11-21 12:15:00,103.72,154.89600000000002,39.683,32.047 +2020-11-21 12:30:00,101.44,155.484,39.683,32.047 +2020-11-21 12:45:00,101.91,155.91899999999998,39.683,32.047 +2020-11-21 13:00:00,97.83,155.659,37.154,32.047 +2020-11-21 13:15:00,97.45,153.987,37.154,32.047 +2020-11-21 13:30:00,97.31,153.30200000000002,37.154,32.047 +2020-11-21 13:45:00,96.77,153.42,37.154,32.047 +2020-11-21 14:00:00,94.23,152.981,36.457,32.047 +2020-11-21 14:15:00,92.82,152.19899999999998,36.457,32.047 +2020-11-21 14:30:00,91.21,151.05100000000002,36.457,32.047 +2020-11-21 14:45:00,90.74,151.286,36.457,32.047 +2020-11-21 15:00:00,89.08,151.498,38.257,32.047 +2020-11-21 15:15:00,90.29,152.30200000000002,38.257,32.047 +2020-11-21 15:30:00,90.87,153.183,38.257,32.047 +2020-11-21 15:45:00,91.79,154.035,38.257,32.047 +2020-11-21 16:00:00,94.22,155.641,41.181000000000004,32.047 +2020-11-21 16:15:00,97.63,158.055,41.181000000000004,32.047 +2020-11-21 16:30:00,100.73,159.40200000000002,41.181000000000004,32.047 +2020-11-21 16:45:00,102.19,160.024,41.181000000000004,32.047 +2020-11-21 17:00:00,104.89,163.127,46.806000000000004,32.047 +2020-11-21 17:15:00,108.34,164.282,46.806000000000004,32.047 +2020-11-21 17:30:00,107.18,164.275,46.806000000000004,32.047 +2020-11-21 17:45:00,107.64,163.287,46.806000000000004,32.047 +2020-11-21 18:00:00,107.72,165.36599999999999,52.073,32.047 +2020-11-21 18:15:00,107.31,165.763,52.073,32.047 +2020-11-21 18:30:00,105.98,165.696,52.073,32.047 +2020-11-21 18:45:00,105.32,163.127,52.073,32.047 +2020-11-21 19:00:00,103.23,166.097,53.608000000000004,32.047 +2020-11-21 19:15:00,101.82,163.806,53.608000000000004,32.047 +2020-11-21 19:30:00,100.14,162.893,53.608000000000004,32.047 +2020-11-21 19:45:00,98.85,159.30200000000002,53.608000000000004,32.047 +2020-11-21 20:00:00,94.8,157.629,50.265,32.047 +2020-11-21 20:15:00,91.04,154.589,50.265,32.047 +2020-11-21 20:30:00,87.39,151.33,50.265,32.047 +2020-11-21 20:45:00,85.88,148.344,50.265,32.047 +2020-11-21 21:00:00,83.83,148.64700000000002,45.766000000000005,32.047 +2020-11-21 21:15:00,82.44,148.033,45.766000000000005,32.047 +2020-11-21 21:30:00,81.16,146.989,45.766000000000005,32.047 +2020-11-21 21:45:00,82.28,145.325,45.766000000000005,32.047 +2020-11-21 22:00:00,77.41,140.82399999999998,45.97,32.047 +2020-11-21 22:15:00,75.61,137.806,45.97,32.047 +2020-11-21 22:30:00,72.97,134.268,45.97,32.047 +2020-11-21 22:45:00,72.35,131.268,45.97,32.047 +2020-11-21 23:00:00,69.85,128.459,40.415,32.047 +2020-11-21 23:15:00,68.87,123.02,40.415,32.047 +2020-11-21 23:30:00,66.45,120.694,40.415,32.047 +2020-11-21 23:45:00,64.91,117.774,40.415,32.047 +2020-11-22 00:00:00,62.39,98.36399999999999,36.376,32.047 +2020-11-22 00:15:00,61.59,94.28299999999999,36.376,32.047 +2020-11-22 00:30:00,60.39,95.315,36.376,32.047 +2020-11-22 00:45:00,59.43,97.46,36.376,32.047 +2020-11-22 01:00:00,60.22,99.115,32.992,32.047 +2020-11-22 01:15:00,57.85,100.927,32.992,32.047 +2020-11-22 01:30:00,56.88,101.039,32.992,32.047 +2020-11-22 01:45:00,56.3,101.34299999999999,32.992,32.047 +2020-11-22 02:00:00,55.45,102.54299999999999,32.327,32.047 +2020-11-22 02:15:00,55.69,102.346,32.327,32.047 +2020-11-22 02:30:00,55.72,102.307,32.327,32.047 +2020-11-22 02:45:00,55.6,104.40899999999999,32.327,32.047 +2020-11-22 03:00:00,55.22,106.87299999999999,31.169,32.047 +2020-11-22 03:15:00,55.28,106.764,31.169,32.047 +2020-11-22 03:30:00,55.5,108.458,31.169,32.047 +2020-11-22 03:45:00,55.87,109.579,31.169,32.047 +2020-11-22 04:00:00,55.68,119.53,30.796,32.047 +2020-11-22 04:15:00,56.25,128.539,30.796,32.047 +2020-11-22 04:30:00,56.7,128.373,30.796,32.047 +2020-11-22 04:45:00,57.4,129.07399999999998,30.796,32.047 +2020-11-22 05:00:00,57.89,144.434,30.848000000000003,32.047 +2020-11-22 05:15:00,58.33,155.689,30.848000000000003,32.047 +2020-11-22 05:30:00,58.4,152.545,30.848000000000003,32.047 +2020-11-22 05:45:00,58.91,148.976,30.848000000000003,32.047 +2020-11-22 06:00:00,60.42,162.407,31.166,32.047 +2020-11-22 06:15:00,61.02,180.342,31.166,32.047 +2020-11-22 06:30:00,61.68,174.887,31.166,32.047 +2020-11-22 06:45:00,63.19,168.197,31.166,32.047 +2020-11-22 07:00:00,66.58,167.417,33.527,32.047 +2020-11-22 07:15:00,67.92,170.138,33.527,32.047 +2020-11-22 07:30:00,68.4,172.968,33.527,32.047 +2020-11-22 07:45:00,70.27,175.56599999999997,33.527,32.047 +2020-11-22 08:00:00,73.27,178.896,36.616,32.047 +2020-11-22 08:15:00,74.74,181.408,36.616,32.047 +2020-11-22 08:30:00,76.33,183.252,36.616,32.047 +2020-11-22 08:45:00,76.44,183.30700000000002,36.616,32.047 +2020-11-22 09:00:00,77.09,179.15400000000002,37.857,32.047 +2020-11-22 09:15:00,78.76,177.422,37.857,32.047 +2020-11-22 09:30:00,79.57,175.412,37.857,32.047 +2020-11-22 09:45:00,79.12,173.343,37.857,32.047 +2020-11-22 10:00:00,78.1,172.08700000000002,36.319,32.047 +2020-11-22 10:15:00,80.56,169.87400000000002,36.319,32.047 +2020-11-22 10:30:00,83.15,168.53900000000002,36.319,32.047 +2020-11-22 10:45:00,84.7,166.643,36.319,32.047 +2020-11-22 11:00:00,85.59,165.68099999999998,37.236999999999995,32.047 +2020-11-22 11:15:00,87.48,163.744,37.236999999999995,32.047 +2020-11-22 11:30:00,89.9,163.045,37.236999999999995,32.047 +2020-11-22 11:45:00,94.79,162.2,37.236999999999995,32.047 +2020-11-22 12:00:00,90.94,156.726,34.871,32.047 +2020-11-22 12:15:00,89.01,155.812,34.871,32.047 +2020-11-22 12:30:00,86.85,155.20600000000002,34.871,32.047 +2020-11-22 12:45:00,85.53,154.701,34.871,32.047 +2020-11-22 13:00:00,81.64,153.731,29.738000000000003,32.047 +2020-11-22 13:15:00,78.73,154.57,29.738000000000003,32.047 +2020-11-22 13:30:00,78.52,153.511,29.738000000000003,32.047 +2020-11-22 13:45:00,76.68,153.241,29.738000000000003,32.047 +2020-11-22 14:00:00,76.68,153.178,27.333000000000002,32.047 +2020-11-22 14:15:00,75.93,153.518,27.333000000000002,32.047 +2020-11-22 14:30:00,75.96,153.26,27.333000000000002,32.047 +2020-11-22 14:45:00,75.98,152.963,27.333000000000002,32.047 +2020-11-22 15:00:00,80.48,151.885,28.232,32.047 +2020-11-22 15:15:00,77.4,153.216,28.232,32.047 +2020-11-22 15:30:00,79.06,154.611,28.232,32.047 +2020-11-22 15:45:00,81.3,156.118,28.232,32.047 +2020-11-22 16:00:00,84.0,159.056,32.815,32.047 +2020-11-22 16:15:00,86.71,160.746,32.815,32.047 +2020-11-22 16:30:00,91.42,162.487,32.815,32.047 +2020-11-22 16:45:00,94.2,163.279,32.815,32.047 +2020-11-22 17:00:00,97.01,166.412,43.068999999999996,32.047 +2020-11-22 17:15:00,99.13,167.55599999999998,43.068999999999996,32.047 +2020-11-22 17:30:00,106.82,167.94099999999997,43.068999999999996,32.047 +2020-11-22 17:45:00,108.43,168.998,43.068999999999996,32.047 +2020-11-22 18:00:00,106.98,170.72,50.498999999999995,32.047 +2020-11-22 18:15:00,102.7,172.16400000000002,50.498999999999995,32.047 +2020-11-22 18:30:00,103.87,170.24900000000002,50.498999999999995,32.047 +2020-11-22 18:45:00,101.15,169.312,50.498999999999995,32.047 +2020-11-22 19:00:00,101.38,172.34799999999998,53.481,32.047 +2020-11-22 19:15:00,97.91,170.38400000000001,53.481,32.047 +2020-11-22 19:30:00,97.08,169.269,53.481,32.047 +2020-11-22 19:45:00,94.87,166.83,53.481,32.047 +2020-11-22 20:00:00,100.26,165.14700000000002,51.687,32.047 +2020-11-22 20:15:00,100.16,162.92600000000002,51.687,32.047 +2020-11-22 20:30:00,97.79,160.846,51.687,32.047 +2020-11-22 20:45:00,89.12,156.549,51.687,32.047 +2020-11-22 21:00:00,89.33,154.567,47.674,32.047 +2020-11-22 21:15:00,90.04,153.346,47.674,32.047 +2020-11-22 21:30:00,87.6,152.453,47.674,32.047 +2020-11-22 21:45:00,87.83,150.965,47.674,32.047 +2020-11-22 22:00:00,86.54,145.78,48.178000000000004,32.047 +2020-11-22 22:15:00,87.22,141.814,48.178000000000004,32.047 +2020-11-22 22:30:00,85.35,135.501,48.178000000000004,32.047 +2020-11-22 22:45:00,86.7,131.559,48.178000000000004,32.047 +2020-11-22 23:00:00,87.2,126.255,42.553999999999995,32.047 +2020-11-22 23:15:00,88.4,122.626,42.553999999999995,32.047 +2020-11-22 23:30:00,84.48,120.92200000000001,42.553999999999995,32.047 +2020-11-22 23:45:00,81.66,118.785,42.553999999999995,32.047 +2020-11-23 00:00:00,81.01,102.585,37.177,32.225 +2020-11-23 00:15:00,80.79,101.152,37.177,32.225 +2020-11-23 00:30:00,79.11,102.22200000000001,37.177,32.225 +2020-11-23 00:45:00,74.45,103.821,37.177,32.225 +2020-11-23 01:00:00,77.84,105.58,35.358000000000004,32.225 +2020-11-23 01:15:00,77.83,106.954,35.358000000000004,32.225 +2020-11-23 01:30:00,77.76,107.191,35.358000000000004,32.225 +2020-11-23 01:45:00,72.18,107.56299999999999,35.358000000000004,32.225 +2020-11-23 02:00:00,77.53,108.836,35.03,32.225 +2020-11-23 02:15:00,77.99,109.693,35.03,32.225 +2020-11-23 02:30:00,77.7,109.962,35.03,32.225 +2020-11-23 02:45:00,71.51,111.509,35.03,32.225 +2020-11-23 03:00:00,77.86,115.11200000000001,34.394,32.225 +2020-11-23 03:15:00,77.98,116.559,34.394,32.225 +2020-11-23 03:30:00,79.9,118.154,34.394,32.225 +2020-11-23 03:45:00,76.4,118.714,34.394,32.225 +2020-11-23 04:00:00,75.65,132.888,34.421,32.225 +2020-11-23 04:15:00,75.21,145.955,34.421,32.225 +2020-11-23 04:30:00,80.58,147.53,34.421,32.225 +2020-11-23 04:45:00,86.22,148.444,34.421,32.225 +2020-11-23 05:00:00,89.91,178.38099999999997,39.435,32.225 +2020-11-23 05:15:00,93.08,208.532,39.435,32.225 +2020-11-23 05:30:00,91.38,205.338,39.435,32.225 +2020-11-23 05:45:00,101.04,196.43599999999998,39.435,32.225 +2020-11-23 06:00:00,111.88,192.929,55.685,32.225 +2020-11-23 06:15:00,119.66,196.643,55.685,32.225 +2020-11-23 06:30:00,117.56,198.74,55.685,32.225 +2020-11-23 06:45:00,126.52,200.50099999999998,55.685,32.225 +2020-11-23 07:00:00,127.0,201.90599999999998,66.837,32.225 +2020-11-23 07:15:00,130.33,206.11599999999999,66.837,32.225 +2020-11-23 07:30:00,133.16,208.145,66.837,32.225 +2020-11-23 07:45:00,134.79,208.628,66.837,32.225 +2020-11-23 08:00:00,135.1,207.56599999999997,72.217,32.225 +2020-11-23 08:15:00,134.95,208.025,72.217,32.225 +2020-11-23 08:30:00,135.26,206.227,72.217,32.225 +2020-11-23 08:45:00,135.33,203.44,72.217,32.225 +2020-11-23 09:00:00,137.24,198.29,66.117,32.225 +2020-11-23 09:15:00,141.14,193.26,66.117,32.225 +2020-11-23 09:30:00,142.69,190.23,66.117,32.225 +2020-11-23 09:45:00,142.86,188.003,66.117,32.225 +2020-11-23 10:00:00,143.04,185.90900000000002,62.1,32.225 +2020-11-23 10:15:00,143.04,183.359,62.1,32.225 +2020-11-23 10:30:00,143.62,181.167,62.1,32.225 +2020-11-23 10:45:00,139.51,179.63299999999998,62.1,32.225 +2020-11-23 11:00:00,144.08,176.495,60.021,32.225 +2020-11-23 11:15:00,159.0,176.148,60.021,32.225 +2020-11-23 11:30:00,160.47,176.75799999999998,60.021,32.225 +2020-11-23 11:45:00,160.25,175.63299999999998,60.021,32.225 +2020-11-23 12:00:00,147.32,171.385,56.75899999999999,32.225 +2020-11-23 12:15:00,145.08,170.497,56.75899999999999,32.225 +2020-11-23 12:30:00,148.09,169.957,56.75899999999999,32.225 +2020-11-23 12:45:00,145.6,170.778,56.75899999999999,32.225 +2020-11-23 13:00:00,143.46,170.442,56.04600000000001,32.225 +2020-11-23 13:15:00,142.41,169.93900000000002,56.04600000000001,32.225 +2020-11-23 13:30:00,140.37,168.447,56.04600000000001,32.225 +2020-11-23 13:45:00,135.08,168.343,56.04600000000001,32.225 +2020-11-23 14:00:00,135.61,167.577,55.475,32.225 +2020-11-23 14:15:00,135.86,167.467,55.475,32.225 +2020-11-23 14:30:00,135.91,166.745,55.475,32.225 +2020-11-23 14:45:00,136.54,166.763,55.475,32.225 +2020-11-23 15:00:00,136.12,167.21400000000003,57.048,32.225 +2020-11-23 15:15:00,135.68,167.205,57.048,32.225 +2020-11-23 15:30:00,135.08,168.033,57.048,32.225 +2020-11-23 15:45:00,135.81,169.081,57.048,32.225 +2020-11-23 16:00:00,136.9,172.327,59.06,32.225 +2020-11-23 16:15:00,137.68,173.388,59.06,32.225 +2020-11-23 16:30:00,139.13,174.18400000000003,59.06,32.225 +2020-11-23 16:45:00,141.15,173.987,59.06,32.225 +2020-11-23 17:00:00,155.46,176.75400000000002,65.419,32.225 +2020-11-23 17:15:00,157.73,177.153,65.419,32.225 +2020-11-23 17:30:00,158.42,177.005,65.419,32.225 +2020-11-23 17:45:00,145.53,176.706,65.419,32.225 +2020-11-23 18:00:00,137.98,178.655,69.345,32.225 +2020-11-23 18:15:00,139.34,177.84,69.345,32.225 +2020-11-23 18:30:00,138.47,176.386,69.345,32.225 +2020-11-23 18:45:00,139.68,176.57299999999998,69.345,32.225 +2020-11-23 19:00:00,137.89,178.16,73.825,32.225 +2020-11-23 19:15:00,134.19,175.365,73.825,32.225 +2020-11-23 19:30:00,131.82,174.611,73.825,32.225 +2020-11-23 19:45:00,129.59,171.31599999999997,73.825,32.225 +2020-11-23 20:00:00,125.6,167.36900000000003,64.027,32.225 +2020-11-23 20:15:00,119.28,163.202,64.027,32.225 +2020-11-23 20:30:00,122.12,159.554,64.027,32.225 +2020-11-23 20:45:00,121.81,156.717,64.027,32.225 +2020-11-23 21:00:00,118.81,155.09,57.952,32.225 +2020-11-23 21:15:00,109.73,152.90200000000002,57.952,32.225 +2020-11-23 21:30:00,108.05,151.36,57.952,32.225 +2020-11-23 21:45:00,106.57,149.395,57.952,32.225 +2020-11-23 22:00:00,105.12,141.372,53.031000000000006,32.225 +2020-11-23 22:15:00,106.96,136.566,53.031000000000006,32.225 +2020-11-23 22:30:00,104.93,121.29,53.031000000000006,32.225 +2020-11-23 22:45:00,101.2,113.13799999999999,53.031000000000006,32.225 +2020-11-23 23:00:00,92.0,108.491,45.085,32.225 +2020-11-23 23:15:00,94.85,106.9,45.085,32.225 +2020-11-23 23:30:00,94.19,107.60799999999999,45.085,32.225 +2020-11-23 23:45:00,91.56,107.67299999999999,45.085,32.225 +2020-11-24 00:00:00,86.66,101.96,42.843,32.225 +2020-11-24 00:15:00,81.99,101.87299999999999,42.843,32.225 +2020-11-24 00:30:00,80.81,102.262,42.843,32.225 +2020-11-24 00:45:00,83.16,103.147,42.843,32.225 +2020-11-24 01:00:00,86.21,104.68,41.542,32.225 +2020-11-24 01:15:00,86.93,105.70100000000001,41.542,32.225 +2020-11-24 01:30:00,83.03,106.073,41.542,32.225 +2020-11-24 01:45:00,80.91,106.603,41.542,32.225 +2020-11-24 02:00:00,80.28,107.804,40.19,32.225 +2020-11-24 02:15:00,85.04,108.777,40.19,32.225 +2020-11-24 02:30:00,80.94,108.46600000000001,40.19,32.225 +2020-11-24 02:45:00,83.11,110.08,40.19,32.225 +2020-11-24 03:00:00,82.33,112.54700000000001,39.626,32.225 +2020-11-24 03:15:00,81.11,113.471,39.626,32.225 +2020-11-24 03:30:00,80.6,115.473,39.626,32.225 +2020-11-24 03:45:00,81.65,115.988,39.626,32.225 +2020-11-24 04:00:00,89.16,129.751,40.196999999999996,32.225 +2020-11-24 04:15:00,89.98,142.534,40.196999999999996,32.225 +2020-11-24 04:30:00,91.48,143.811,40.196999999999996,32.225 +2020-11-24 04:45:00,88.32,145.847,40.196999999999996,32.225 +2020-11-24 05:00:00,95.04,180.31,43.378,32.225 +2020-11-24 05:15:00,100.05,210.38299999999998,43.378,32.225 +2020-11-24 05:30:00,105.49,205.94099999999997,43.378,32.225 +2020-11-24 05:45:00,104.35,196.915,43.378,32.225 +2020-11-24 06:00:00,111.75,192.609,55.691,32.225 +2020-11-24 06:15:00,115.57,197.745,55.691,32.225 +2020-11-24 06:30:00,120.7,199.278,55.691,32.225 +2020-11-24 06:45:00,125.63,200.59400000000002,55.691,32.225 +2020-11-24 07:00:00,131.53,201.87,65.567,32.225 +2020-11-24 07:15:00,134.38,205.908,65.567,32.225 +2020-11-24 07:30:00,135.59,207.489,65.567,32.225 +2020-11-24 07:45:00,136.05,207.987,65.567,32.225 +2020-11-24 08:00:00,133.21,207.02200000000002,73.001,32.225 +2020-11-24 08:15:00,132.47,206.514,73.001,32.225 +2020-11-24 08:30:00,131.85,204.61,73.001,32.225 +2020-11-24 08:45:00,132.0,201.40599999999998,73.001,32.225 +2020-11-24 09:00:00,133.22,195.56900000000002,67.08800000000001,32.225 +2020-11-24 09:15:00,134.13,191.907,67.08800000000001,32.225 +2020-11-24 09:30:00,136.8,189.577,67.08800000000001,32.225 +2020-11-24 09:45:00,138.03,187.364,67.08800000000001,32.225 +2020-11-24 10:00:00,143.53,184.52599999999998,62.803000000000004,32.225 +2020-11-24 10:15:00,143.26,181.0,62.803000000000004,32.225 +2020-11-24 10:30:00,144.11,178.94400000000002,62.803000000000004,32.225 +2020-11-24 10:45:00,142.0,177.80599999999998,62.803000000000004,32.225 +2020-11-24 11:00:00,145.32,175.952,60.155,32.225 +2020-11-24 11:15:00,159.14,175.36,60.155,32.225 +2020-11-24 11:30:00,159.74,174.77200000000002,60.155,32.225 +2020-11-24 11:45:00,158.94,174.179,60.155,32.225 +2020-11-24 12:00:00,144.91,168.69400000000002,56.845,32.225 +2020-11-24 12:15:00,143.79,167.502,56.845,32.225 +2020-11-24 12:30:00,144.9,167.753,56.845,32.225 +2020-11-24 12:45:00,148.31,168.429,56.845,32.225 +2020-11-24 13:00:00,143.29,167.641,56.163000000000004,32.225 +2020-11-24 13:15:00,142.71,167.11900000000003,56.163000000000004,32.225 +2020-11-24 13:30:00,138.34,166.644,56.163000000000004,32.225 +2020-11-24 13:45:00,137.62,166.546,56.163000000000004,32.225 +2020-11-24 14:00:00,137.28,166.058,55.934,32.225 +2020-11-24 14:15:00,136.5,166.065,55.934,32.225 +2020-11-24 14:30:00,136.48,165.97799999999998,55.934,32.225 +2020-11-24 14:45:00,135.39,165.81,55.934,32.225 +2020-11-24 15:00:00,134.87,165.862,57.43899999999999,32.225 +2020-11-24 15:15:00,134.85,166.291,57.43899999999999,32.225 +2020-11-24 15:30:00,135.23,167.31599999999997,57.43899999999999,32.225 +2020-11-24 15:45:00,134.55,168.06900000000002,57.43899999999999,32.225 +2020-11-24 16:00:00,136.4,171.49900000000002,59.968999999999994,32.225 +2020-11-24 16:15:00,141.88,173.015,59.968999999999994,32.225 +2020-11-24 16:30:00,145.09,174.326,59.968999999999994,32.225 +2020-11-24 16:45:00,146.27,174.53099999999998,59.968999999999994,32.225 +2020-11-24 17:00:00,147.1,177.77900000000002,67.428,32.225 +2020-11-24 17:15:00,160.92,178.29,67.428,32.225 +2020-11-24 17:30:00,161.04,178.67,67.428,32.225 +2020-11-24 17:45:00,161.02,178.213,67.428,32.225 +2020-11-24 18:00:00,147.32,179.94099999999997,71.533,32.225 +2020-11-24 18:15:00,138.99,178.87099999999998,71.533,32.225 +2020-11-24 18:30:00,137.9,177.114,71.533,32.225 +2020-11-24 18:45:00,141.41,177.975,71.533,32.225 +2020-11-24 19:00:00,139.01,179.456,73.32300000000001,32.225 +2020-11-24 19:15:00,136.46,176.43,73.32300000000001,32.225 +2020-11-24 19:30:00,133.33,175.03799999999998,73.32300000000001,32.225 +2020-11-24 19:45:00,132.31,171.794,73.32300000000001,32.225 +2020-11-24 20:00:00,128.54,168.054,64.166,32.225 +2020-11-24 20:15:00,122.92,163.094,64.166,32.225 +2020-11-24 20:30:00,122.74,160.308,64.166,32.225 +2020-11-24 20:45:00,124.43,157.046,64.166,32.225 +2020-11-24 21:00:00,120.21,154.929,57.891999999999996,32.225 +2020-11-24 21:15:00,116.54,153.293,57.891999999999996,32.225 +2020-11-24 21:30:00,111.79,151.145,57.891999999999996,32.225 +2020-11-24 21:45:00,110.12,149.408,57.891999999999996,32.225 +2020-11-24 22:00:00,104.81,142.917,53.242,32.225 +2020-11-24 22:15:00,106.93,137.826,53.242,32.225 +2020-11-24 22:30:00,109.19,122.727,53.242,32.225 +2020-11-24 22:45:00,106.58,114.824,53.242,32.225 +2020-11-24 23:00:00,99.59,110.068,46.665,32.225 +2020-11-24 23:15:00,93.79,107.912,46.665,32.225 +2020-11-24 23:30:00,97.04,108.311,46.665,32.225 +2020-11-24 23:45:00,95.69,108.001,46.665,32.225 +2020-11-25 00:00:00,94.25,102.323,43.16,32.225 +2020-11-25 00:15:00,89.05,102.209,43.16,32.225 +2020-11-25 00:30:00,88.28,102.59700000000001,43.16,32.225 +2020-11-25 00:45:00,90.4,103.465,43.16,32.225 +2020-11-25 01:00:00,88.67,105.03299999999999,40.972,32.225 +2020-11-25 01:15:00,83.25,106.059,40.972,32.225 +2020-11-25 01:30:00,82.33,106.444,40.972,32.225 +2020-11-25 01:45:00,79.08,106.96,40.972,32.225 +2020-11-25 02:00:00,81.52,108.182,39.749,32.225 +2020-11-25 02:15:00,83.98,109.15799999999999,39.749,32.225 +2020-11-25 02:30:00,87.31,108.84299999999999,39.749,32.225 +2020-11-25 02:45:00,88.71,110.45700000000001,39.749,32.225 +2020-11-25 03:00:00,85.46,112.91,39.422,32.225 +2020-11-25 03:15:00,84.79,113.861,39.422,32.225 +2020-11-25 03:30:00,89.21,115.866,39.422,32.225 +2020-11-25 03:45:00,90.74,116.37200000000001,39.422,32.225 +2020-11-25 04:00:00,92.44,130.12,40.505,32.225 +2020-11-25 04:15:00,89.59,142.911,40.505,32.225 +2020-11-25 04:30:00,92.56,144.178,40.505,32.225 +2020-11-25 04:45:00,96.12,146.219,40.505,32.225 +2020-11-25 05:00:00,97.39,180.678,43.397,32.225 +2020-11-25 05:15:00,101.36,210.732,43.397,32.225 +2020-11-25 05:30:00,106.35,206.30599999999998,43.397,32.225 +2020-11-25 05:45:00,111.83,197.285,43.397,32.225 +2020-11-25 06:00:00,112.45,192.995,55.218,32.225 +2020-11-25 06:15:00,119.29,198.136,55.218,32.225 +2020-11-25 06:30:00,122.62,199.71200000000002,55.218,32.225 +2020-11-25 06:45:00,127.32,201.063,55.218,32.225 +2020-11-25 07:00:00,129.42,202.33900000000003,67.39,32.225 +2020-11-25 07:15:00,133.53,206.388,67.39,32.225 +2020-11-25 07:30:00,133.46,207.983,67.39,32.225 +2020-11-25 07:45:00,133.22,208.485,67.39,32.225 +2020-11-25 08:00:00,134.72,207.533,74.345,32.225 +2020-11-25 08:15:00,134.93,207.014,74.345,32.225 +2020-11-25 08:30:00,133.18,205.128,74.345,32.225 +2020-11-25 08:45:00,136.06,201.892,74.345,32.225 +2020-11-25 09:00:00,136.12,196.03400000000002,69.336,32.225 +2020-11-25 09:15:00,136.89,192.37599999999998,69.336,32.225 +2020-11-25 09:30:00,137.83,190.047,69.336,32.225 +2020-11-25 09:45:00,136.21,187.817,69.336,32.225 +2020-11-25 10:00:00,134.1,184.968,64.291,32.225 +2020-11-25 10:15:00,134.21,181.41099999999997,64.291,32.225 +2020-11-25 10:30:00,132.81,179.333,64.291,32.225 +2020-11-25 10:45:00,132.27,178.18200000000002,64.291,32.225 +2020-11-25 11:00:00,130.7,176.317,62.20399999999999,32.225 +2020-11-25 11:15:00,130.65,175.709,62.20399999999999,32.225 +2020-11-25 11:30:00,129.63,175.11900000000003,62.20399999999999,32.225 +2020-11-25 11:45:00,128.59,174.517,62.20399999999999,32.225 +2020-11-25 12:00:00,127.98,169.023,59.042,32.225 +2020-11-25 12:15:00,127.46,167.83700000000002,59.042,32.225 +2020-11-25 12:30:00,129.41,168.113,59.042,32.225 +2020-11-25 12:45:00,127.0,168.794,59.042,32.225 +2020-11-25 13:00:00,126.51,167.97,57.907,32.225 +2020-11-25 13:15:00,126.11,167.454,57.907,32.225 +2020-11-25 13:30:00,126.17,166.975,57.907,32.225 +2020-11-25 13:45:00,126.16,166.868,57.907,32.225 +2020-11-25 14:00:00,128.27,166.345,58.358000000000004,32.225 +2020-11-25 14:15:00,129.25,166.362,58.358000000000004,32.225 +2020-11-25 14:30:00,129.64,166.30700000000002,58.358000000000004,32.225 +2020-11-25 14:45:00,130.02,166.146,58.358000000000004,32.225 +2020-11-25 15:00:00,131.43,166.205,59.348,32.225 +2020-11-25 15:15:00,133.51,166.64,59.348,32.225 +2020-11-25 15:30:00,133.44,167.696,59.348,32.225 +2020-11-25 15:45:00,134.78,168.456,59.348,32.225 +2020-11-25 16:00:00,136.6,171.878,61.413999999999994,32.225 +2020-11-25 16:15:00,139.28,173.418,61.413999999999994,32.225 +2020-11-25 16:30:00,143.76,174.731,61.413999999999994,32.225 +2020-11-25 16:45:00,145.53,174.976,61.413999999999994,32.225 +2020-11-25 17:00:00,146.91,178.197,67.107,32.225 +2020-11-25 17:15:00,147.41,178.731,67.107,32.225 +2020-11-25 17:30:00,147.84,179.12099999999998,67.107,32.225 +2020-11-25 17:45:00,147.01,178.673,67.107,32.225 +2020-11-25 18:00:00,144.4,180.41400000000002,71.92,32.225 +2020-11-25 18:15:00,143.14,179.304,71.92,32.225 +2020-11-25 18:30:00,141.88,177.55700000000002,71.92,32.225 +2020-11-25 18:45:00,142.19,178.42,71.92,32.225 +2020-11-25 19:00:00,141.09,179.89700000000002,75.09,32.225 +2020-11-25 19:15:00,137.11,176.861,75.09,32.225 +2020-11-25 19:30:00,134.84,175.452,75.09,32.225 +2020-11-25 19:45:00,133.69,172.17700000000002,75.09,32.225 +2020-11-25 20:00:00,130.33,168.446,65.977,32.225 +2020-11-25 20:15:00,122.19,163.476,65.977,32.225 +2020-11-25 20:30:00,123.02,160.661,65.977,32.225 +2020-11-25 20:45:00,124.89,157.409,65.977,32.225 +2020-11-25 21:00:00,121.48,155.282,58.798,32.225 +2020-11-25 21:15:00,116.62,153.631,58.798,32.225 +2020-11-25 21:30:00,108.88,151.487,58.798,32.225 +2020-11-25 21:45:00,109.31,149.749,58.798,32.225 +2020-11-25 22:00:00,105.77,143.263,54.486000000000004,32.225 +2020-11-25 22:15:00,109.26,138.168,54.486000000000004,32.225 +2020-11-25 22:30:00,106.25,123.12200000000001,54.486000000000004,32.225 +2020-11-25 22:45:00,104.95,115.227,54.486000000000004,32.225 +2020-11-25 23:00:00,95.13,110.454,47.783,32.225 +2020-11-25 23:15:00,98.94,108.286,47.783,32.225 +2020-11-25 23:30:00,97.56,108.693,47.783,32.225 +2020-11-25 23:45:00,94.42,108.366,47.783,32.225 +2020-11-26 00:00:00,86.96,102.682,43.88,32.225 +2020-11-26 00:15:00,83.82,102.54,43.88,32.225 +2020-11-26 00:30:00,85.5,102.928,43.88,32.225 +2020-11-26 00:45:00,88.72,103.77799999999999,43.88,32.225 +2020-11-26 01:00:00,88.89,105.38,42.242,32.225 +2020-11-26 01:15:00,85.06,106.413,42.242,32.225 +2020-11-26 01:30:00,83.57,106.809,42.242,32.225 +2020-11-26 01:45:00,85.67,107.31299999999999,42.242,32.225 +2020-11-26 02:00:00,86.12,108.552,40.918,32.225 +2020-11-26 02:15:00,83.43,109.53299999999999,40.918,32.225 +2020-11-26 02:30:00,83.05,109.21600000000001,40.918,32.225 +2020-11-26 02:45:00,85.0,110.82700000000001,40.918,32.225 +2020-11-26 03:00:00,85.05,113.26700000000001,40.411,32.225 +2020-11-26 03:15:00,83.54,114.24600000000001,40.411,32.225 +2020-11-26 03:30:00,84.59,116.25299999999999,40.411,32.225 +2020-11-26 03:45:00,88.11,116.75299999999999,40.411,32.225 +2020-11-26 04:00:00,89.54,130.483,41.246,32.225 +2020-11-26 04:15:00,86.6,143.284,41.246,32.225 +2020-11-26 04:30:00,88.88,144.537,41.246,32.225 +2020-11-26 04:45:00,93.86,146.585,41.246,32.225 +2020-11-26 05:00:00,97.17,181.04,44.533,32.225 +2020-11-26 05:15:00,98.12,211.076,44.533,32.225 +2020-11-26 05:30:00,99.16,206.66400000000002,44.533,32.225 +2020-11-26 05:45:00,105.0,197.649,44.533,32.225 +2020-11-26 06:00:00,117.69,193.375,55.005,32.225 +2020-11-26 06:15:00,124.77,198.52,55.005,32.225 +2020-11-26 06:30:00,126.33,200.138,55.005,32.225 +2020-11-26 06:45:00,125.77,201.525,55.005,32.225 +2020-11-26 07:00:00,130.95,202.803,64.597,32.225 +2020-11-26 07:15:00,134.46,206.86,64.597,32.225 +2020-11-26 07:30:00,133.78,208.47,64.597,32.225 +2020-11-26 07:45:00,135.55,208.97400000000002,64.597,32.225 +2020-11-26 08:00:00,136.97,208.035,71.71600000000001,32.225 +2020-11-26 08:15:00,136.62,207.50400000000002,71.71600000000001,32.225 +2020-11-26 08:30:00,135.96,205.636,71.71600000000001,32.225 +2020-11-26 08:45:00,134.99,202.37,71.71600000000001,32.225 +2020-11-26 09:00:00,134.73,196.488,66.51899999999999,32.225 +2020-11-26 09:15:00,134.01,192.83599999999998,66.51899999999999,32.225 +2020-11-26 09:30:00,134.51,190.507,66.51899999999999,32.225 +2020-11-26 09:45:00,133.76,188.261,66.51899999999999,32.225 +2020-11-26 10:00:00,133.11,185.40099999999998,63.04,32.225 +2020-11-26 10:15:00,133.63,181.81599999999997,63.04,32.225 +2020-11-26 10:30:00,132.09,179.71400000000003,63.04,32.225 +2020-11-26 10:45:00,131.91,178.551,63.04,32.225 +2020-11-26 11:00:00,130.31,176.675,60.998000000000005,32.225 +2020-11-26 11:15:00,129.82,176.05,60.998000000000005,32.225 +2020-11-26 11:30:00,129.38,175.459,60.998000000000005,32.225 +2020-11-26 11:45:00,129.19,174.84900000000002,60.998000000000005,32.225 +2020-11-26 12:00:00,129.43,169.345,58.27,32.225 +2020-11-26 12:15:00,129.92,168.167,58.27,32.225 +2020-11-26 12:30:00,130.77,168.467,58.27,32.225 +2020-11-26 12:45:00,128.99,169.15200000000002,58.27,32.225 +2020-11-26 13:00:00,127.51,168.292,57.196000000000005,32.225 +2020-11-26 13:15:00,127.43,167.783,57.196000000000005,32.225 +2020-11-26 13:30:00,128.14,167.3,57.196000000000005,32.225 +2020-11-26 13:45:00,128.96,167.18400000000003,57.196000000000005,32.225 +2020-11-26 14:00:00,131.54,166.625,57.38399999999999,32.225 +2020-11-26 14:15:00,132.48,166.65400000000002,57.38399999999999,32.225 +2020-11-26 14:30:00,133.07,166.63,57.38399999999999,32.225 +2020-11-26 14:45:00,134.6,166.475,57.38399999999999,32.225 +2020-11-26 15:00:00,135.57,166.542,58.647,32.225 +2020-11-26 15:15:00,134.79,166.982,58.647,32.225 +2020-11-26 15:30:00,133.95,168.06900000000002,58.647,32.225 +2020-11-26 15:45:00,134.77,168.833,58.647,32.225 +2020-11-26 16:00:00,135.09,172.248,60.083999999999996,32.225 +2020-11-26 16:15:00,139.08,173.813,60.083999999999996,32.225 +2020-11-26 16:30:00,142.52,175.12900000000002,60.083999999999996,32.225 +2020-11-26 16:45:00,144.11,175.41400000000002,60.083999999999996,32.225 +2020-11-26 17:00:00,145.11,178.605,65.85600000000001,32.225 +2020-11-26 17:15:00,145.39,179.16299999999998,65.85600000000001,32.225 +2020-11-26 17:30:00,144.99,179.56400000000002,65.85600000000001,32.225 +2020-11-26 17:45:00,144.73,179.125,65.85600000000001,32.225 +2020-11-26 18:00:00,142.36,180.87900000000002,69.855,32.225 +2020-11-26 18:15:00,139.12,179.732,69.855,32.225 +2020-11-26 18:30:00,138.76,177.99200000000002,69.855,32.225 +2020-11-26 18:45:00,139.25,178.859,69.855,32.225 +2020-11-26 19:00:00,138.59,180.332,74.015,32.225 +2020-11-26 19:15:00,135.27,177.285,74.015,32.225 +2020-11-26 19:30:00,133.83,175.861,74.015,32.225 +2020-11-26 19:45:00,132.18,172.55599999999998,74.015,32.225 +2020-11-26 20:00:00,127.04,168.832,65.316,32.225 +2020-11-26 20:15:00,124.0,163.852,65.316,32.225 +2020-11-26 20:30:00,120.42,161.009,65.316,32.225 +2020-11-26 20:45:00,117.9,157.766,65.316,32.225 +2020-11-26 21:00:00,116.75,155.628,58.403999999999996,32.225 +2020-11-26 21:15:00,114.04,153.963,58.403999999999996,32.225 +2020-11-26 21:30:00,110.09,151.82299999999998,58.403999999999996,32.225 +2020-11-26 21:45:00,106.75,150.084,58.403999999999996,32.225 +2020-11-26 22:00:00,103.22,143.605,54.092,32.225 +2020-11-26 22:15:00,99.89,138.503,54.092,32.225 +2020-11-26 22:30:00,96.72,123.51100000000001,54.092,32.225 +2020-11-26 22:45:00,95.07,115.624,54.092,32.225 +2020-11-26 23:00:00,93.68,110.834,48.18600000000001,32.225 +2020-11-26 23:15:00,90.11,108.655,48.18600000000001,32.225 +2020-11-26 23:30:00,88.8,109.069,48.18600000000001,32.225 +2020-11-26 23:45:00,89.14,108.726,48.18600000000001,32.225 +2020-11-27 00:00:00,84.43,101.79899999999999,45.18899999999999,32.225 +2020-11-27 00:15:00,89.72,101.839,45.18899999999999,32.225 +2020-11-27 00:30:00,89.24,102.156,45.18899999999999,32.225 +2020-11-27 00:45:00,87.86,103.16,45.18899999999999,32.225 +2020-11-27 01:00:00,81.04,104.459,43.256,32.225 +2020-11-27 01:15:00,79.47,106.179,43.256,32.225 +2020-11-27 01:30:00,83.22,106.521,43.256,32.225 +2020-11-27 01:45:00,85.75,107.06,43.256,32.225 +2020-11-27 02:00:00,86.44,108.556,42.312,32.225 +2020-11-27 02:15:00,83.54,109.431,42.312,32.225 +2020-11-27 02:30:00,79.41,109.71,42.312,32.225 +2020-11-27 02:45:00,79.4,111.234,42.312,32.225 +2020-11-27 03:00:00,80.77,112.949,41.833,32.225 +2020-11-27 03:15:00,88.63,114.539,41.833,32.225 +2020-11-27 03:30:00,90.12,116.492,41.833,32.225 +2020-11-27 03:45:00,88.67,117.42200000000001,41.833,32.225 +2020-11-27 04:00:00,85.04,131.356,42.732,32.225 +2020-11-27 04:15:00,84.49,143.668,42.732,32.225 +2020-11-27 04:30:00,91.48,145.282,42.732,32.225 +2020-11-27 04:45:00,95.37,146.24,42.732,32.225 +2020-11-27 05:00:00,98.6,179.519,46.254,32.225 +2020-11-27 05:15:00,98.74,211.0,46.254,32.225 +2020-11-27 05:30:00,104.82,207.55599999999998,46.254,32.225 +2020-11-27 05:45:00,107.0,198.40900000000002,46.254,32.225 +2020-11-27 06:00:00,109.72,194.56900000000002,56.76,32.225 +2020-11-27 06:15:00,114.28,198.49099999999999,56.76,32.225 +2020-11-27 06:30:00,121.1,199.40200000000002,56.76,32.225 +2020-11-27 06:45:00,125.73,202.19099999999997,56.76,32.225 +2020-11-27 07:00:00,132.85,202.873,66.029,32.225 +2020-11-27 07:15:00,137.07,207.98,66.029,32.225 +2020-11-27 07:30:00,138.18,209.1,66.029,32.225 +2020-11-27 07:45:00,139.13,208.80700000000002,66.029,32.225 +2020-11-27 08:00:00,140.02,207.06799999999998,73.128,32.225 +2020-11-27 08:15:00,141.53,206.30599999999998,73.128,32.225 +2020-11-27 08:30:00,141.95,205.28,73.128,32.225 +2020-11-27 08:45:00,141.74,200.595,73.128,32.225 +2020-11-27 09:00:00,143.12,194.68900000000002,68.23100000000001,32.225 +2020-11-27 09:15:00,144.97,191.862,68.23100000000001,32.225 +2020-11-27 09:30:00,146.38,189.045,68.23100000000001,32.225 +2020-11-27 09:45:00,148.07,186.753,68.23100000000001,32.225 +2020-11-27 10:00:00,148.43,182.864,64.733,32.225 +2020-11-27 10:15:00,149.35,179.82,64.733,32.225 +2020-11-27 10:30:00,148.75,177.725,64.733,32.225 +2020-11-27 10:45:00,149.72,176.15099999999998,64.733,32.225 +2020-11-27 11:00:00,148.49,174.278,62.0,32.225 +2020-11-27 11:15:00,148.43,172.675,62.0,32.225 +2020-11-27 11:30:00,148.91,173.55599999999998,62.0,32.225 +2020-11-27 11:45:00,148.96,172.81900000000002,62.0,32.225 +2020-11-27 12:00:00,148.12,168.327,57.876999999999995,32.225 +2020-11-27 12:15:00,148.75,165.21900000000002,57.876999999999995,32.225 +2020-11-27 12:30:00,151.8,165.7,57.876999999999995,32.225 +2020-11-27 12:45:00,148.18,166.71200000000002,57.876999999999995,32.225 +2020-11-27 13:00:00,147.61,166.737,55.585,32.225 +2020-11-27 13:15:00,145.8,166.979,55.585,32.225 +2020-11-27 13:30:00,143.06,166.637,55.585,32.225 +2020-11-27 13:45:00,142.49,166.505,55.585,32.225 +2020-11-27 14:00:00,140.86,164.783,54.5,32.225 +2020-11-27 14:15:00,140.22,164.737,54.5,32.225 +2020-11-27 14:30:00,139.38,165.44,54.5,32.225 +2020-11-27 14:45:00,139.36,165.44400000000002,54.5,32.225 +2020-11-27 15:00:00,138.46,165.082,55.131,32.225 +2020-11-27 15:15:00,138.42,165.104,55.131,32.225 +2020-11-27 15:30:00,138.56,164.794,55.131,32.225 +2020-11-27 15:45:00,138.86,165.815,55.131,32.225 +2020-11-27 16:00:00,140.34,168.062,56.8,32.225 +2020-11-27 16:15:00,142.71,169.984,56.8,32.225 +2020-11-27 16:30:00,143.85,171.358,56.8,32.225 +2020-11-27 16:45:00,144.15,171.454,56.8,32.225 +2020-11-27 17:00:00,144.16,175.07299999999998,63.428999999999995,32.225 +2020-11-27 17:15:00,144.21,175.265,63.428999999999995,32.225 +2020-11-27 17:30:00,143.91,175.423,63.428999999999995,32.225 +2020-11-27 17:45:00,143.48,174.76,63.428999999999995,32.225 +2020-11-27 18:00:00,141.75,177.138,67.915,32.225 +2020-11-27 18:15:00,141.19,175.46099999999998,67.915,32.225 +2020-11-27 18:30:00,140.31,174.05900000000003,67.915,32.225 +2020-11-27 18:45:00,140.84,174.99200000000002,67.915,32.225 +2020-11-27 19:00:00,139.02,177.395,69.428,32.225 +2020-11-27 19:15:00,136.92,175.63299999999998,69.428,32.225 +2020-11-27 19:30:00,133.97,173.84,69.428,32.225 +2020-11-27 19:45:00,132.93,169.93900000000002,69.428,32.225 +2020-11-27 20:00:00,127.08,166.252,60.56100000000001,32.225 +2020-11-27 20:15:00,123.22,161.39700000000002,60.56100000000001,32.225 +2020-11-27 20:30:00,120.97,158.409,60.56100000000001,32.225 +2020-11-27 20:45:00,121.98,155.536,60.56100000000001,32.225 +2020-11-27 21:00:00,120.28,154.04399999999998,55.18600000000001,32.225 +2020-11-27 21:15:00,115.06,153.016,55.18600000000001,32.225 +2020-11-27 21:30:00,106.27,150.89700000000002,55.18600000000001,32.225 +2020-11-27 21:45:00,104.92,149.668,55.18600000000001,32.225 +2020-11-27 22:00:00,98.21,144.031,51.433,32.225 +2020-11-27 22:15:00,95.41,138.768,51.433,32.225 +2020-11-27 22:30:00,92.29,130.185,51.433,32.225 +2020-11-27 22:45:00,89.5,125.54,51.433,32.225 +2020-11-27 23:00:00,88.76,120.616,46.201,32.225 +2020-11-27 23:15:00,90.76,116.462,46.201,32.225 +2020-11-27 23:30:00,88.25,115.31200000000001,46.201,32.225 +2020-11-27 23:45:00,84.02,114.34899999999999,46.201,32.225 +2020-11-28 00:00:00,77.33,99.921,42.576,32.047 +2020-11-28 00:15:00,75.71,96.16,42.576,32.047 +2020-11-28 00:30:00,79.64,97.53200000000001,42.576,32.047 +2020-11-28 00:45:00,79.58,99.0,42.576,32.047 +2020-11-28 01:00:00,78.45,100.935,39.34,32.047 +2020-11-28 01:15:00,69.57,101.92299999999999,39.34,32.047 +2020-11-28 01:30:00,71.73,101.686,39.34,32.047 +2020-11-28 01:45:00,75.49,102.255,39.34,32.047 +2020-11-28 02:00:00,75.22,104.189,37.582,32.047 +2020-11-28 02:15:00,73.99,104.60799999999999,37.582,32.047 +2020-11-28 02:30:00,67.25,103.791,37.582,32.047 +2020-11-28 02:45:00,71.5,105.54299999999999,37.582,32.047 +2020-11-28 03:00:00,74.56,107.538,36.523,32.047 +2020-11-28 03:15:00,74.51,107.991,36.523,32.047 +2020-11-28 03:30:00,72.04,108.655,36.523,32.047 +2020-11-28 03:45:00,70.18,109.949,36.523,32.047 +2020-11-28 04:00:00,74.75,120.022,36.347,32.047 +2020-11-28 04:15:00,70.48,130.019,36.347,32.047 +2020-11-28 04:30:00,69.82,129.468,36.347,32.047 +2020-11-28 04:45:00,68.36,130.064,36.347,32.047 +2020-11-28 05:00:00,68.09,148.316,36.407,32.047 +2020-11-28 05:15:00,67.78,161.641,36.407,32.047 +2020-11-28 05:30:00,68.17,158.80200000000002,36.407,32.047 +2020-11-28 05:45:00,70.47,155.115,36.407,32.047 +2020-11-28 06:00:00,72.18,169.19400000000002,38.228,32.047 +2020-11-28 06:15:00,75.04,188.45,38.228,32.047 +2020-11-28 06:30:00,76.95,184.31099999999998,38.228,32.047 +2020-11-28 06:45:00,78.95,178.916,38.228,32.047 +2020-11-28 07:00:00,81.61,176.02900000000002,41.905,32.047 +2020-11-28 07:15:00,85.49,179.83700000000002,41.905,32.047 +2020-11-28 07:30:00,86.76,183.553,41.905,32.047 +2020-11-28 07:45:00,89.52,186.821,41.905,32.047 +2020-11-28 08:00:00,92.6,188.55,46.051,32.047 +2020-11-28 08:15:00,93.47,190.86,46.051,32.047 +2020-11-28 08:30:00,94.42,191.234,46.051,32.047 +2020-11-28 08:45:00,96.07,189.398,46.051,32.047 +2020-11-28 09:00:00,97.99,185.484,46.683,32.047 +2020-11-28 09:15:00,98.87,183.395,46.683,32.047 +2020-11-28 09:30:00,100.11,181.44400000000002,46.683,32.047 +2020-11-28 09:45:00,100.75,179.19799999999998,46.683,32.047 +2020-11-28 10:00:00,100.87,175.627,44.425,32.047 +2020-11-28 10:15:00,100.95,172.757,44.425,32.047 +2020-11-28 10:30:00,101.61,170.725,44.425,32.047 +2020-11-28 10:45:00,102.02,170.206,44.425,32.047 +2020-11-28 11:00:00,102.71,168.458,42.148999999999994,32.047 +2020-11-28 11:15:00,102.34,166.38299999999998,42.148999999999994,32.047 +2020-11-28 11:30:00,102.7,166.33900000000003,42.148999999999994,32.047 +2020-11-28 11:45:00,102.53,164.873,42.148999999999994,32.047 +2020-11-28 12:00:00,101.89,159.649,39.683,32.047 +2020-11-28 12:15:00,99.63,157.246,39.683,32.047 +2020-11-28 12:30:00,98.83,158.006,39.683,32.047 +2020-11-28 12:45:00,95.47,158.469,39.683,32.047 +2020-11-28 13:00:00,91.62,157.96,37.154,32.047 +2020-11-28 13:15:00,89.78,156.328,37.154,32.047 +2020-11-28 13:30:00,88.89,155.621,37.154,32.047 +2020-11-28 13:45:00,89.56,155.675,37.154,32.047 +2020-11-28 14:00:00,88.24,154.984,36.457,32.047 +2020-11-28 14:15:00,89.2,154.278,36.457,32.047 +2020-11-28 14:30:00,90.17,153.356,36.457,32.047 +2020-11-28 14:45:00,90.93,153.635,36.457,32.047 +2020-11-28 15:00:00,91.29,153.899,38.257,32.047 +2020-11-28 15:15:00,91.79,154.743,38.257,32.047 +2020-11-28 15:30:00,92.89,155.843,38.257,32.047 +2020-11-28 15:45:00,94.51,156.736,38.257,32.047 +2020-11-28 16:00:00,96.87,158.286,41.181000000000004,32.047 +2020-11-28 16:15:00,100.02,160.869,41.181000000000004,32.047 +2020-11-28 16:30:00,104.43,162.238,41.181000000000004,32.047 +2020-11-28 16:45:00,105.58,163.142,41.181000000000004,32.047 +2020-11-28 17:00:00,107.55,166.046,46.806000000000004,32.047 +2020-11-28 17:15:00,108.65,167.36599999999999,46.806000000000004,32.047 +2020-11-28 17:30:00,109.6,167.429,46.806000000000004,32.047 +2020-11-28 17:45:00,109.96,166.5,46.806000000000004,32.047 +2020-11-28 18:00:00,110.46,168.673,52.073,32.047 +2020-11-28 18:15:00,109.84,168.799,52.073,32.047 +2020-11-28 18:30:00,109.58,168.78900000000002,52.073,32.047 +2020-11-28 18:45:00,109.05,166.24200000000002,52.073,32.047 +2020-11-28 19:00:00,106.97,169.187,53.608000000000004,32.047 +2020-11-28 19:15:00,105.88,166.82299999999998,53.608000000000004,32.047 +2020-11-28 19:30:00,104.51,165.793,53.608000000000004,32.047 +2020-11-28 19:45:00,103.77,161.985,53.608000000000004,32.047 +2020-11-28 20:00:00,99.85,160.374,50.265,32.047 +2020-11-28 20:15:00,95.69,157.26,50.265,32.047 +2020-11-28 20:30:00,93.1,153.798,50.265,32.047 +2020-11-28 20:45:00,91.53,150.88299999999998,50.265,32.047 +2020-11-28 21:00:00,89.7,151.108,45.766000000000005,32.047 +2020-11-28 21:15:00,87.06,150.393,45.766000000000005,32.047 +2020-11-28 21:30:00,85.08,149.381,45.766000000000005,32.047 +2020-11-28 21:45:00,84.12,147.709,45.766000000000005,32.047 +2020-11-28 22:00:00,82.85,143.25,45.97,32.047 +2020-11-28 22:15:00,80.31,140.19299999999998,45.97,32.047 +2020-11-28 22:30:00,77.52,137.02700000000002,45.97,32.047 +2020-11-28 22:45:00,75.91,134.08100000000002,45.97,32.047 +2020-11-28 23:00:00,73.58,131.162,40.415,32.047 +2020-11-28 23:15:00,72.14,125.635,40.415,32.047 +2020-11-28 23:30:00,69.33,123.365,40.415,32.047 +2020-11-28 23:45:00,66.87,120.325,40.415,32.047 +2020-11-29 00:00:00,64.94,100.87299999999999,36.376,32.047 +2020-11-29 00:15:00,62.98,96.604,36.376,32.047 +2020-11-29 00:30:00,61.95,97.626,36.376,32.047 +2020-11-29 00:45:00,60.96,99.65100000000001,36.376,32.047 +2020-11-29 01:00:00,59.8,101.545,32.992,32.047 +2020-11-29 01:15:00,59.26,103.4,32.992,32.047 +2020-11-29 01:30:00,58.37,103.59,32.992,32.047 +2020-11-29 01:45:00,57.51,103.811,32.992,32.047 +2020-11-29 02:00:00,57.27,105.13799999999999,32.327,32.047 +2020-11-29 02:15:00,56.22,104.96799999999999,32.327,32.047 +2020-11-29 02:30:00,55.73,104.90899999999999,32.327,32.047 +2020-11-29 02:45:00,55.26,106.99799999999999,32.327,32.047 +2020-11-29 03:00:00,54.75,109.369,31.169,32.047 +2020-11-29 03:15:00,55.27,109.456,31.169,32.047 +2020-11-29 03:30:00,55.26,111.166,31.169,32.047 +2020-11-29 03:45:00,55.47,112.23700000000001,31.169,32.047 +2020-11-29 04:00:00,56.47,122.075,30.796,32.047 +2020-11-29 04:15:00,56.6,131.143,30.796,32.047 +2020-11-29 04:30:00,56.82,130.894,30.796,32.047 +2020-11-29 04:45:00,57.66,131.634,30.796,32.047 +2020-11-29 05:00:00,58.4,146.967,30.848000000000003,32.047 +2020-11-29 05:15:00,59.09,158.09799999999998,30.848000000000003,32.047 +2020-11-29 05:30:00,59.43,155.056,30.848000000000003,32.047 +2020-11-29 05:45:00,60.08,151.525,30.848000000000003,32.047 +2020-11-29 06:00:00,61.05,165.06400000000002,31.166,32.047 +2020-11-29 06:15:00,61.8,183.03400000000002,31.166,32.047 +2020-11-29 06:30:00,62.33,177.872,31.166,32.047 +2020-11-29 06:45:00,64.49,171.43099999999998,31.166,32.047 +2020-11-29 07:00:00,66.6,170.65900000000002,33.527,32.047 +2020-11-29 07:15:00,68.82,173.44400000000002,33.527,32.047 +2020-11-29 07:30:00,70.6,176.37099999999998,33.527,32.047 +2020-11-29 07:45:00,72.8,178.989,33.527,32.047 +2020-11-29 08:00:00,75.93,182.40900000000002,36.616,32.047 +2020-11-29 08:15:00,78.15,184.84400000000002,36.616,32.047 +2020-11-29 08:30:00,79.59,186.805,36.616,32.047 +2020-11-29 08:45:00,81.98,186.64700000000002,36.616,32.047 +2020-11-29 09:00:00,83.56,182.33700000000002,37.857,32.047 +2020-11-29 09:15:00,85.67,180.64,37.857,32.047 +2020-11-29 09:30:00,86.37,178.63299999999998,37.857,32.047 +2020-11-29 09:45:00,87.65,176.449,37.857,32.047 +2020-11-29 10:00:00,88.71,175.118,36.319,32.047 +2020-11-29 10:15:00,89.33,172.702,36.319,32.047 +2020-11-29 10:30:00,89.75,171.203,36.319,32.047 +2020-11-29 10:45:00,91.28,169.226,36.319,32.047 +2020-11-29 11:00:00,94.21,168.18400000000003,37.236999999999995,32.047 +2020-11-29 11:15:00,98.71,166.132,37.236999999999995,32.047 +2020-11-29 11:30:00,100.96,165.42700000000002,37.236999999999995,32.047 +2020-11-29 11:45:00,103.01,164.52,37.236999999999995,32.047 +2020-11-29 12:00:00,100.88,158.976,34.871,32.047 +2020-11-29 12:15:00,98.94,158.11700000000002,34.871,32.047 +2020-11-29 12:30:00,96.38,157.68,34.871,32.047 +2020-11-29 12:45:00,94.52,157.204,34.871,32.047 +2020-11-29 13:00:00,94.12,155.987,29.738000000000003,32.047 +2020-11-29 13:15:00,92.16,156.864,29.738000000000003,32.047 +2020-11-29 13:30:00,90.98,155.782,29.738000000000003,32.047 +2020-11-29 13:45:00,89.74,155.44899999999998,29.738000000000003,32.047 +2020-11-29 14:00:00,89.32,155.141,27.333000000000002,32.047 +2020-11-29 14:15:00,88.36,155.555,27.333000000000002,32.047 +2020-11-29 14:30:00,90.19,155.519,27.333000000000002,32.047 +2020-11-29 14:45:00,90.43,155.267,27.333000000000002,32.047 +2020-11-29 15:00:00,89.89,154.24200000000002,28.232,32.047 +2020-11-29 15:15:00,90.56,155.61,28.232,32.047 +2020-11-29 15:30:00,91.22,157.219,28.232,32.047 +2020-11-29 15:45:00,92.39,158.764,28.232,32.047 +2020-11-29 16:00:00,95.42,161.649,32.815,32.047 +2020-11-29 16:15:00,96.9,163.506,32.815,32.047 +2020-11-29 16:30:00,98.92,165.269,32.815,32.047 +2020-11-29 16:45:00,101.02,166.33900000000003,32.815,32.047 +2020-11-29 17:00:00,102.53,169.273,43.068999999999996,32.047 +2020-11-29 17:15:00,104.58,170.584,43.068999999999996,32.047 +2020-11-29 17:30:00,105.89,171.041,43.068999999999996,32.047 +2020-11-29 17:45:00,107.7,172.16,43.068999999999996,32.047 +2020-11-29 18:00:00,106.71,173.975,50.498999999999995,32.047 +2020-11-29 18:15:00,105.17,175.155,50.498999999999995,32.047 +2020-11-29 18:30:00,103.9,173.296,50.498999999999995,32.047 +2020-11-29 18:45:00,102.8,172.385,50.498999999999995,32.047 +2020-11-29 19:00:00,101.44,175.391,53.481,32.047 +2020-11-29 19:15:00,99.87,173.355,53.481,32.047 +2020-11-29 19:30:00,98.61,172.127,53.481,32.047 +2020-11-29 19:45:00,96.7,169.475,53.481,32.047 +2020-11-29 20:00:00,95.81,167.84900000000002,51.687,32.047 +2020-11-29 20:15:00,93.42,165.55900000000003,51.687,32.047 +2020-11-29 20:30:00,91.99,163.27700000000002,51.687,32.047 +2020-11-29 20:45:00,90.53,159.05200000000002,51.687,32.047 +2020-11-29 21:00:00,89.47,156.99200000000002,47.674,32.047 +2020-11-29 21:15:00,87.86,155.668,47.674,32.047 +2020-11-29 21:30:00,87.32,154.80700000000002,47.674,32.047 +2020-11-29 21:45:00,87.42,153.313,47.674,32.047 +2020-11-29 22:00:00,87.27,148.168,48.178000000000004,32.047 +2020-11-29 22:15:00,87.13,144.168,48.178000000000004,32.047 +2020-11-29 22:30:00,85.14,138.221,48.178000000000004,32.047 +2020-11-29 22:45:00,83.37,134.334,48.178000000000004,32.047 +2020-11-29 23:00:00,80.39,128.91899999999998,42.553999999999995,32.047 +2020-11-29 23:15:00,78.24,125.204,42.553999999999995,32.047 +2020-11-29 23:30:00,76.63,123.557,42.553999999999995,32.047 +2020-11-29 23:45:00,74.94,121.303,42.553999999999995,32.047 +2020-11-30 00:00:00,70.81,105.06200000000001,37.177,32.225 +2020-11-30 00:15:00,71.25,103.441,37.177,32.225 +2020-11-30 00:30:00,71.29,104.5,37.177,32.225 +2020-11-30 00:45:00,72.06,105.978,37.177,32.225 +2020-11-30 01:00:00,67.48,107.973,35.358000000000004,32.225 +2020-11-30 01:15:00,69.08,109.38799999999999,35.358000000000004,32.225 +2020-11-30 01:30:00,68.47,109.70200000000001,35.358000000000004,32.225 +2020-11-30 01:45:00,68.71,109.99,35.358000000000004,32.225 +2020-11-30 02:00:00,66.0,111.391,35.03,32.225 +2020-11-30 02:15:00,67.54,112.273,35.03,32.225 +2020-11-30 02:30:00,67.4,112.52600000000001,35.03,32.225 +2020-11-30 02:45:00,68.06,114.06,35.03,32.225 +2020-11-30 03:00:00,66.68,117.569,34.394,32.225 +2020-11-30 03:15:00,67.69,119.211,34.394,32.225 +2020-11-30 03:30:00,68.14,120.822,34.394,32.225 +2020-11-30 03:45:00,69.36,121.333,34.394,32.225 +2020-11-30 04:00:00,71.04,135.393,34.421,32.225 +2020-11-30 04:15:00,71.36,148.519,34.421,32.225 +2020-11-30 04:30:00,80.36,150.013,34.421,32.225 +2020-11-30 04:45:00,83.58,150.964,34.421,32.225 +2020-11-30 05:00:00,84.07,180.87099999999998,39.435,32.225 +2020-11-30 05:15:00,83.14,210.899,39.435,32.225 +2020-11-30 05:30:00,93.56,207.80200000000002,39.435,32.225 +2020-11-30 05:45:00,101.91,198.94,39.435,32.225 +2020-11-30 06:00:00,110.42,195.543,55.685,32.225 +2020-11-30 06:15:00,109.5,199.292,55.685,32.225 +2020-11-30 06:30:00,112.78,201.679,55.685,32.225 +2020-11-30 06:45:00,120.16,203.687,55.685,32.225 +2020-11-30 07:00:00,123.28,205.102,66.837,32.225 +2020-11-30 07:15:00,128.77,209.372,66.837,32.225 +2020-11-30 07:30:00,128.06,211.49400000000003,66.837,32.225 +2020-11-30 07:45:00,128.21,211.993,66.837,32.225 +2020-11-30 08:00:00,135.05,211.018,72.217,32.225 +2020-11-30 08:15:00,132.17,211.399,72.217,32.225 +2020-11-30 08:30:00,130.69,209.71200000000002,72.217,32.225 +2020-11-30 08:45:00,130.49,206.71200000000002,72.217,32.225 +2020-11-30 09:00:00,131.71,201.407,66.117,32.225 +2020-11-30 09:15:00,132.93,196.41099999999997,66.117,32.225 +2020-11-30 09:30:00,133.88,193.389,66.117,32.225 +2020-11-30 09:45:00,136.35,191.048,66.117,32.225 +2020-11-30 10:00:00,133.09,188.88,62.1,32.225 +2020-11-30 10:15:00,133.18,186.13,62.1,32.225 +2020-11-30 10:30:00,131.07,183.77599999999998,62.1,32.225 +2020-11-30 10:45:00,130.86,182.16299999999998,62.1,32.225 +2020-11-30 11:00:00,129.58,178.945,60.021,32.225 +2020-11-30 11:15:00,127.7,178.484,60.021,32.225 +2020-11-30 11:30:00,128.06,179.08900000000003,60.021,32.225 +2020-11-30 11:45:00,127.63,177.903,60.021,32.225 +2020-11-30 12:00:00,127.01,173.58900000000003,56.75899999999999,32.225 +2020-11-30 12:15:00,129.44,172.75599999999997,56.75899999999999,32.225 +2020-11-30 12:30:00,126.09,172.38299999999998,56.75899999999999,32.225 +2020-11-30 12:45:00,130.5,173.231,56.75899999999999,32.225 +2020-11-30 13:00:00,127.8,172.65200000000002,56.04600000000001,32.225 +2020-11-30 13:15:00,135.15,172.187,56.04600000000001,32.225 +2020-11-30 13:30:00,133.2,170.669,56.04600000000001,32.225 +2020-11-30 13:45:00,134.94,170.502,56.04600000000001,32.225 +2020-11-30 14:00:00,132.77,169.498,55.475,32.225 +2020-11-30 14:15:00,134.22,169.459,55.475,32.225 +2020-11-30 14:30:00,134.93,168.958,55.475,32.225 +2020-11-30 14:45:00,139.22,169.021,55.475,32.225 +2020-11-30 15:00:00,139.45,169.52599999999998,57.048,32.225 +2020-11-30 15:15:00,138.24,169.551,57.048,32.225 +2020-11-30 15:30:00,137.99,170.58900000000003,57.048,32.225 +2020-11-30 15:45:00,136.61,171.674,57.048,32.225 +2020-11-30 16:00:00,139.97,174.865,59.06,32.225 +2020-11-30 16:15:00,141.95,176.092,59.06,32.225 +2020-11-30 16:30:00,140.58,176.91,59.06,32.225 +2020-11-30 16:45:00,140.15,176.986,59.06,32.225 +2020-11-30 17:00:00,142.38,179.55700000000002,65.419,32.225 +2020-11-30 17:15:00,140.03,180.122,65.419,32.225 +2020-11-30 17:30:00,141.11,180.05200000000002,65.419,32.225 +2020-11-30 17:45:00,140.16,179.81400000000002,65.419,32.225 +2020-11-30 18:00:00,138.78,181.859,69.345,32.225 +2020-11-30 18:15:00,137.07,180.787,69.345,32.225 +2020-11-30 18:30:00,136.41,179.389,69.345,32.225 +2020-11-30 18:45:00,137.66,179.602,69.345,32.225 +2020-11-30 19:00:00,134.46,181.15599999999998,73.825,32.225 +2020-11-30 19:15:00,133.63,178.29,73.825,32.225 +2020-11-30 19:30:00,137.08,177.426,73.825,32.225 +2020-11-30 19:45:00,135.8,173.923,73.825,32.225 +2020-11-30 20:00:00,129.29,170.03,64.027,32.225 +2020-11-30 20:15:00,123.1,165.794,64.027,32.225 +2020-11-30 20:30:00,117.76,161.946,64.027,32.225 +2020-11-30 20:45:00,115.12,159.184,64.027,32.225 +2020-11-30 21:00:00,116.06,157.475,57.952,32.225 +2020-11-30 21:15:00,113.82,155.185,57.952,32.225 +2020-11-30 21:30:00,112.58,153.67600000000002,57.952,32.225 +2020-11-30 21:45:00,105.49,151.708,57.952,32.225 +2020-11-30 22:00:00,101.57,143.72299999999998,53.031000000000006,32.225 +2020-11-30 22:15:00,95.52,138.885,53.031000000000006,32.225 +2020-11-30 22:30:00,97.58,123.971,53.031000000000006,32.225 +2020-11-30 22:45:00,97.01,115.875,53.031000000000006,32.225 +2020-11-30 23:00:00,92.05,111.11399999999999,45.085,32.225 +2020-11-30 23:15:00,89.71,109.441,45.085,32.225 +2020-11-30 23:30:00,83.96,110.206,45.085,32.225 +2020-11-30 23:45:00,81.23,110.156,45.085,32.225 +2020-12-01 00:00:00,77.38,114.376,43.537,32.65 +2020-12-01 00:15:00,76.24,114.65799999999999,43.537,32.65 +2020-12-01 00:30:00,76.87,115.84100000000001,43.537,32.65 +2020-12-01 00:45:00,75.04,117.59700000000001,43.537,32.65 +2020-12-01 01:00:00,73.08,119.316,41.854,32.65 +2020-12-01 01:15:00,73.82,120.051,41.854,32.65 +2020-12-01 01:30:00,71.9,120.405,41.854,32.65 +2020-12-01 01:45:00,73.54,121.234,41.854,32.65 +2020-12-01 02:00:00,71.89,122.501,40.321,32.65 +2020-12-01 02:15:00,73.96,123.98899999999999,40.321,32.65 +2020-12-01 02:30:00,71.76,124.215,40.321,32.65 +2020-12-01 02:45:00,72.0,126.095,40.321,32.65 +2020-12-01 03:00:00,71.75,128.914,39.632,32.65 +2020-12-01 03:15:00,75.34,129.286,39.632,32.65 +2020-12-01 03:30:00,74.29,131.14,39.632,32.65 +2020-12-01 03:45:00,75.41,132.171,39.632,32.65 +2020-12-01 04:00:00,76.26,145.259,40.183,32.65 +2020-12-01 04:15:00,77.11,157.393,40.183,32.65 +2020-12-01 04:30:00,79.19,160.136,40.183,32.65 +2020-12-01 04:45:00,80.44,162.73,40.183,32.65 +2020-12-01 05:00:00,84.76,197.88,43.945,32.65 +2020-12-01 05:15:00,88.06,227.541,43.945,32.65 +2020-12-01 05:30:00,90.98,222.683,43.945,32.65 +2020-12-01 05:45:00,99.38,214.511,43.945,32.65 +2020-12-01 06:00:00,107.58,210.252,56.048,32.65 +2020-12-01 06:15:00,111.62,215.68,56.048,32.65 +2020-12-01 06:30:00,119.23,217.26,56.048,32.65 +2020-12-01 06:45:00,121.67,219.46099999999998,56.048,32.65 +2020-12-01 07:00:00,129.63,218.843,65.74,32.65 +2020-12-01 07:15:00,130.84,223.71400000000003,65.74,32.65 +2020-12-01 07:30:00,137.48,226.422,65.74,32.65 +2020-12-01 07:45:00,137.37,227.78900000000002,65.74,32.65 +2020-12-01 08:00:00,140.6,226.493,72.757,32.65 +2020-12-01 08:15:00,141.1,226.658,72.757,32.65 +2020-12-01 08:30:00,140.35,224.92700000000002,72.757,32.65 +2020-12-01 08:45:00,140.13,222.5,72.757,32.65 +2020-12-01 09:00:00,141.71,216.40599999999998,67.692,32.65 +2020-12-01 09:15:00,141.8,212.893,67.692,32.65 +2020-12-01 09:30:00,142.81,210.30700000000002,67.692,32.65 +2020-12-01 09:45:00,145.16,207.66299999999998,67.692,32.65 +2020-12-01 10:00:00,142.57,203.207,63.506,32.65 +2020-12-01 10:15:00,141.13,199.245,63.506,32.65 +2020-12-01 10:30:00,139.34,197.101,63.506,32.65 +2020-12-01 10:45:00,143.54,195.78,63.506,32.65 +2020-12-01 11:00:00,143.81,194.96099999999998,60.758,32.65 +2020-12-01 11:15:00,146.07,194.06400000000002,60.758,32.65 +2020-12-01 11:30:00,145.51,192.882,60.758,32.65 +2020-12-01 11:45:00,143.54,191.393,60.758,32.65 +2020-12-01 12:00:00,141.07,185.77599999999998,57.519,32.65 +2020-12-01 12:15:00,140.56,184.696,57.519,32.65 +2020-12-01 12:30:00,136.23,184.53,57.519,32.65 +2020-12-01 12:45:00,133.91,185.405,57.519,32.65 +2020-12-01 13:00:00,132.2,184.952,56.46,32.65 +2020-12-01 13:15:00,136.5,184.533,56.46,32.65 +2020-12-01 13:30:00,133.44,184.457,56.46,32.65 +2020-12-01 13:45:00,129.4,184.488,56.46,32.65 +2020-12-01 14:00:00,127.85,183.696,56.207,32.65 +2020-12-01 14:15:00,132.64,184.13400000000001,56.207,32.65 +2020-12-01 14:30:00,135.84,184.32299999999998,56.207,32.65 +2020-12-01 14:45:00,135.32,184.10299999999998,56.207,32.65 +2020-12-01 15:00:00,135.69,184.78400000000002,57.391999999999996,32.65 +2020-12-01 15:15:00,135.61,185.36599999999999,57.391999999999996,32.65 +2020-12-01 15:30:00,133.27,187.30200000000002,57.391999999999996,32.65 +2020-12-01 15:45:00,133.91,189.111,57.391999999999996,32.65 +2020-12-01 16:00:00,137.4,190.207,59.955,32.65 +2020-12-01 16:15:00,139.6,191.11599999999999,59.955,32.65 +2020-12-01 16:30:00,142.04,193.775,59.955,32.65 +2020-12-01 16:45:00,141.31,194.75400000000002,59.955,32.65 +2020-12-01 17:00:00,144.35,197.62,67.063,32.65 +2020-12-01 17:15:00,144.05,197.739,67.063,32.65 +2020-12-01 17:30:00,146.6,198.13,67.063,32.65 +2020-12-01 17:45:00,145.09,197.708,67.063,32.65 +2020-12-01 18:00:00,144.03,198.524,71.477,32.65 +2020-12-01 18:15:00,142.72,196.93200000000002,71.477,32.65 +2020-12-01 18:30:00,142.42,195.355,71.477,32.65 +2020-12-01 18:45:00,141.42,195.035,71.477,32.65 +2020-12-01 19:00:00,139.2,196.077,74.32,32.65 +2020-12-01 19:15:00,138.58,192.56,74.32,32.65 +2020-12-01 19:30:00,136.59,190.588,74.32,32.65 +2020-12-01 19:45:00,136.62,187.38299999999998,74.32,32.65 +2020-12-01 20:00:00,127.68,183.91299999999998,66.157,32.65 +2020-12-01 20:15:00,124.64,178.15099999999998,66.157,32.65 +2020-12-01 20:30:00,120.77,174.831,66.157,32.65 +2020-12-01 20:45:00,119.59,171.986,66.157,32.65 +2020-12-01 21:00:00,113.2,170.00900000000001,59.806000000000004,32.65 +2020-12-01 21:15:00,113.24,168.122,59.806000000000004,32.65 +2020-12-01 21:30:00,115.21,166.013,59.806000000000004,32.65 +2020-12-01 21:45:00,113.56,164.245,59.806000000000004,32.65 +2020-12-01 22:00:00,109.11,157.439,54.785,32.65 +2020-12-01 22:15:00,104.46,151.769,54.785,32.65 +2020-12-01 22:30:00,97.9,136.998,54.785,32.65 +2020-12-01 22:45:00,101.22,128.881,54.785,32.65 +2020-12-01 23:00:00,96.47,123.572,47.176,32.65 +2020-12-01 23:15:00,95.75,121.62100000000001,47.176,32.65 +2020-12-01 23:30:00,88.96,121.59299999999999,47.176,32.65 +2020-12-01 23:45:00,85.14,121.363,47.176,32.65 +2020-12-02 00:00:00,84.73,114.73200000000001,43.42,32.65 +2020-12-02 00:15:00,88.25,114.98200000000001,43.42,32.65 +2020-12-02 00:30:00,88.93,116.162,43.42,32.65 +2020-12-02 00:45:00,86.11,117.9,43.42,32.65 +2020-12-02 01:00:00,82.71,119.66,40.869,32.65 +2020-12-02 01:15:00,87.22,120.396,40.869,32.65 +2020-12-02 01:30:00,85.57,120.758,40.869,32.65 +2020-12-02 01:45:00,81.75,121.573,40.869,32.65 +2020-12-02 02:00:00,83.12,122.861,39.541,32.65 +2020-12-02 02:15:00,84.79,124.352,39.541,32.65 +2020-12-02 02:30:00,82.28,124.575,39.541,32.65 +2020-12-02 02:45:00,82.21,126.456,39.541,32.65 +2020-12-02 03:00:00,84.94,129.262,39.052,32.65 +2020-12-02 03:15:00,86.09,129.664,39.052,32.65 +2020-12-02 03:30:00,83.57,131.52100000000002,39.052,32.65 +2020-12-02 03:45:00,84.34,132.55200000000002,39.052,32.65 +2020-12-02 04:00:00,87.42,145.606,40.36,32.65 +2020-12-02 04:15:00,90.24,157.736,40.36,32.65 +2020-12-02 04:30:00,90.6,160.465,40.36,32.65 +2020-12-02 04:45:00,88.51,163.063,40.36,32.65 +2020-12-02 05:00:00,94.36,198.175,43.133,32.65 +2020-12-02 05:15:00,100.52,227.77700000000002,43.133,32.65 +2020-12-02 05:30:00,105.35,222.95,43.133,32.65 +2020-12-02 05:45:00,105.35,214.801,43.133,32.65 +2020-12-02 06:00:00,109.55,210.567,54.953,32.65 +2020-12-02 06:15:00,115.06,215.998,54.953,32.65 +2020-12-02 06:30:00,123.34,217.628,54.953,32.65 +2020-12-02 06:45:00,127.78,219.878,54.953,32.65 +2020-12-02 07:00:00,134.98,219.26,66.566,32.65 +2020-12-02 07:15:00,137.75,224.141,66.566,32.65 +2020-12-02 07:30:00,136.68,226.858,66.566,32.65 +2020-12-02 07:45:00,138.02,228.231,66.566,32.65 +2020-12-02 08:00:00,141.25,226.949,72.902,32.65 +2020-12-02 08:15:00,138.63,227.109,72.902,32.65 +2020-12-02 08:30:00,137.77,225.389,72.902,32.65 +2020-12-02 08:45:00,136.96,222.929,72.902,32.65 +2020-12-02 09:00:00,134.22,216.808,68.465,32.65 +2020-12-02 09:15:00,138.14,213.30200000000002,68.465,32.65 +2020-12-02 09:30:00,138.14,210.72099999999998,68.465,32.65 +2020-12-02 09:45:00,137.02,208.063,68.465,32.65 +2020-12-02 10:00:00,134.5,203.59900000000002,63.625,32.65 +2020-12-02 10:15:00,134.21,199.612,63.625,32.65 +2020-12-02 10:30:00,134.09,197.44299999999998,63.625,32.65 +2020-12-02 10:45:00,133.17,196.112,63.625,32.65 +2020-12-02 11:00:00,128.69,195.27599999999998,61.628,32.65 +2020-12-02 11:15:00,131.51,194.36599999999999,61.628,32.65 +2020-12-02 11:30:00,130.92,193.18099999999998,61.628,32.65 +2020-12-02 11:45:00,130.63,191.68400000000003,61.628,32.65 +2020-12-02 12:00:00,128.7,186.065,58.708999999999996,32.65 +2020-12-02 12:15:00,125.44,184.99599999999998,58.708999999999996,32.65 +2020-12-02 12:30:00,126.63,184.84900000000002,58.708999999999996,32.65 +2020-12-02 12:45:00,128.48,185.729,58.708999999999996,32.65 +2020-12-02 13:00:00,126.35,185.24,57.373000000000005,32.65 +2020-12-02 13:15:00,128.95,184.826,57.373000000000005,32.65 +2020-12-02 13:30:00,127.1,184.747,57.373000000000005,32.65 +2020-12-02 13:45:00,127.31,184.768,57.373000000000005,32.65 +2020-12-02 14:00:00,125.8,183.947,57.684,32.65 +2020-12-02 14:15:00,128.72,184.392,57.684,32.65 +2020-12-02 14:30:00,128.58,184.61,57.684,32.65 +2020-12-02 14:45:00,128.73,184.4,57.684,32.65 +2020-12-02 15:00:00,132.19,185.09799999999998,58.03,32.65 +2020-12-02 15:15:00,132.73,185.68099999999998,58.03,32.65 +2020-12-02 15:30:00,132.49,187.644,58.03,32.65 +2020-12-02 15:45:00,133.74,189.456,58.03,32.65 +2020-12-02 16:00:00,137.37,190.55200000000002,59.97,32.65 +2020-12-02 16:15:00,138.66,191.484,59.97,32.65 +2020-12-02 16:30:00,141.97,194.15,59.97,32.65 +2020-12-02 16:45:00,142.93,195.16400000000002,59.97,32.65 +2020-12-02 17:00:00,147.26,198.00400000000002,65.661,32.65 +2020-12-02 17:15:00,146.69,198.15099999999998,65.661,32.65 +2020-12-02 17:30:00,147.72,198.554,65.661,32.65 +2020-12-02 17:45:00,146.61,198.139,65.661,32.65 +2020-12-02 18:00:00,144.03,198.97299999999998,70.96300000000001,32.65 +2020-12-02 18:15:00,143.55,197.338,70.96300000000001,32.65 +2020-12-02 18:30:00,141.98,195.77,70.96300000000001,32.65 +2020-12-02 18:45:00,139.97,195.455,70.96300000000001,32.65 +2020-12-02 19:00:00,141.8,196.488,74.133,32.65 +2020-12-02 19:15:00,140.75,192.959,74.133,32.65 +2020-12-02 19:30:00,137.4,190.96900000000002,74.133,32.65 +2020-12-02 19:45:00,137.36,187.734,74.133,32.65 +2020-12-02 20:00:00,130.36,184.267,65.613,32.65 +2020-12-02 20:15:00,122.11,178.49400000000003,65.613,32.65 +2020-12-02 20:30:00,123.19,175.14700000000002,65.613,32.65 +2020-12-02 20:45:00,122.73,172.321,65.613,32.65 +2020-12-02 21:00:00,117.07,170.332,58.583,32.65 +2020-12-02 21:15:00,110.96,168.428,58.583,32.65 +2020-12-02 21:30:00,110.94,166.321,58.583,32.65 +2020-12-02 21:45:00,111.13,164.558,58.583,32.65 +2020-12-02 22:00:00,105.41,157.761,54.411,32.65 +2020-12-02 22:15:00,105.31,152.091,54.411,32.65 +2020-12-02 22:30:00,102.13,137.376,54.411,32.65 +2020-12-02 22:45:00,104.64,129.267,54.411,32.65 +2020-12-02 23:00:00,96.98,123.932,47.878,32.65 +2020-12-02 23:15:00,94.32,121.976,47.878,32.65 +2020-12-02 23:30:00,94.93,121.96,47.878,32.65 +2020-12-02 23:45:00,93.72,121.713,47.878,32.65 +2020-12-03 00:00:00,87.84,115.08200000000001,44.513000000000005,32.65 +2020-12-03 00:15:00,83.11,115.303,44.513000000000005,32.65 +2020-12-03 00:30:00,84.51,116.478,44.513000000000005,32.65 +2020-12-03 00:45:00,87.95,118.196,44.513000000000005,32.65 +2020-12-03 01:00:00,84.16,119.99700000000001,43.169,32.65 +2020-12-03 01:15:00,83.09,120.73299999999999,43.169,32.65 +2020-12-03 01:30:00,85.34,121.10600000000001,43.169,32.65 +2020-12-03 01:45:00,86.49,121.904,43.169,32.65 +2020-12-03 02:00:00,82.06,123.214,41.763999999999996,32.65 +2020-12-03 02:15:00,82.6,124.706,41.763999999999996,32.65 +2020-12-03 02:30:00,85.09,124.931,41.763999999999996,32.65 +2020-12-03 02:45:00,86.07,126.811,41.763999999999996,32.65 +2020-12-03 03:00:00,80.57,129.602,41.155,32.65 +2020-12-03 03:15:00,80.92,130.036,41.155,32.65 +2020-12-03 03:30:00,83.75,131.89700000000002,41.155,32.65 +2020-12-03 03:45:00,87.62,132.925,41.155,32.65 +2020-12-03 04:00:00,89.28,145.94799999999998,41.96,32.65 +2020-12-03 04:15:00,85.37,158.075,41.96,32.65 +2020-12-03 04:30:00,83.55,160.786,41.96,32.65 +2020-12-03 04:45:00,85.67,163.389,41.96,32.65 +2020-12-03 05:00:00,90.67,198.46200000000002,45.206,32.65 +2020-12-03 05:15:00,94.78,228.007,45.206,32.65 +2020-12-03 05:30:00,95.98,223.21200000000002,45.206,32.65 +2020-12-03 05:45:00,101.33,215.085,45.206,32.65 +2020-12-03 06:00:00,109.52,210.877,55.398999999999994,32.65 +2020-12-03 06:15:00,116.92,216.308,55.398999999999994,32.65 +2020-12-03 06:30:00,120.32,217.99,55.398999999999994,32.65 +2020-12-03 06:45:00,124.73,220.28599999999997,55.398999999999994,32.65 +2020-12-03 07:00:00,131.84,219.67,64.627,32.65 +2020-12-03 07:15:00,134.4,224.56099999999998,64.627,32.65 +2020-12-03 07:30:00,137.22,227.28400000000002,64.627,32.65 +2020-12-03 07:45:00,137.69,228.66400000000002,64.627,32.65 +2020-12-03 08:00:00,141.29,227.395,70.895,32.65 +2020-12-03 08:15:00,139.35,227.55,70.895,32.65 +2020-12-03 08:30:00,139.65,225.842,70.895,32.65 +2020-12-03 08:45:00,139.71,223.34900000000002,70.895,32.65 +2020-12-03 09:00:00,139.33,217.199,66.382,32.65 +2020-12-03 09:15:00,140.17,213.701,66.382,32.65 +2020-12-03 09:30:00,140.93,211.125,66.382,32.65 +2020-12-03 09:45:00,141.66,208.455,66.382,32.65 +2020-12-03 10:00:00,143.6,203.981,62.739,32.65 +2020-12-03 10:15:00,145.07,199.96900000000002,62.739,32.65 +2020-12-03 10:30:00,144.64,197.77700000000002,62.739,32.65 +2020-12-03 10:45:00,144.41,196.437,62.739,32.65 +2020-12-03 11:00:00,143.34,195.584,60.843,32.65 +2020-12-03 11:15:00,145.71,194.658,60.843,32.65 +2020-12-03 11:30:00,144.57,193.47099999999998,60.843,32.65 +2020-12-03 11:45:00,144.85,191.968,60.843,32.65 +2020-12-03 12:00:00,142.96,186.34599999999998,58.466,32.65 +2020-12-03 12:15:00,140.5,185.28900000000002,58.466,32.65 +2020-12-03 12:30:00,139.07,185.16,58.466,32.65 +2020-12-03 12:45:00,139.98,186.046,58.466,32.65 +2020-12-03 13:00:00,137.73,185.521,56.883,32.65 +2020-12-03 13:15:00,138.78,185.111,56.883,32.65 +2020-12-03 13:30:00,136.57,185.028,56.883,32.65 +2020-12-03 13:45:00,136.43,185.03799999999998,56.883,32.65 +2020-12-03 14:00:00,135.01,184.19099999999997,56.503,32.65 +2020-12-03 14:15:00,135.23,184.644,56.503,32.65 +2020-12-03 14:30:00,135.78,184.89,56.503,32.65 +2020-12-03 14:45:00,136.83,184.68900000000002,56.503,32.65 +2020-12-03 15:00:00,136.93,185.407,57.803999999999995,32.65 +2020-12-03 15:15:00,136.54,185.989,57.803999999999995,32.65 +2020-12-03 15:30:00,135.85,187.979,57.803999999999995,32.65 +2020-12-03 15:45:00,135.94,189.792,57.803999999999995,32.65 +2020-12-03 16:00:00,139.8,190.887,59.379,32.65 +2020-12-03 16:15:00,140.97,191.84400000000002,59.379,32.65 +2020-12-03 16:30:00,142.86,194.516,59.379,32.65 +2020-12-03 16:45:00,142.14,195.565,59.379,32.65 +2020-12-03 17:00:00,143.33,198.38,64.71600000000001,32.65 +2020-12-03 17:15:00,143.46,198.551,64.71600000000001,32.65 +2020-12-03 17:30:00,145.46,198.96900000000002,64.71600000000001,32.65 +2020-12-03 17:45:00,144.91,198.55900000000003,64.71600000000001,32.65 +2020-12-03 18:00:00,141.72,199.41299999999998,68.803,32.65 +2020-12-03 18:15:00,139.73,197.738,68.803,32.65 +2020-12-03 18:30:00,138.04,196.17700000000002,68.803,32.65 +2020-12-03 18:45:00,139.53,195.868,68.803,32.65 +2020-12-03 19:00:00,136.48,196.892,72.934,32.65 +2020-12-03 19:15:00,136.16,193.352,72.934,32.65 +2020-12-03 19:30:00,132.96,191.345,72.934,32.65 +2020-12-03 19:45:00,132.54,188.079,72.934,32.65 +2020-12-03 20:00:00,124.03,184.614,65.175,32.65 +2020-12-03 20:15:00,120.74,178.831,65.175,32.65 +2020-12-03 20:30:00,117.79,175.456,65.175,32.65 +2020-12-03 20:45:00,115.66,172.65099999999998,65.175,32.65 +2020-12-03 21:00:00,113.13,170.648,58.55,32.65 +2020-12-03 21:15:00,111.13,168.72799999999998,58.55,32.65 +2020-12-03 21:30:00,107.15,166.62099999999998,58.55,32.65 +2020-12-03 21:45:00,104.82,164.864,58.55,32.65 +2020-12-03 22:00:00,98.64,158.077,55.041000000000004,32.65 +2020-12-03 22:15:00,100.35,152.408,55.041000000000004,32.65 +2020-12-03 22:30:00,99.69,137.747,55.041000000000004,32.65 +2020-12-03 22:45:00,99.18,129.645,55.041000000000004,32.65 +2020-12-03 23:00:00,96.32,124.286,48.258,32.65 +2020-12-03 23:15:00,90.21,122.32600000000001,48.258,32.65 +2020-12-03 23:30:00,91.13,122.322,48.258,32.65 +2020-12-03 23:45:00,90.28,122.055,48.258,32.65 +2020-12-04 00:00:00,88.17,114.368,45.02,32.65 +2020-12-04 00:15:00,80.56,114.755,45.02,32.65 +2020-12-04 00:30:00,74.99,115.79,45.02,32.65 +2020-12-04 00:45:00,78.87,117.596,45.02,32.65 +2020-12-04 01:00:00,80.8,119.12799999999999,42.695,32.65 +2020-12-04 01:15:00,81.08,120.788,42.695,32.65 +2020-12-04 01:30:00,79.01,120.94,42.695,32.65 +2020-12-04 01:45:00,77.31,121.831,42.695,32.65 +2020-12-04 02:00:00,74.12,123.24,41.511,32.65 +2020-12-04 02:15:00,74.97,124.619,41.511,32.65 +2020-12-04 02:30:00,79.2,125.36200000000001,41.511,32.65 +2020-12-04 02:45:00,82.28,127.291,41.511,32.65 +2020-12-04 03:00:00,79.37,129.062,41.162,32.65 +2020-12-04 03:15:00,79.26,130.482,41.162,32.65 +2020-12-04 03:30:00,82.2,132.33100000000002,41.162,32.65 +2020-12-04 03:45:00,85.02,133.678,41.162,32.65 +2020-12-04 04:00:00,84.4,146.89600000000002,42.226000000000006,32.65 +2020-12-04 04:15:00,78.87,158.79,42.226000000000006,32.65 +2020-12-04 04:30:00,75.73,161.70600000000002,42.226000000000006,32.65 +2020-12-04 04:45:00,80.98,163.172,42.226000000000006,32.65 +2020-12-04 05:00:00,85.06,196.937,45.597,32.65 +2020-12-04 05:15:00,88.99,227.93,45.597,32.65 +2020-12-04 05:30:00,89.73,224.248,45.597,32.65 +2020-12-04 05:45:00,96.09,216.083,45.597,32.65 +2020-12-04 06:00:00,108.01,212.333,56.263999999999996,32.65 +2020-12-04 06:15:00,112.74,216.285,56.263999999999996,32.65 +2020-12-04 06:30:00,117.29,217.148,56.263999999999996,32.65 +2020-12-04 06:45:00,121.66,221.128,56.263999999999996,32.65 +2020-12-04 07:00:00,130.21,219.666,66.888,32.65 +2020-12-04 07:15:00,129.15,225.585,66.888,32.65 +2020-12-04 07:30:00,132.61,228.15400000000002,66.888,32.65 +2020-12-04 07:45:00,134.61,228.649,66.888,32.65 +2020-12-04 08:00:00,137.01,226.28,73.459,32.65 +2020-12-04 08:15:00,136.94,226.03599999999997,73.459,32.65 +2020-12-04 08:30:00,137.62,225.31599999999997,73.459,32.65 +2020-12-04 08:45:00,137.29,221.213,73.459,32.65 +2020-12-04 09:00:00,138.54,215.50599999999997,69.087,32.65 +2020-12-04 09:15:00,139.96,212.579,69.087,32.65 +2020-12-04 09:30:00,139.56,209.581,69.087,32.65 +2020-12-04 09:45:00,138.84,206.78799999999998,69.087,32.65 +2020-12-04 10:00:00,136.1,201.173,65.404,32.65 +2020-12-04 10:15:00,138.43,197.835,65.404,32.65 +2020-12-04 10:30:00,137.86,195.547,65.404,32.65 +2020-12-04 10:45:00,138.39,193.75599999999997,65.404,32.65 +2020-12-04 11:00:00,136.83,192.87,63.0,32.65 +2020-12-04 11:15:00,137.96,191.03599999999997,63.0,32.65 +2020-12-04 11:30:00,138.11,191.58900000000003,63.0,32.65 +2020-12-04 11:45:00,138.08,190.125,63.0,32.65 +2020-12-04 12:00:00,136.37,185.582,59.083,32.65 +2020-12-04 12:15:00,134.96,182.451,59.083,32.65 +2020-12-04 12:30:00,134.55,182.497,59.083,32.65 +2020-12-04 12:45:00,137.65,183.898,59.083,32.65 +2020-12-04 13:00:00,132.64,184.27200000000002,56.611999999999995,32.65 +2020-12-04 13:15:00,133.9,184.679,56.611999999999995,32.65 +2020-12-04 13:30:00,132.84,184.613,56.611999999999995,32.65 +2020-12-04 13:45:00,133.43,184.551,56.611999999999995,32.65 +2020-12-04 14:00:00,130.52,182.54,55.161,32.65 +2020-12-04 14:15:00,129.92,182.826,55.161,32.65 +2020-12-04 14:30:00,127.64,183.615,55.161,32.65 +2020-12-04 14:45:00,127.22,183.72099999999998,55.161,32.65 +2020-12-04 15:00:00,125.79,183.976,55.583,32.65 +2020-12-04 15:15:00,126.08,184.122,55.583,32.65 +2020-12-04 15:30:00,126.65,184.62599999999998,55.583,32.65 +2020-12-04 15:45:00,131.71,186.58900000000003,55.583,32.65 +2020-12-04 16:00:00,133.56,186.52,57.611999999999995,32.65 +2020-12-04 16:15:00,135.6,187.78900000000002,57.611999999999995,32.65 +2020-12-04 16:30:00,138.73,190.56400000000002,57.611999999999995,32.65 +2020-12-04 16:45:00,136.33,191.52700000000002,57.611999999999995,32.65 +2020-12-04 17:00:00,138.83,194.524,64.14,32.65 +2020-12-04 17:15:00,137.41,194.31099999999998,64.14,32.65 +2020-12-04 17:30:00,138.46,194.424,64.14,32.65 +2020-12-04 17:45:00,138.3,193.78900000000002,64.14,32.65 +2020-12-04 18:00:00,135.16,195.34900000000002,68.086,32.65 +2020-12-04 18:15:00,133.86,193.245,68.086,32.65 +2020-12-04 18:30:00,134.09,192.083,68.086,32.65 +2020-12-04 18:45:00,137.38,191.77700000000002,68.086,32.65 +2020-12-04 19:00:00,132.52,193.69400000000002,69.915,32.65 +2020-12-04 19:15:00,129.24,191.503,69.915,32.65 +2020-12-04 19:30:00,128.95,189.06900000000002,69.915,32.65 +2020-12-04 19:45:00,126.18,185.313,69.915,32.65 +2020-12-04 20:00:00,118.12,181.905,61.695,32.65 +2020-12-04 20:15:00,116.18,176.11900000000003,61.695,32.65 +2020-12-04 20:30:00,113.76,172.675,61.695,32.65 +2020-12-04 20:45:00,111.07,170.445,61.695,32.65 +2020-12-04 21:00:00,107.3,168.935,56.041000000000004,32.65 +2020-12-04 21:15:00,103.46,167.43400000000003,56.041000000000004,32.65 +2020-12-04 21:30:00,100.57,165.37400000000002,56.041000000000004,32.65 +2020-12-04 21:45:00,103.14,164.165,56.041000000000004,32.65 +2020-12-04 22:00:00,98.06,158.365,51.888999999999996,32.65 +2020-12-04 22:15:00,96.92,152.564,51.888999999999996,32.65 +2020-12-04 22:30:00,84.82,144.314,51.888999999999996,32.65 +2020-12-04 22:45:00,84.39,139.736,51.888999999999996,32.65 +2020-12-04 23:00:00,84.2,133.908,45.787,32.65 +2020-12-04 23:15:00,84.93,130.002,45.787,32.65 +2020-12-04 23:30:00,82.36,128.553,45.787,32.65 +2020-12-04 23:45:00,78.57,127.60700000000001,45.787,32.65 +2020-12-05 00:00:00,72.37,111.85600000000001,41.815,32.468 +2020-12-05 00:15:00,73.35,107.964,41.815,32.468 +2020-12-05 00:30:00,73.52,110.323,41.815,32.468 +2020-12-05 00:45:00,69.58,112.825,41.815,32.468 +2020-12-05 01:00:00,65.32,115.024,38.645,32.468 +2020-12-05 01:15:00,67.07,115.698,38.645,32.468 +2020-12-05 01:30:00,70.05,115.344,38.645,32.468 +2020-12-05 01:45:00,72.67,116.0,38.645,32.468 +2020-12-05 02:00:00,67.0,118.118,36.696,32.468 +2020-12-05 02:15:00,65.94,119.133,36.696,32.468 +2020-12-05 02:30:00,62.92,118.76799999999999,36.696,32.468 +2020-12-05 02:45:00,67.89,120.795,36.696,32.468 +2020-12-05 03:00:00,68.09,123.185,35.42,32.468 +2020-12-05 03:15:00,69.91,123.42200000000001,35.42,32.468 +2020-12-05 03:30:00,67.12,123.686,35.42,32.468 +2020-12-05 03:45:00,70.54,125.135,35.42,32.468 +2020-12-05 04:00:00,70.06,134.191,35.167,32.468 +2020-12-05 04:15:00,64.46,143.546,35.167,32.468 +2020-12-05 04:30:00,62.74,144.289,35.167,32.468 +2020-12-05 04:45:00,63.37,145.262,35.167,32.468 +2020-12-05 05:00:00,63.4,163.07299999999998,35.311,32.468 +2020-12-05 05:15:00,63.89,175.081,35.311,32.468 +2020-12-05 05:30:00,64.64,171.774,35.311,32.468 +2020-12-05 05:45:00,66.98,169.051,35.311,32.468 +2020-12-05 06:00:00,68.66,184.03099999999998,37.117,32.468 +2020-12-05 06:15:00,70.86,204.283,37.117,32.468 +2020-12-05 06:30:00,72.62,199.84,37.117,32.468 +2020-12-05 06:45:00,75.93,194.908,37.117,32.468 +2020-12-05 07:00:00,78.46,189.69299999999998,40.948,32.468 +2020-12-05 07:15:00,81.06,194.37400000000002,40.948,32.468 +2020-12-05 07:30:00,83.25,199.646,40.948,32.468 +2020-12-05 07:45:00,85.28,204.113,40.948,32.468 +2020-12-05 08:00:00,88.29,205.796,44.903,32.468 +2020-12-05 08:15:00,88.3,209.168,44.903,32.468 +2020-12-05 08:30:00,89.21,210.06599999999997,44.903,32.468 +2020-12-05 08:45:00,91.96,209.077,44.903,32.468 +2020-12-05 09:00:00,93.43,205.105,46.283,32.468 +2020-12-05 09:15:00,93.03,202.935,46.283,32.468 +2020-12-05 09:30:00,95.57,200.84099999999998,46.283,32.468 +2020-12-05 09:45:00,93.63,198.196,46.283,32.468 +2020-12-05 10:00:00,95.54,192.859,44.103,32.468 +2020-12-05 10:15:00,96.38,189.66,44.103,32.468 +2020-12-05 10:30:00,98.31,187.513,44.103,32.468 +2020-12-05 10:45:00,98.64,187.00599999999997,44.103,32.468 +2020-12-05 11:00:00,101.27,186.32299999999998,42.373999999999995,32.468 +2020-12-05 11:15:00,101.54,183.795,42.373999999999995,32.468 +2020-12-05 11:30:00,99.33,183.24,42.373999999999995,32.468 +2020-12-05 11:45:00,97.85,180.82299999999998,42.373999999999995,32.468 +2020-12-05 12:00:00,99.19,175.40900000000002,39.937,32.468 +2020-12-05 12:15:00,96.78,172.928,39.937,32.468 +2020-12-05 12:30:00,95.3,173.30599999999998,39.937,32.468 +2020-12-05 12:45:00,95.84,173.953,39.937,32.468 +2020-12-05 13:00:00,90.76,173.893,37.138000000000005,32.468 +2020-12-05 13:15:00,88.94,172.267,37.138000000000005,32.468 +2020-12-05 13:30:00,88.02,171.75099999999998,37.138000000000005,32.468 +2020-12-05 13:45:00,84.59,172.145,37.138000000000005,32.468 +2020-12-05 14:00:00,86.03,171.328,36.141999999999996,32.468 +2020-12-05 14:15:00,87.73,171.08599999999998,36.141999999999996,32.468 +2020-12-05 14:30:00,88.66,170.1,36.141999999999996,32.468 +2020-12-05 14:45:00,88.57,170.43400000000003,36.141999999999996,32.468 +2020-12-05 15:00:00,90.45,171.362,37.964,32.468 +2020-12-05 15:15:00,92.3,172.308,37.964,32.468 +2020-12-05 15:30:00,95.92,174.362,37.964,32.468 +2020-12-05 15:45:00,95.54,176.33700000000002,37.964,32.468 +2020-12-05 16:00:00,99.12,175.083,40.699,32.468 +2020-12-05 16:15:00,103.2,177.25900000000001,40.699,32.468 +2020-12-05 16:30:00,101.85,179.989,40.699,32.468 +2020-12-05 16:45:00,102.17,181.863,40.699,32.468 +2020-12-05 17:00:00,105.16,184.262,46.216,32.468 +2020-12-05 17:15:00,104.75,185.71099999999998,46.216,32.468 +2020-12-05 17:30:00,109.26,185.743,46.216,32.468 +2020-12-05 17:45:00,106.26,184.71400000000003,46.216,32.468 +2020-12-05 18:00:00,107.15,185.812,51.123999999999995,32.468 +2020-12-05 18:15:00,107.16,185.459,51.123999999999995,32.468 +2020-12-05 18:30:00,106.1,185.628,51.123999999999995,32.468 +2020-12-05 18:45:00,105.22,182.0,51.123999999999995,32.468 +2020-12-05 19:00:00,103.27,184.81900000000002,52.336000000000006,32.468 +2020-12-05 19:15:00,102.54,182.122,52.336000000000006,32.468 +2020-12-05 19:30:00,101.72,180.421,52.336000000000006,32.468 +2020-12-05 19:45:00,99.84,176.465,52.336000000000006,32.468 +2020-12-05 20:00:00,95.78,175.215,48.825,32.468 +2020-12-05 20:15:00,92.88,171.52599999999998,48.825,32.468 +2020-12-05 20:30:00,90.67,167.706,48.825,32.468 +2020-12-05 20:45:00,89.27,165.101,48.825,32.468 +2020-12-05 21:00:00,84.32,165.80599999999998,43.729,32.468 +2020-12-05 21:15:00,84.83,164.725,43.729,32.468 +2020-12-05 21:30:00,83.33,163.88400000000001,43.729,32.468 +2020-12-05 21:45:00,81.56,162.278,43.729,32.468 +2020-12-05 22:00:00,77.6,157.82299999999998,44.126000000000005,32.468 +2020-12-05 22:15:00,76.48,154.488,44.126000000000005,32.468 +2020-12-05 22:30:00,73.47,152.457,44.126000000000005,32.468 +2020-12-05 22:45:00,72.33,149.74200000000002,44.126000000000005,32.468 +2020-12-05 23:00:00,67.46,146.29399999999998,38.169000000000004,32.468 +2020-12-05 23:15:00,67.5,140.766,38.169000000000004,32.468 +2020-12-05 23:30:00,65.09,137.606,38.169000000000004,32.468 +2020-12-05 23:45:00,63.28,134.237,38.169000000000004,32.468 +2020-12-06 00:00:00,59.2,112.60799999999999,35.232,32.468 +2020-12-06 00:15:00,58.21,108.366,35.232,32.468 +2020-12-06 00:30:00,58.38,110.34700000000001,35.232,32.468 +2020-12-06 00:45:00,57.76,113.522,35.232,32.468 +2020-12-06 01:00:00,53.35,115.618,31.403000000000002,32.468 +2020-12-06 01:15:00,54.31,117.323,31.403000000000002,32.468 +2020-12-06 01:30:00,53.96,117.494,31.403000000000002,32.468 +2020-12-06 01:45:00,53.89,117.824,31.403000000000002,32.468 +2020-12-06 02:00:00,52.08,119.219,30.69,32.468 +2020-12-06 02:15:00,53.48,119.389,30.69,32.468 +2020-12-06 02:30:00,52.62,119.875,30.69,32.468 +2020-12-06 02:45:00,52.89,122.36,30.69,32.468 +2020-12-06 03:00:00,50.89,125.052,29.516,32.468 +2020-12-06 03:15:00,52.01,124.8,29.516,32.468 +2020-12-06 03:30:00,49.54,126.455,29.516,32.468 +2020-12-06 03:45:00,51.95,127.82600000000001,29.516,32.468 +2020-12-06 04:00:00,51.32,136.608,29.148000000000003,32.468 +2020-12-06 04:15:00,52.22,144.953,29.148000000000003,32.468 +2020-12-06 04:30:00,53.06,145.747,29.148000000000003,32.468 +2020-12-06 04:45:00,54.12,146.994,29.148000000000003,32.468 +2020-12-06 05:00:00,54.06,161.283,28.706,32.468 +2020-12-06 05:15:00,55.45,170.83599999999998,28.706,32.468 +2020-12-06 05:30:00,55.14,167.392,28.706,32.468 +2020-12-06 05:45:00,55.66,164.93400000000003,28.706,32.468 +2020-12-06 06:00:00,57.08,179.829,28.771,32.468 +2020-12-06 06:15:00,58.52,198.37599999999998,28.771,32.468 +2020-12-06 06:30:00,60.18,192.88299999999998,28.771,32.468 +2020-12-06 06:45:00,62.27,186.947,28.771,32.468 +2020-12-06 07:00:00,64.58,184.169,31.39,32.468 +2020-12-06 07:15:00,66.66,188.005,31.39,32.468 +2020-12-06 07:30:00,70.38,192.06599999999997,31.39,32.468 +2020-12-06 07:45:00,71.0,195.782,31.39,32.468 +2020-12-06 08:00:00,72.79,199.30599999999998,34.972,32.468 +2020-12-06 08:15:00,75.41,202.59,34.972,32.468 +2020-12-06 08:30:00,74.46,205.142,34.972,32.468 +2020-12-06 08:45:00,78.84,206.12,34.972,32.468 +2020-12-06 09:00:00,80.64,201.72299999999998,36.709,32.468 +2020-12-06 09:15:00,82.63,200.10299999999998,36.709,32.468 +2020-12-06 09:30:00,84.01,197.863,36.709,32.468 +2020-12-06 09:45:00,85.27,195.09799999999998,36.709,32.468 +2020-12-06 10:00:00,87.21,192.24400000000003,35.812,32.468 +2020-12-06 10:15:00,88.37,189.549,35.812,32.468 +2020-12-06 10:30:00,89.09,187.975,35.812,32.468 +2020-12-06 10:45:00,91.13,185.59900000000002,35.812,32.468 +2020-12-06 11:00:00,92.13,185.801,36.746,32.468 +2020-12-06 11:15:00,94.24,183.386,36.746,32.468 +2020-12-06 11:30:00,96.69,181.97799999999998,36.746,32.468 +2020-12-06 11:45:00,97.52,180.15400000000002,36.746,32.468 +2020-12-06 12:00:00,95.11,174.195,35.048,32.468 +2020-12-06 12:15:00,93.73,173.592,35.048,32.468 +2020-12-06 12:30:00,90.61,172.55700000000002,35.048,32.468 +2020-12-06 12:45:00,90.11,172.25,35.048,32.468 +2020-12-06 13:00:00,86.89,171.447,29.987,32.468 +2020-12-06 13:15:00,86.08,172.782,29.987,32.468 +2020-12-06 13:30:00,85.02,172.041,29.987,32.468 +2020-12-06 13:45:00,84.86,171.791,29.987,32.468 +2020-12-06 14:00:00,83.89,171.206,27.21,32.468 +2020-12-06 14:15:00,85.55,172.157,27.21,32.468 +2020-12-06 14:30:00,85.84,172.392,27.21,32.468 +2020-12-06 14:45:00,85.27,172.317,27.21,32.468 +2020-12-06 15:00:00,86.37,171.761,27.726999999999997,32.468 +2020-12-06 15:15:00,86.71,173.438,27.726999999999997,32.468 +2020-12-06 15:30:00,87.73,176.092,27.726999999999997,32.468 +2020-12-06 15:45:00,90.08,178.752,27.726999999999997,32.468 +2020-12-06 16:00:00,91.02,179.32,32.23,32.468 +2020-12-06 16:15:00,92.78,180.62599999999998,32.23,32.468 +2020-12-06 16:30:00,94.77,183.63,32.23,32.468 +2020-12-06 16:45:00,95.94,185.667,32.23,32.468 +2020-12-06 17:00:00,99.97,188.047,42.016999999999996,32.468 +2020-12-06 17:15:00,100.43,189.213,42.016999999999996,32.468 +2020-12-06 17:30:00,101.22,189.55,42.016999999999996,32.468 +2020-12-06 17:45:00,103.06,190.769,42.016999999999996,32.468 +2020-12-06 18:00:00,104.02,191.354,49.338,32.468 +2020-12-06 18:15:00,103.43,192.268,49.338,32.468 +2020-12-06 18:30:00,103.55,190.385,49.338,32.468 +2020-12-06 18:45:00,102.55,188.59400000000002,49.338,32.468 +2020-12-06 19:00:00,101.83,191.08900000000003,52.369,32.468 +2020-12-06 19:15:00,100.33,188.959,52.369,32.468 +2020-12-06 19:30:00,99.34,187.078,52.369,32.468 +2020-12-06 19:45:00,96.53,184.515,52.369,32.468 +2020-12-06 20:00:00,95.12,183.24099999999999,50.405,32.468 +2020-12-06 20:15:00,95.13,180.514,50.405,32.468 +2020-12-06 20:30:00,94.12,177.896,50.405,32.468 +2020-12-06 20:45:00,99.95,174.113,50.405,32.468 +2020-12-06 21:00:00,96.19,172.24400000000003,46.235,32.468 +2020-12-06 21:15:00,96.46,170.52599999999998,46.235,32.468 +2020-12-06 21:30:00,87.84,169.972,46.235,32.468 +2020-12-06 21:45:00,89.67,168.50900000000001,46.235,32.468 +2020-12-06 22:00:00,87.62,162.901,46.861000000000004,32.468 +2020-12-06 22:15:00,90.69,158.804,46.861000000000004,32.468 +2020-12-06 22:30:00,89.86,153.694,46.861000000000004,32.468 +2020-12-06 22:45:00,88.9,150.136,46.861000000000004,32.468 +2020-12-06 23:00:00,78.62,143.899,41.302,32.468 +2020-12-06 23:15:00,77.12,140.21,41.302,32.468 +2020-12-06 23:30:00,79.89,137.859,41.302,32.468 +2020-12-06 23:45:00,77.11,135.356,41.302,32.468 +2020-12-07 00:00:00,72.47,117.10600000000001,37.164,32.65 +2020-12-07 00:15:00,69.76,115.786,37.164,32.65 +2020-12-07 00:30:00,69.85,117.88799999999999,37.164,32.65 +2020-12-07 00:45:00,69.38,120.509,37.164,32.65 +2020-12-07 01:00:00,67.99,122.65899999999999,34.994,32.65 +2020-12-07 01:15:00,70.59,123.839,34.994,32.65 +2020-12-07 01:30:00,73.49,124.073,34.994,32.65 +2020-12-07 01:45:00,70.94,124.50200000000001,34.994,32.65 +2020-12-07 02:00:00,66.53,125.896,34.571,32.65 +2020-12-07 02:15:00,67.94,127.52,34.571,32.65 +2020-12-07 02:30:00,66.77,128.33700000000002,34.571,32.65 +2020-12-07 02:45:00,67.8,130.205,34.571,32.65 +2020-12-07 03:00:00,68.56,134.14700000000002,33.934,32.65 +2020-12-07 03:15:00,68.92,135.562,33.934,32.65 +2020-12-07 03:30:00,72.78,136.951,33.934,32.65 +2020-12-07 03:45:00,77.58,137.773,33.934,32.65 +2020-12-07 04:00:00,78.96,150.85,34.107,32.65 +2020-12-07 04:15:00,76.3,163.312,34.107,32.65 +2020-12-07 04:30:00,75.15,166.274,34.107,32.65 +2020-12-07 04:45:00,76.91,167.683,34.107,32.65 +2020-12-07 05:00:00,80.43,197.60299999999998,39.575,32.65 +2020-12-07 05:15:00,83.47,227.14,39.575,32.65 +2020-12-07 05:30:00,88.42,224.0,39.575,32.65 +2020-12-07 05:45:00,93.74,215.947,39.575,32.65 +2020-12-07 06:00:00,104.42,213.045,56.156000000000006,32.65 +2020-12-07 06:15:00,111.84,216.856,56.156000000000006,32.65 +2020-12-07 06:30:00,115.43,219.375,56.156000000000006,32.65 +2020-12-07 06:45:00,121.15,222.22299999999998,56.156000000000006,32.65 +2020-12-07 07:00:00,126.05,221.801,67.926,32.65 +2020-12-07 07:15:00,129.31,226.882,67.926,32.65 +2020-12-07 07:30:00,132.06,230.178,67.926,32.65 +2020-12-07 07:45:00,131.74,231.34599999999998,67.926,32.65 +2020-12-07 08:00:00,134.22,230.016,72.58,32.65 +2020-12-07 08:15:00,134.5,231.165,72.58,32.65 +2020-12-07 08:30:00,134.15,229.669,72.58,32.65 +2020-12-07 08:45:00,132.35,227.305,72.58,32.65 +2020-12-07 09:00:00,135.85,221.885,66.984,32.65 +2020-12-07 09:15:00,133.39,216.815,66.984,32.65 +2020-12-07 09:30:00,137.13,213.58900000000003,66.984,32.65 +2020-12-07 09:45:00,137.46,211.10299999999998,66.984,32.65 +2020-12-07 10:00:00,137.29,207.167,63.158,32.65 +2020-12-07 10:15:00,137.05,204.125,63.158,32.65 +2020-12-07 10:30:00,137.9,201.66,63.158,32.65 +2020-12-07 10:45:00,135.05,200.014,63.158,32.65 +2020-12-07 11:00:00,137.29,197.605,61.141000000000005,32.65 +2020-12-07 11:15:00,137.89,196.96099999999998,61.141000000000005,32.65 +2020-12-07 11:30:00,136.52,196.918,61.141000000000005,32.65 +2020-12-07 11:45:00,136.73,194.68599999999998,61.141000000000005,32.65 +2020-12-07 12:00:00,135.1,190.422,57.961000000000006,32.65 +2020-12-07 12:15:00,132.49,189.833,57.961000000000006,32.65 +2020-12-07 12:30:00,133.75,189.05700000000002,57.961000000000006,32.65 +2020-12-07 12:45:00,132.35,190.261,57.961000000000006,32.65 +2020-12-07 13:00:00,131.69,190.002,56.843,32.65 +2020-12-07 13:15:00,130.03,189.96599999999998,56.843,32.65 +2020-12-07 13:30:00,129.19,188.69799999999998,56.843,32.65 +2020-12-07 13:45:00,129.62,188.476,56.843,32.65 +2020-12-07 14:00:00,131.03,187.24900000000002,55.992,32.65 +2020-12-07 14:15:00,133.01,187.574,55.992,32.65 +2020-12-07 14:30:00,132.23,187.301,55.992,32.65 +2020-12-07 14:45:00,130.84,187.215,55.992,32.65 +2020-12-07 15:00:00,132.97,188.435,57.523,32.65 +2020-12-07 15:15:00,132.34,188.688,57.523,32.65 +2020-12-07 15:30:00,133.31,190.551,57.523,32.65 +2020-12-07 15:45:00,133.8,192.769,57.523,32.65 +2020-12-07 16:00:00,136.26,193.521,59.471000000000004,32.65 +2020-12-07 16:15:00,137.17,194.09599999999998,59.471000000000004,32.65 +2020-12-07 16:30:00,133.45,196.149,59.471000000000004,32.65 +2020-12-07 16:45:00,141.66,197.045,59.471000000000004,32.65 +2020-12-07 17:00:00,155.57,199.225,65.066,32.65 +2020-12-07 17:15:00,156.59,199.476,65.066,32.65 +2020-12-07 17:30:00,158.11,199.28400000000002,65.066,32.65 +2020-12-07 17:45:00,143.66,199.03099999999998,65.066,32.65 +2020-12-07 18:00:00,137.35,200.05900000000003,69.581,32.65 +2020-12-07 18:15:00,140.33,198.747,69.581,32.65 +2020-12-07 18:30:00,138.53,197.518,69.581,32.65 +2020-12-07 18:45:00,138.89,196.44299999999998,69.581,32.65 +2020-12-07 19:00:00,138.22,197.331,73.771,32.65 +2020-12-07 19:15:00,133.86,194.03,73.771,32.65 +2020-12-07 19:30:00,132.32,192.628,73.771,32.65 +2020-12-07 19:45:00,130.15,189.213,73.771,32.65 +2020-12-07 20:00:00,125.89,185.597,65.035,32.65 +2020-12-07 20:15:00,120.27,180.40099999999998,65.035,32.65 +2020-12-07 20:30:00,116.83,175.91099999999997,65.035,32.65 +2020-12-07 20:45:00,115.19,173.76,65.035,32.65 +2020-12-07 21:00:00,113.15,172.405,58.7,32.65 +2020-12-07 21:15:00,109.2,169.50599999999997,58.7,32.65 +2020-12-07 21:30:00,106.77,168.132,58.7,32.65 +2020-12-07 21:45:00,102.99,166.173,58.7,32.65 +2020-12-07 22:00:00,100.77,157.714,53.888000000000005,32.65 +2020-12-07 22:15:00,98.17,152.30700000000002,53.888000000000005,32.65 +2020-12-07 22:30:00,96.13,137.769,53.888000000000005,32.65 +2020-12-07 22:45:00,92.55,129.42,53.888000000000005,32.65 +2020-12-07 23:00:00,91.82,123.929,45.501999999999995,32.65 +2020-12-07 23:15:00,89.24,122.87299999999999,45.501999999999995,32.65 +2020-12-07 23:30:00,86.63,123.27600000000001,45.501999999999995,32.65 +2020-12-07 23:45:00,83.86,123.38,45.501999999999995,32.65 +2020-12-08 00:00:00,81.48,116.75399999999999,43.537,32.65 +2020-12-08 00:15:00,80.94,116.822,43.537,32.65 +2020-12-08 00:30:00,78.77,117.975,43.537,32.65 +2020-12-08 00:45:00,79.27,119.594,43.537,32.65 +2020-12-08 01:00:00,79.97,121.589,41.854,32.65 +2020-12-08 01:15:00,75.56,122.323,41.854,32.65 +2020-12-08 01:30:00,74.99,122.73700000000001,41.854,32.65 +2020-12-08 01:45:00,74.98,123.461,41.854,32.65 +2020-12-08 02:00:00,75.24,124.875,40.321,32.65 +2020-12-08 02:15:00,76.79,126.37799999999999,40.321,32.65 +2020-12-08 02:30:00,77.69,126.604,40.321,32.65 +2020-12-08 02:45:00,75.39,128.481,40.321,32.65 +2020-12-08 03:00:00,75.09,131.209,39.632,32.65 +2020-12-08 03:15:00,77.11,131.793,39.632,32.65 +2020-12-08 03:30:00,77.43,133.668,39.632,32.65 +2020-12-08 03:45:00,78.5,134.694,39.632,32.65 +2020-12-08 04:00:00,80.28,147.555,40.183,32.65 +2020-12-08 04:15:00,79.41,159.66299999999998,40.183,32.65 +2020-12-08 04:30:00,80.97,162.30100000000002,40.183,32.65 +2020-12-08 04:45:00,83.79,164.922,40.183,32.65 +2020-12-08 05:00:00,87.76,199.80200000000002,43.945,32.65 +2020-12-08 05:15:00,91.47,229.07,43.945,32.65 +2020-12-08 05:30:00,96.61,224.416,43.945,32.65 +2020-12-08 05:45:00,100.78,216.40099999999998,43.945,32.65 +2020-12-08 06:00:00,109.95,212.322,56.048,32.65 +2020-12-08 06:15:00,114.88,217.763,56.048,32.65 +2020-12-08 06:30:00,120.13,219.68400000000003,56.048,32.65 +2020-12-08 06:45:00,125.43,222.21400000000003,56.048,32.65 +2020-12-08 07:00:00,131.43,221.608,65.74,32.65 +2020-12-08 07:15:00,135.23,226.53900000000002,65.74,32.65 +2020-12-08 07:30:00,137.34,229.292,65.74,32.65 +2020-12-08 07:45:00,140.61,230.69,65.74,32.65 +2020-12-08 08:00:00,140.68,229.48,72.757,32.65 +2020-12-08 08:15:00,141.83,229.604,72.757,32.65 +2020-12-08 08:30:00,140.68,227.933,72.757,32.65 +2020-12-08 08:45:00,140.89,225.28400000000002,72.757,32.65 +2020-12-08 09:00:00,137.03,218.998,67.692,32.65 +2020-12-08 09:15:00,139.39,215.53799999999998,67.692,32.65 +2020-12-08 09:30:00,139.92,212.989,67.692,32.65 +2020-12-08 09:45:00,139.63,210.25900000000001,67.692,32.65 +2020-12-08 10:00:00,141.01,205.74200000000002,63.506,32.65 +2020-12-08 10:15:00,141.07,201.618,63.506,32.65 +2020-12-08 10:30:00,142.01,199.31,63.506,32.65 +2020-12-08 10:45:00,148.52,197.928,63.506,32.65 +2020-12-08 11:00:00,159.29,196.99099999999999,60.758,32.65 +2020-12-08 11:15:00,158.54,195.997,60.758,32.65 +2020-12-08 11:30:00,159.27,194.8,60.758,32.65 +2020-12-08 11:45:00,146.55,193.266,60.758,32.65 +2020-12-08 12:00:00,143.87,187.637,57.519,32.65 +2020-12-08 12:15:00,142.59,186.643,57.519,32.65 +2020-12-08 12:30:00,142.23,186.595,57.519,32.65 +2020-12-08 12:45:00,140.71,187.507,57.519,32.65 +2020-12-08 13:00:00,136.17,186.812,56.46,32.65 +2020-12-08 13:15:00,135.08,186.417,56.46,32.65 +2020-12-08 13:30:00,133.39,186.313,56.46,32.65 +2020-12-08 13:45:00,132.97,186.27599999999998,56.46,32.65 +2020-12-08 14:00:00,133.26,185.308,56.207,32.65 +2020-12-08 14:15:00,134.5,185.797,56.207,32.65 +2020-12-08 14:30:00,134.65,186.173,56.207,32.65 +2020-12-08 14:45:00,134.64,186.024,56.207,32.65 +2020-12-08 15:00:00,135.74,186.833,57.391999999999996,32.65 +2020-12-08 15:15:00,137.12,187.408,57.391999999999996,32.65 +2020-12-08 15:30:00,138.16,189.517,57.391999999999996,32.65 +2020-12-08 15:45:00,139.22,191.333,57.391999999999996,32.65 +2020-12-08 16:00:00,144.76,192.426,59.955,32.65 +2020-12-08 16:15:00,147.35,193.497,59.955,32.65 +2020-12-08 16:30:00,147.5,196.205,59.955,32.65 +2020-12-08 16:45:00,149.58,197.412,59.955,32.65 +2020-12-08 17:00:00,161.96,200.109,67.063,32.65 +2020-12-08 17:15:00,162.06,200.412,67.063,32.65 +2020-12-08 17:30:00,163.06,200.90400000000002,67.063,32.65 +2020-12-08 17:45:00,150.37,200.532,67.063,32.65 +2020-12-08 18:00:00,142.45,201.481,71.477,32.65 +2020-12-08 18:15:00,144.58,199.622,71.477,32.65 +2020-12-08 18:30:00,143.42,198.09599999999998,71.477,32.65 +2020-12-08 18:45:00,143.59,197.825,71.477,32.65 +2020-12-08 19:00:00,141.4,198.78900000000002,74.32,32.65 +2020-12-08 19:15:00,138.68,195.19799999999998,74.32,32.65 +2020-12-08 19:30:00,136.14,193.11599999999999,74.32,32.65 +2020-12-08 19:45:00,135.11,189.708,74.32,32.65 +2020-12-08 20:00:00,130.38,186.248,66.157,32.65 +2020-12-08 20:15:00,125.0,180.418,66.157,32.65 +2020-12-08 20:30:00,121.61,176.912,66.157,32.65 +2020-12-08 20:45:00,119.36,174.207,66.157,32.65 +2020-12-08 21:00:00,116.99,172.13299999999998,59.806000000000004,32.65 +2020-12-08 21:15:00,116.17,170.135,59.806000000000004,32.65 +2020-12-08 21:30:00,118.31,168.03,59.806000000000004,32.65 +2020-12-08 21:45:00,115.49,166.30599999999998,59.806000000000004,32.65 +2020-12-08 22:00:00,106.63,159.56,54.785,32.65 +2020-12-08 22:15:00,103.62,153.899,54.785,32.65 +2020-12-08 22:30:00,104.21,139.499,54.785,32.65 +2020-12-08 22:45:00,103.55,131.433,54.785,32.65 +2020-12-08 23:00:00,100.51,125.949,47.176,32.65 +2020-12-08 23:15:00,93.05,123.975,47.176,32.65 +2020-12-08 23:30:00,89.98,124.031,47.176,32.65 +2020-12-08 23:45:00,88.91,123.681,47.176,32.65 +2020-12-09 00:00:00,91.27,117.072,43.42,32.65 +2020-12-09 00:15:00,91.07,117.11,43.42,32.65 +2020-12-09 00:30:00,87.44,118.256,43.42,32.65 +2020-12-09 00:45:00,84.26,119.85700000000001,43.42,32.65 +2020-12-09 01:00:00,86.4,121.887,40.869,32.65 +2020-12-09 01:15:00,87.29,122.62,40.869,32.65 +2020-12-09 01:30:00,87.03,123.04299999999999,40.869,32.65 +2020-12-09 01:45:00,82.74,123.751,40.869,32.65 +2020-12-09 02:00:00,80.31,125.185,39.541,32.65 +2020-12-09 02:15:00,81.19,126.69,39.541,32.65 +2020-12-09 02:30:00,86.0,126.91799999999999,39.541,32.65 +2020-12-09 02:45:00,86.44,128.79399999999998,39.541,32.65 +2020-12-09 03:00:00,85.55,131.509,39.052,32.65 +2020-12-09 03:15:00,80.17,132.123,39.052,32.65 +2020-12-09 03:30:00,84.96,134.0,39.052,32.65 +2020-12-09 03:45:00,88.74,135.02700000000002,39.052,32.65 +2020-12-09 04:00:00,90.1,147.856,40.36,32.65 +2020-12-09 04:15:00,86.33,159.96,40.36,32.65 +2020-12-09 04:30:00,85.2,162.585,40.36,32.65 +2020-12-09 04:45:00,86.88,165.207,40.36,32.65 +2020-12-09 05:00:00,93.26,200.05,43.133,32.65 +2020-12-09 05:15:00,99.1,229.263,43.133,32.65 +2020-12-09 05:30:00,104.98,224.636,43.133,32.65 +2020-12-09 05:45:00,108.07,216.644,43.133,32.65 +2020-12-09 06:00:00,110.28,212.59,54.953,32.65 +2020-12-09 06:15:00,116.51,218.035,54.953,32.65 +2020-12-09 06:30:00,119.87,220.00099999999998,54.953,32.65 +2020-12-09 06:45:00,127.56,222.575,54.953,32.65 +2020-12-09 07:00:00,130.61,221.97299999999998,66.566,32.65 +2020-12-09 07:15:00,131.47,226.91099999999997,66.566,32.65 +2020-12-09 07:30:00,133.34,229.667,66.566,32.65 +2020-12-09 07:45:00,136.34,231.06599999999997,66.566,32.65 +2020-12-09 08:00:00,138.72,229.86599999999999,72.902,32.65 +2020-12-09 08:15:00,140.05,229.984,72.902,32.65 +2020-12-09 08:30:00,140.81,228.317,72.902,32.65 +2020-12-09 08:45:00,140.14,225.637,72.902,32.65 +2020-12-09 09:00:00,141.66,219.325,68.465,32.65 +2020-12-09 09:15:00,142.55,215.872,68.465,32.65 +2020-12-09 09:30:00,144.01,213.329,68.465,32.65 +2020-12-09 09:45:00,148.03,210.588,68.465,32.65 +2020-12-09 10:00:00,148.43,206.063,63.625,32.65 +2020-12-09 10:15:00,148.69,201.92,63.625,32.65 +2020-12-09 10:30:00,147.3,199.59,63.625,32.65 +2020-12-09 10:45:00,150.11,198.201,63.625,32.65 +2020-12-09 11:00:00,165.23,197.245,61.628,32.65 +2020-12-09 11:15:00,167.64,196.24,61.628,32.65 +2020-12-09 11:30:00,171.35,195.041,61.628,32.65 +2020-12-09 11:45:00,158.93,193.50099999999998,61.628,32.65 +2020-12-09 12:00:00,150.69,187.872,58.708999999999996,32.65 +2020-12-09 12:15:00,147.05,186.891,58.708999999999996,32.65 +2020-12-09 12:30:00,147.05,186.858,58.708999999999996,32.65 +2020-12-09 12:45:00,144.87,187.774,58.708999999999996,32.65 +2020-12-09 13:00:00,143.99,187.047,57.373000000000005,32.65 +2020-12-09 13:15:00,143.5,186.655,57.373000000000005,32.65 +2020-12-09 13:30:00,140.33,186.545,57.373000000000005,32.65 +2020-12-09 13:45:00,139.48,186.498,57.373000000000005,32.65 +2020-12-09 14:00:00,139.53,185.511,57.684,32.65 +2020-12-09 14:15:00,138.98,186.005,57.684,32.65 +2020-12-09 14:30:00,138.49,186.405,57.684,32.65 +2020-12-09 14:45:00,137.14,186.267,57.684,32.65 +2020-12-09 15:00:00,135.82,187.095,58.03,32.65 +2020-12-09 15:15:00,135.39,187.666,58.03,32.65 +2020-12-09 15:30:00,138.38,189.798,58.03,32.65 +2020-12-09 15:45:00,138.54,191.613,58.03,32.65 +2020-12-09 16:00:00,139.67,192.706,59.97,32.65 +2020-12-09 16:15:00,145.31,193.799,59.97,32.65 +2020-12-09 16:30:00,147.26,196.513,59.97,32.65 +2020-12-09 16:45:00,151.24,197.75099999999998,59.97,32.65 +2020-12-09 17:00:00,169.05,200.424,65.661,32.65 +2020-12-09 17:15:00,169.17,200.75400000000002,65.661,32.65 +2020-12-09 17:30:00,170.79,201.261,65.661,32.65 +2020-12-09 17:45:00,149.97,200.899,65.661,32.65 +2020-12-09 18:00:00,141.53,201.86599999999999,70.96300000000001,32.65 +2020-12-09 18:15:00,143.4,199.97400000000002,70.96300000000001,32.65 +2020-12-09 18:30:00,142.84,198.456,70.96300000000001,32.65 +2020-12-09 18:45:00,143.61,198.19400000000002,70.96300000000001,32.65 +2020-12-09 19:00:00,142.88,199.144,74.133,32.65 +2020-12-09 19:15:00,140.01,195.545,74.133,32.65 +2020-12-09 19:30:00,138.02,193.44799999999998,74.133,32.65 +2020-12-09 19:45:00,135.99,190.014,74.133,32.65 +2020-12-09 20:00:00,129.82,186.553,65.613,32.65 +2020-12-09 20:15:00,124.78,180.71400000000003,65.613,32.65 +2020-12-09 20:30:00,122.6,177.18400000000003,65.613,32.65 +2020-12-09 20:45:00,120.04,174.5,65.613,32.65 +2020-12-09 21:00:00,116.74,172.41,58.583,32.65 +2020-12-09 21:15:00,113.73,170.396,58.583,32.65 +2020-12-09 21:30:00,111.56,168.293,58.583,32.65 +2020-12-09 21:45:00,112.32,166.574,58.583,32.65 +2020-12-09 22:00:00,111.72,159.83700000000002,54.411,32.65 +2020-12-09 22:15:00,110.88,154.179,54.411,32.65 +2020-12-09 22:30:00,104.66,139.827,54.411,32.65 +2020-12-09 22:45:00,99.54,131.77,54.411,32.65 +2020-12-09 23:00:00,93.47,126.26100000000001,47.878,32.65 +2020-12-09 23:15:00,92.23,124.285,47.878,32.65 +2020-12-09 23:30:00,90.56,124.353,47.878,32.65 +2020-12-09 23:45:00,89.81,123.98700000000001,47.878,32.65 +2020-12-10 00:00:00,92.49,117.383,44.513000000000005,32.65 +2020-12-10 00:15:00,87.7,117.391,44.513000000000005,32.65 +2020-12-10 00:30:00,90.23,118.53200000000001,44.513000000000005,32.65 +2020-12-10 00:45:00,85.04,120.113,44.513000000000005,32.65 +2020-12-10 01:00:00,82.13,122.18,43.169,32.65 +2020-12-10 01:15:00,80.96,122.911,43.169,32.65 +2020-12-10 01:30:00,85.8,123.34,43.169,32.65 +2020-12-10 01:45:00,87.1,124.03299999999999,43.169,32.65 +2020-12-10 02:00:00,87.01,125.48899999999999,41.763999999999996,32.65 +2020-12-10 02:15:00,82.06,126.995,41.763999999999996,32.65 +2020-12-10 02:30:00,85.45,127.22399999999999,41.763999999999996,32.65 +2020-12-10 02:45:00,87.43,129.1,41.763999999999996,32.65 +2020-12-10 03:00:00,87.6,131.804,41.155,32.65 +2020-12-10 03:15:00,83.72,132.446,41.155,32.65 +2020-12-10 03:30:00,87.63,134.326,41.155,32.65 +2020-12-10 03:45:00,90.33,135.352,41.155,32.65 +2020-12-10 04:00:00,91.8,148.15,41.96,32.65 +2020-12-10 04:15:00,88.57,160.25,41.96,32.65 +2020-12-10 04:30:00,87.86,162.861,41.96,32.65 +2020-12-10 04:45:00,87.24,165.486,41.96,32.65 +2020-12-10 05:00:00,94.72,200.29,45.206,32.65 +2020-12-10 05:15:00,98.25,229.452,45.206,32.65 +2020-12-10 05:30:00,98.01,224.84900000000002,45.206,32.65 +2020-12-10 05:45:00,102.63,216.87900000000002,45.206,32.65 +2020-12-10 06:00:00,109.41,212.851,55.398999999999994,32.65 +2020-12-10 06:15:00,116.05,218.298,55.398999999999994,32.65 +2020-12-10 06:30:00,120.99,220.30900000000003,55.398999999999994,32.65 +2020-12-10 06:45:00,125.66,222.928,55.398999999999994,32.65 +2020-12-10 07:00:00,129.75,222.33,64.627,32.65 +2020-12-10 07:15:00,134.39,227.273,64.627,32.65 +2020-12-10 07:30:00,136.07,230.03400000000002,64.627,32.65 +2020-12-10 07:45:00,137.41,231.43200000000002,64.627,32.65 +2020-12-10 08:00:00,138.31,230.24200000000002,70.895,32.65 +2020-12-10 08:15:00,138.27,230.352,70.895,32.65 +2020-12-10 08:30:00,139.4,228.68900000000002,70.895,32.65 +2020-12-10 08:45:00,139.75,225.979,70.895,32.65 +2020-12-10 09:00:00,140.06,219.641,66.382,32.65 +2020-12-10 09:15:00,143.71,216.195,66.382,32.65 +2020-12-10 09:30:00,145.38,213.66,66.382,32.65 +2020-12-10 09:45:00,143.55,210.907,66.382,32.65 +2020-12-10 10:00:00,144.96,206.37400000000002,62.739,32.65 +2020-12-10 10:15:00,143.67,202.21099999999998,62.739,32.65 +2020-12-10 10:30:00,142.44,199.86,62.739,32.65 +2020-12-10 10:45:00,142.02,198.46400000000003,62.739,32.65 +2020-12-10 11:00:00,140.23,197.49099999999999,60.843,32.65 +2020-12-10 11:15:00,141.63,196.47299999999998,60.843,32.65 +2020-12-10 11:30:00,140.83,195.273,60.843,32.65 +2020-12-10 11:45:00,140.68,193.729,60.843,32.65 +2020-12-10 12:00:00,140.94,188.09900000000002,58.466,32.65 +2020-12-10 12:15:00,140.46,187.13,58.466,32.65 +2020-12-10 12:30:00,138.97,187.112,58.466,32.65 +2020-12-10 12:45:00,136.01,188.033,58.466,32.65 +2020-12-10 13:00:00,137.69,187.275,56.883,32.65 +2020-12-10 13:15:00,137.48,186.88400000000001,56.883,32.65 +2020-12-10 13:30:00,132.54,186.77,56.883,32.65 +2020-12-10 13:45:00,133.09,186.713,56.883,32.65 +2020-12-10 14:00:00,135.21,185.706,56.503,32.65 +2020-12-10 14:15:00,136.53,186.206,56.503,32.65 +2020-12-10 14:30:00,137.12,186.63,56.503,32.65 +2020-12-10 14:45:00,137.05,186.502,56.503,32.65 +2020-12-10 15:00:00,137.96,187.34900000000002,57.803999999999995,32.65 +2020-12-10 15:15:00,139.18,187.918,57.803999999999995,32.65 +2020-12-10 15:30:00,136.67,190.06900000000002,57.803999999999995,32.65 +2020-12-10 15:45:00,137.64,191.88400000000001,57.803999999999995,32.65 +2020-12-10 16:00:00,138.45,192.976,59.379,32.65 +2020-12-10 16:15:00,142.96,194.09,59.379,32.65 +2020-12-10 16:30:00,146.32,196.812,59.379,32.65 +2020-12-10 16:45:00,153.38,198.078,59.379,32.65 +2020-12-10 17:00:00,168.95,200.729,64.71600000000001,32.65 +2020-12-10 17:15:00,169.75,201.08599999999998,64.71600000000001,32.65 +2020-12-10 17:30:00,169.32,201.61,64.71600000000001,32.65 +2020-12-10 17:45:00,147.93,201.25599999999997,64.71600000000001,32.65 +2020-12-10 18:00:00,141.13,202.24400000000003,68.803,32.65 +2020-12-10 18:15:00,143.26,200.32,68.803,32.65 +2020-12-10 18:30:00,143.39,198.808,68.803,32.65 +2020-12-10 18:45:00,144.16,198.555,68.803,32.65 +2020-12-10 19:00:00,142.57,199.49099999999999,72.934,32.65 +2020-12-10 19:15:00,140.71,195.882,72.934,32.65 +2020-12-10 19:30:00,137.64,193.773,72.934,32.65 +2020-12-10 19:45:00,135.04,190.31400000000002,72.934,32.65 +2020-12-10 20:00:00,132.42,186.852,65.175,32.65 +2020-12-10 20:15:00,126.79,181.005,65.175,32.65 +2020-12-10 20:30:00,122.81,177.45,65.175,32.65 +2020-12-10 20:45:00,120.88,174.785,65.175,32.65 +2020-12-10 21:00:00,117.89,172.68099999999998,58.55,32.65 +2020-12-10 21:15:00,115.42,170.65,58.55,32.65 +2020-12-10 21:30:00,118.82,168.548,58.55,32.65 +2020-12-10 21:45:00,116.05,166.838,58.55,32.65 +2020-12-10 22:00:00,113.48,160.107,55.041000000000004,32.65 +2020-12-10 22:15:00,110.17,154.452,55.041000000000004,32.65 +2020-12-10 22:30:00,99.6,140.149,55.041000000000004,32.65 +2020-12-10 22:45:00,100.62,132.09799999999998,55.041000000000004,32.65 +2020-12-10 23:00:00,94.51,126.565,48.258,32.65 +2020-12-10 23:15:00,91.88,124.586,48.258,32.65 +2020-12-10 23:30:00,89.58,124.66799999999999,48.258,32.65 +2020-12-10 23:45:00,91.27,124.288,48.258,32.65 +2020-12-11 00:00:00,91.85,116.62899999999999,45.02,32.65 +2020-12-11 00:15:00,90.83,116.805,45.02,32.65 +2020-12-11 00:30:00,85.32,117.803,45.02,32.65 +2020-12-11 00:45:00,82.24,119.473,45.02,32.65 +2020-12-11 01:00:00,79.17,121.26299999999999,42.695,32.65 +2020-12-11 01:15:00,81.0,122.91799999999999,42.695,32.65 +2020-12-11 01:30:00,77.89,123.124,42.695,32.65 +2020-12-11 01:45:00,78.18,123.911,42.695,32.65 +2020-12-11 02:00:00,81.93,125.464,41.511,32.65 +2020-12-11 02:15:00,86.0,126.85700000000001,41.511,32.65 +2020-12-11 02:30:00,86.34,127.60600000000001,41.511,32.65 +2020-12-11 02:45:00,81.69,129.531,41.511,32.65 +2020-12-11 03:00:00,81.72,131.216,41.162,32.65 +2020-12-11 03:15:00,86.02,132.842,41.162,32.65 +2020-12-11 03:30:00,88.54,134.709,41.162,32.65 +2020-12-11 03:45:00,89.73,136.055,41.162,32.65 +2020-12-11 04:00:00,84.52,149.05200000000002,42.226000000000006,32.65 +2020-12-11 04:15:00,82.83,160.917,42.226000000000006,32.65 +2020-12-11 04:30:00,84.73,163.737,42.226000000000006,32.65 +2020-12-11 04:45:00,86.99,165.222,42.226000000000006,32.65 +2020-12-11 05:00:00,89.74,198.715,45.597,32.65 +2020-12-11 05:15:00,93.03,229.33,45.597,32.65 +2020-12-11 05:30:00,96.79,225.83599999999998,45.597,32.65 +2020-12-11 05:45:00,101.33,217.828,45.597,32.65 +2020-12-11 06:00:00,110.09,214.25900000000001,56.263999999999996,32.65 +2020-12-11 06:15:00,118.24,218.227,56.263999999999996,32.65 +2020-12-11 06:30:00,122.75,219.41400000000002,56.263999999999996,32.65 +2020-12-11 06:45:00,127.63,223.713,56.263999999999996,32.65 +2020-12-11 07:00:00,132.56,222.27200000000002,66.888,32.65 +2020-12-11 07:15:00,135.59,228.239,66.888,32.65 +2020-12-11 07:30:00,138.93,230.83900000000003,66.888,32.65 +2020-12-11 07:45:00,140.11,231.35,66.888,32.65 +2020-12-11 08:00:00,140.74,229.05599999999998,73.459,32.65 +2020-12-11 08:15:00,138.35,228.766,73.459,32.65 +2020-12-11 08:30:00,138.94,228.084,73.459,32.65 +2020-12-11 08:45:00,138.68,223.765,73.459,32.65 +2020-12-11 09:00:00,138.76,217.87099999999998,69.087,32.65 +2020-12-11 09:15:00,142.43,214.997,69.087,32.65 +2020-12-11 09:30:00,145.27,212.041,69.087,32.65 +2020-12-11 09:45:00,145.82,209.167,69.087,32.65 +2020-12-11 10:00:00,146.67,203.49599999999998,65.404,32.65 +2020-12-11 10:15:00,146.04,200.011,65.404,32.65 +2020-12-11 10:30:00,146.52,197.567,65.404,32.65 +2020-12-11 10:45:00,146.39,195.722,65.404,32.65 +2020-12-11 11:00:00,144.72,194.715,63.0,32.65 +2020-12-11 11:15:00,144.28,192.791,63.0,32.65 +2020-12-11 11:30:00,145.11,193.333,63.0,32.65 +2020-12-11 11:45:00,143.67,191.829,63.0,32.65 +2020-12-11 12:00:00,143.59,187.28,59.083,32.65 +2020-12-11 12:15:00,143.45,184.239,59.083,32.65 +2020-12-11 12:30:00,142.82,184.391,59.083,32.65 +2020-12-11 12:45:00,140.7,185.827,59.083,32.65 +2020-12-11 13:00:00,139.92,185.97299999999998,56.611999999999995,32.65 +2020-12-11 13:15:00,139.16,186.396,56.611999999999995,32.65 +2020-12-11 13:30:00,136.21,186.297,56.611999999999995,32.65 +2020-12-11 13:45:00,136.82,186.169,56.611999999999995,32.65 +2020-12-11 14:00:00,134.19,184.005,55.161,32.65 +2020-12-11 14:15:00,134.68,184.335,55.161,32.65 +2020-12-11 14:30:00,134.81,185.299,55.161,32.65 +2020-12-11 14:45:00,133.75,185.48,55.161,32.65 +2020-12-11 15:00:00,133.3,185.865,55.583,32.65 +2020-12-11 15:15:00,132.89,185.993,55.583,32.65 +2020-12-11 15:30:00,133.49,186.65400000000002,55.583,32.65 +2020-12-11 15:45:00,133.98,188.61599999999999,55.583,32.65 +2020-12-11 16:00:00,135.78,188.544,57.611999999999995,32.65 +2020-12-11 16:15:00,139.22,189.968,57.611999999999995,32.65 +2020-12-11 16:30:00,141.38,192.791,57.611999999999995,32.65 +2020-12-11 16:45:00,141.75,193.968,57.611999999999995,32.65 +2020-12-11 17:00:00,142.8,196.8,64.14,32.65 +2020-12-11 17:15:00,142.37,196.774,64.14,32.65 +2020-12-11 17:30:00,142.8,196.99900000000002,64.14,32.65 +2020-12-11 17:45:00,141.37,196.421,64.14,32.65 +2020-12-11 18:00:00,139.86,198.11599999999999,68.086,32.65 +2020-12-11 18:15:00,137.98,195.77200000000002,68.086,32.65 +2020-12-11 18:30:00,137.12,194.65900000000002,68.086,32.65 +2020-12-11 18:45:00,137.09,194.41099999999997,68.086,32.65 +2020-12-11 19:00:00,135.72,196.236,69.915,32.65 +2020-12-11 19:15:00,133.39,193.97799999999998,69.915,32.65 +2020-12-11 19:30:00,131.85,191.445,69.915,32.65 +2020-12-11 19:45:00,128.74,187.503,69.915,32.65 +2020-12-11 20:00:00,123.92,184.093,61.695,32.65 +2020-12-11 20:15:00,119.13,178.245,61.695,32.65 +2020-12-11 20:30:00,116.21,174.625,61.695,32.65 +2020-12-11 20:45:00,114.15,172.53400000000002,61.695,32.65 +2020-12-11 21:00:00,114.45,170.922,56.041000000000004,32.65 +2020-12-11 21:15:00,112.95,169.31,56.041000000000004,32.65 +2020-12-11 21:30:00,111.79,167.25400000000002,56.041000000000004,32.65 +2020-12-11 21:45:00,111.52,166.093,56.041000000000004,32.65 +2020-12-11 22:00:00,100.51,160.34799999999998,51.888999999999996,32.65 +2020-12-11 22:15:00,97.98,154.564,51.888999999999996,32.65 +2020-12-11 22:30:00,92.39,146.664,51.888999999999996,32.65 +2020-12-11 22:45:00,92.97,142.139,51.888999999999996,32.65 +2020-12-11 23:00:00,94.85,136.137,45.787,32.65 +2020-12-11 23:15:00,93.72,132.213,45.787,32.65 +2020-12-11 23:30:00,88.87,130.852,45.787,32.65 +2020-12-11 23:45:00,84.74,129.795,45.787,32.65 +2020-12-12 00:00:00,84.97,114.07600000000001,41.815,32.468 +2020-12-12 00:15:00,83.87,109.97399999999999,41.815,32.468 +2020-12-12 00:30:00,79.35,112.294,41.815,32.468 +2020-12-12 00:45:00,76.4,114.661,41.815,32.468 +2020-12-12 01:00:00,78.93,117.113,38.645,32.468 +2020-12-12 01:15:00,79.22,117.779,38.645,32.468 +2020-12-12 01:30:00,74.91,117.477,38.645,32.468 +2020-12-12 01:45:00,70.19,118.03,38.645,32.468 +2020-12-12 02:00:00,69.07,120.29,36.696,32.468 +2020-12-12 02:15:00,68.62,121.31700000000001,36.696,32.468 +2020-12-12 02:30:00,76.58,120.963,36.696,32.468 +2020-12-12 02:45:00,71.16,122.986,36.696,32.468 +2020-12-12 03:00:00,72.83,125.289,35.42,32.468 +2020-12-12 03:15:00,75.73,125.73200000000001,35.42,32.468 +2020-12-12 03:30:00,75.68,126.01299999999999,35.42,32.468 +2020-12-12 03:45:00,71.18,127.463,35.42,32.468 +2020-12-12 04:00:00,69.45,136.298,35.167,32.468 +2020-12-12 04:15:00,68.25,145.624,35.167,32.468 +2020-12-12 04:30:00,69.44,146.27200000000002,35.167,32.468 +2020-12-12 04:45:00,69.89,147.263,35.167,32.468 +2020-12-12 05:00:00,71.14,164.803,35.311,32.468 +2020-12-12 05:15:00,71.23,176.438,35.311,32.468 +2020-12-12 05:30:00,71.29,173.31400000000002,35.311,32.468 +2020-12-12 05:45:00,72.91,170.747,35.311,32.468 +2020-12-12 06:00:00,74.72,185.908,37.117,32.468 +2020-12-12 06:15:00,76.39,206.178,37.117,32.468 +2020-12-12 06:30:00,78.26,202.05200000000002,37.117,32.468 +2020-12-12 06:45:00,80.93,197.43599999999998,37.117,32.468 +2020-12-12 07:00:00,84.33,192.245,40.948,32.468 +2020-12-12 07:15:00,86.73,196.97,40.948,32.468 +2020-12-12 07:30:00,90.02,202.268,40.948,32.468 +2020-12-12 07:45:00,93.49,206.74400000000003,40.948,32.468 +2020-12-12 08:00:00,95.85,208.5,44.903,32.468 +2020-12-12 08:15:00,97.55,211.82299999999998,44.903,32.468 +2020-12-12 08:30:00,99.61,212.753,44.903,32.468 +2020-12-12 08:45:00,103.07,211.549,44.903,32.468 +2020-12-12 09:00:00,104.78,207.393,46.283,32.468 +2020-12-12 09:15:00,105.94,205.275,46.283,32.468 +2020-12-12 09:30:00,106.91,203.227,46.283,32.468 +2020-12-12 09:45:00,107.66,200.5,46.283,32.468 +2020-12-12 10:00:00,108.32,195.109,44.103,32.468 +2020-12-12 10:15:00,109.17,191.769,44.103,32.468 +2020-12-12 10:30:00,109.72,189.468,44.103,32.468 +2020-12-12 10:45:00,110.26,188.91,44.103,32.468 +2020-12-12 11:00:00,111.78,188.105,42.373999999999995,32.468 +2020-12-12 11:15:00,113.31,185.49,42.373999999999995,32.468 +2020-12-12 11:30:00,113.65,184.924,42.373999999999995,32.468 +2020-12-12 11:45:00,113.32,182.46900000000002,42.373999999999995,32.468 +2020-12-12 12:00:00,111.9,177.05200000000002,39.937,32.468 +2020-12-12 12:15:00,111.01,174.662,39.937,32.468 +2020-12-12 12:30:00,108.83,175.142,39.937,32.468 +2020-12-12 12:45:00,106.63,175.82299999999998,39.937,32.468 +2020-12-12 13:00:00,103.27,175.53900000000002,37.138000000000005,32.468 +2020-12-12 13:15:00,101.79,173.928,37.138000000000005,32.468 +2020-12-12 13:30:00,101.3,173.377,37.138000000000005,32.468 +2020-12-12 13:45:00,101.1,173.704,37.138000000000005,32.468 +2020-12-12 14:00:00,100.88,172.74400000000003,36.141999999999996,32.468 +2020-12-12 14:15:00,100.41,172.542,36.141999999999996,32.468 +2020-12-12 14:30:00,100.19,171.72799999999998,36.141999999999996,32.468 +2020-12-12 14:45:00,99.5,172.138,36.141999999999996,32.468 +2020-12-12 15:00:00,98.71,173.196,37.964,32.468 +2020-12-12 15:15:00,98.98,174.12,37.964,32.468 +2020-12-12 15:30:00,99.94,176.325,37.964,32.468 +2020-12-12 15:45:00,100.8,178.297,37.964,32.468 +2020-12-12 16:00:00,104.51,177.041,40.699,32.468 +2020-12-12 16:15:00,107.74,179.368,40.699,32.468 +2020-12-12 16:30:00,108.74,182.148,40.699,32.468 +2020-12-12 16:45:00,109.29,184.231,40.699,32.468 +2020-12-12 17:00:00,110.17,186.467,46.216,32.468 +2020-12-12 17:15:00,110.88,188.104,46.216,32.468 +2020-12-12 17:30:00,111.64,188.24900000000002,46.216,32.468 +2020-12-12 17:45:00,112.51,187.28099999999998,46.216,32.468 +2020-12-12 18:00:00,112.37,188.514,51.123999999999995,32.468 +2020-12-12 18:15:00,112.02,187.929,51.123999999999995,32.468 +2020-12-12 18:30:00,111.33,188.148,51.123999999999995,32.468 +2020-12-12 18:45:00,111.02,184.58,51.123999999999995,32.468 +2020-12-12 19:00:00,110.03,187.303,52.336000000000006,32.468 +2020-12-12 19:15:00,108.32,184.541,52.336000000000006,32.468 +2020-12-12 19:30:00,107.24,182.74400000000003,52.336000000000006,32.468 +2020-12-12 19:45:00,106.61,178.607,52.336000000000006,32.468 +2020-12-12 20:00:00,102.62,177.35299999999998,48.825,32.468 +2020-12-12 20:15:00,97.95,173.604,48.825,32.468 +2020-12-12 20:30:00,96.03,169.61,48.825,32.468 +2020-12-12 20:45:00,94.62,167.145,48.825,32.468 +2020-12-12 21:00:00,92.77,167.74599999999998,43.729,32.468 +2020-12-12 21:15:00,90.0,166.553,43.729,32.468 +2020-12-12 21:30:00,87.78,165.718,43.729,32.468 +2020-12-12 21:45:00,87.21,164.16,43.729,32.468 +2020-12-12 22:00:00,86.33,159.75799999999998,44.126000000000005,32.468 +2020-12-12 22:15:00,84.41,156.444,44.126000000000005,32.468 +2020-12-12 22:30:00,82.37,154.756,44.126000000000005,32.468 +2020-12-12 22:45:00,80.33,152.092,44.126000000000005,32.468 +2020-12-12 23:00:00,76.41,148.47299999999998,38.169000000000004,32.468 +2020-12-12 23:15:00,74.99,142.928,38.169000000000004,32.468 +2020-12-12 23:30:00,72.58,139.857,38.169000000000004,32.468 +2020-12-12 23:45:00,70.05,136.382,38.169000000000004,32.468 +2020-12-13 00:00:00,67.83,114.786,35.232,32.468 +2020-12-13 00:15:00,65.77,110.337,35.232,32.468 +2020-12-13 00:30:00,64.76,112.277,35.232,32.468 +2020-12-13 00:45:00,63.83,115.316,35.232,32.468 +2020-12-13 01:00:00,62.51,117.65799999999999,31.403000000000002,32.468 +2020-12-13 01:15:00,61.48,119.354,31.403000000000002,32.468 +2020-12-13 01:30:00,61.2,119.575,31.403000000000002,32.468 +2020-12-13 01:45:00,60.78,119.804,31.403000000000002,32.468 +2020-12-13 02:00:00,59.95,121.339,30.69,32.468 +2020-12-13 02:15:00,59.57,121.521,30.69,32.468 +2020-12-13 02:30:00,59.18,122.01899999999999,30.69,32.468 +2020-12-13 02:45:00,59.27,124.5,30.69,32.468 +2020-12-13 03:00:00,59.3,127.10700000000001,29.516,32.468 +2020-12-13 03:15:00,59.76,127.05799999999999,29.516,32.468 +2020-12-13 03:30:00,60.59,128.73,29.516,32.468 +2020-12-13 03:45:00,60.29,130.10399999999998,29.516,32.468 +2020-12-13 04:00:00,59.87,138.665,29.148000000000003,32.468 +2020-12-13 04:15:00,59.82,146.981,29.148000000000003,32.468 +2020-12-13 04:30:00,60.61,147.683,29.148000000000003,32.468 +2020-12-13 04:45:00,60.83,148.946,29.148000000000003,32.468 +2020-12-13 05:00:00,61.5,162.963,28.706,32.468 +2020-12-13 05:15:00,62.25,172.148,28.706,32.468 +2020-12-13 05:30:00,63.05,168.88299999999998,28.706,32.468 +2020-12-13 05:45:00,63.76,166.579,28.706,32.468 +2020-12-13 06:00:00,64.65,181.65599999999998,28.771,32.468 +2020-12-13 06:15:00,65.56,200.222,28.771,32.468 +2020-12-13 06:30:00,65.84,195.041,28.771,32.468 +2020-12-13 06:45:00,67.8,189.417,28.771,32.468 +2020-12-13 07:00:00,69.8,186.665,31.39,32.468 +2020-12-13 07:15:00,72.1,190.542,31.39,32.468 +2020-12-13 07:30:00,74.71,194.625,31.39,32.468 +2020-12-13 07:45:00,76.75,198.34400000000002,31.39,32.468 +2020-12-13 08:00:00,78.86,201.938,34.972,32.468 +2020-12-13 08:15:00,81.37,205.171,34.972,32.468 +2020-12-13 08:30:00,82.52,207.74599999999998,34.972,32.468 +2020-12-13 08:45:00,84.69,208.512,34.972,32.468 +2020-12-13 09:00:00,85.83,203.933,36.709,32.468 +2020-12-13 09:15:00,87.39,202.365,36.709,32.468 +2020-12-13 09:30:00,88.29,200.173,36.709,32.468 +2020-12-13 09:45:00,89.37,197.327,36.709,32.468 +2020-12-13 10:00:00,91.42,194.422,35.812,32.468 +2020-12-13 10:15:00,92.02,191.58900000000003,35.812,32.468 +2020-12-13 10:30:00,93.21,189.86599999999999,35.812,32.468 +2020-12-13 10:45:00,95.56,187.44,35.812,32.468 +2020-12-13 11:00:00,97.35,187.52,36.746,32.468 +2020-12-13 11:15:00,99.58,185.02,36.746,32.468 +2020-12-13 11:30:00,99.04,183.602,36.746,32.468 +2020-12-13 11:45:00,101.44,181.74200000000002,36.746,32.468 +2020-12-13 12:00:00,102.29,175.783,35.048,32.468 +2020-12-13 12:15:00,100.68,175.27200000000002,35.048,32.468 +2020-12-13 12:30:00,97.69,174.334,35.048,32.468 +2020-12-13 12:45:00,93.5,174.06099999999998,35.048,32.468 +2020-12-13 13:00:00,93.18,173.04,29.987,32.468 +2020-12-13 13:15:00,91.25,174.38400000000001,29.987,32.468 +2020-12-13 13:30:00,90.27,173.607,29.987,32.468 +2020-12-13 13:45:00,90.93,173.293,29.987,32.468 +2020-12-13 14:00:00,91.2,172.57299999999998,27.21,32.468 +2020-12-13 14:15:00,91.53,173.56,27.21,32.468 +2020-12-13 14:30:00,92.77,173.96400000000003,27.21,32.468 +2020-12-13 14:45:00,92.68,173.96599999999998,27.21,32.468 +2020-12-13 15:00:00,92.95,173.54,27.726999999999997,32.468 +2020-12-13 15:15:00,93.32,175.192,27.726999999999997,32.468 +2020-12-13 15:30:00,93.65,177.99,27.726999999999997,32.468 +2020-12-13 15:45:00,94.82,180.645,27.726999999999997,32.468 +2020-12-13 16:00:00,97.84,181.21,32.23,32.468 +2020-12-13 16:15:00,102.34,182.666,32.23,32.468 +2020-12-13 16:30:00,104.88,185.718,32.23,32.468 +2020-12-13 16:45:00,106.38,187.96200000000002,32.23,32.468 +2020-12-13 17:00:00,108.65,190.179,42.016999999999996,32.468 +2020-12-13 17:15:00,110.19,191.53400000000002,42.016999999999996,32.468 +2020-12-13 17:30:00,112.07,191.988,42.016999999999996,32.468 +2020-12-13 17:45:00,113.2,193.269,42.016999999999996,32.468 +2020-12-13 18:00:00,112.55,193.99099999999999,49.338,32.468 +2020-12-13 18:15:00,110.56,194.68200000000002,49.338,32.468 +2020-12-13 18:30:00,110.25,192.84799999999998,49.338,32.468 +2020-12-13 18:45:00,110.1,191.11900000000003,49.338,32.468 +2020-12-13 19:00:00,108.26,193.512,52.369,32.468 +2020-12-13 19:15:00,106.2,191.321,52.369,32.468 +2020-12-13 19:30:00,105.15,189.34799999999998,52.369,32.468 +2020-12-13 19:45:00,104.42,186.611,52.369,32.468 +2020-12-13 20:00:00,108.33,185.328,50.405,32.468 +2020-12-13 20:15:00,108.05,182.543,50.405,32.468 +2020-12-13 20:30:00,103.92,179.755,50.405,32.468 +2020-12-13 20:45:00,96.6,176.112,50.405,32.468 +2020-12-13 21:00:00,98.84,174.137,46.235,32.468 +2020-12-13 21:15:00,101.2,172.30700000000002,46.235,32.468 +2020-12-13 21:30:00,100.09,171.757,46.235,32.468 +2020-12-13 21:45:00,99.54,170.34599999999998,46.235,32.468 +2020-12-13 22:00:00,91.06,164.78900000000002,46.861000000000004,32.468 +2020-12-13 22:15:00,90.94,160.716,46.861000000000004,32.468 +2020-12-13 22:30:00,85.27,155.94,46.861000000000004,32.468 +2020-12-13 22:45:00,86.11,152.435,46.861000000000004,32.468 +2020-12-13 23:00:00,87.91,146.025,41.302,32.468 +2020-12-13 23:15:00,88.21,142.32299999999998,41.302,32.468 +2020-12-13 23:30:00,84.64,140.062,41.302,32.468 +2020-12-13 23:45:00,77.19,137.455,41.302,32.468 +2020-12-14 00:00:00,73.85,119.242,37.164,32.65 +2020-12-14 00:15:00,76.66,117.71700000000001,37.164,32.65 +2020-12-14 00:30:00,78.78,119.77600000000001,37.164,32.65 +2020-12-14 00:45:00,77.4,122.26100000000001,37.164,32.65 +2020-12-14 01:00:00,68.45,124.65100000000001,34.994,32.65 +2020-12-14 01:15:00,73.71,125.82,34.994,32.65 +2020-12-14 01:30:00,75.05,126.103,34.994,32.65 +2020-12-14 01:45:00,74.17,126.43,34.994,32.65 +2020-12-14 02:00:00,71.84,127.964,34.571,32.65 +2020-12-14 02:15:00,74.01,129.599,34.571,32.65 +2020-12-14 02:30:00,74.73,130.429,34.571,32.65 +2020-12-14 02:45:00,74.65,132.29399999999998,34.571,32.65 +2020-12-14 03:00:00,70.83,136.15200000000002,33.934,32.65 +2020-12-14 03:15:00,76.74,137.769,33.934,32.65 +2020-12-14 03:30:00,78.07,139.173,33.934,32.65 +2020-12-14 03:45:00,77.08,139.999,33.934,32.65 +2020-12-14 04:00:00,73.01,152.859,34.107,32.65 +2020-12-14 04:15:00,78.81,165.28900000000002,34.107,32.65 +2020-12-14 04:30:00,80.92,168.162,34.107,32.65 +2020-12-14 04:45:00,82.24,169.58599999999998,34.107,32.65 +2020-12-14 05:00:00,82.67,199.233,39.575,32.65 +2020-12-14 05:15:00,82.48,228.407,39.575,32.65 +2020-12-14 05:30:00,87.89,225.44099999999997,39.575,32.65 +2020-12-14 05:45:00,93.59,217.542,39.575,32.65 +2020-12-14 06:00:00,104.33,214.821,56.156000000000006,32.65 +2020-12-14 06:15:00,110.84,218.653,56.156000000000006,32.65 +2020-12-14 06:30:00,116.28,221.476,56.156000000000006,32.65 +2020-12-14 06:45:00,125.61,224.635,56.156000000000006,32.65 +2020-12-14 07:00:00,128.28,224.24200000000002,67.926,32.65 +2020-12-14 07:15:00,129.58,229.359,67.926,32.65 +2020-12-14 07:30:00,133.95,232.672,67.926,32.65 +2020-12-14 07:45:00,132.49,233.838,67.926,32.65 +2020-12-14 08:00:00,136.16,232.574,72.58,32.65 +2020-12-14 08:15:00,133.51,233.67,72.58,32.65 +2020-12-14 08:30:00,136.78,232.19,72.58,32.65 +2020-12-14 08:45:00,132.47,229.61599999999999,72.58,32.65 +2020-12-14 09:00:00,134.5,224.016,66.984,32.65 +2020-12-14 09:15:00,136.6,218.998,66.984,32.65 +2020-12-14 09:30:00,141.47,215.822,66.984,32.65 +2020-12-14 09:45:00,137.79,213.25799999999998,66.984,32.65 +2020-12-14 10:00:00,135.32,209.27,63.158,32.65 +2020-12-14 10:15:00,131.67,206.097,63.158,32.65 +2020-12-14 10:30:00,131.48,203.485,63.158,32.65 +2020-12-14 10:45:00,133.72,201.791,63.158,32.65 +2020-12-14 11:00:00,129.21,199.26,61.141000000000005,32.65 +2020-12-14 11:15:00,129.73,198.532,61.141000000000005,32.65 +2020-12-14 11:30:00,131.24,198.482,61.141000000000005,32.65 +2020-12-14 11:45:00,132.76,196.21599999999998,61.141000000000005,32.65 +2020-12-14 12:00:00,129.52,191.954,57.961000000000006,32.65 +2020-12-14 12:15:00,133.74,191.457,57.961000000000006,32.65 +2020-12-14 12:30:00,133.03,190.775,57.961000000000006,32.65 +2020-12-14 12:45:00,135.84,192.012,57.961000000000006,32.65 +2020-12-14 13:00:00,131.88,191.54,56.843,32.65 +2020-12-14 13:15:00,130.95,191.511,56.843,32.65 +2020-12-14 13:30:00,128.48,190.205,56.843,32.65 +2020-12-14 13:45:00,128.95,189.919,56.843,32.65 +2020-12-14 14:00:00,123.91,188.565,55.992,32.65 +2020-12-14 14:15:00,124.92,188.925,55.992,32.65 +2020-12-14 14:30:00,124.35,188.81599999999997,55.992,32.65 +2020-12-14 14:45:00,126.22,188.80700000000002,55.992,32.65 +2020-12-14 15:00:00,127.2,190.158,57.523,32.65 +2020-12-14 15:15:00,128.82,190.382,57.523,32.65 +2020-12-14 15:30:00,126.77,192.38400000000001,57.523,32.65 +2020-12-14 15:45:00,128.99,194.595,57.523,32.65 +2020-12-14 16:00:00,133.74,195.34400000000002,59.471000000000004,32.65 +2020-12-14 16:15:00,135.18,196.06599999999997,59.471000000000004,32.65 +2020-12-14 16:30:00,136.84,198.168,59.471000000000004,32.65 +2020-12-14 16:45:00,137.31,199.264,59.471000000000004,32.65 +2020-12-14 17:00:00,139.82,201.285,65.066,32.65 +2020-12-14 17:15:00,139.13,201.725,65.066,32.65 +2020-12-14 17:30:00,139.59,201.65200000000002,65.066,32.65 +2020-12-14 17:45:00,139.83,201.465,65.066,32.65 +2020-12-14 18:00:00,137.48,202.62900000000002,69.581,32.65 +2020-12-14 18:15:00,135.23,201.102,69.581,32.65 +2020-12-14 18:30:00,134.3,199.922,69.581,32.65 +2020-12-14 18:45:00,134.53,198.91299999999998,69.581,32.65 +2020-12-14 19:00:00,132.27,199.696,73.771,32.65 +2020-12-14 19:15:00,137.65,196.335,73.771,32.65 +2020-12-14 19:30:00,137.55,194.843,73.771,32.65 +2020-12-14 19:45:00,136.21,191.26,73.771,32.65 +2020-12-14 20:00:00,121.11,187.632,65.035,32.65 +2020-12-14 20:15:00,117.82,182.38099999999997,65.035,32.65 +2020-12-14 20:30:00,115.14,177.72400000000002,65.035,32.65 +2020-12-14 20:45:00,115.32,175.71200000000002,65.035,32.65 +2020-12-14 21:00:00,109.37,174.25,58.7,32.65 +2020-12-14 21:15:00,108.96,171.238,58.7,32.65 +2020-12-14 21:30:00,104.73,169.86900000000003,58.7,32.65 +2020-12-14 21:45:00,104.04,167.965,58.7,32.65 +2020-12-14 22:00:00,99.84,159.553,53.888000000000005,32.65 +2020-12-14 22:15:00,96.41,154.173,53.888000000000005,32.65 +2020-12-14 22:30:00,96.15,139.96200000000002,53.888000000000005,32.65 +2020-12-14 22:45:00,97.48,131.666,53.888000000000005,32.65 +2020-12-14 23:00:00,92.99,126.00399999999999,45.501999999999995,32.65 +2020-12-14 23:15:00,90.86,124.936,45.501999999999995,32.65 +2020-12-14 23:30:00,86.06,125.43,45.501999999999995,32.65 +2020-12-14 23:45:00,88.58,125.434,45.501999999999995,32.65 +2020-12-15 00:00:00,83.52,115.656,43.537,32.65 +2020-12-15 00:15:00,83.19,115.315,43.537,32.65 +2020-12-15 00:30:00,81.66,116.205,43.537,32.65 +2020-12-15 00:45:00,84.41,117.449,43.537,32.65 +2020-12-15 01:00:00,80.92,119.626,41.854,32.65 +2020-12-15 01:15:00,78.66,120.473,41.854,32.65 +2020-12-15 01:30:00,78.58,120.975,41.854,32.65 +2020-12-15 01:45:00,80.79,121.521,41.854,32.65 +2020-12-15 02:00:00,78.33,123.03200000000001,40.321,32.65 +2020-12-15 02:15:00,79.13,124.414,40.321,32.65 +2020-12-15 02:30:00,79.46,124.492,40.321,32.65 +2020-12-15 02:45:00,81.65,126.29299999999999,40.321,32.65 +2020-12-15 03:00:00,80.09,128.789,39.632,32.65 +2020-12-15 03:15:00,79.26,129.722,39.632,32.65 +2020-12-15 03:30:00,80.46,131.651,39.632,32.65 +2020-12-15 03:45:00,82.17,132.52100000000002,39.632,32.65 +2020-12-15 04:00:00,82.38,145.236,40.183,32.65 +2020-12-15 04:15:00,81.57,157.416,40.183,32.65 +2020-12-15 04:30:00,85.91,159.6,40.183,32.65 +2020-12-15 04:45:00,85.18,162.086,40.183,32.65 +2020-12-15 05:00:00,85.38,196.30599999999998,43.945,32.65 +2020-12-15 05:15:00,84.75,225.167,43.945,32.65 +2020-12-15 05:30:00,94.66,220.77900000000002,43.945,32.65 +2020-12-15 05:45:00,104.6,212.74599999999998,43.945,32.65 +2020-12-15 06:00:00,114.07,208.825,56.048,32.65 +2020-12-15 06:15:00,115.06,214.197,56.048,32.65 +2020-12-15 06:30:00,115.45,216.41,56.048,32.65 +2020-12-15 06:45:00,118.13,219.02599999999998,56.048,32.65 +2020-12-15 07:00:00,127.23,218.861,65.74,32.65 +2020-12-15 07:15:00,130.09,223.575,65.74,32.65 +2020-12-15 07:30:00,130.79,226.06599999999997,65.74,32.65 +2020-12-15 07:45:00,134.25,227.21,65.74,32.65 +2020-12-15 08:00:00,137.81,226.11599999999999,72.757,32.65 +2020-12-15 08:15:00,135.91,225.97099999999998,72.757,32.65 +2020-12-15 08:30:00,136.55,224.303,72.757,32.65 +2020-12-15 08:45:00,137.31,221.18200000000002,72.757,32.65 +2020-12-15 09:00:00,136.97,214.54,67.692,32.65 +2020-12-15 09:15:00,141.95,211.025,67.692,32.65 +2020-12-15 09:30:00,142.11,208.47,67.692,32.65 +2020-12-15 09:45:00,142.8,205.74200000000002,67.692,32.65 +2020-12-15 10:00:00,141.36,201.498,63.506,32.65 +2020-12-15 10:15:00,141.94,197.08599999999998,63.506,32.65 +2020-12-15 10:30:00,141.49,194.91400000000002,63.506,32.65 +2020-12-15 10:45:00,143.1,193.605,63.506,32.65 +2020-12-15 11:00:00,141.22,192.74099999999999,60.758,32.65 +2020-12-15 11:15:00,142.97,191.713,60.758,32.65 +2020-12-15 11:30:00,142.93,190.668,60.758,32.65 +2020-12-15 11:45:00,141.87,189.172,60.758,32.65 +2020-12-15 12:00:00,141.11,183.533,57.519,32.65 +2020-12-15 12:15:00,141.16,182.61700000000002,57.519,32.65 +2020-12-15 12:30:00,140.9,182.8,57.519,32.65 +2020-12-15 12:45:00,138.4,183.675,57.519,32.65 +2020-12-15 13:00:00,133.81,182.6,56.46,32.65 +2020-12-15 13:15:00,131.95,182.195,56.46,32.65 +2020-12-15 13:30:00,129.26,181.915,56.46,32.65 +2020-12-15 13:45:00,129.67,181.796,56.46,32.65 +2020-12-15 14:00:00,123.7,180.81599999999997,56.207,32.65 +2020-12-15 14:15:00,124.77,181.243,56.207,32.65 +2020-12-15 14:30:00,124.87,181.71400000000003,56.207,32.65 +2020-12-15 14:45:00,125.31,181.653,56.207,32.65 +2020-12-15 15:00:00,127.76,182.535,57.391999999999996,32.65 +2020-12-15 15:15:00,126.63,183.02599999999998,57.391999999999996,32.65 +2020-12-15 15:30:00,126.46,185.03799999999998,57.391999999999996,32.65 +2020-12-15 15:45:00,128.96,186.55,57.391999999999996,32.65 +2020-12-15 16:00:00,131.98,188.59799999999998,59.955,32.65 +2020-12-15 16:15:00,135.15,189.975,59.955,32.65 +2020-12-15 16:30:00,137.94,192.37400000000002,59.955,32.65 +2020-12-15 16:45:00,139.15,193.635,59.955,32.65 +2020-12-15 17:00:00,141.27,196.472,67.063,32.65 +2020-12-15 17:15:00,141.7,197.14700000000002,67.063,32.65 +2020-12-15 17:30:00,143.06,197.852,67.063,32.65 +2020-12-15 17:45:00,142.57,197.611,67.063,32.65 +2020-12-15 18:00:00,140.73,198.834,71.477,32.65 +2020-12-15 18:15:00,139.06,197.072,71.477,32.65 +2020-12-15 18:30:00,137.45,195.517,71.477,32.65 +2020-12-15 18:45:00,137.98,195.32299999999998,71.477,32.65 +2020-12-15 19:00:00,135.69,196.59099999999998,74.32,32.65 +2020-12-15 19:15:00,134.51,193.03599999999997,74.32,32.65 +2020-12-15 19:30:00,132.57,191.079,74.32,32.65 +2020-12-15 19:45:00,131.33,187.28799999999998,74.32,32.65 +2020-12-15 20:00:00,123.93,183.83700000000002,66.157,32.65 +2020-12-15 20:15:00,121.36,178.165,66.157,32.65 +2020-12-15 20:30:00,114.46,174.50599999999997,66.157,32.65 +2020-12-15 20:45:00,115.6,171.75400000000002,66.157,32.65 +2020-12-15 21:00:00,115.82,169.782,59.806000000000004,32.65 +2020-12-15 21:15:00,114.44,167.699,59.806000000000004,32.65 +2020-12-15 21:30:00,113.31,165.55900000000003,59.806000000000004,32.65 +2020-12-15 21:45:00,103.61,163.97,59.806000000000004,32.65 +2020-12-15 22:00:00,102.01,157.408,54.785,32.65 +2020-12-15 22:15:00,98.37,151.947,54.785,32.65 +2020-12-15 22:30:00,92.73,137.92,54.785,32.65 +2020-12-15 22:45:00,94.84,130.05100000000002,54.785,32.65 +2020-12-15 23:00:00,93.0,124.61200000000001,47.176,32.65 +2020-12-15 23:15:00,94.63,122.538,47.176,32.65 +2020-12-15 23:30:00,87.81,122.829,47.176,32.65 +2020-12-15 23:45:00,85.28,122.35,47.176,32.65 +2020-12-16 00:00:00,80.62,115.92399999999999,43.42,32.65 +2020-12-16 00:15:00,83.74,115.554,43.42,32.65 +2020-12-16 00:30:00,81.51,116.43799999999999,43.42,32.65 +2020-12-16 00:45:00,80.18,117.663,43.42,32.65 +2020-12-16 01:00:00,69.83,119.867,40.869,32.65 +2020-12-16 01:15:00,80.0,120.713,40.869,32.65 +2020-12-16 01:30:00,80.76,121.22,40.869,32.65 +2020-12-16 01:45:00,80.19,121.75399999999999,40.869,32.65 +2020-12-16 02:00:00,74.32,123.281,39.541,32.65 +2020-12-16 02:15:00,78.32,124.664,39.541,32.65 +2020-12-16 02:30:00,77.27,124.74799999999999,39.541,32.65 +2020-12-16 02:45:00,80.86,126.546,39.541,32.65 +2020-12-16 03:00:00,75.94,129.032,39.052,32.65 +2020-12-16 03:15:00,79.31,129.991,39.052,32.65 +2020-12-16 03:30:00,79.64,131.922,39.052,32.65 +2020-12-16 03:45:00,82.22,132.792,39.052,32.65 +2020-12-16 04:00:00,79.05,145.48,40.36,32.65 +2020-12-16 04:15:00,81.08,157.658,40.36,32.65 +2020-12-16 04:30:00,85.18,159.833,40.36,32.65 +2020-12-16 04:45:00,82.1,162.319,40.36,32.65 +2020-12-16 05:00:00,83.77,196.507,43.133,32.65 +2020-12-16 05:15:00,87.36,225.327,43.133,32.65 +2020-12-16 05:30:00,91.05,220.956,43.133,32.65 +2020-12-16 05:45:00,95.36,212.94299999999998,43.133,32.65 +2020-12-16 06:00:00,105.17,209.046,54.953,32.65 +2020-12-16 06:15:00,108.16,214.421,54.953,32.65 +2020-12-16 06:30:00,114.63,216.671,54.953,32.65 +2020-12-16 06:45:00,117.72,219.325,54.953,32.65 +2020-12-16 07:00:00,127.82,219.166,66.566,32.65 +2020-12-16 07:15:00,130.45,223.882,66.566,32.65 +2020-12-16 07:30:00,131.15,226.372,66.566,32.65 +2020-12-16 07:45:00,132.85,227.511,66.566,32.65 +2020-12-16 08:00:00,137.7,226.423,72.902,32.65 +2020-12-16 08:15:00,135.94,226.269,72.902,32.65 +2020-12-16 08:30:00,136.44,224.597,72.902,32.65 +2020-12-16 08:45:00,137.62,221.44799999999998,72.902,32.65 +2020-12-16 09:00:00,137.95,214.785,68.465,32.65 +2020-12-16 09:15:00,139.43,211.27599999999998,68.465,32.65 +2020-12-16 09:30:00,140.51,208.729,68.465,32.65 +2020-12-16 09:45:00,139.57,205.99,68.465,32.65 +2020-12-16 10:00:00,137.58,201.74,63.625,32.65 +2020-12-16 10:15:00,137.97,197.31400000000002,63.625,32.65 +2020-12-16 10:30:00,140.2,195.123,63.625,32.65 +2020-12-16 10:45:00,138.04,193.81,63.625,32.65 +2020-12-16 11:00:00,138.03,192.929,61.628,32.65 +2020-12-16 11:15:00,136.32,191.89,61.628,32.65 +2020-12-16 11:30:00,136.53,190.845,61.628,32.65 +2020-12-16 11:45:00,136.0,189.34599999999998,61.628,32.65 +2020-12-16 12:00:00,135.94,183.708,58.708999999999996,32.65 +2020-12-16 12:15:00,135.61,182.80599999999998,58.708999999999996,32.65 +2020-12-16 12:30:00,133.9,182.998,58.708999999999996,32.65 +2020-12-16 12:45:00,135.77,183.877,58.708999999999996,32.65 +2020-12-16 13:00:00,133.66,182.77900000000002,57.373000000000005,32.65 +2020-12-16 13:15:00,133.97,182.372,57.373000000000005,32.65 +2020-12-16 13:30:00,134.94,182.085,57.373000000000005,32.65 +2020-12-16 13:45:00,130.4,181.957,57.373000000000005,32.65 +2020-12-16 14:00:00,129.27,180.96599999999998,57.684,32.65 +2020-12-16 14:15:00,131.13,181.395,57.684,32.65 +2020-12-16 14:30:00,131.55,181.887,57.684,32.65 +2020-12-16 14:45:00,131.65,181.83599999999998,57.684,32.65 +2020-12-16 15:00:00,133.62,182.735,58.03,32.65 +2020-12-16 15:15:00,133.02,183.22,58.03,32.65 +2020-12-16 15:30:00,131.8,185.247,58.03,32.65 +2020-12-16 15:45:00,133.43,186.75799999999998,58.03,32.65 +2020-12-16 16:00:00,137.44,188.804,59.97,32.65 +2020-12-16 16:15:00,138.97,190.2,59.97,32.65 +2020-12-16 16:30:00,140.77,192.604,59.97,32.65 +2020-12-16 16:45:00,140.66,193.891,59.97,32.65 +2020-12-16 17:00:00,143.13,196.707,65.661,32.65 +2020-12-16 17:15:00,142.28,197.408,65.661,32.65 +2020-12-16 17:30:00,143.12,198.132,65.661,32.65 +2020-12-16 17:45:00,142.9,197.90200000000002,65.661,32.65 +2020-12-16 18:00:00,139.93,199.144,70.96300000000001,32.65 +2020-12-16 18:15:00,139.4,197.36,70.96300000000001,32.65 +2020-12-16 18:30:00,138.28,195.812,70.96300000000001,32.65 +2020-12-16 18:45:00,137.34,195.62900000000002,70.96300000000001,32.65 +2020-12-16 19:00:00,135.22,196.88,74.133,32.65 +2020-12-16 19:15:00,133.45,193.31799999999998,74.133,32.65 +2020-12-16 19:30:00,130.36,191.351,74.133,32.65 +2020-12-16 19:45:00,130.37,187.542,74.133,32.65 +2020-12-16 20:00:00,125.93,184.088,65.613,32.65 +2020-12-16 20:15:00,119.64,178.40900000000002,65.613,32.65 +2020-12-16 20:30:00,118.92,174.72799999999998,65.613,32.65 +2020-12-16 20:45:00,115.48,171.995,65.613,32.65 +2020-12-16 21:00:00,115.75,170.007,58.583,32.65 +2020-12-16 21:15:00,117.64,167.907,58.583,32.65 +2020-12-16 21:30:00,113.57,165.769,58.583,32.65 +2020-12-16 21:45:00,107.19,164.188,58.583,32.65 +2020-12-16 22:00:00,102.1,157.631,54.411,32.65 +2020-12-16 22:15:00,102.82,152.17600000000002,54.411,32.65 +2020-12-16 22:30:00,100.28,138.189,54.411,32.65 +2020-12-16 22:45:00,101.94,130.327,54.411,32.65 +2020-12-16 23:00:00,95.99,124.867,47.878,32.65 +2020-12-16 23:15:00,90.51,122.79,47.878,32.65 +2020-12-16 23:30:00,87.38,123.094,47.878,32.65 +2020-12-16 23:45:00,89.89,122.604,47.878,32.65 +2020-12-17 00:00:00,84.21,116.184,44.513000000000005,32.65 +2020-12-17 00:15:00,85.18,115.789,44.513000000000005,32.65 +2020-12-17 00:30:00,74.91,116.663,44.513000000000005,32.65 +2020-12-17 00:45:00,77.85,117.87100000000001,44.513000000000005,32.65 +2020-12-17 01:00:00,72.93,120.101,43.169,32.65 +2020-12-17 01:15:00,73.81,120.945,43.169,32.65 +2020-12-17 01:30:00,79.24,121.45700000000001,43.169,32.65 +2020-12-17 01:45:00,81.74,121.978,43.169,32.65 +2020-12-17 02:00:00,79.9,123.524,41.763999999999996,32.65 +2020-12-17 02:15:00,75.28,124.90799999999999,41.763999999999996,32.65 +2020-12-17 02:30:00,79.95,124.994,41.763999999999996,32.65 +2020-12-17 02:45:00,81.25,126.792,41.763999999999996,32.65 +2020-12-17 03:00:00,79.07,129.269,41.155,32.65 +2020-12-17 03:15:00,76.1,130.252,41.155,32.65 +2020-12-17 03:30:00,80.42,132.184,41.155,32.65 +2020-12-17 03:45:00,81.44,133.056,41.155,32.65 +2020-12-17 04:00:00,76.27,145.717,41.96,32.65 +2020-12-17 04:15:00,77.05,157.893,41.96,32.65 +2020-12-17 04:30:00,82.44,160.058,41.96,32.65 +2020-12-17 04:45:00,82.35,162.546,41.96,32.65 +2020-12-17 05:00:00,84.79,196.699,45.206,32.65 +2020-12-17 05:15:00,87.77,225.481,45.206,32.65 +2020-12-17 05:30:00,92.75,221.127,45.206,32.65 +2020-12-17 05:45:00,94.22,213.13099999999997,45.206,32.65 +2020-12-17 06:00:00,101.61,209.25799999999998,55.398999999999994,32.65 +2020-12-17 06:15:00,107.73,214.637,55.398999999999994,32.65 +2020-12-17 06:30:00,111.51,216.922,55.398999999999994,32.65 +2020-12-17 06:45:00,116.77,219.614,55.398999999999994,32.65 +2020-12-17 07:00:00,125.56,219.46200000000002,64.627,32.65 +2020-12-17 07:15:00,127.94,224.178,64.627,32.65 +2020-12-17 07:30:00,129.03,226.668,64.627,32.65 +2020-12-17 07:45:00,130.58,227.801,64.627,32.65 +2020-12-17 08:00:00,131.85,226.71900000000002,70.895,32.65 +2020-12-17 08:15:00,132.24,226.554,70.895,32.65 +2020-12-17 08:30:00,132.44,224.88,70.895,32.65 +2020-12-17 08:45:00,131.91,221.704,70.895,32.65 +2020-12-17 09:00:00,135.0,215.017,66.382,32.65 +2020-12-17 09:15:00,133.95,211.517,66.382,32.65 +2020-12-17 09:30:00,134.58,208.97799999999998,66.382,32.65 +2020-12-17 09:45:00,134.76,206.22799999999998,66.382,32.65 +2020-12-17 10:00:00,134.7,201.97299999999998,62.739,32.65 +2020-12-17 10:15:00,134.75,197.532,62.739,32.65 +2020-12-17 10:30:00,131.89,195.32299999999998,62.739,32.65 +2020-12-17 10:45:00,131.47,194.005,62.739,32.65 +2020-12-17 11:00:00,130.35,193.108,60.843,32.65 +2020-12-17 11:15:00,130.67,192.05900000000003,60.843,32.65 +2020-12-17 11:30:00,128.97,191.014,60.843,32.65 +2020-12-17 11:45:00,127.33,189.512,60.843,32.65 +2020-12-17 12:00:00,121.37,183.87400000000002,58.466,32.65 +2020-12-17 12:15:00,126.04,182.985,58.466,32.65 +2020-12-17 12:30:00,128.2,183.188,58.466,32.65 +2020-12-17 12:45:00,130.0,184.072,58.466,32.65 +2020-12-17 13:00:00,126.85,182.94799999999998,56.883,32.65 +2020-12-17 13:15:00,126.09,182.54,56.883,32.65 +2020-12-17 13:30:00,125.29,182.247,56.883,32.65 +2020-12-17 13:45:00,126.95,182.11,56.883,32.65 +2020-12-17 14:00:00,126.35,181.108,56.503,32.65 +2020-12-17 14:15:00,127.72,181.54,56.503,32.65 +2020-12-17 14:30:00,124.54,182.05200000000002,56.503,32.65 +2020-12-17 14:45:00,123.37,182.012,56.503,32.65 +2020-12-17 15:00:00,124.4,182.928,57.803999999999995,32.65 +2020-12-17 15:15:00,125.58,183.40599999999998,57.803999999999995,32.65 +2020-12-17 15:30:00,125.0,185.44799999999998,57.803999999999995,32.65 +2020-12-17 15:45:00,124.76,186.956,57.803999999999995,32.65 +2020-12-17 16:00:00,128.21,189.00099999999998,59.379,32.65 +2020-12-17 16:15:00,129.34,190.415,59.379,32.65 +2020-12-17 16:30:00,129.97,192.825,59.379,32.65 +2020-12-17 16:45:00,130.22,194.135,59.379,32.65 +2020-12-17 17:00:00,132.3,196.93099999999998,64.71600000000001,32.65 +2020-12-17 17:15:00,132.66,197.66,64.71600000000001,32.65 +2020-12-17 17:30:00,133.9,198.40200000000002,64.71600000000001,32.65 +2020-12-17 17:45:00,133.14,198.18400000000003,64.71600000000001,32.65 +2020-12-17 18:00:00,130.47,199.445,68.803,32.65 +2020-12-17 18:15:00,129.63,197.641,68.803,32.65 +2020-12-17 18:30:00,128.9,196.09799999999998,68.803,32.65 +2020-12-17 18:45:00,128.16,195.926,68.803,32.65 +2020-12-17 19:00:00,126.35,197.15900000000002,72.934,32.65 +2020-12-17 19:15:00,123.77,193.59099999999998,72.934,32.65 +2020-12-17 19:30:00,122.92,191.61599999999999,72.934,32.65 +2020-12-17 19:45:00,121.49,187.78799999999998,72.934,32.65 +2020-12-17 20:00:00,114.67,184.33,65.175,32.65 +2020-12-17 20:15:00,109.95,178.645,65.175,32.65 +2020-12-17 20:30:00,106.37,174.94400000000002,65.175,32.65 +2020-12-17 20:45:00,104.71,172.22799999999998,65.175,32.65 +2020-12-17 21:00:00,100.41,170.226,58.55,32.65 +2020-12-17 21:15:00,99.67,168.11,58.55,32.65 +2020-12-17 21:30:00,97.77,165.972,58.55,32.65 +2020-12-17 21:45:00,96.78,164.40099999999998,58.55,32.65 +2020-12-17 22:00:00,90.43,157.846,55.041000000000004,32.65 +2020-12-17 22:15:00,88.71,152.398,55.041000000000004,32.65 +2020-12-17 22:30:00,84.97,138.44899999999998,55.041000000000004,32.65 +2020-12-17 22:45:00,82.32,130.597,55.041000000000004,32.65 +2020-12-17 23:00:00,77.33,125.113,48.258,32.65 +2020-12-17 23:15:00,78.58,123.036,48.258,32.65 +2020-12-17 23:30:00,74.54,123.352,48.258,32.65 +2020-12-17 23:45:00,74.17,122.852,48.258,32.65 +2020-12-18 00:00:00,69.93,115.34700000000001,45.02,32.65 +2020-12-18 00:15:00,68.18,115.12299999999999,45.02,32.65 +2020-12-18 00:30:00,67.68,115.87,45.02,32.65 +2020-12-18 00:45:00,67.22,117.18,45.02,32.65 +2020-12-18 01:00:00,62.8,119.12299999999999,42.695,32.65 +2020-12-18 01:15:00,62.98,120.82700000000001,42.695,32.65 +2020-12-18 01:30:00,62.72,121.15299999999999,42.695,32.65 +2020-12-18 01:45:00,63.95,121.755,42.695,32.65 +2020-12-18 02:00:00,61.06,123.43,41.511,32.65 +2020-12-18 02:15:00,62.5,124.70299999999999,41.511,32.65 +2020-12-18 02:30:00,62.92,125.325,41.511,32.65 +2020-12-18 02:45:00,63.6,127.141,41.511,32.65 +2020-12-18 03:00:00,61.83,128.67600000000002,41.162,32.65 +2020-12-18 03:15:00,62.77,130.548,41.162,32.65 +2020-12-18 03:30:00,63.63,132.457,41.162,32.65 +2020-12-18 03:45:00,64.24,133.673,41.162,32.65 +2020-12-18 04:00:00,64.94,146.534,42.226000000000006,32.65 +2020-12-18 04:15:00,66.41,158.416,42.226000000000006,32.65 +2020-12-18 04:30:00,68.08,160.825,42.226000000000006,32.65 +2020-12-18 04:45:00,70.29,162.19299999999998,42.226000000000006,32.65 +2020-12-18 05:00:00,72.72,195.077,45.597,32.65 +2020-12-18 05:15:00,74.61,225.30200000000002,45.597,32.65 +2020-12-18 05:30:00,78.87,222.00799999999998,45.597,32.65 +2020-12-18 05:45:00,83.71,213.953,45.597,32.65 +2020-12-18 06:00:00,92.43,210.528,56.263999999999996,32.65 +2020-12-18 06:15:00,96.81,214.503,56.263999999999996,32.65 +2020-12-18 06:30:00,100.32,215.99,56.263999999999996,32.65 +2020-12-18 06:45:00,104.38,220.27700000000002,56.263999999999996,32.65 +2020-12-18 07:00:00,110.53,219.352,66.888,32.65 +2020-12-18 07:15:00,113.01,225.084,66.888,32.65 +2020-12-18 07:30:00,116.17,227.328,66.888,32.65 +2020-12-18 07:45:00,119.09,227.59599999999998,66.888,32.65 +2020-12-18 08:00:00,121.73,225.487,73.459,32.65 +2020-12-18 08:15:00,120.52,224.96400000000003,73.459,32.65 +2020-12-18 08:30:00,121.35,224.218,73.459,32.65 +2020-12-18 08:45:00,122.72,219.497,73.459,32.65 +2020-12-18 09:00:00,124.32,213.14,69.087,32.65 +2020-12-18 09:15:00,125.38,210.268,69.087,32.65 +2020-12-18 09:30:00,126.56,207.3,69.087,32.65 +2020-12-18 09:45:00,126.98,204.45,69.087,32.65 +2020-12-18 10:00:00,127.19,199.09799999999998,65.404,32.65 +2020-12-18 10:15:00,126.67,195.303,65.404,32.65 +2020-12-18 10:30:00,127.1,193.03099999999998,65.404,32.65 +2020-12-18 10:45:00,125.45,191.282,65.404,32.65 +2020-12-18 11:00:00,123.87,190.359,63.0,32.65 +2020-12-18 11:15:00,125.98,188.398,63.0,32.65 +2020-12-18 11:30:00,126.3,189.011,63.0,32.65 +2020-12-18 11:45:00,124.8,187.51,63.0,32.65 +2020-12-18 12:00:00,123.25,182.92700000000002,59.083,32.65 +2020-12-18 12:15:00,123.46,180.024,59.083,32.65 +2020-12-18 12:30:00,122.58,180.393,59.083,32.65 +2020-12-18 12:45:00,124.82,181.736,59.083,32.65 +2020-12-18 13:00:00,120.54,181.50900000000001,56.611999999999995,32.65 +2020-12-18 13:15:00,119.45,181.887,56.611999999999995,32.65 +2020-12-18 13:30:00,117.01,181.64,56.611999999999995,32.65 +2020-12-18 13:45:00,115.3,181.44799999999998,56.611999999999995,32.65 +2020-12-18 14:00:00,111.85,179.312,55.161,32.65 +2020-12-18 14:15:00,111.13,179.59599999999998,55.161,32.65 +2020-12-18 14:30:00,110.97,180.68599999999998,55.161,32.65 +2020-12-18 14:45:00,110.48,180.91400000000002,55.161,32.65 +2020-12-18 15:00:00,111.97,181.382,55.583,32.65 +2020-12-18 15:15:00,109.45,181.424,55.583,32.65 +2020-12-18 15:30:00,108.82,182.00400000000002,55.583,32.65 +2020-12-18 15:45:00,110.05,183.68400000000003,55.583,32.65 +2020-12-18 16:00:00,114.02,184.56900000000002,57.611999999999995,32.65 +2020-12-18 16:15:00,116.24,186.3,57.611999999999995,32.65 +2020-12-18 16:30:00,116.88,188.799,57.611999999999995,32.65 +2020-12-18 16:45:00,117.32,189.99,57.611999999999995,32.65 +2020-12-18 17:00:00,119.59,193.02,64.14,32.65 +2020-12-18 17:15:00,117.88,193.373,64.14,32.65 +2020-12-18 17:30:00,118.76,193.835,64.14,32.65 +2020-12-18 17:45:00,117.85,193.396,64.14,32.65 +2020-12-18 18:00:00,116.42,195.33700000000002,68.086,32.65 +2020-12-18 18:15:00,115.7,193.104,68.086,32.65 +2020-12-18 18:30:00,115.31,191.945,68.086,32.65 +2020-12-18 18:45:00,114.72,191.793,68.086,32.65 +2020-12-18 19:00:00,112.86,193.90900000000002,69.915,32.65 +2020-12-18 19:15:00,110.63,191.672,69.915,32.65 +2020-12-18 19:30:00,107.94,189.292,69.915,32.65 +2020-12-18 19:45:00,106.94,184.96900000000002,69.915,32.65 +2020-12-18 20:00:00,101.64,181.555,61.695,32.65 +2020-12-18 20:15:00,99.78,175.899,61.695,32.65 +2020-12-18 20:30:00,94.34,172.12099999999998,61.695,32.65 +2020-12-18 20:45:00,92.41,169.93200000000002,61.695,32.65 +2020-12-18 21:00:00,88.08,168.44799999999998,56.041000000000004,32.65 +2020-12-18 21:15:00,86.79,166.792,56.041000000000004,32.65 +2020-12-18 21:30:00,85.7,164.696,56.041000000000004,32.65 +2020-12-18 21:45:00,83.61,163.665,56.041000000000004,32.65 +2020-12-18 22:00:00,79.01,158.058,51.888999999999996,32.65 +2020-12-18 22:15:00,77.65,152.47799999999998,51.888999999999996,32.65 +2020-12-18 22:30:00,75.96,144.877,51.888999999999996,32.65 +2020-12-18 22:45:00,74.55,140.467,51.888999999999996,32.65 +2020-12-18 23:00:00,68.49,134.586,45.787,32.65 +2020-12-18 23:15:00,67.5,130.575,45.787,32.65 +2020-12-18 23:30:00,65.03,129.435,45.787,32.65 +2020-12-18 23:45:00,63.68,128.28,45.787,32.65 +2020-12-19 00:00:00,58.59,112.90299999999999,41.815,32.468 +2020-12-19 00:15:00,56.86,108.542,41.815,32.468 +2020-12-19 00:30:00,56.32,110.538,41.815,32.468 +2020-12-19 00:45:00,55.88,112.488,41.815,32.468 +2020-12-19 01:00:00,53.45,115.075,38.645,32.468 +2020-12-19 01:15:00,53.4,115.85600000000001,38.645,32.468 +2020-12-19 01:30:00,53.25,115.65799999999999,38.645,32.468 +2020-12-19 01:45:00,53.53,116.088,38.645,32.468 +2020-12-19 02:00:00,51.2,118.405,36.696,32.468 +2020-12-19 02:15:00,51.19,119.29299999999999,36.696,32.468 +2020-12-19 02:30:00,51.56,118.825,36.696,32.468 +2020-12-19 02:45:00,51.39,120.76700000000001,36.696,32.468 +2020-12-19 03:00:00,49.17,122.844,35.42,32.468 +2020-12-19 03:15:00,49.88,123.54899999999999,35.42,32.468 +2020-12-19 03:30:00,50.39,123.95100000000001,35.42,32.468 +2020-12-19 03:45:00,50.38,125.331,35.42,32.468 +2020-12-19 04:00:00,50.5,134.138,35.167,32.468 +2020-12-19 04:15:00,51.79,143.55200000000002,35.167,32.468 +2020-12-19 04:30:00,52.07,143.812,35.167,32.468 +2020-12-19 04:45:00,52.89,144.718,35.167,32.468 +2020-12-19 05:00:00,55.09,161.997,35.311,32.468 +2020-12-19 05:15:00,59.51,173.59400000000002,35.311,32.468 +2020-12-19 05:30:00,56.55,170.71599999999998,35.311,32.468 +2020-12-19 05:45:00,59.21,168.06099999999998,35.311,32.468 +2020-12-19 06:00:00,62.57,183.03900000000002,37.117,32.468 +2020-12-19 06:15:00,65.09,202.952,37.117,32.468 +2020-12-19 06:30:00,65.7,199.22299999999998,37.117,32.468 +2020-12-19 06:45:00,67.91,194.838,37.117,32.468 +2020-12-19 07:00:00,72.04,190.25900000000001,40.948,32.468 +2020-12-19 07:15:00,73.9,194.74,40.948,32.468 +2020-12-19 07:30:00,76.28,199.62900000000002,40.948,32.468 +2020-12-19 07:45:00,80.12,203.72299999999998,40.948,32.468 +2020-12-19 08:00:00,84.67,205.49,44.903,32.468 +2020-12-19 08:15:00,84.63,208.417,44.903,32.468 +2020-12-19 08:30:00,85.9,209.208,44.903,32.468 +2020-12-19 08:45:00,89.47,207.513,44.903,32.468 +2020-12-19 09:00:00,91.2,202.946,46.283,32.468 +2020-12-19 09:15:00,91.66,200.81900000000002,46.283,32.468 +2020-12-19 09:30:00,92.64,198.743,46.283,32.468 +2020-12-19 09:45:00,94.43,196.018,46.283,32.468 +2020-12-19 10:00:00,95.05,190.972,44.103,32.468 +2020-12-19 10:15:00,97.92,187.33599999999998,44.103,32.468 +2020-12-19 10:30:00,97.13,185.18900000000002,44.103,32.468 +2020-12-19 10:45:00,98.68,184.658,44.103,32.468 +2020-12-19 11:00:00,98.05,183.915,42.373999999999995,32.468 +2020-12-19 11:15:00,97.08,181.329,42.373999999999995,32.468 +2020-12-19 11:30:00,99.87,180.894,42.373999999999995,32.468 +2020-12-19 11:45:00,100.27,178.51,42.373999999999995,32.468 +2020-12-19 12:00:00,97.52,173.118,39.937,32.468 +2020-12-19 12:15:00,97.76,170.877,39.937,32.468 +2020-12-19 12:30:00,98.11,171.55,39.937,32.468 +2020-12-19 12:45:00,100.55,172.199,39.937,32.468 +2020-12-19 13:00:00,99.76,171.535,37.138000000000005,32.468 +2020-12-19 13:15:00,98.38,169.935,37.138000000000005,32.468 +2020-12-19 13:30:00,95.17,169.263,37.138000000000005,32.468 +2020-12-19 13:45:00,95.3,169.452,37.138000000000005,32.468 +2020-12-19 14:00:00,93.76,168.46900000000002,36.141999999999996,32.468 +2020-12-19 14:15:00,94.4,168.18900000000002,36.141999999999996,32.468 +2020-12-19 14:30:00,93.73,167.553,36.141999999999996,32.468 +2020-12-19 14:45:00,95.27,168.02,36.141999999999996,32.468 +2020-12-19 15:00:00,95.53,169.136,37.964,32.468 +2020-12-19 15:15:00,95.12,169.968,37.964,32.468 +2020-12-19 15:30:00,94.85,172.035,37.964,32.468 +2020-12-19 15:45:00,95.87,173.68400000000003,37.964,32.468 +2020-12-19 16:00:00,102.71,173.486,40.699,32.468 +2020-12-19 16:15:00,100.85,176.049,40.699,32.468 +2020-12-19 16:30:00,103.71,178.514,40.699,32.468 +2020-12-19 16:45:00,102.26,180.572,40.699,32.468 +2020-12-19 17:00:00,105.22,182.958,46.216,32.468 +2020-12-19 17:15:00,103.82,184.842,46.216,32.468 +2020-12-19 17:30:00,107.42,185.225,46.216,32.468 +2020-12-19 17:45:00,106.86,184.43,46.216,32.468 +2020-12-19 18:00:00,106.39,185.953,51.123999999999995,32.468 +2020-12-19 18:15:00,105.15,185.488,51.123999999999995,32.468 +2020-12-19 18:30:00,104.09,185.662,51.123999999999995,32.468 +2020-12-19 18:45:00,103.35,182.18599999999998,51.123999999999995,32.468 +2020-12-19 19:00:00,101.94,185.102,52.336000000000006,32.468 +2020-12-19 19:15:00,100.64,182.34900000000002,52.336000000000006,32.468 +2020-12-19 19:30:00,101.69,180.71,52.336000000000006,32.468 +2020-12-19 19:45:00,98.12,176.26,52.336000000000006,32.468 +2020-12-19 20:00:00,92.73,174.96900000000002,48.825,32.468 +2020-12-19 20:15:00,89.48,171.326,48.825,32.468 +2020-12-19 20:30:00,86.99,167.16099999999997,48.825,32.468 +2020-12-19 20:45:00,85.88,164.67,48.825,32.468 +2020-12-19 21:00:00,82.34,165.27700000000002,43.729,32.468 +2020-12-19 21:15:00,85.85,164.014,43.729,32.468 +2020-12-19 21:30:00,81.94,163.107,43.729,32.468 +2020-12-19 21:45:00,81.18,161.67600000000002,43.729,32.468 +2020-12-19 22:00:00,76.01,157.365,44.126000000000005,32.468 +2020-12-19 22:15:00,75.43,154.187,44.126000000000005,32.468 +2020-12-19 22:30:00,71.47,152.58100000000002,44.126000000000005,32.468 +2020-12-19 22:45:00,70.06,149.986,44.126000000000005,32.468 +2020-12-19 23:00:00,66.49,146.393,38.169000000000004,32.468 +2020-12-19 23:15:00,66.77,140.828,38.169000000000004,32.468 +2020-12-19 23:30:00,64.05,138.111,38.169000000000004,32.468 +2020-12-19 23:45:00,61.94,134.629,38.169000000000004,32.468 +2020-12-20 00:00:00,57.44,113.59899999999999,35.232,32.468 +2020-12-20 00:15:00,55.83,108.861,35.232,32.468 +2020-12-20 00:30:00,55.22,110.48299999999999,35.232,32.468 +2020-12-20 00:45:00,53.65,113.07600000000001,35.232,32.468 +2020-12-20 01:00:00,50.86,115.56200000000001,31.403000000000002,32.468 +2020-12-20 01:15:00,51.31,117.324,31.403000000000002,32.468 +2020-12-20 01:30:00,50.82,117.62100000000001,31.403000000000002,32.468 +2020-12-20 01:45:00,50.65,117.726,31.403000000000002,32.468 +2020-12-20 02:00:00,48.06,119.34700000000001,30.69,32.468 +2020-12-20 02:15:00,48.95,119.454,30.69,32.468 +2020-12-20 02:30:00,49.69,119.81200000000001,30.69,32.468 +2020-12-20 02:45:00,49.72,122.182,30.69,32.468 +2020-12-20 03:00:00,52.26,124.57700000000001,29.516,32.468 +2020-12-20 03:15:00,50.67,124.82,29.516,32.468 +2020-12-20 03:30:00,50.57,126.522,29.516,32.468 +2020-12-20 03:45:00,50.51,127.795,29.516,32.468 +2020-12-20 04:00:00,50.06,136.341,29.148000000000003,32.468 +2020-12-20 04:15:00,50.44,144.77100000000002,29.148000000000003,32.468 +2020-12-20 04:30:00,51.53,145.145,29.148000000000003,32.468 +2020-12-20 04:45:00,52.44,146.28799999999998,29.148000000000003,32.468 +2020-12-20 05:00:00,53.76,160.211,28.706,32.468 +2020-12-20 05:15:00,54.72,169.44299999999998,28.706,32.468 +2020-12-20 05:30:00,54.25,166.403,28.706,32.468 +2020-12-20 05:45:00,55.03,163.986,28.706,32.468 +2020-12-20 06:00:00,57.82,178.773,28.771,32.468 +2020-12-20 06:15:00,58.08,197.097,28.771,32.468 +2020-12-20 06:30:00,59.03,192.31900000000002,28.771,32.468 +2020-12-20 06:45:00,60.79,186.922,28.771,32.468 +2020-12-20 07:00:00,62.77,184.69099999999997,31.39,32.468 +2020-12-20 07:15:00,63.39,188.282,31.39,32.468 +2020-12-20 07:30:00,65.38,192.062,31.39,32.468 +2020-12-20 07:45:00,67.0,195.424,31.39,32.468 +2020-12-20 08:00:00,72.03,198.976,34.972,32.468 +2020-12-20 08:15:00,73.09,201.861,34.972,32.468 +2020-12-20 08:30:00,75.12,204.26,34.972,32.468 +2020-12-20 08:45:00,78.34,204.452,34.972,32.468 +2020-12-20 09:00:00,79.75,199.475,36.709,32.468 +2020-12-20 09:15:00,81.63,197.854,36.709,32.468 +2020-12-20 09:30:00,81.76,195.66099999999997,36.709,32.468 +2020-12-20 09:45:00,83.87,192.864,36.709,32.468 +2020-12-20 10:00:00,85.18,190.226,35.812,32.468 +2020-12-20 10:15:00,86.79,187.08700000000002,35.812,32.468 +2020-12-20 10:30:00,87.22,185.505,35.812,32.468 +2020-12-20 10:45:00,88.97,183.232,35.812,32.468 +2020-12-20 11:00:00,87.56,183.31900000000002,36.746,32.468 +2020-12-20 11:15:00,90.47,180.827,36.746,32.468 +2020-12-20 11:30:00,91.82,179.59799999999998,36.746,32.468 +2020-12-20 11:45:00,93.1,177.797,36.746,32.468 +2020-12-20 12:00:00,92.2,171.933,35.048,32.468 +2020-12-20 12:15:00,91.24,171.46599999999998,35.048,32.468 +2020-12-20 12:30:00,91.52,170.787,35.048,32.468 +2020-12-20 12:45:00,87.78,170.493,35.048,32.468 +2020-12-20 13:00:00,84.85,169.11700000000002,29.987,32.468 +2020-12-20 13:15:00,83.06,170.32299999999998,29.987,32.468 +2020-12-20 13:30:00,80.86,169.38299999999998,29.987,32.468 +2020-12-20 13:45:00,80.54,169.005,29.987,32.468 +2020-12-20 14:00:00,80.42,168.31099999999998,27.21,32.468 +2020-12-20 14:15:00,80.53,169.185,27.21,32.468 +2020-12-20 14:30:00,83.4,169.662,27.21,32.468 +2020-12-20 14:45:00,81.31,169.695,27.21,32.468 +2020-12-20 15:00:00,85.33,169.394,27.726999999999997,32.468 +2020-12-20 15:15:00,82.97,170.889,27.726999999999997,32.468 +2020-12-20 15:30:00,84.51,173.511,27.726999999999997,32.468 +2020-12-20 15:45:00,86.95,175.827,27.726999999999997,32.468 +2020-12-20 16:00:00,90.83,177.327,32.23,32.468 +2020-12-20 16:15:00,91.19,179.05700000000002,32.23,32.468 +2020-12-20 16:30:00,92.7,181.822,32.23,32.468 +2020-12-20 16:45:00,93.89,184.03099999999998,32.23,32.468 +2020-12-20 17:00:00,96.36,186.42700000000002,42.016999999999996,32.468 +2020-12-20 17:15:00,96.89,188.093,42.016999999999996,32.468 +2020-12-20 17:30:00,101.65,188.80599999999998,42.016999999999996,32.468 +2020-12-20 17:45:00,99.7,190.2,42.016999999999996,32.468 +2020-12-20 18:00:00,99.58,191.257,49.338,32.468 +2020-12-20 18:15:00,98.86,192.024,49.338,32.468 +2020-12-20 18:30:00,99.68,190.203,49.338,32.468 +2020-12-20 18:45:00,98.16,188.51,49.338,32.468 +2020-12-20 19:00:00,96.38,191.18099999999998,52.369,32.468 +2020-12-20 19:15:00,95.5,188.947,52.369,32.468 +2020-12-20 19:30:00,94.26,187.13400000000001,52.369,32.468 +2020-12-20 19:45:00,93.64,184.032,52.369,32.468 +2020-12-20 20:00:00,92.2,182.707,50.405,32.468 +2020-12-20 20:15:00,91.3,179.99400000000003,50.405,32.468 +2020-12-20 20:30:00,89.41,177.025,50.405,32.468 +2020-12-20 20:45:00,88.72,173.33700000000002,50.405,32.468 +2020-12-20 21:00:00,82.11,171.449,46.235,32.468 +2020-12-20 21:15:00,79.31,169.55900000000003,46.235,32.468 +2020-12-20 21:30:00,78.23,168.908,46.235,32.468 +2020-12-20 21:45:00,80.71,167.632,46.235,32.468 +2020-12-20 22:00:00,76.48,162.267,46.861000000000004,32.468 +2020-12-20 22:15:00,75.69,158.30200000000002,46.861000000000004,32.468 +2020-12-20 22:30:00,72.53,153.685,46.861000000000004,32.468 +2020-12-20 22:45:00,71.4,150.233,46.861000000000004,32.468 +2020-12-20 23:00:00,67.24,143.93200000000002,41.302,32.468 +2020-12-20 23:15:00,67.09,140.191,41.302,32.468 +2020-12-20 23:30:00,65.09,138.24200000000002,41.302,32.468 +2020-12-20 23:45:00,63.54,135.611,41.302,32.468 +2020-12-21 00:00:00,59.61,117.899,37.164,32.65 +2020-12-21 00:15:00,58.76,116.00200000000001,37.164,32.65 +2020-12-21 00:30:00,55.48,117.723,37.164,32.65 +2020-12-21 00:45:00,58.12,119.76799999999999,37.164,32.65 +2020-12-21 01:00:00,54.85,122.302,34.994,32.65 +2020-12-21 01:15:00,52.98,123.559,34.994,32.65 +2020-12-21 01:30:00,50.94,123.928,34.994,32.65 +2020-12-21 01:45:00,50.8,124.125,34.994,32.65 +2020-12-21 02:00:00,52.33,125.757,34.571,32.65 +2020-12-21 02:15:00,52.41,127.214,34.571,32.65 +2020-12-21 02:30:00,52.65,127.90100000000001,34.571,32.65 +2020-12-21 02:45:00,53.11,129.673,34.571,32.65 +2020-12-21 03:00:00,51.52,133.284,33.934,32.65 +2020-12-21 03:15:00,52.71,135.15,33.934,32.65 +2020-12-21 03:30:00,52.07,136.624,33.934,32.65 +2020-12-21 03:45:00,49.61,137.35299999999998,33.934,32.65 +2020-12-21 04:00:00,53.48,150.143,34.107,32.65 +2020-12-21 04:15:00,52.93,162.64,34.107,32.65 +2020-12-21 04:30:00,53.67,165.072,34.107,32.65 +2020-12-21 04:45:00,54.53,166.38400000000001,34.107,32.65 +2020-12-21 05:00:00,56.42,195.558,39.575,32.65 +2020-12-21 05:15:00,57.68,224.365,39.575,32.65 +2020-12-21 05:30:00,58.83,221.533,39.575,32.65 +2020-12-21 05:45:00,60.34,213.628,39.575,32.65 +2020-12-21 06:00:00,62.76,210.952,56.156000000000006,32.65 +2020-12-21 06:15:00,66.38,214.78599999999997,56.156000000000006,32.65 +2020-12-21 06:30:00,67.63,217.83,56.156000000000006,32.65 +2020-12-21 06:45:00,70.42,221.06,56.156000000000006,32.65 +2020-12-21 07:00:00,74.09,221.109,67.926,32.65 +2020-12-21 07:15:00,75.56,225.983,67.926,32.65 +2020-12-21 07:30:00,78.04,228.984,67.926,32.65 +2020-12-21 07:45:00,81.33,229.912,67.926,32.65 +2020-12-21 08:00:00,85.45,228.74900000000002,72.58,32.65 +2020-12-21 08:15:00,86.9,229.53400000000002,72.58,32.65 +2020-12-21 08:30:00,89.21,228.0,72.58,32.65 +2020-12-21 08:45:00,91.43,225.005,72.58,32.65 +2020-12-21 09:00:00,94.89,219.02200000000002,66.984,32.65 +2020-12-21 09:15:00,97.25,214.018,66.984,32.65 +2020-12-21 09:30:00,101.27,210.845,66.984,32.65 +2020-12-21 09:45:00,99.72,208.222,66.984,32.65 +2020-12-21 10:00:00,101.1,204.55200000000002,63.158,32.65 +2020-12-21 10:15:00,100.36,201.084,63.158,32.65 +2020-12-21 10:30:00,101.56,198.63400000000001,63.158,32.65 +2020-12-21 10:45:00,102.86,196.997,63.158,32.65 +2020-12-21 11:00:00,100.92,194.606,61.141000000000005,32.65 +2020-12-21 11:15:00,101.74,193.829,61.141000000000005,32.65 +2020-12-21 11:30:00,103.83,193.94,61.141000000000005,32.65 +2020-12-21 11:45:00,102.91,191.77200000000002,61.141000000000005,32.65 +2020-12-21 12:00:00,101.04,187.456,57.961000000000006,32.65 +2020-12-21 12:15:00,101.4,187.00900000000001,57.961000000000006,32.65 +2020-12-21 12:30:00,98.37,186.525,57.961000000000006,32.65 +2020-12-21 12:45:00,97.4,187.678,57.961000000000006,32.65 +2020-12-21 13:00:00,94.93,186.868,56.843,32.65 +2020-12-21 13:15:00,92.49,186.717,56.843,32.65 +2020-12-21 13:30:00,90.19,185.27599999999998,56.843,32.65 +2020-12-21 13:45:00,89.94,184.963,56.843,32.65 +2020-12-21 14:00:00,86.88,183.642,55.992,32.65 +2020-12-21 14:15:00,84.1,183.937,55.992,32.65 +2020-12-21 14:30:00,81.84,183.912,55.992,32.65 +2020-12-21 14:45:00,83.32,184.023,55.992,32.65 +2020-12-21 15:00:00,83.47,185.426,57.523,32.65 +2020-12-21 15:15:00,81.79,185.524,57.523,32.65 +2020-12-21 15:30:00,81.2,187.407,57.523,32.65 +2020-12-21 15:45:00,82.37,189.275,57.523,32.65 +2020-12-21 16:00:00,85.28,191.02,59.471000000000004,32.65 +2020-12-21 16:15:00,84.75,192.044,59.471000000000004,32.65 +2020-12-21 16:30:00,86.17,193.86700000000002,59.471000000000004,32.65 +2020-12-21 16:45:00,86.8,194.97,59.471000000000004,32.65 +2020-12-21 17:00:00,89.11,197.155,65.066,32.65 +2020-12-21 17:15:00,90.27,197.953,65.066,32.65 +2020-12-21 17:30:00,92.59,198.148,65.066,32.65 +2020-12-21 17:45:00,92.01,198.112,65.066,32.65 +2020-12-21 18:00:00,92.03,199.575,69.581,32.65 +2020-12-21 18:15:00,92.38,198.145,69.581,32.65 +2020-12-21 18:30:00,91.75,196.93200000000002,69.581,32.65 +2020-12-21 18:45:00,91.35,196.043,69.581,32.65 +2020-12-21 19:00:00,87.77,197.149,73.771,32.65 +2020-12-21 19:15:00,86.86,193.826,73.771,32.65 +2020-12-21 19:30:00,85.1,192.47299999999998,73.771,32.65 +2020-12-21 19:45:00,84.71,188.53799999999998,73.771,32.65 +2020-12-21 20:00:00,81.21,184.893,65.035,32.65 +2020-12-21 20:15:00,80.86,179.843,65.035,32.65 +2020-12-21 20:30:00,79.94,175.08700000000002,65.035,32.65 +2020-12-21 20:45:00,78.91,172.983,65.035,32.65 +2020-12-21 21:00:00,76.0,171.56799999999998,58.7,32.65 +2020-12-21 21:15:00,76.31,168.549,58.7,32.65 +2020-12-21 21:30:00,74.92,167.11900000000003,58.7,32.65 +2020-12-21 21:45:00,74.61,165.36,58.7,32.65 +2020-12-21 22:00:00,72.26,157.161,53.888000000000005,32.65 +2020-12-21 22:15:00,72.03,152.0,53.888000000000005,32.65 +2020-12-21 22:30:00,70.9,138.101,53.888000000000005,32.65 +2020-12-21 22:45:00,70.7,130.013,53.888000000000005,32.65 +2020-12-21 23:00:00,67.92,124.43299999999999,45.501999999999995,32.65 +2020-12-21 23:15:00,65.32,123.19,45.501999999999995,32.65 +2020-12-21 23:30:00,63.5,123.906,45.501999999999995,32.65 +2020-12-21 23:45:00,63.09,123.789,45.501999999999995,32.65 +2020-12-22 00:00:00,77.11,117.387,43.537,32.65 +2020-12-22 00:15:00,75.55,116.86,43.537,32.65 +2020-12-22 00:30:00,74.5,117.693,43.537,32.65 +2020-12-22 00:45:00,75.85,118.81,43.537,32.65 +2020-12-22 01:00:00,74.35,121.161,41.854,32.65 +2020-12-22 01:15:00,72.04,121.991,41.854,32.65 +2020-12-22 01:30:00,70.74,122.525,41.854,32.65 +2020-12-22 01:45:00,72.79,122.985,41.854,32.65 +2020-12-22 02:00:00,71.78,124.613,40.321,32.65 +2020-12-22 02:15:00,72.48,126.00299999999999,40.321,32.65 +2020-12-22 02:30:00,72.39,126.111,40.321,32.65 +2020-12-22 02:45:00,73.21,127.904,40.321,32.65 +2020-12-22 03:00:00,72.62,130.333,39.632,32.65 +2020-12-22 03:15:00,73.58,131.439,39.632,32.65 +2020-12-22 03:30:00,75.36,133.376,39.632,32.65 +2020-12-22 03:45:00,75.07,134.254,39.632,32.65 +2020-12-22 04:00:00,74.68,146.79,40.183,32.65 +2020-12-22 04:15:00,74.32,158.94799999999998,40.183,32.65 +2020-12-22 04:30:00,75.7,161.071,40.183,32.65 +2020-12-22 04:45:00,77.44,163.56,40.183,32.65 +2020-12-22 05:00:00,80.88,197.551,43.945,32.65 +2020-12-22 05:15:00,83.17,226.143,43.945,32.65 +2020-12-22 05:30:00,88.89,221.862,43.945,32.65 +2020-12-22 05:45:00,94.9,213.957,43.945,32.65 +2020-12-22 06:00:00,103.72,210.203,56.048,32.65 +2020-12-22 06:15:00,108.11,215.607,56.048,32.65 +2020-12-22 06:30:00,112.38,218.05200000000002,56.048,32.65 +2020-12-22 06:45:00,116.06,220.926,56.048,32.65 +2020-12-22 07:00:00,122.27,220.813,65.74,32.65 +2020-12-22 07:15:00,126.37,225.525,65.74,32.65 +2020-12-22 07:30:00,128.12,227.997,65.74,32.65 +2020-12-22 07:45:00,131.48,229.09,65.74,32.65 +2020-12-22 08:00:00,133.47,228.032,72.757,32.65 +2020-12-22 08:15:00,133.0,227.81099999999998,72.757,32.65 +2020-12-22 08:30:00,133.5,226.104,72.757,32.65 +2020-12-22 08:45:00,134.21,222.799,72.757,32.65 +2020-12-22 09:00:00,133.97,216.007,67.692,32.65 +2020-12-22 09:15:00,136.46,212.53900000000002,67.692,32.65 +2020-12-22 09:30:00,137.68,210.049,67.692,32.65 +2020-12-22 09:45:00,137.1,207.25099999999998,67.692,32.65 +2020-12-22 10:00:00,137.74,202.968,63.506,32.65 +2020-12-22 10:15:00,137.9,198.468,63.506,32.65 +2020-12-22 10:30:00,137.65,196.17700000000002,63.506,32.65 +2020-12-22 10:45:00,140.41,194.83900000000003,63.506,32.65 +2020-12-22 11:00:00,142.25,193.857,60.758,32.65 +2020-12-22 11:15:00,143.49,192.766,60.758,32.65 +2020-12-22 11:30:00,143.27,191.726,60.758,32.65 +2020-12-22 11:45:00,138.82,190.21099999999998,60.758,32.65 +2020-12-22 12:00:00,137.52,184.581,57.519,32.65 +2020-12-22 12:15:00,137.54,183.763,57.519,32.65 +2020-12-22 12:30:00,137.08,184.00599999999997,57.519,32.65 +2020-12-22 12:45:00,135.61,184.908,57.519,32.65 +2020-12-22 13:00:00,132.97,183.676,56.46,32.65 +2020-12-22 13:15:00,132.28,183.25400000000002,56.46,32.65 +2020-12-22 13:30:00,130.5,182.924,56.46,32.65 +2020-12-22 13:45:00,130.46,182.74599999999998,56.46,32.65 +2020-12-22 14:00:00,129.11,181.707,56.207,32.65 +2020-12-22 14:15:00,129.2,182.144,56.207,32.65 +2020-12-22 14:30:00,127.97,182.748,56.207,32.65 +2020-12-22 14:45:00,128.67,182.766,56.207,32.65 +2020-12-22 15:00:00,128.91,183.767,57.391999999999996,32.65 +2020-12-22 15:15:00,128.69,184.206,57.391999999999996,32.65 +2020-12-22 15:30:00,128.66,186.304,57.391999999999996,32.65 +2020-12-22 15:45:00,129.67,187.796,57.391999999999996,32.65 +2020-12-22 16:00:00,133.28,189.83700000000002,59.955,32.65 +2020-12-22 16:15:00,135.66,191.335,59.955,32.65 +2020-12-22 16:30:00,137.94,193.77200000000002,59.955,32.65 +2020-12-22 16:45:00,139.17,195.19400000000002,59.955,32.65 +2020-12-22 17:00:00,143.78,197.887,67.063,32.65 +2020-12-22 17:15:00,144.03,198.753,67.063,32.65 +2020-12-22 17:30:00,145.49,199.59599999999998,67.063,32.65 +2020-12-22 17:45:00,142.58,199.43900000000002,67.063,32.65 +2020-12-22 18:00:00,139.78,200.797,71.477,32.65 +2020-12-22 18:15:00,138.93,198.907,71.477,32.65 +2020-12-22 18:30:00,137.96,197.396,71.477,32.65 +2020-12-22 18:45:00,138.52,197.283,71.477,32.65 +2020-12-22 19:00:00,134.79,198.418,74.32,32.65 +2020-12-22 19:15:00,134.11,194.825,74.32,32.65 +2020-12-22 19:30:00,134.57,192.81400000000002,74.32,32.65 +2020-12-22 19:45:00,134.19,188.90900000000002,74.32,32.65 +2020-12-22 20:00:00,126.29,185.421,66.157,32.65 +2020-12-22 20:15:00,120.72,179.71200000000002,66.157,32.65 +2020-12-22 20:30:00,116.25,175.917,66.157,32.65 +2020-12-22 20:45:00,114.97,173.28900000000002,66.157,32.65 +2020-12-22 21:00:00,111.39,171.205,59.806000000000004,32.65 +2020-12-22 21:15:00,112.49,169.00799999999998,59.806000000000004,32.65 +2020-12-22 21:30:00,112.51,166.87900000000002,59.806000000000004,32.65 +2020-12-22 21:45:00,108.75,165.354,59.806000000000004,32.65 +2020-12-22 22:00:00,100.85,158.816,54.785,32.65 +2020-12-22 22:15:00,98.46,153.403,54.785,32.65 +2020-12-22 22:30:00,93.77,139.632,54.785,32.65 +2020-12-22 22:45:00,94.39,131.819,54.785,32.65 +2020-12-22 23:00:00,92.62,126.223,47.176,32.65 +2020-12-22 23:15:00,91.43,124.146,47.176,32.65 +2020-12-22 23:30:00,86.04,124.527,47.176,32.65 +2020-12-22 23:45:00,83.32,123.98200000000001,47.176,32.65 +2020-12-23 00:00:00,83.37,117.60700000000001,43.42,32.65 +2020-12-23 00:15:00,83.59,117.055,43.42,32.65 +2020-12-23 00:30:00,83.23,117.87899999999999,43.42,32.65 +2020-12-23 00:45:00,80.94,118.979,43.42,32.65 +2020-12-23 01:00:00,76.7,121.35,40.869,32.65 +2020-12-23 01:15:00,80.48,122.177,40.869,32.65 +2020-12-23 01:30:00,81.54,122.714,40.869,32.65 +2020-12-23 01:45:00,80.75,123.163,40.869,32.65 +2020-12-23 02:00:00,77.53,124.807,39.541,32.65 +2020-12-23 02:15:00,76.71,126.197,39.541,32.65 +2020-12-23 02:30:00,79.2,126.31,39.541,32.65 +2020-12-23 02:45:00,81.57,128.10299999999998,39.541,32.65 +2020-12-23 03:00:00,79.82,130.524,39.052,32.65 +2020-12-23 03:15:00,77.81,131.65200000000002,39.052,32.65 +2020-12-23 03:30:00,79.49,133.59,39.052,32.65 +2020-12-23 03:45:00,83.44,134.471,39.052,32.65 +2020-12-23 04:00:00,82.03,146.98,40.36,32.65 +2020-12-23 04:15:00,80.22,159.136,40.36,32.65 +2020-12-23 04:30:00,80.62,161.252,40.36,32.65 +2020-12-23 04:45:00,83.03,163.74,40.36,32.65 +2020-12-23 05:00:00,87.93,197.69799999999998,43.133,32.65 +2020-12-23 05:15:00,91.08,226.25599999999997,43.133,32.65 +2020-12-23 05:30:00,94.96,221.985,43.133,32.65 +2020-12-23 05:45:00,98.6,214.09900000000002,43.133,32.65 +2020-12-23 06:00:00,105.55,210.36900000000003,54.953,32.65 +2020-12-23 06:15:00,110.85,215.77700000000002,54.953,32.65 +2020-12-23 06:30:00,115.87,218.25099999999998,54.953,32.65 +2020-12-23 06:45:00,120.4,221.162,54.953,32.65 +2020-12-23 07:00:00,126.78,221.05700000000002,66.566,32.65 +2020-12-23 07:15:00,128.65,225.765,66.566,32.65 +2020-12-23 07:30:00,130.99,228.233,66.566,32.65 +2020-12-23 07:45:00,133.38,229.315,66.566,32.65 +2020-12-23 08:00:00,135.54,228.261,72.902,32.65 +2020-12-23 08:15:00,135.89,228.02700000000002,72.902,32.65 +2020-12-23 08:30:00,136.41,226.31099999999998,72.902,32.65 +2020-12-23 08:45:00,136.61,222.982,72.902,32.65 +2020-12-23 09:00:00,137.52,216.169,68.465,32.65 +2020-12-23 09:15:00,138.65,212.708,68.465,32.65 +2020-12-23 09:30:00,139.53,210.229,68.465,32.65 +2020-12-23 09:45:00,140.58,207.421,68.465,32.65 +2020-12-23 10:00:00,139.15,203.13299999999998,63.625,32.65 +2020-12-23 10:15:00,139.36,198.62400000000002,63.625,32.65 +2020-12-23 10:30:00,139.02,196.31799999999998,63.625,32.65 +2020-12-23 10:45:00,138.24,194.97799999999998,63.625,32.65 +2020-12-23 11:00:00,141.5,193.97799999999998,61.628,32.65 +2020-12-23 11:15:00,140.02,192.88,61.628,32.65 +2020-12-23 11:30:00,142.39,191.84,61.628,32.65 +2020-12-23 11:45:00,137.8,190.325,61.628,32.65 +2020-12-23 12:00:00,134.47,184.69799999999998,58.708999999999996,32.65 +2020-12-23 12:15:00,132.86,183.894,58.708999999999996,32.65 +2020-12-23 12:30:00,132.25,184.143,58.708999999999996,32.65 +2020-12-23 12:45:00,133.37,185.047,58.708999999999996,32.65 +2020-12-23 13:00:00,133.19,183.796,57.373000000000005,32.65 +2020-12-23 13:15:00,133.97,183.37,57.373000000000005,32.65 +2020-12-23 13:30:00,132.3,183.033,57.373000000000005,32.65 +2020-12-23 13:45:00,130.97,182.84799999999998,57.373000000000005,32.65 +2020-12-23 14:00:00,130.64,181.804,57.684,32.65 +2020-12-23 14:15:00,130.79,182.24099999999999,57.684,32.65 +2020-12-23 14:30:00,130.4,182.863,57.684,32.65 +2020-12-23 14:45:00,130.0,182.893,57.684,32.65 +2020-12-23 15:00:00,130.36,183.90900000000002,58.03,32.65 +2020-12-23 15:15:00,130.22,184.33900000000003,58.03,32.65 +2020-12-23 15:30:00,129.82,186.446,58.03,32.65 +2020-12-23 15:45:00,131.13,187.935,58.03,32.65 +2020-12-23 16:00:00,133.7,189.97299999999998,59.97,32.65 +2020-12-23 16:15:00,136.31,191.487,59.97,32.65 +2020-12-23 16:30:00,139.13,193.93,59.97,32.65 +2020-12-23 16:45:00,141.42,195.372,59.97,32.65 +2020-12-23 17:00:00,147.76,198.045,65.661,32.65 +2020-12-23 17:15:00,147.93,198.938,65.661,32.65 +2020-12-23 17:30:00,148.05,199.803,65.661,32.65 +2020-12-23 17:45:00,143.23,199.658,65.661,32.65 +2020-12-23 18:00:00,140.17,201.03599999999997,70.96300000000001,32.65 +2020-12-23 18:15:00,139.83,199.13299999999998,70.96300000000001,32.65 +2020-12-23 18:30:00,138.54,197.62900000000002,70.96300000000001,32.65 +2020-12-23 18:45:00,138.83,197.52900000000002,70.96300000000001,32.65 +2020-12-23 19:00:00,136.66,198.641,74.133,32.65 +2020-12-23 19:15:00,134.75,195.046,74.133,32.65 +2020-12-23 19:30:00,133.88,193.02900000000002,74.133,32.65 +2020-12-23 19:45:00,133.25,189.11,74.133,32.65 +2020-12-23 20:00:00,126.72,185.614,65.613,32.65 +2020-12-23 20:15:00,120.6,179.90200000000002,65.613,32.65 +2020-12-23 20:30:00,117.16,176.08900000000003,65.613,32.65 +2020-12-23 20:45:00,115.94,173.47799999999998,65.613,32.65 +2020-12-23 21:00:00,113.09,171.37900000000002,58.583,32.65 +2020-12-23 21:15:00,114.76,169.165,58.583,32.65 +2020-12-23 21:30:00,112.8,167.037,58.583,32.65 +2020-12-23 21:45:00,109.3,165.523,58.583,32.65 +2020-12-23 22:00:00,103.75,158.988,54.411,32.65 +2020-12-23 22:15:00,102.73,153.583,54.411,32.65 +2020-12-23 22:30:00,99.23,139.843,54.411,32.65 +2020-12-23 22:45:00,99.37,132.03799999999998,54.411,32.65 +2020-12-23 23:00:00,94.42,126.421,47.878,32.65 +2020-12-23 23:15:00,89.7,124.345,47.878,32.65 +2020-12-23 23:30:00,86.46,124.74,47.878,32.65 +2020-12-23 23:45:00,87.03,124.18799999999999,47.878,32.65 +2020-12-24 00:00:00,57.0,117.822,44.513000000000005,32.468 +2020-12-24 00:15:00,57.34,117.244,44.513000000000005,32.468 +2020-12-24 00:30:00,56.42,118.05799999999999,44.513000000000005,32.468 +2020-12-24 00:45:00,55.53,119.14,44.513000000000005,32.468 +2020-12-24 01:00:00,52.63,121.53200000000001,43.169,32.468 +2020-12-24 01:15:00,53.35,122.355,43.169,32.468 +2020-12-24 01:30:00,52.36,122.896,43.169,32.468 +2020-12-24 01:45:00,52.48,123.33200000000001,43.169,32.468 +2020-12-24 02:00:00,51.25,124.992,41.763999999999996,32.468 +2020-12-24 02:15:00,52.09,126.383,41.763999999999996,32.468 +2020-12-24 02:30:00,51.45,126.501,41.763999999999996,32.468 +2020-12-24 02:45:00,51.82,128.293,41.763999999999996,32.468 +2020-12-24 03:00:00,51.68,130.705,41.155,32.468 +2020-12-24 03:15:00,52.17,131.857,41.155,32.468 +2020-12-24 03:30:00,52.33,133.796,41.155,32.468 +2020-12-24 03:45:00,53.49,134.678,41.155,32.468 +2020-12-24 04:00:00,54.37,147.165,41.96,32.468 +2020-12-24 04:15:00,54.98,159.316,41.96,32.468 +2020-12-24 04:30:00,55.93,161.425,41.96,32.468 +2020-12-24 04:45:00,57.9,163.912,41.96,32.468 +2020-12-24 05:00:00,61.23,197.83700000000002,45.206,32.468 +2020-12-24 05:15:00,61.7,226.36,45.206,32.468 +2020-12-24 05:30:00,63.74,222.101,45.206,32.468 +2020-12-24 05:45:00,65.72,214.232,45.206,32.468 +2020-12-24 06:00:00,71.25,210.52599999999998,55.398999999999994,32.468 +2020-12-24 06:15:00,73.82,215.94,55.398999999999994,32.468 +2020-12-24 06:30:00,75.73,218.44099999999997,55.398999999999994,32.468 +2020-12-24 06:45:00,79.76,221.386,55.398999999999994,32.468 +2020-12-24 07:00:00,84.31,221.29,64.627,32.468 +2020-12-24 07:15:00,85.26,225.99599999999998,64.627,32.468 +2020-12-24 07:30:00,86.83,228.458,64.627,32.468 +2020-12-24 07:45:00,89.99,229.53,64.627,32.468 +2020-12-24 08:00:00,92.92,228.477,70.895,32.468 +2020-12-24 08:15:00,93.1,228.232,70.895,32.468 +2020-12-24 08:30:00,94.95,226.505,70.895,32.468 +2020-12-24 08:45:00,95.57,223.15200000000002,70.895,32.468 +2020-12-24 09:00:00,99.59,216.31900000000002,66.382,32.468 +2020-12-24 09:15:00,100.3,212.865,66.382,32.468 +2020-12-24 09:30:00,100.82,210.396,66.382,32.468 +2020-12-24 09:45:00,100.23,207.579,66.382,32.468 +2020-12-24 10:00:00,101.06,203.28799999999998,62.739,32.468 +2020-12-24 10:15:00,101.79,198.771,62.739,32.468 +2020-12-24 10:30:00,102.05,196.449,62.739,32.468 +2020-12-24 10:45:00,102.54,195.107,62.739,32.468 +2020-12-24 11:00:00,100.69,194.09,60.843,32.468 +2020-12-24 11:15:00,101.48,192.986,60.843,32.468 +2020-12-24 11:30:00,100.88,191.946,60.843,32.468 +2020-12-24 11:45:00,99.0,190.43,60.843,32.468 +2020-12-24 12:00:00,94.85,184.805,58.466,32.468 +2020-12-24 12:15:00,95.31,184.016,58.466,32.468 +2020-12-24 12:30:00,93.98,184.271,58.466,32.468 +2020-12-24 12:45:00,96.03,185.178,58.466,32.468 +2020-12-24 13:00:00,94.18,183.908,56.883,32.468 +2020-12-24 13:15:00,90.44,183.48,56.883,32.468 +2020-12-24 13:30:00,90.13,183.132,56.883,32.468 +2020-12-24 13:45:00,90.54,182.94,56.883,32.468 +2020-12-24 14:00:00,89.78,181.893,56.503,32.468 +2020-12-24 14:15:00,92.43,182.33,56.503,32.468 +2020-12-24 14:30:00,90.39,182.968,56.503,32.468 +2020-12-24 14:45:00,92.14,183.01,56.503,32.468 +2020-12-24 15:00:00,92.57,184.044,57.803999999999995,32.468 +2020-12-24 15:15:00,93.64,184.463,57.803999999999995,32.468 +2020-12-24 15:30:00,94.37,186.578,57.803999999999995,32.468 +2020-12-24 15:45:00,97.44,188.062,57.803999999999995,32.468 +2020-12-24 16:00:00,103.78,190.1,59.379,32.468 +2020-12-24 16:15:00,107.92,191.628,59.379,32.468 +2020-12-24 16:30:00,106.71,194.077,59.379,32.468 +2020-12-24 16:45:00,107.58,195.53900000000002,59.379,32.468 +2020-12-24 17:00:00,111.26,198.19299999999998,64.71600000000001,32.468 +2020-12-24 17:15:00,110.82,199.113,64.71600000000001,32.468 +2020-12-24 17:30:00,112.07,200.0,64.71600000000001,32.468 +2020-12-24 17:45:00,111.35,199.86700000000002,64.71600000000001,32.468 +2020-12-24 18:00:00,110.75,201.264,68.803,32.468 +2020-12-24 18:15:00,110.62,199.34900000000002,68.803,32.468 +2020-12-24 18:30:00,110.52,197.851,68.803,32.468 +2020-12-24 18:45:00,110.48,197.765,68.803,32.468 +2020-12-24 19:00:00,111.49,198.856,72.934,32.468 +2020-12-24 19:15:00,106.52,195.25599999999997,72.934,32.468 +2020-12-24 19:30:00,104.08,193.234,72.934,32.468 +2020-12-24 19:45:00,102.68,189.304,72.934,32.468 +2020-12-24 20:00:00,96.72,185.8,65.175,32.468 +2020-12-24 20:15:00,96.77,180.084,65.175,32.468 +2020-12-24 20:30:00,91.18,176.25400000000002,65.175,32.468 +2020-12-24 20:45:00,89.83,173.66,65.175,32.468 +2020-12-24 21:00:00,86.22,171.545,58.55,32.468 +2020-12-24 21:15:00,85.58,169.31400000000002,58.55,32.468 +2020-12-24 21:30:00,83.25,167.188,58.55,32.468 +2020-12-24 21:45:00,85.54,165.68599999999998,58.55,32.468 +2020-12-24 22:00:00,76.93,159.151,55.041000000000004,32.468 +2020-12-24 22:15:00,77.34,153.755,55.041000000000004,32.468 +2020-12-24 22:30:00,73.53,140.047,55.041000000000004,32.468 +2020-12-24 22:45:00,72.69,132.249,55.041000000000004,32.468 +2020-12-24 23:00:00,68.02,126.61,48.258,32.468 +2020-12-24 23:15:00,68.11,124.535,48.258,32.468 +2020-12-24 23:30:00,64.77,124.944,48.258,32.468 +2020-12-24 23:45:00,67.67,124.384,48.258,32.468 +2020-12-25 00:00:00,57.87,114.70100000000001,32.311,32.468 +2020-12-25 00:15:00,58.21,109.835,32.311,32.468 +2020-12-25 00:30:00,57.43,111.413,32.311,32.468 +2020-12-25 00:45:00,57.71,113.917,32.311,32.468 +2020-12-25 01:00:00,54.98,116.509,25.569000000000003,32.468 +2020-12-25 01:15:00,55.19,118.25200000000001,25.569000000000003,32.468 +2020-12-25 01:30:00,54.78,118.568,25.569000000000003,32.468 +2020-12-25 01:45:00,53.55,118.613,25.569000000000003,32.468 +2020-12-25 02:00:00,50.76,120.314,21.038,32.468 +2020-12-25 02:15:00,52.54,120.425,21.038,32.468 +2020-12-25 02:30:00,49.88,120.809,21.038,32.468 +2020-12-25 02:45:00,52.68,123.17399999999999,21.038,32.468 +2020-12-25 03:00:00,51.32,125.52600000000001,19.865,32.468 +2020-12-25 03:15:00,51.45,125.884,19.865,32.468 +2020-12-25 03:30:00,52.01,127.59,19.865,32.468 +2020-12-25 03:45:00,52.84,128.874,19.865,32.468 +2020-12-25 04:00:00,52.17,137.297,19.076,32.468 +2020-12-25 04:15:00,52.24,145.708,19.076,32.468 +2020-12-25 04:30:00,53.63,146.046,19.076,32.468 +2020-12-25 04:45:00,54.09,147.187,19.076,32.468 +2020-12-25 05:00:00,53.22,160.945,20.174,32.468 +2020-12-25 05:15:00,54.53,169.99900000000002,20.174,32.468 +2020-12-25 05:30:00,53.56,167.02200000000002,20.174,32.468 +2020-12-25 05:45:00,57.2,164.69400000000002,20.174,32.468 +2020-12-25 06:00:00,57.45,179.6,19.854,32.468 +2020-12-25 06:15:00,58.55,197.949,19.854,32.468 +2020-12-25 06:30:00,59.92,193.317,19.854,32.468 +2020-12-25 06:45:00,61.05,188.095,19.854,32.468 +2020-12-25 07:00:00,63.95,185.90599999999998,23.096999999999998,32.468 +2020-12-25 07:15:00,65.26,189.485,23.096999999999998,32.468 +2020-12-25 07:30:00,66.28,193.239,23.096999999999998,32.468 +2020-12-25 07:45:00,69.33,196.55,23.096999999999998,32.468 +2020-12-25 08:00:00,71.62,200.118,30.849,32.468 +2020-12-25 08:15:00,74.42,202.94400000000002,30.849,32.468 +2020-12-25 08:30:00,76.82,205.296,30.849,32.468 +2020-12-25 08:45:00,78.68,205.364,30.849,32.468 +2020-12-25 09:00:00,81.32,200.285,30.03,32.468 +2020-12-25 09:15:00,83.82,198.69799999999998,30.03,32.468 +2020-12-25 09:30:00,85.11,196.558,30.03,32.468 +2020-12-25 09:45:00,87.16,193.717,30.03,32.468 +2020-12-25 10:00:00,87.91,191.055,27.625999999999998,32.468 +2020-12-25 10:15:00,89.23,187.868,27.625999999999998,32.468 +2020-12-25 10:30:00,91.55,186.21,27.625999999999998,32.468 +2020-12-25 10:45:00,93.17,183.923,27.625999999999998,32.468 +2020-12-25 11:00:00,94.65,183.926,29.03,32.468 +2020-12-25 11:15:00,98.58,181.39700000000002,29.03,32.468 +2020-12-25 11:30:00,100.6,180.172,29.03,32.468 +2020-12-25 11:45:00,100.05,178.365,29.03,32.468 +2020-12-25 12:00:00,95.72,172.512,25.93,32.468 +2020-12-25 12:15:00,92.54,172.11900000000003,25.93,32.468 +2020-12-25 12:30:00,88.4,171.47,25.93,32.468 +2020-12-25 12:45:00,84.51,171.19299999999998,25.93,32.468 +2020-12-25 13:00:00,81.0,169.72099999999998,16.363,32.468 +2020-12-25 13:15:00,80.85,170.907,16.363,32.468 +2020-12-25 13:30:00,80.27,169.92700000000002,16.363,32.468 +2020-12-25 13:45:00,80.22,169.51,16.363,32.468 +2020-12-25 14:00:00,77.57,168.797,14.370999999999999,32.468 +2020-12-25 14:15:00,78.09,169.671,14.370999999999999,32.468 +2020-12-25 14:30:00,77.16,170.231,14.370999999999999,32.468 +2020-12-25 14:45:00,76.87,170.32299999999998,14.370999999999999,32.468 +2020-12-25 15:00:00,77.34,170.106,19.031,32.468 +2020-12-25 15:15:00,76.88,171.554,19.031,32.468 +2020-12-25 15:30:00,76.93,174.22,19.031,32.468 +2020-12-25 15:45:00,78.2,176.516,19.031,32.468 +2020-12-25 16:00:00,81.38,178.011,24.998,32.468 +2020-12-25 16:15:00,82.28,179.81900000000002,24.998,32.468 +2020-12-25 16:30:00,86.93,182.61,24.998,32.468 +2020-12-25 16:45:00,86.17,184.921,24.998,32.468 +2020-12-25 17:00:00,89.63,187.218,35.976,32.468 +2020-12-25 17:15:00,87.98,189.021,35.976,32.468 +2020-12-25 17:30:00,89.65,189.84099999999998,35.976,32.468 +2020-12-25 17:45:00,90.91,191.3,35.976,32.468 +2020-12-25 18:00:00,91.63,192.454,41.513000000000005,32.468 +2020-12-25 18:15:00,90.25,193.15400000000002,41.513000000000005,32.468 +2020-12-25 18:30:00,91.06,191.364,41.513000000000005,32.468 +2020-12-25 18:45:00,91.59,189.735,41.513000000000005,32.468 +2020-12-25 19:00:00,90.53,192.299,45.607,32.468 +2020-12-25 19:15:00,89.44,190.046,45.607,32.468 +2020-12-25 19:30:00,88.41,188.205,45.607,32.468 +2020-12-25 19:45:00,87.49,185.03900000000002,45.607,32.468 +2020-12-25 20:00:00,85.33,183.676,43.372,32.468 +2020-12-25 20:15:00,84.64,180.94400000000002,43.372,32.468 +2020-12-25 20:30:00,83.34,177.888,43.372,32.468 +2020-12-25 20:45:00,82.68,174.28599999999997,43.372,32.468 +2020-12-25 21:00:00,80.07,172.31599999999997,39.458,32.468 +2020-12-25 21:15:00,79.28,170.345,39.458,32.468 +2020-12-25 21:30:00,78.47,169.702,39.458,32.468 +2020-12-25 21:45:00,77.69,168.477,39.458,32.468 +2020-12-25 22:00:00,74.04,163.124,40.15,32.468 +2020-12-25 22:15:00,75.14,159.202,40.15,32.468 +2020-12-25 22:30:00,71.33,154.743,40.15,32.468 +2020-12-25 22:45:00,71.18,151.332,40.15,32.468 +2020-12-25 23:00:00,67.24,144.92,33.876999999999995,32.468 +2020-12-25 23:15:00,66.92,141.184,33.876999999999995,32.468 +2020-12-25 23:30:00,63.35,139.3,33.876999999999995,32.468 +2020-12-25 23:45:00,62.32,136.632,33.876999999999995,32.468 +2020-12-26 00:00:00,57.9,114.9,32.311,32.468 +2020-12-26 00:15:00,58.19,110.01,32.311,32.468 +2020-12-26 00:30:00,57.69,111.57700000000001,32.311,32.468 +2020-12-26 00:45:00,58.18,114.064,32.311,32.468 +2020-12-26 01:00:00,54.2,116.675,25.569000000000003,32.468 +2020-12-26 01:15:00,53.83,118.415,25.569000000000003,32.468 +2020-12-26 01:30:00,54.04,118.73299999999999,25.569000000000003,32.468 +2020-12-26 01:45:00,53.35,118.766,25.569000000000003,32.468 +2020-12-26 02:00:00,50.27,120.48200000000001,21.038,32.468 +2020-12-26 02:15:00,50.84,120.59299999999999,21.038,32.468 +2020-12-26 02:30:00,50.18,120.984,21.038,32.468 +2020-12-26 02:45:00,49.92,123.348,21.038,32.468 +2020-12-26 03:00:00,50.44,125.69200000000001,19.865,32.468 +2020-12-26 03:15:00,51.13,126.073,19.865,32.468 +2020-12-26 03:30:00,51.68,127.77799999999999,19.865,32.468 +2020-12-26 03:45:00,52.19,129.064,19.865,32.468 +2020-12-26 04:00:00,52.65,137.464,19.076,32.468 +2020-12-26 04:15:00,51.74,145.872,19.076,32.468 +2020-12-26 04:30:00,52.08,146.203,19.076,32.468 +2020-12-26 04:45:00,52.91,147.342,19.076,32.468 +2020-12-26 05:00:00,52.87,161.06799999999998,20.174,32.468 +2020-12-26 05:15:00,54.46,170.08900000000003,20.174,32.468 +2020-12-26 05:30:00,54.44,167.12099999999998,20.174,32.468 +2020-12-26 05:45:00,56.77,164.81099999999998,20.174,32.468 +2020-12-26 06:00:00,56.75,179.74099999999999,19.854,32.468 +2020-12-26 06:15:00,60.38,198.097,19.854,32.468 +2020-12-26 06:30:00,58.83,193.49,19.854,32.468 +2020-12-26 06:45:00,59.86,188.3,19.854,32.468 +2020-12-26 07:00:00,62.28,186.122,23.096999999999998,32.468 +2020-12-26 07:15:00,63.68,189.696,23.096999999999998,32.468 +2020-12-26 07:30:00,64.23,193.442,23.096999999999998,32.468 +2020-12-26 07:45:00,69.19,196.74200000000002,23.096999999999998,32.468 +2020-12-26 08:00:00,69.13,200.31099999999998,28.963,32.468 +2020-12-26 08:15:00,74.4,203.125,28.963,32.468 +2020-12-26 08:30:00,73.12,205.46400000000003,28.963,32.468 +2020-12-26 08:45:00,75.14,205.50900000000001,28.963,32.468 +2020-12-26 09:00:00,78.03,200.41099999999997,28.194000000000003,32.468 +2020-12-26 09:15:00,80.29,198.832,28.194000000000003,32.468 +2020-12-26 09:30:00,83.3,196.702,28.194000000000003,32.468 +2020-12-26 09:45:00,81.36,193.852,28.194000000000003,32.468 +2020-12-26 10:00:00,86.11,191.188,25.936999999999998,32.468 +2020-12-26 10:15:00,85.18,187.99400000000003,25.936999999999998,32.468 +2020-12-26 10:30:00,89.16,186.321,25.936999999999998,32.468 +2020-12-26 10:45:00,88.48,184.033,25.936999999999998,32.468 +2020-12-26 11:00:00,92.1,184.018,27.256,32.468 +2020-12-26 11:15:00,93.15,181.483,27.256,32.468 +2020-12-26 11:30:00,95.06,180.26,27.256,32.468 +2020-12-26 11:45:00,97.74,178.452,27.256,32.468 +2020-12-26 12:00:00,92.69,172.60299999999998,24.345,32.468 +2020-12-26 12:15:00,92.99,172.22400000000002,24.345,32.468 +2020-12-26 12:30:00,85.85,171.58,24.345,32.468 +2020-12-26 12:45:00,84.15,171.30599999999998,24.345,32.468 +2020-12-26 13:00:00,84.26,169.81599999999997,15.363,32.468 +2020-12-26 13:15:00,80.45,170.998,15.363,32.468 +2020-12-26 13:30:00,78.78,170.00900000000001,15.363,32.468 +2020-12-26 13:45:00,79.31,169.584,15.363,32.468 +2020-12-26 14:00:00,76.77,168.87099999999998,13.492,32.468 +2020-12-26 14:15:00,76.41,169.743,13.492,32.468 +2020-12-26 14:30:00,76.48,170.31900000000002,13.492,32.468 +2020-12-26 14:45:00,77.52,170.423,13.492,32.468 +2020-12-26 15:00:00,78.64,170.22299999999998,17.868,32.468 +2020-12-26 15:15:00,80.39,171.66,17.868,32.468 +2020-12-26 15:30:00,77.92,174.332,17.868,32.468 +2020-12-26 15:45:00,79.11,176.623,17.868,32.468 +2020-12-26 16:00:00,86.11,178.118,23.47,32.468 +2020-12-26 16:15:00,83.6,179.93900000000002,23.47,32.468 +2020-12-26 16:30:00,87.85,182.736,23.47,32.468 +2020-12-26 16:45:00,86.53,185.065,23.47,32.468 +2020-12-26 17:00:00,89.38,187.34400000000002,33.777,32.468 +2020-12-26 17:15:00,90.4,189.174,33.777,32.468 +2020-12-26 17:30:00,94.34,190.016,33.777,32.468 +2020-12-26 17:45:00,92.08,191.488,33.777,32.468 +2020-12-26 18:00:00,93.83,192.66099999999997,38.975,32.468 +2020-12-26 18:15:00,92.14,193.35299999999998,38.975,32.468 +2020-12-26 18:30:00,92.33,191.56799999999998,38.975,32.468 +2020-12-26 18:45:00,93.19,189.954,38.975,32.468 +2020-12-26 19:00:00,91.96,192.495,42.818999999999996,32.468 +2020-12-26 19:15:00,91.71,190.238,42.818999999999996,32.468 +2020-12-26 19:30:00,90.43,188.393,42.818999999999996,32.468 +2020-12-26 19:45:00,90.65,185.217,42.818999999999996,32.468 +2020-12-26 20:00:00,86.8,183.845,43.372,32.468 +2020-12-26 20:15:00,86.59,181.109,43.372,32.468 +2020-12-26 20:30:00,84.5,178.03799999999998,43.372,32.468 +2020-12-26 20:45:00,83.84,174.453,43.372,32.468 +2020-12-26 21:00:00,79.9,172.46599999999998,39.458,32.468 +2020-12-26 21:15:00,79.31,170.479,39.458,32.468 +2020-12-26 21:30:00,79.36,169.83700000000002,39.458,32.468 +2020-12-26 21:45:00,81.72,168.62400000000002,39.458,32.468 +2020-12-26 22:00:00,74.52,163.27200000000002,40.15,32.468 +2020-12-26 22:15:00,74.0,159.359,40.15,32.468 +2020-12-26 22:30:00,72.07,154.929,40.15,32.468 +2020-12-26 22:45:00,70.15,151.52700000000002,40.15,32.468 +2020-12-26 23:00:00,66.32,145.092,33.876999999999995,32.468 +2020-12-26 23:15:00,66.62,141.359,33.876999999999995,32.468 +2020-12-26 23:30:00,63.94,139.489,33.876999999999995,32.468 +2020-12-26 23:45:00,64.95,136.814,33.876999999999995,32.468 +2020-12-27 00:00:00,56.66,115.09299999999999,35.232,32.468 +2020-12-27 00:15:00,57.29,110.179,35.232,32.468 +2020-12-27 00:30:00,56.01,111.735,35.232,32.468 +2020-12-27 00:45:00,54.37,114.205,35.232,32.468 +2020-12-27 01:00:00,52.25,116.833,31.403000000000002,32.468 +2020-12-27 01:15:00,53.32,118.569,31.403000000000002,32.468 +2020-12-27 01:30:00,52.46,118.88799999999999,31.403000000000002,32.468 +2020-12-27 01:45:00,51.48,118.911,31.403000000000002,32.468 +2020-12-27 02:00:00,49.93,120.641,30.69,32.468 +2020-12-27 02:15:00,51.57,120.75299999999999,30.69,32.468 +2020-12-27 02:30:00,50.82,121.15,30.69,32.468 +2020-12-27 02:45:00,50.77,123.514,30.69,32.468 +2020-12-27 03:00:00,49.34,125.84899999999999,29.516,32.468 +2020-12-27 03:15:00,50.62,126.25200000000001,29.516,32.468 +2020-12-27 03:30:00,51.39,127.959,29.516,32.468 +2020-12-27 03:45:00,51.75,129.248,29.516,32.468 +2020-12-27 04:00:00,50.79,137.624,29.148000000000003,32.468 +2020-12-27 04:15:00,51.03,146.02700000000002,29.148000000000003,32.468 +2020-12-27 04:30:00,51.16,146.35299999999998,29.148000000000003,32.468 +2020-12-27 04:45:00,52.11,147.49,29.148000000000003,32.468 +2020-12-27 05:00:00,52.51,161.184,28.706,32.468 +2020-12-27 05:15:00,53.68,170.172,28.706,32.468 +2020-12-27 05:30:00,52.91,167.213,28.706,32.468 +2020-12-27 05:45:00,54.55,164.921,28.706,32.468 +2020-12-27 06:00:00,55.49,179.87400000000002,28.771,32.468 +2020-12-27 06:15:00,55.76,198.235,28.771,32.468 +2020-12-27 06:30:00,56.15,193.65400000000002,28.771,32.468 +2020-12-27 06:45:00,57.92,188.49599999999998,28.771,32.468 +2020-12-27 07:00:00,60.11,186.328,31.39,32.468 +2020-12-27 07:15:00,61.04,189.898,31.39,32.468 +2020-12-27 07:30:00,65.58,193.636,31.39,32.468 +2020-12-27 07:45:00,65.21,196.923,31.39,32.468 +2020-12-27 08:00:00,66.03,200.493,34.972,32.468 +2020-12-27 08:15:00,66.67,203.295,34.972,32.468 +2020-12-27 08:30:00,68.78,205.62,34.972,32.468 +2020-12-27 08:45:00,72.45,205.642,34.972,32.468 +2020-12-27 09:00:00,73.81,200.525,36.709,32.468 +2020-12-27 09:15:00,75.71,198.952,36.709,32.468 +2020-12-27 09:30:00,75.67,196.835,36.709,32.468 +2020-12-27 09:45:00,76.59,193.976,36.709,32.468 +2020-12-27 10:00:00,77.56,191.308,35.812,32.468 +2020-12-27 10:15:00,79.49,188.108,35.812,32.468 +2020-12-27 10:30:00,81.12,186.422,35.812,32.468 +2020-12-27 10:45:00,83.85,184.13299999999998,35.812,32.468 +2020-12-27 11:00:00,84.02,184.101,36.746,32.468 +2020-12-27 11:15:00,86.47,181.56,36.746,32.468 +2020-12-27 11:30:00,87.71,180.338,36.746,32.468 +2020-12-27 11:45:00,88.37,178.52900000000002,36.746,32.468 +2020-12-27 12:00:00,87.6,172.685,35.048,32.468 +2020-12-27 12:15:00,85.66,172.321,35.048,32.468 +2020-12-27 12:30:00,83.94,171.68,35.048,32.468 +2020-12-27 12:45:00,84.23,171.40900000000002,35.048,32.468 +2020-12-27 13:00:00,81.23,169.90400000000002,29.987,32.468 +2020-12-27 13:15:00,79.67,171.08,29.987,32.468 +2020-12-27 13:30:00,78.24,170.083,29.987,32.468 +2020-12-27 13:45:00,77.15,169.65,29.987,32.468 +2020-12-27 14:00:00,73.27,168.938,27.21,32.468 +2020-12-27 14:15:00,75.35,169.808,27.21,32.468 +2020-12-27 14:30:00,74.83,170.398,27.21,32.468 +2020-12-27 14:45:00,74.81,170.515,27.21,32.468 +2020-12-27 15:00:00,75.04,170.331,27.726999999999997,32.468 +2020-12-27 15:15:00,76.57,171.757,27.726999999999997,32.468 +2020-12-27 15:30:00,77.1,174.43400000000003,27.726999999999997,32.468 +2020-12-27 15:45:00,79.95,176.72,27.726999999999997,32.468 +2020-12-27 16:00:00,85.35,178.213,32.23,32.468 +2020-12-27 16:15:00,85.01,180.049,32.23,32.468 +2020-12-27 16:30:00,85.83,182.852,32.23,32.468 +2020-12-27 16:45:00,87.36,185.197,32.23,32.468 +2020-12-27 17:00:00,89.42,187.457,42.016999999999996,32.468 +2020-12-27 17:15:00,89.72,189.315,42.016999999999996,32.468 +2020-12-27 17:30:00,91.66,190.179,42.016999999999996,32.468 +2020-12-27 17:45:00,92.42,191.665,42.016999999999996,32.468 +2020-12-27 18:00:00,93.13,192.858,49.338,32.468 +2020-12-27 18:15:00,92.27,193.541,49.338,32.468 +2020-12-27 18:30:00,93.04,191.763,49.338,32.468 +2020-12-27 18:45:00,92.9,190.16299999999998,49.338,32.468 +2020-12-27 19:00:00,91.41,192.68,52.369,32.468 +2020-12-27 19:15:00,90.74,190.42,52.369,32.468 +2020-12-27 19:30:00,89.58,188.57299999999998,52.369,32.468 +2020-12-27 19:45:00,88.24,185.388,52.369,32.468 +2020-12-27 20:00:00,84.87,184.005,50.405,32.468 +2020-12-27 20:15:00,84.15,181.267,50.405,32.468 +2020-12-27 20:30:00,82.18,178.18099999999998,50.405,32.468 +2020-12-27 20:45:00,80.95,174.613,50.405,32.468 +2020-12-27 21:00:00,77.99,172.61,46.235,32.468 +2020-12-27 21:15:00,79.18,170.606,46.235,32.468 +2020-12-27 21:30:00,78.7,169.965,46.235,32.468 +2020-12-27 21:45:00,80.23,168.764,46.235,32.468 +2020-12-27 22:00:00,76.73,163.412,46.861000000000004,32.468 +2020-12-27 22:15:00,73.06,159.51,46.861000000000004,32.468 +2020-12-27 22:30:00,70.7,155.107,46.861000000000004,32.468 +2020-12-27 22:45:00,70.0,151.71200000000002,46.861000000000004,32.468 +2020-12-27 23:00:00,65.04,145.257,41.302,32.468 +2020-12-27 23:15:00,65.95,141.525,41.302,32.468 +2020-12-27 23:30:00,64.23,139.66899999999998,41.302,32.468 +2020-12-27 23:45:00,63.33,136.99,41.302,32.468 +2020-12-28 00:00:00,57.03,119.344,37.164,32.468 +2020-12-28 00:15:00,57.21,117.274,37.164,32.468 +2020-12-28 00:30:00,56.31,118.925,37.164,32.468 +2020-12-28 00:45:00,56.35,120.848,37.164,32.468 +2020-12-28 01:00:00,52.26,123.51899999999999,34.994,32.468 +2020-12-28 01:15:00,53.0,124.74799999999999,34.994,32.468 +2020-12-28 01:30:00,52.53,125.137,34.994,32.468 +2020-12-28 01:45:00,52.32,125.25399999999999,34.994,32.468 +2020-12-28 02:00:00,51.03,126.993,34.571,32.468 +2020-12-28 02:15:00,52.03,128.454,34.571,32.468 +2020-12-28 02:30:00,50.99,129.18200000000002,34.571,32.468 +2020-12-28 02:45:00,50.55,130.94799999999998,34.571,32.468 +2020-12-28 03:00:00,49.6,134.501,33.934,32.468 +2020-12-28 03:15:00,51.33,136.524,33.934,32.468 +2020-12-28 03:30:00,50.91,138.001,33.934,32.468 +2020-12-28 03:45:00,51.45,138.749,33.934,32.468 +2020-12-28 04:00:00,51.82,151.371,34.107,32.468 +2020-12-28 04:15:00,52.15,163.84,34.107,32.468 +2020-12-28 04:30:00,53.03,166.226,34.107,32.468 +2020-12-28 04:45:00,54.7,167.53099999999998,34.107,32.468 +2020-12-28 05:00:00,57.21,196.475,39.575,32.468 +2020-12-28 05:15:00,58.21,225.044,39.575,32.468 +2020-12-28 05:30:00,58.82,222.28799999999998,39.575,32.468 +2020-12-28 05:45:00,60.69,214.507,39.575,32.468 +2020-12-28 06:00:00,63.74,211.99599999999998,56.156000000000006,32.468 +2020-12-28 06:15:00,64.22,215.868,56.156000000000006,32.468 +2020-12-28 06:30:00,66.48,219.101,56.156000000000006,32.468 +2020-12-28 06:45:00,67.97,222.567,56.156000000000006,32.468 +2020-12-28 07:00:00,71.66,222.68099999999998,67.926,32.468 +2020-12-28 07:15:00,73.71,227.53,67.926,32.468 +2020-12-28 07:30:00,73.74,230.484,67.926,32.468 +2020-12-28 07:45:00,76.15,231.332,67.926,32.468 +2020-12-28 08:00:00,78.92,230.185,72.58,32.468 +2020-12-28 08:15:00,77.89,230.88400000000001,72.58,32.468 +2020-12-28 08:30:00,79.51,229.27,72.58,32.468 +2020-12-28 08:45:00,81.98,226.108,72.58,32.468 +2020-12-28 09:00:00,84.91,219.989,66.984,32.468 +2020-12-28 09:15:00,86.68,215.032,66.984,32.468 +2020-12-28 09:30:00,86.35,211.93599999999998,66.984,32.468 +2020-12-28 09:45:00,87.57,209.25400000000002,66.984,32.468 +2020-12-28 10:00:00,88.75,205.554,63.158,32.468 +2020-12-28 10:15:00,89.35,202.032,63.158,32.468 +2020-12-28 10:30:00,90.53,199.482,63.158,32.468 +2020-12-28 10:45:00,92.46,197.829,63.158,32.468 +2020-12-28 11:00:00,92.51,195.31900000000002,61.141000000000005,32.468 +2020-12-28 11:15:00,93.89,194.49599999999998,61.141000000000005,32.468 +2020-12-28 11:30:00,95.76,194.61599999999999,61.141000000000005,32.468 +2020-12-28 11:45:00,98.55,192.44299999999998,61.141000000000005,32.468 +2020-12-28 12:00:00,96.46,188.148,57.961000000000006,32.468 +2020-12-28 12:15:00,96.89,187.805,57.961000000000006,32.468 +2020-12-28 12:30:00,94.61,187.355,57.961000000000006,32.468 +2020-12-28 12:45:00,93.99,188.53,57.961000000000006,32.468 +2020-12-28 13:00:00,91.48,187.59599999999998,56.843,32.468 +2020-12-28 13:15:00,89.62,187.41299999999998,56.843,32.468 +2020-12-28 13:30:00,87.6,185.91299999999998,56.843,32.468 +2020-12-28 13:45:00,89.12,185.547,56.843,32.468 +2020-12-28 14:00:00,86.23,184.21599999999998,55.992,32.468 +2020-12-28 14:15:00,84.24,184.503,55.992,32.468 +2020-12-28 14:30:00,84.27,184.58700000000002,55.992,32.468 +2020-12-28 14:45:00,84.15,184.78400000000002,55.992,32.468 +2020-12-28 15:00:00,83.99,186.304,57.523,32.468 +2020-12-28 15:15:00,84.98,186.33,57.523,32.468 +2020-12-28 15:30:00,82.61,188.26,57.523,32.468 +2020-12-28 15:45:00,84.31,190.09599999999998,57.523,32.468 +2020-12-28 16:00:00,90.69,191.83599999999998,59.471000000000004,32.468 +2020-12-28 16:15:00,89.04,192.96099999999998,59.471000000000004,32.468 +2020-12-28 16:30:00,91.46,194.821,59.471000000000004,32.468 +2020-12-28 16:45:00,92.1,196.054,59.471000000000004,32.468 +2020-12-28 17:00:00,95.79,198.107,65.066,32.468 +2020-12-28 17:15:00,96.36,199.09599999999998,65.066,32.468 +2020-12-28 17:30:00,94.64,199.445,65.066,32.468 +2020-12-28 17:45:00,94.08,199.503,65.066,32.468 +2020-12-28 18:00:00,92.03,201.101,69.581,32.468 +2020-12-28 18:15:00,92.63,199.59799999999998,69.581,32.468 +2020-12-28 18:30:00,92.68,198.426,69.581,32.468 +2020-12-28 18:45:00,95.26,197.632,69.581,32.468 +2020-12-28 19:00:00,91.26,198.58,73.771,32.468 +2020-12-28 19:15:00,88.66,195.235,73.771,32.468 +2020-12-28 19:30:00,89.79,193.85,73.771,32.468 +2020-12-28 19:45:00,85.86,189.838,73.771,32.468 +2020-12-28 20:00:00,81.83,186.13400000000001,65.035,32.468 +2020-12-28 20:15:00,80.18,181.06099999999998,65.035,32.468 +2020-12-28 20:30:00,78.48,176.19099999999997,65.035,32.468 +2020-12-28 20:45:00,77.08,174.206,65.035,32.468 +2020-12-28 21:00:00,73.88,172.676,58.7,32.468 +2020-12-28 21:15:00,73.68,169.542,58.7,32.468 +2020-12-28 21:30:00,72.87,168.12400000000002,58.7,32.468 +2020-12-28 21:45:00,72.61,166.44099999999997,58.7,32.468 +2020-12-28 22:00:00,69.98,158.253,53.888000000000005,32.468 +2020-12-28 22:15:00,69.76,153.156,53.888000000000005,32.468 +2020-12-28 22:30:00,68.69,139.464,53.888000000000005,32.468 +2020-12-28 22:45:00,68.64,131.43200000000002,53.888000000000005,32.468 +2020-12-28 23:00:00,64.36,125.7,45.501999999999995,32.468 +2020-12-28 23:15:00,66.25,124.46799999999999,45.501999999999995,32.468 +2020-12-28 23:30:00,64.12,125.277,45.501999999999995,32.468 +2020-12-28 23:45:00,64.06,125.116,45.501999999999995,32.468 +2020-12-29 00:00:00,57.18,118.78299999999999,43.537,32.468 +2020-12-29 00:15:00,56.17,118.086,43.537,32.468 +2020-12-29 00:30:00,56.18,118.84700000000001,43.537,32.468 +2020-12-29 00:45:00,56.31,119.844,43.537,32.468 +2020-12-29 01:00:00,53.79,122.324,41.854,32.468 +2020-12-29 01:15:00,53.88,123.124,41.854,32.468 +2020-12-29 01:30:00,50.49,123.676,41.854,32.468 +2020-12-29 01:45:00,53.7,124.057,41.854,32.468 +2020-12-29 02:00:00,51.98,125.79,40.321,32.468 +2020-12-29 02:15:00,53.31,127.184,40.321,32.468 +2020-12-29 02:30:00,53.66,127.334,40.321,32.468 +2020-12-29 02:45:00,53.8,129.121,40.321,32.468 +2020-12-29 03:00:00,53.3,131.494,39.632,32.468 +2020-12-29 03:15:00,58.73,132.754,39.632,32.468 +2020-12-29 03:30:00,61.45,134.694,39.632,32.468 +2020-12-29 03:45:00,56.91,135.592,39.632,32.468 +2020-12-29 04:00:00,58.6,147.961,40.183,32.468 +2020-12-29 04:15:00,58.92,160.092,40.183,32.468 +2020-12-29 04:30:00,58.73,162.172,40.183,32.468 +2020-12-29 04:45:00,60.76,164.65200000000002,40.183,32.468 +2020-12-29 05:00:00,65.7,198.412,43.945,32.468 +2020-12-29 05:15:00,68.23,226.771,43.945,32.468 +2020-12-29 05:30:00,72.46,222.56099999999998,43.945,32.468 +2020-12-29 05:45:00,78.0,214.78,43.945,32.468 +2020-12-29 06:00:00,86.13,211.19,56.048,32.468 +2020-12-29 06:15:00,90.88,216.63400000000001,56.048,32.468 +2020-12-29 06:30:00,98.39,219.25900000000001,56.048,32.468 +2020-12-29 06:45:00,101.09,222.36700000000002,56.048,32.468 +2020-12-29 07:00:00,109.02,222.32,65.74,32.468 +2020-12-29 07:15:00,110.78,227.003,65.74,32.468 +2020-12-29 07:30:00,111.89,229.425,65.74,32.468 +2020-12-29 07:45:00,116.47,230.43099999999998,65.74,32.468 +2020-12-29 08:00:00,119.28,229.387,72.757,32.468 +2020-12-29 08:15:00,119.18,229.077,72.757,32.468 +2020-12-29 08:30:00,121.46,227.28400000000002,72.757,32.468 +2020-12-29 08:45:00,123.85,223.81599999999997,72.757,32.468 +2020-12-29 09:00:00,126.31,216.889,67.692,32.468 +2020-12-29 09:15:00,127.18,213.46900000000002,67.692,32.468 +2020-12-29 09:30:00,129.54,211.058,67.692,32.468 +2020-12-29 09:45:00,127.09,208.2,67.692,32.468 +2020-12-29 10:00:00,130.14,203.892,63.506,32.468 +2020-12-29 10:15:00,131.8,199.34099999999998,63.506,32.468 +2020-12-29 10:30:00,131.24,196.954,63.506,32.468 +2020-12-29 10:45:00,133.38,195.604,63.506,32.468 +2020-12-29 11:00:00,132.11,194.502,60.758,32.468 +2020-12-29 11:15:00,132.81,193.368,60.758,32.468 +2020-12-29 11:30:00,132.74,192.33700000000002,60.758,32.468 +2020-12-29 11:45:00,132.51,190.82,60.758,32.468 +2020-12-29 12:00:00,133.01,185.213,57.519,32.468 +2020-12-29 12:15:00,132.94,184.5,57.519,32.468 +2020-12-29 12:30:00,132.61,184.77200000000002,57.519,32.468 +2020-12-29 12:45:00,131.32,185.69400000000002,57.519,32.468 +2020-12-29 13:00:00,129.11,184.345,56.46,32.468 +2020-12-29 13:15:00,129.11,183.888,56.46,32.468 +2020-12-29 13:30:00,128.11,183.49900000000002,56.46,32.468 +2020-12-29 13:45:00,128.44,183.268,56.46,32.468 +2020-12-29 14:00:00,129.73,182.227,56.207,32.468 +2020-12-29 14:15:00,128.01,182.65400000000002,56.207,32.468 +2020-12-29 14:30:00,127.57,183.363,56.207,32.468 +2020-12-29 14:45:00,128.05,183.468,56.207,32.468 +2020-12-29 15:00:00,125.11,184.584,57.391999999999996,32.468 +2020-12-29 15:15:00,124.05,184.94799999999998,57.391999999999996,32.468 +2020-12-29 15:30:00,122.28,187.088,57.391999999999996,32.468 +2020-12-29 15:45:00,122.29,188.547,57.391999999999996,32.468 +2020-12-29 16:00:00,126.09,190.58,59.955,32.468 +2020-12-29 16:15:00,127.33,192.178,59.955,32.468 +2020-12-29 16:30:00,131.25,194.65200000000002,59.955,32.468 +2020-12-29 16:45:00,132.28,196.199,59.955,32.468 +2020-12-29 17:00:00,135.14,198.761,67.063,32.468 +2020-12-29 17:15:00,134.64,199.817,67.063,32.468 +2020-12-29 17:30:00,135.54,200.817,67.063,32.468 +2020-12-29 17:45:00,135.22,200.755,67.063,32.468 +2020-12-29 18:00:00,135.98,202.24900000000002,71.477,32.468 +2020-12-29 18:15:00,133.12,200.295,71.477,32.468 +2020-12-29 18:30:00,132.74,198.825,71.477,32.468 +2020-12-29 18:45:00,132.76,198.80900000000003,71.477,32.468 +2020-12-29 19:00:00,129.1,199.782,74.32,32.468 +2020-12-29 19:15:00,131.66,196.169,74.32,32.468 +2020-12-29 19:30:00,131.57,194.13,74.32,32.468 +2020-12-29 19:45:00,134.28,190.15400000000002,74.32,32.468 +2020-12-29 20:00:00,124.16,186.60299999999998,66.157,32.468 +2020-12-29 20:15:00,118.77,180.87400000000002,66.157,32.468 +2020-12-29 20:30:00,114.09,176.968,66.157,32.468 +2020-12-29 20:45:00,113.21,174.458,66.157,32.468 +2020-12-29 21:00:00,104.2,172.25900000000001,59.806000000000004,32.468 +2020-12-29 21:15:00,110.79,169.947,59.806000000000004,32.468 +2020-12-29 21:30:00,106.78,167.829,59.806000000000004,32.468 +2020-12-29 21:45:00,106.31,166.38299999999998,59.806000000000004,32.468 +2020-12-29 22:00:00,95.44,159.85399999999998,54.785,32.468 +2020-12-29 22:15:00,92.96,154.50799999999998,54.785,32.468 +2020-12-29 22:30:00,85.11,140.935,54.785,32.468 +2020-12-29 22:45:00,86.87,133.178,54.785,32.468 +2020-12-29 23:00:00,82.77,127.431,47.176,32.468 +2020-12-29 23:15:00,84.86,125.367,47.176,32.468 +2020-12-29 23:30:00,77.52,125.84200000000001,47.176,32.468 +2020-12-29 23:45:00,78.36,125.257,47.176,32.468 +2020-12-30 00:00:00,81.02,118.954,43.42,32.468 +2020-12-30 00:15:00,81.82,118.23299999999999,43.42,32.468 +2020-12-30 00:30:00,81.73,118.985,43.42,32.468 +2020-12-30 00:45:00,76.13,119.964,43.42,32.468 +2020-12-30 01:00:00,70.28,122.458,40.869,32.468 +2020-12-30 01:15:00,74.3,123.25299999999999,40.869,32.468 +2020-12-30 01:30:00,77.22,123.807,40.869,32.468 +2020-12-30 01:45:00,76.85,124.179,40.869,32.468 +2020-12-30 02:00:00,73.1,125.92399999999999,39.541,32.468 +2020-12-30 02:15:00,70.43,127.319,39.541,32.468 +2020-12-30 02:30:00,71.98,127.475,39.541,32.468 +2020-12-30 02:45:00,76.52,129.263,39.541,32.468 +2020-12-30 03:00:00,75.41,131.627,39.052,32.468 +2020-12-30 03:15:00,74.83,132.908,39.052,32.468 +2020-12-30 03:30:00,72.89,134.84799999999998,39.052,32.468 +2020-12-30 03:45:00,79.01,135.749,39.052,32.468 +2020-12-30 04:00:00,76.82,148.096,40.36,32.468 +2020-12-30 04:15:00,72.95,160.222,40.36,32.468 +2020-12-30 04:30:00,72.14,162.297,40.36,32.468 +2020-12-30 04:45:00,80.94,164.775,40.36,32.468 +2020-12-30 05:00:00,88.48,198.503,43.133,32.468 +2020-12-30 05:15:00,91.18,226.833,43.133,32.468 +2020-12-30 05:30:00,90.88,222.62900000000002,43.133,32.468 +2020-12-30 05:45:00,93.94,214.865,43.133,32.468 +2020-12-30 06:00:00,101.26,211.298,54.953,32.468 +2020-12-30 06:15:00,106.96,216.748,54.953,32.468 +2020-12-30 06:30:00,112.28,219.395,54.953,32.468 +2020-12-30 06:45:00,115.14,222.53400000000002,54.953,32.468 +2020-12-30 07:00:00,123.05,222.49900000000002,66.566,32.468 +2020-12-30 07:15:00,123.6,227.174,66.566,32.468 +2020-12-30 07:30:00,126.82,229.58599999999998,66.566,32.468 +2020-12-30 07:45:00,128.84,230.578,66.566,32.468 +2020-12-30 08:00:00,131.65,229.533,72.902,32.468 +2020-12-30 08:15:00,130.78,229.21,72.902,32.468 +2020-12-30 08:30:00,132.01,227.4,72.902,32.468 +2020-12-30 08:45:00,132.85,223.91099999999997,72.902,32.468 +2020-12-30 09:00:00,134.02,216.967,68.465,32.468 +2020-12-30 09:15:00,135.31,213.55200000000002,68.465,32.468 +2020-12-30 09:30:00,136.11,211.15400000000002,68.465,32.468 +2020-12-30 09:45:00,137.25,208.28900000000002,68.465,32.468 +2020-12-30 10:00:00,135.85,203.97799999999998,63.625,32.468 +2020-12-30 10:15:00,138.58,199.425,63.625,32.468 +2020-12-30 10:30:00,138.23,197.024,63.625,32.468 +2020-12-30 10:45:00,136.57,195.674,63.625,32.468 +2020-12-30 11:00:00,133.24,194.555,61.628,32.468 +2020-12-30 11:15:00,126.44,193.417,61.628,32.468 +2020-12-30 11:30:00,130.71,192.387,61.628,32.468 +2020-12-30 11:45:00,129.54,190.87099999999998,61.628,32.468 +2020-12-30 12:00:00,125.24,185.268,58.708999999999996,32.468 +2020-12-30 12:15:00,124.69,184.571,58.708999999999996,32.468 +2020-12-30 12:30:00,123.95,184.84400000000002,58.708999999999996,32.468 +2020-12-30 12:45:00,129.68,185.769,58.708999999999996,32.468 +2020-12-30 13:00:00,133.73,184.407,57.373000000000005,32.468 +2020-12-30 13:15:00,136.53,183.94400000000002,57.373000000000005,32.468 +2020-12-30 13:30:00,133.6,183.545,57.373000000000005,32.468 +2020-12-30 13:45:00,133.51,183.30599999999998,57.373000000000005,32.468 +2020-12-30 14:00:00,132.59,182.271,57.684,32.468 +2020-12-30 14:15:00,131.17,182.69400000000002,57.684,32.468 +2020-12-30 14:30:00,128.89,183.417,57.684,32.468 +2020-12-30 14:45:00,127.23,183.533,57.684,32.468 +2020-12-30 15:00:00,128.01,184.666,58.03,32.468 +2020-12-30 15:15:00,127.89,185.017,58.03,32.468 +2020-12-30 15:30:00,127.38,187.16,58.03,32.468 +2020-12-30 15:45:00,128.01,188.612,58.03,32.468 +2020-12-30 16:00:00,131.71,190.645,59.97,32.468 +2020-12-30 16:15:00,132.63,192.255,59.97,32.468 +2020-12-30 16:30:00,136.55,194.735,59.97,32.468 +2020-12-30 16:45:00,136.97,196.297,59.97,32.468 +2020-12-30 17:00:00,140.13,198.84099999999998,65.661,32.468 +2020-12-30 17:15:00,138.72,199.924,65.661,32.468 +2020-12-30 17:30:00,136.98,200.94799999999998,65.661,32.468 +2020-12-30 17:45:00,138.66,200.9,65.661,32.468 +2020-12-30 18:00:00,138.81,202.41299999999998,70.96300000000001,32.468 +2020-12-30 18:15:00,137.74,200.455,70.96300000000001,32.468 +2020-12-30 18:30:00,135.58,198.99099999999999,70.96300000000001,32.468 +2020-12-30 18:45:00,136.58,198.989,70.96300000000001,32.468 +2020-12-30 19:00:00,133.41,199.937,74.133,32.468 +2020-12-30 19:15:00,132.16,196.324,74.133,32.468 +2020-12-30 19:30:00,129.15,194.283,74.133,32.468 +2020-12-30 19:45:00,128.43,190.3,74.133,32.468 +2020-12-30 20:00:00,120.97,186.738,65.613,32.468 +2020-12-30 20:15:00,116.68,181.007,65.613,32.468 +2020-12-30 20:30:00,112.92,177.08900000000003,65.613,32.468 +2020-12-30 20:45:00,113.77,174.59599999999998,65.613,32.468 +2020-12-30 21:00:00,108.79,172.378,58.583,32.468 +2020-12-30 21:15:00,113.74,170.051,58.583,32.468 +2020-12-30 21:30:00,111.85,167.933,58.583,32.468 +2020-12-30 21:45:00,107.84,166.5,58.583,32.468 +2020-12-30 22:00:00,97.67,159.97,54.411,32.468 +2020-12-30 22:15:00,98.11,154.636,54.411,32.468 +2020-12-30 22:30:00,96.26,141.086,54.411,32.468 +2020-12-30 22:45:00,94.93,133.338,54.411,32.468 +2020-12-30 23:00:00,90.4,127.57,47.878,32.468 +2020-12-30 23:15:00,82.73,125.509,47.878,32.468 +2020-12-30 23:30:00,77.9,125.99799999999999,47.878,32.468 +2020-12-30 23:45:00,78.08,125.40899999999999,47.878,32.468 +2020-12-31 00:00:00,72.56,119.118,44.513000000000005,32.468 +2020-12-31 00:15:00,75.51,118.375,44.513000000000005,32.468 +2020-12-31 00:30:00,76.2,119.11399999999999,44.513000000000005,32.468 +2020-12-31 00:45:00,79.94,120.07600000000001,44.513000000000005,32.468 +2020-12-31 01:00:00,76.22,122.584,43.169,32.468 +2020-12-31 01:15:00,75.52,123.374,43.169,32.468 +2020-12-31 01:30:00,69.71,123.929,43.169,32.468 +2020-12-31 01:45:00,72.15,124.29,43.169,32.468 +2020-12-31 02:00:00,68.46,126.051,41.763999999999996,32.468 +2020-12-31 02:15:00,68.93,127.444,41.763999999999996,32.468 +2020-12-31 02:30:00,69.13,127.60799999999999,41.763999999999996,32.468 +2020-12-31 02:45:00,71.05,129.394,41.763999999999996,32.468 +2020-12-31 03:00:00,77.24,131.754,41.155,32.468 +2020-12-31 03:15:00,78.88,133.054,41.155,32.468 +2020-12-31 03:30:00,79.6,134.993,41.155,32.468 +2020-12-31 03:45:00,73.47,135.89700000000002,41.155,32.468 +2020-12-31 04:00:00,71.66,148.222,41.96,32.468 +2020-12-31 04:15:00,72.6,160.344,41.96,32.468 +2020-12-31 04:30:00,74.43,162.416,41.96,32.468 +2020-12-31 04:45:00,76.31,164.89,41.96,32.468 +2020-12-31 05:00:00,80.9,198.58599999999998,45.206,32.468 +2020-12-31 05:15:00,83.91,226.886,45.206,32.468 +2020-12-31 05:30:00,88.55,222.68900000000002,45.206,32.468 +2020-12-31 05:45:00,92.98,214.942,45.206,32.468 +2020-12-31 06:00:00,101.92,211.398,55.398999999999994,32.468 +2020-12-31 06:15:00,106.02,216.854,55.398999999999994,32.468 +2020-12-31 06:30:00,110.67,219.52200000000002,55.398999999999994,32.468 +2020-12-31 06:45:00,115.36,222.69,55.398999999999994,32.468 +2020-12-31 07:00:00,121.21,222.667,64.627,32.468 +2020-12-31 07:15:00,125.44,227.334,64.627,32.468 +2020-12-31 07:30:00,127.04,229.737,64.627,32.468 +2020-12-31 07:45:00,129.3,230.713,64.627,32.468 +2020-12-31 08:00:00,131.83,229.668,70.895,32.468 +2020-12-31 08:15:00,129.92,229.331,70.895,32.468 +2020-12-31 08:30:00,130.03,227.505,70.895,32.468 +2020-12-31 08:45:00,129.65,223.993,70.895,32.468 +2020-12-31 09:00:00,130.4,217.032,66.382,32.468 +2020-12-31 09:15:00,132.18,213.62400000000002,66.382,32.468 +2020-12-31 09:30:00,133.68,211.239,66.382,32.468 +2020-12-31 09:45:00,133.99,208.36700000000002,66.382,32.468 +2020-12-31 10:00:00,133.44,204.053,62.739,32.468 +2020-12-31 10:15:00,135.64,199.49599999999998,62.739,32.468 +2020-12-31 10:30:00,134.92,197.084,62.739,32.468 +2020-12-31 10:45:00,136.65,195.734,62.739,32.468 +2020-12-31 11:00:00,136.01,194.59799999999998,60.843,32.468 +2020-12-31 11:15:00,135.6,193.455,60.843,32.468 +2020-12-31 11:30:00,135.49,192.428,60.843,32.468 +2020-12-31 11:45:00,136.56,190.91299999999998,60.843,32.468 +2020-12-31 12:00:00,136.24,185.315,58.466,32.468 +2020-12-31 12:15:00,135.89,184.63299999999998,58.466,32.468 +2020-12-31 12:30:00,133.92,184.908,58.466,32.468 +2020-12-31 12:45:00,134.1,185.834,58.466,32.468 +2020-12-31 13:00:00,130.94,184.46,56.883,32.468 +2020-12-31 13:15:00,129.98,183.99,56.883,32.468 +2020-12-31 13:30:00,129.31,183.582,56.883,32.468 +2020-12-31 13:45:00,127.59,183.33599999999998,56.883,32.468 +2020-12-31 14:00:00,126.5,182.30700000000002,56.503,32.468 +2020-12-31 14:15:00,129.81,182.727,56.503,32.468 +2020-12-31 14:30:00,128.98,183.46099999999998,56.503,32.468 +2020-12-31 14:45:00,127.66,183.59,56.503,32.468 +2020-12-31 15:00:00,131.69,184.74,57.803999999999995,32.468 +2020-12-31 15:15:00,134.08,185.078,57.803999999999995,32.468 +2020-12-31 15:30:00,130.49,187.222,57.803999999999995,32.468 +2020-12-31 15:45:00,132.71,188.668,57.803999999999995,32.468 +2020-12-31 16:00:00,134.12,190.7,59.379,32.468 +2020-12-31 16:15:00,134.79,192.322,59.379,32.468 +2020-12-31 16:30:00,137.68,194.80599999999998,59.379,32.468 +2020-12-31 16:45:00,138.3,196.382,59.379,32.468 +2020-12-31 17:00:00,141.23,198.90900000000002,64.71600000000001,32.468 +2020-12-31 17:15:00,140.55,200.021,64.71600000000001,32.468 +2020-12-31 17:30:00,141.63,201.067,64.71600000000001,32.468 +2020-12-31 17:45:00,141.55,201.035,64.71600000000001,32.468 +2020-12-31 18:00:00,140.02,202.56799999999998,68.803,32.468 +2020-12-31 18:15:00,138.28,200.606,68.803,32.468 +2020-12-31 18:30:00,137.19,199.14700000000002,68.803,32.468 +2020-12-31 18:45:00,137.6,199.16,68.803,32.468 +2020-12-31 19:00:00,134.26,200.084,72.934,32.468 +2020-12-31 19:15:00,133.22,196.468,72.934,32.468 +2020-12-31 19:30:00,134.64,194.42700000000002,72.934,32.468 +2020-12-31 19:45:00,136.88,190.43900000000002,72.934,32.468 +2020-12-31 20:00:00,130.24,186.864,65.175,32.468 +2020-12-31 20:15:00,119.33,181.132,65.175,32.468 +2020-12-31 20:30:00,118.31,177.201,65.175,32.468 +2020-12-31 20:45:00,112.7,174.72400000000002,65.175,32.468 +2020-12-31 21:00:00,107.44,172.49,58.55,32.468 +2020-12-31 21:15:00,112.86,170.146,58.55,32.468 +2020-12-31 21:30:00,111.93,168.03,58.55,32.468 +2020-12-31 21:45:00,108.29,166.609,58.55,32.468 +2020-12-31 22:00:00,98.52,160.079,55.041000000000004,32.468 +2020-12-31 22:15:00,94.85,154.757,55.041000000000004,32.468 +2020-12-31 22:30:00,98.1,141.22899999999998,55.041000000000004,32.468 +2020-12-31 22:45:00,96.62,133.488,55.041000000000004,32.468 +2020-12-31 23:00:00,92.22,127.7,48.258,32.468 +2020-12-31 23:15:00,84.12,125.64200000000001,48.258,32.468 +2020-12-31 23:30:00,79.31,126.145,48.258,32.468 +2020-12-31 23:45:00,83.54,125.553,48.258,32.468 diff --git a/examples/05_Two-stage-optimization/two_stage_optimization.py b/examples/05_Two-stage-optimization/two_stage_optimization.py new file mode 100644 index 000000000..5e19d54c1 --- /dev/null +++ b/examples/05_Two-stage-optimization/two_stage_optimization.py @@ -0,0 +1,148 @@ +""" +This script demonstrates how to use the different calcualtion types in the flixOPt framework +to model the same energy system. THe Results will be compared to each other. +""" + +import logging +import pathlib +import timeit + +import pandas as pd +import xarray as xr + +import flixopt as fx + +logger = logging.getLogger('flixopt') + +if __name__ == '__main__': + # Data Import + data_import = pd.read_csv(pathlib.Path('Zeitreihen2020.csv'), index_col=0).sort_index() + filtered_data = data_import[:500] + + filtered_data.index = pd.to_datetime(filtered_data.index) + timesteps = filtered_data.index + + # Access specific columns and convert to 1D-numpy array + electricity_demand = filtered_data['P_Netz/MW'].to_numpy() + heat_demand = filtered_data['Q_Netz/MW'].to_numpy() + electricity_price = filtered_data['Strompr.€/MWh'].to_numpy() + gas_price = filtered_data['Gaspr.€/MWh'].to_numpy() + + flow_system = fx.FlowSystem(timesteps) + flow_system.add_elements( + fx.Bus('Strom'), + fx.Bus('Fernwärme'), + fx.Bus('Gas'), + fx.Bus('Kohle'), + fx.Effect('costs', '€', 'Kosten', is_standard=True, is_objective=True), + fx.Effect('CO2', 'kg', 'CO2_e-Emissionen'), + fx.Effect('PE', 'kWh_PE', 'Primärenergie'), + fx.linear_converters.Boiler( + 'Kessel', + eta=0.85, + Q_th=fx.Flow(label='Q_th', bus='Fernwärme'), + Q_fu=fx.Flow( + label='Q_fu', + bus='Gas', + size=fx.InvestParameters(specific_effects={'costs': 1_000}, minimum_size=10, maximum_size=500), + relative_minimum=0.2, + previous_flow_rate=20, + on_off_parameters=fx.OnOffParameters(effects_per_switch_on=300), + ), + ), + fx.linear_converters.CHP( + 'BHKW2', + eta_th=0.58, + eta_el=0.22, + on_off_parameters=fx.OnOffParameters(effects_per_switch_on=1_000, consecutive_on_hours_min=10, consecutive_off_hours_min=10), + P_el=fx.Flow('P_el', bus='Strom'), + Q_th=fx.Flow('Q_th', bus='Fernwärme'), + Q_fu=fx.Flow('Q_fu', bus='Kohle', + size=fx.InvestParameters(specific_effects={'costs':3_000}, minimum_size=10, maximum_size=500), + relative_minimum=0.3, previous_flow_rate=100), + ), + fx.Storage( + 'Speicher', + capacity_in_flow_hours=684, + initial_charge_state=137, + minimal_final_charge_state=137, + maximal_final_charge_state=158, + eta_charge=1, + eta_discharge=1, + relative_loss_per_hour=0.001, + prevent_simultaneous_charge_and_discharge=True, + charging=fx.Flow('Q_th_load', size=137, bus='Fernwärme'), + discharging=fx.Flow('Q_th_unload', size=158, bus='Fernwärme'), + ), + fx.Sink( + 'Wärmelast', sink=fx.Flow('Q_th_Last', bus='Fernwärme', size=1, fixed_relative_profile=heat_demand) + ), + fx.Source( + 'Gastarif', + source=fx.Flow('Q_Gas', bus='Gas', size=1000, effects_per_flow_hour={'costs': gas_price, 'CO2': 0.3}), + ), + fx.Source( + 'Kohletarif', + source=fx.Flow('Q_Kohle', bus='Kohle', size=1000, effects_per_flow_hour={'costs': 4.6, 'CO2': 0.3}), + ), + fx.Source( + 'Einspeisung', + source=fx.Flow('P_el', bus='Strom', size=1000, effects_per_flow_hour={'costs': electricity_price + 0.5, 'CO2': 0.3}), + ), + fx.Sink( + 'Stromlast', + sink=fx.Flow('P_el_Last', bus='Strom', size=1, fixed_relative_profile=electricity_demand), + ), + fx.Source( + 'Stromtarif', + source=fx.Flow('P_el', bus='Strom', size=1000, effects_per_flow_hour={'costs': electricity_price, 'CO2': 0.3}), + ), + ) + + # Separate optimization of flow sizes and dispatch + start = timeit.default_timer() + calculation_sizing = fx.FullCalculation('Sizing', flow_system.resample('4h')) + calculation_sizing.do_modeling() + calculation_sizing.solve(fx.solvers.HighsSolver(0.1/100, 600)) + timer_sizing = timeit.default_timer() - start + flow_sizes = xr.Dataset({flow.size.name: flow.size for flow in calculation_sizing.results.flows.values()}) + + calculation_dispatch = fx.FullCalculation('Sizing', flow_system) + calculation_dispatch.do_modeling() + for name, da in flow_sizes.data_vars.items(): + if name in calculation_dispatch.model.variables: + con = calculation_dispatch.model.add_constraints( + calculation_dispatch.model[name] == da, + name=f'{name}_fixing', + ) + logger.info(f'Constraint {con.name} added:\n{con}') + + calculation_dispatch.solve(fx.solvers.HighsSolver(0.1 / 100, 600)) + timer_dispatch = timeit.default_timer() - start + + if (calculation_dispatch.results.sizes().round(5) == calculation_sizing.results.sizes().round(5)).all(): + logger.info('Sizes where correctly equalized') + else: + raise RuntimeError('Sizes where not correctly equalized') + + # Optimization of both flow sizes and dispatch together + start = timeit.default_timer() + calculation_combined = fx.FullCalculation('Sizing', flow_system) + calculation_combined.do_modeling() + calculation_combined.solve(fx.solvers.HighsSolver(0.1/100, 600)) + timer_combined = timeit.default_timer() - start + + # Comparison of results + comparison = xr.concat( + [calculation_combined.results.solution, calculation_dispatch.results.solution], dim='mode' + ).assign_coords(mode=['Combined', 'Two-stage']) + comparison['Duration [s]'] = xr.DataArray([timer_combined, timer_sizing + timer_dispatch], dims='mode') + + comparison_main = comparison[['Duration [s]', 'costs|total', 'costs(invest)|total', 'costs(operation)|total', 'BHKW2(Q_fu)|size', 'Kessel(Q_fu)|size']] + comparison_main = xr.concat([ + comparison_main, + ((comparison_main.sel(mode='Two-stage') - comparison_main.sel(mode='Combined')) + / comparison_main.sel(mode='Combined') * 100).assign_coords(mode='Diff [%]') + ], dim='mode') + + print(comparison_main.to_pandas().T.round(2)) From ef0acfc38c9f12868551e6ec14109b576aa95a23 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 16 Jul 2025 10:24:30 +0200 Subject: [PATCH 178/336] Add example that leverages resampling adn fixing of Investments --- .../two_stage_optimization.py | 45 +++++++++---------- flixopt/calculation.py | 23 ++++++++++ 2 files changed, 43 insertions(+), 25 deletions(-) diff --git a/examples/05_Two-stage-optimization/two_stage_optimization.py b/examples/05_Two-stage-optimization/two_stage_optimization.py index 5e19d54c1..7f0a412bf 100644 --- a/examples/05_Two-stage-optimization/two_stage_optimization.py +++ b/examples/05_Two-stage-optimization/two_stage_optimization.py @@ -1,6 +1,10 @@ """ -This script demonstrates how to use the different calcualtion types in the flixOPt framework -to model the same energy system. THe Results will be compared to each other. +This script demonstrates how to use downsampling of a FlowSystem to effectively reduce the size of a model. +This can be very useful when working with large models or during developement state, +as it can drastically reduce the computational time. +This leads to faster results and easier debugging. +A common use case is to do optimize the investments of a model with a downsampled version of the original model, and than fix the computed sizes when calculating th actual dispatch. +While the final optimum might differ from the fglobal optimum, the solving will be much faster. """ import logging @@ -38,17 +42,17 @@ fx.Effect('CO2', 'kg', 'CO2_e-Emissionen'), fx.Effect('PE', 'kWh_PE', 'Primärenergie'), fx.linear_converters.Boiler( - 'Kessel', - eta=0.85, - Q_th=fx.Flow(label='Q_th', bus='Fernwärme'), - Q_fu=fx.Flow( - label='Q_fu', - bus='Gas', - size=fx.InvestParameters(specific_effects={'costs': 1_000}, minimum_size=10, maximum_size=500), - relative_minimum=0.2, - previous_flow_rate=20, - on_off_parameters=fx.OnOffParameters(effects_per_switch_on=300), - ), + 'Kessel', + eta=0.85, + Q_th=fx.Flow(label='Q_th', bus='Fernwärme'), + Q_fu=fx.Flow( + label='Q_fu', + bus='Gas', + size=fx.InvestParameters(specific_effects={'costs': 1_000}, minimum_size=10, maximum_size=500), + relative_minimum=0.2, + previous_flow_rate=20, + on_off_parameters=fx.OnOffParameters(effects_per_switch_on=300), + ), ), fx.linear_converters.CHP( 'BHKW2', @@ -63,10 +67,8 @@ ), fx.Storage( 'Speicher', - capacity_in_flow_hours=684, - initial_charge_state=137, - minimal_final_charge_state=137, - maximal_final_charge_state=158, + capacity_in_flow_hours=fx.InvestParameters(minimum_size=10, maximum_size=1000, specific_effects={'costs': 60}), + initial_charge_state='lastValueOfSim', eta_charge=1, eta_discharge=1, relative_loss_per_hour=0.001, @@ -109,14 +111,7 @@ calculation_dispatch = fx.FullCalculation('Sizing', flow_system) calculation_dispatch.do_modeling() - for name, da in flow_sizes.data_vars.items(): - if name in calculation_dispatch.model.variables: - con = calculation_dispatch.model.add_constraints( - calculation_dispatch.model[name] == da, - name=f'{name}_fixing', - ) - logger.info(f'Constraint {con.name} added:\n{con}') - + calculation_dispatch.fix_sizes(calculation_sizing.results.solution) calculation_dispatch.solve(fx.solvers.HighsSolver(0.1 / 100, 600)) timer_dispatch = timeit.default_timer() - start diff --git a/flixopt/calculation.py b/flixopt/calculation.py index 0fb735bef..88e686681 100644 --- a/flixopt/calculation.py +++ b/flixopt/calculation.py @@ -180,6 +180,29 @@ def do_modeling(self) -> SystemModel: self.durations['modeling'] = round(timeit.default_timer() - t_start, 2) return self.model + def fix_sizes(self, ds: xr.Dataset, decimal_rounding: Optional[int] = 5): + """Fix the sizes of the calculations to specified values. + + Args: + ds: The dataset that contains the variable names mapped to their sizes. If None, the dataset is loaded from the results. + decimal_rounding: The number of decimal places to round the sizes to. If no rounding is applied, numerical errors might lead to infeasibility. + """ + if decimal_rounding is not None: + ds = ds.round(decimal_rounding) + + for name, da in ds.data_vars.items(): + if '|size' not in name: + continue + if name not in self.model.variables: + logger.debug(f'Variable {name} not found in calculation model. Skipping.') + continue + + con = self.model.add_constraints( + self.model[name] == da, + name=f'{name}-fixed', + ) + logger.debug(f'Fixed "{name}":\n{con}') + def solve(self, solver: _Solver, log_file: Optional[pathlib.Path] = None, log_main_results: bool = True): t_start = timeit.default_timer() From 706c1ec919e28eba6ca454897365334e4e67a3d7 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 16 Jul 2025 10:36:37 +0200 Subject: [PATCH 179/336] Add flag to Calculation if its modeled --- flixopt/calculation.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/flixopt/calculation.py b/flixopt/calculation.py index 88e686681..e7ea8d053 100644 --- a/flixopt/calculation.py +++ b/flixopt/calculation.py @@ -95,6 +95,8 @@ def __init__( f'Folder {self.folder} and its parent do not exist. Please create them first.' ) from e + self._modeled = False + @property def main_results(self) -> Dict[str, Union[Scalar, Dict]]: from flixopt.features import InvestmentModel @@ -164,6 +166,10 @@ def active_timesteps(self) -> pd.DatetimeIndex: ) return self._active_timesteps + @property + def modeled(self) -> bool: + return True if self.model is not None else False + class FullCalculation(Calculation): """ @@ -187,6 +193,8 @@ def fix_sizes(self, ds: xr.Dataset, decimal_rounding: Optional[int] = 5): ds: The dataset that contains the variable names mapped to their sizes. If None, the dataset is loaded from the results. decimal_rounding: The number of decimal places to round the sizes to. If no rounding is applied, numerical errors might lead to infeasibility. """ + if not self.modeled: + raise RuntimeError('Model was not created. Call do_modeling() first.') if decimal_rounding is not None: ds = ds.round(decimal_rounding) From a4cdb433f48cb50ecce5fe2ccab2f7610d961013 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 16 Jul 2025 10:37:03 +0200 Subject: [PATCH 180/336] Make flag for connected_and_transformed FLowSystem public --- flixopt/flow_system.py | 30 +++++++++++++++++------------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index 7001ca9e3..b0dc746bb 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -208,7 +208,7 @@ def to_dataset(self) -> xr.Dataset: Returns: xr.Dataset: Dataset containing all DataArrays with structure in attributes """ - if not self._connected_and_transformed: + if not self.connected_and_transformed: logger.warning('FlowSystem is not connected_and_transformed. Connecting and transforming data now.') self.connect_and_transform() @@ -278,7 +278,7 @@ def to_netcdf(self, path: Union[str, pathlib.Path], compression: int = 0): path: The path to the netCDF file. compression: The compression level to use when saving the file. """ - if not self._connected_and_transformed: + if not self.connected_and_transformed: logger.warning('FlowSystem is not connected. Calling connect_and_transform() now.') self.connect_and_transform() @@ -294,7 +294,7 @@ def get_structure(self, clean: bool = False, stats: bool = False) -> Dict: clean: If True, remove None and empty dicts and lists. stats: If True, replace DataArray references with statistics """ - if not self._connected_and_transformed: + if not self.connected_and_transformed: logger.warning('FlowSystem is not connected. Calling connect_and_transform() now.') self.connect_and_transform() @@ -308,7 +308,7 @@ def to_json(self, path: Union[str, pathlib.Path]): Args: path: The path to the JSON file. """ - if not self._connected_and_transformed: + if not self.connected_and_transformed: logger.warning('FlowSystem needs to be connected and transformed before saving to JSON. Calling connect_and_transform() now.') self.connect_and_transform() @@ -387,7 +387,7 @@ def connect_and_transform(self): logger.warning(f'Scenario weights are not normalized to 1. This is reccomended for a better scaled model. ' f'Sum of weights={self.scenario_weights.sum().item()}') - if not self._connected_and_transformed: + if not self.connected_and_transformed: self._connect_network() for element in list(self.components.values()) + list(self.effects.effects.values()) + list(self.buses.values()): element.transform_data(self) @@ -401,7 +401,7 @@ def add_elements(self, *elements: Element) -> None: *elements: childs of Element like Boiler, HeatPump, Bus,... modeling Elements """ - if self._connected_and_transformed: + if self.connected_and_transformed: warnings.warn( 'You are adding elements to an already connected FlowSystem. This is not recommended (But it works).', stacklevel=2, @@ -420,7 +420,7 @@ def add_elements(self, *elements: Element) -> None: ) def create_model(self) -> SystemModel: - if not self._connected_and_transformed: + if not self.connected_and_transformed: raise RuntimeError('FlowSystem is not connected_and_transformed. Call FlowSystem.connect_and_transform() first.') self.model = SystemModel(self) return self.model @@ -445,7 +445,7 @@ def plot_network( return plotting.plot_network(node_infos, edge_infos, path, controls, show) def network_infos(self) -> Tuple[Dict[str, Dict[str, str]], Dict[str, Dict[str, str]]]: - if not self._connected_and_transformed: + if not self.connected_and_transformed: self.connect_and_transform() nodes = { node.label_full: { @@ -532,7 +532,7 @@ def _connect_network(self): def __repr__(self) -> str: """Compact representation for debugging.""" - status = '✓' if self._connected_and_transformed else '⚠' + status = '✓' if self.connected_and_transformed else '⚠' return ( f'FlowSystem({len(self.timesteps)} timesteps ' f'[{self.timesteps[0].strftime("%Y-%m-%d")} to {self.timesteps[-1].strftime("%Y-%m-%d")}], ' @@ -562,7 +562,7 @@ def format_elements(element_names: list, label: str, alignment: int = 12): format_elements(list(self.components.keys()), 'Components'), format_elements(list(self.buses.keys()), 'Buses'), format_elements(list(self.effects.effects.keys()), 'Effects'), - f'Status: {"Connected & Transformed" if self._connected_and_transformed else "Not connected"}', + f'Status: {"Connected & Transformed" if self.connected_and_transformed else "Not connected"}', ] return '\n'.join(lines) @@ -641,7 +641,7 @@ def sel(self, time: Optional[Union[str, slice, List[str], pd.Timestamp, pd.Datet Returns: FlowSystem: New FlowSystem with selected data """ - if not self._connected_and_transformed: + if not self.connected_and_transformed: self.connect_and_transform() # Build indexers dict from non-None parameters @@ -669,7 +669,7 @@ def isel(self, time: Optional[Union[int, slice, List[int]]] = None, scenario: Op Returns: FlowSystem: New FlowSystem with selected data """ - if not self._connected_and_transformed: + if not self.connected_and_transformed: self.connect_and_transform() # Build indexers dict from non-None parameters @@ -704,7 +704,7 @@ def resample( Returns: FlowSystem: New FlowSystem with resampled data """ - if not self._connected_and_transformed: + if not self.connected_and_transformed: self.connect_and_transform() dataset = self.to_dataset() @@ -737,3 +737,7 @@ def resample( resampled_dataset = resampled_time_data return self.__class__.from_dataset(resampled_dataset) + + @property + def connected_and_transformed(self) -> bool: + return self._connected_and_transformed From 148a8524f45bb2375c16fcd2e0aaebe748c1dae8 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 16 Jul 2025 10:37:27 +0200 Subject: [PATCH 181/336] Make Calcualtion Methods return themselfes to make them chainable --- flixopt/calculation.py | 20 +++++++++++++------- flixopt/flow_system.py | 2 +- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/flixopt/calculation.py b/flixopt/calculation.py index e7ea8d053..764961b78 100644 --- a/flixopt/calculation.py +++ b/flixopt/calculation.py @@ -176,7 +176,7 @@ class FullCalculation(Calculation): class for defined way of solving a flow_system optimization """ - def do_modeling(self) -> SystemModel: + def do_modeling(self) -> 'FullCalculation': t_start = timeit.default_timer() self.flow_system.connect_and_transform() @@ -184,9 +184,9 @@ def do_modeling(self) -> SystemModel: self.model.do_modeling() self.durations['modeling'] = round(timeit.default_timer() - t_start, 2) - return self.model + return self - def fix_sizes(self, ds: xr.Dataset, decimal_rounding: Optional[int] = 5): + def fix_sizes(self, ds: xr.Dataset, decimal_rounding: Optional[int] = 5) -> 'FullCalculation': """Fix the sizes of the calculations to specified values. Args: @@ -211,7 +211,9 @@ def fix_sizes(self, ds: xr.Dataset, decimal_rounding: Optional[int] = 5): ) logger.debug(f'Fixed "{name}":\n{con}') - def solve(self, solver: _Solver, log_file: Optional[pathlib.Path] = None, log_main_results: bool = True): + return self + + def solve(self, solver: _Solver, log_file: Optional[pathlib.Path] = None, log_main_results: bool = True) -> 'FullCalculation': t_start = timeit.default_timer() self.model.solve( @@ -248,6 +250,8 @@ def solve(self, solver: _Solver, log_file: Optional[pathlib.Path] = None, log_ma self.results = CalculationResults.from_calculation(self) + return self + class AggregatedCalculation(FullCalculation): """ @@ -288,7 +292,7 @@ def __init__( self.components_to_clusterize = components_to_clusterize self.aggregation = None - def do_modeling(self) -> SystemModel: + def do_modeling(self) -> 'AggregatedCalculation': t_start = timeit.default_timer() self.flow_system.connect_and_transform() self._perform_aggregation() @@ -302,7 +306,7 @@ def do_modeling(self) -> SystemModel: ) self.aggregation.do_modeling() self.durations['modeling'] = round(timeit.default_timer() - t_start, 2) - return self.model + return self def _perform_aggregation(self): from .aggregation import Aggregation @@ -463,7 +467,7 @@ def _create_sub_calculations(self): def do_modeling_and_solve( self, solver: _Solver, log_file: Optional[pathlib.Path] = None, log_main_results: bool = False - ): + ) -> 'SegmentedCalculation': logger.info(f'{"":#^80}') logger.info(f'{" Segmented Solving ":#^80}') self._create_sub_calculations() @@ -505,6 +509,8 @@ def do_modeling_and_solve( self.results = SegmentedCalculationResults.from_calculation(self) + return self + def _transfer_start_values(self, i: int): """ This function gets the last values of the previous solved segment and diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index b0dc746bb..3d43313b3 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -737,7 +737,7 @@ def resample( resampled_dataset = resampled_time_data return self.__class__.from_dataset(resampled_dataset) - + @property def connected_and_transformed(self) -> bool: return self._connected_and_transformed From 61755f9bd68a15f691ba2c2ea3de721adb282d86 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 16 Jul 2025 13:50:24 +0200 Subject: [PATCH 182/336] Improve example --- examples/05_Two-stage-optimization/two_stage_optimization.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/examples/05_Two-stage-optimization/two_stage_optimization.py b/examples/05_Two-stage-optimization/two_stage_optimization.py index 7f0a412bf..3548726b4 100644 --- a/examples/05_Two-stage-optimization/two_stage_optimization.py +++ b/examples/05_Two-stage-optimization/two_stage_optimization.py @@ -4,7 +4,7 @@ as it can drastically reduce the computational time. This leads to faster results and easier debugging. A common use case is to do optimize the investments of a model with a downsampled version of the original model, and than fix the computed sizes when calculating th actual dispatch. -While the final optimum might differ from the fglobal optimum, the solving will be much faster. +While the final optimum might differ from the global optimum, the solving will be much faster. """ import logging @@ -107,7 +107,6 @@ calculation_sizing.do_modeling() calculation_sizing.solve(fx.solvers.HighsSolver(0.1/100, 600)) timer_sizing = timeit.default_timer() - start - flow_sizes = xr.Dataset({flow.size.name: flow.size for flow in calculation_sizing.results.flows.values()}) calculation_dispatch = fx.FullCalculation('Sizing', flow_system) calculation_dispatch.do_modeling() @@ -133,7 +132,7 @@ ).assign_coords(mode=['Combined', 'Two-stage']) comparison['Duration [s]'] = xr.DataArray([timer_combined, timer_sizing + timer_dispatch], dims='mode') - comparison_main = comparison[['Duration [s]', 'costs|total', 'costs(invest)|total', 'costs(operation)|total', 'BHKW2(Q_fu)|size', 'Kessel(Q_fu)|size']] + comparison_main = comparison[['Duration [s]', 'costs|total', 'costs(invest)|total', 'costs(operation)|total', 'BHKW2(Q_fu)|size', 'Kessel(Q_fu)|size', 'Speicher|size']] comparison_main = xr.concat([ comparison_main, ((comparison_main.sel(mode='Two-stage') - comparison_main.sel(mode='Combined')) From 66f6a8675b421131fcb8f8bedf2a26851783639a Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 16 Jul 2025 15:54:36 +0200 Subject: [PATCH 183/336] Improve Unreleased CHANGELOG.md --- CHANGELOG.md | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 860c2e842..f1ee8a916 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [Unreleased - New Model dimensions] ## What's New @@ -35,37 +35,38 @@ This might occur when scenarios represent years or months, while an investment d * Feature 2 - Description -## [Unreleased] +## [Unreleased - Data Management and IO] ### Changed * **BREAKING**: `relative_minimum_charge_state` and `relative_maximum_charge_state` don't have an extra timestep anymore. The final charge state can now be constrained by parameters `relative_minimum_final_charge_state` and `relative_maximum_final_charge_state` instead +* **BREAKING**: Calculation.do_modeling() now returns the Calculation object instead of its linopy.Model * FlowSystems can not be shared across multiple Calculations anymore. A copy of the FlowSystem is created instead, making every Calculation independent * Type system overhaul - added clear separation between temporal and non-temporal data throughout codebase for better clarity * FlowSystem data management simplified - removed `time_series_collection` pattern in favor of direct timestep properties * Enhanced FlowSystem interface with improved `__repr__()` and `__str__()` methods ### Added -* **NEW**: Complete serialization infrastructure through `Interface` base class +* Complete serialization infrastructure through `Interface` base class * IO for all Interfaces and the FlowSystem with round-trip serialization support * Automatic DataArray extraction and restoration * NetCDF export/import capabilities for all Interface objects and FlowSystem * JSON export for documentation purposes * Recursive handling of nested Interface objects -* **NEW**: FlowSystem data manipulation methods +* FlowSystem data manipulation methods * `sel()` and `isel()` methods for temporal data selection * `resample()` method for temporal resampling * `copy()` method to create a copy of a FlowSystem, including all underlying Elements and their data * `__eq__()` method for FlowSystem comparison -* **NEW**: Storage component enhancements +* Storage component enhancements * `relative_minimum_final_charge_state` parameter for final state control * `relative_maximum_final_charge_state` parameter for final state control -* *Internal*: Enhanced data handling methods - * `fit_to_model_coords()` method for data alignment - * `fit_effects_to_model_coords()` method for effect data processing - * `connect_and_transform()` method replacing separate operations -* **NEW**: Core data handling improvements +* Core data handling improvements * `get_dataarray_stats()` function for statistical summaries * Enhanced `DataConverter` class with better TimeSeriesData support +* Internal: Enhanced data handling methods + * `fit_to_model_coords()` method for data alignment + * `fit_effects_to_model_coords()` method for effect data processing + * `connect_and_transform()` method replacing several operations ### Fixed * Enhanced NetCDF I/O with proper attribute preservation for DataArrays @@ -74,7 +75,7 @@ This might occur when scenarios represent years or months, while an investment d ### Know Issues * Plotly >= 6 may raise errors if "nbformat" is not installed. We pinned plotly to <6, but this may be fixed in the future. -* IO for single Interfaces/Elemenets to Datasets might not work properly if the Interface/Element is not part of a fully transformed and connected FlowSystem. This arrises from Numeric Data not being stored as xr.DataArray by the user. TO avoid this, always use the `to_dataset()` on Elements inside a FlowSystem thats connected and transformed. +* IO for single Interfaces/Elemenets to Datasets might not work properly if the Interface/Element is not part of a fully transformed and connected FlowSystem. This arrises from Numeric Data not being stored as xr.DataArray by the user. To avoid this, always use the `to_dataset()` on Elements inside a FlowSystem thats connected and transformed. ### Deprecated * The `agg_group` and `agg_weight` parameters of `TimeSeriesData` are deprecated and will be removed in a future version. Use `aggregation_group` and `aggregation_weight` instead. From a757e7fb42f7e63e2f4f72abc0a688ca1f70c8be Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 8 Jul 2025 09:45:16 +0200 Subject: [PATCH 184/336] Add year coord to FlowSystem --- flixopt/flow_system.py | 74 +++++++++++++++++++++++++++++++++--------- 1 file changed, 59 insertions(+), 15 deletions(-) diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index 3d43313b3..ffb408369 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -62,29 +62,34 @@ class FlowSystem(Interface): def __init__( self, timesteps: pd.DatetimeIndex, + years: Optional[pd.Index] = None, scenarios: Optional[pd.Index] = None, hours_of_last_timestep: Optional[float] = None, hours_of_previous_timesteps: Optional[Union[int, float, np.ndarray]] = None, - scenario_weights: Optional[NonTemporalDataUser] = None, + weights: Optional[NonTemporalDataUser] = None, ): """ Args: timesteps: The timesteps of the model. + years: The years of the model. scenarios: The scenarios of the model. hours_of_last_timestep: The duration of the last time step. Uses the last time interval if not specified hours_of_previous_timesteps: The duration of previous timesteps. If None, the first time increment of time_series is used. This is needed to calculate previous durations (for example consecutive_on_hours). If you use an array, take care that its long enough to cover all previous values! - scenario_weights: The weights of each scenarios. If None, all scenarios have the same weight (normalized to 1). Its recommended to scale the weights to sum up to 1. + weights: The weights of each year and scenario. If None, all have the same weight (normalized to 1). Its recommended to scale the weights to sum up to 1. """ self.timesteps = self._validate_timesteps(timesteps) self.timesteps_extra = self._create_timesteps_with_extra(timesteps, hours_of_last_timestep) self.hours_of_previous_timesteps = self._calculate_hours_of_previous_timesteps(timesteps, hours_of_previous_timesteps) + self.years = None if years is None else self._validate_years(years) + self.scenarios = None if scenarios is None else self._validate_scenarios(scenarios) - self.scenario_weights = scenario_weights + + self.weights = weights hours_per_timestep = self.calculate_hours_per_timestep(self.timesteps_extra) @@ -130,6 +135,29 @@ def _validate_scenarios(scenarios: pd.Index) -> pd.Index: return scenarios + @staticmethod + def _validate_years(years: pd.Index) -> pd.Index: + """ + Validate and prepare year index. + + Args: + years: The year index to validate + """ + if not isinstance(years, pd.Index) or len(years) == 0: + raise ConversionError('Years must be a non-empty Index') + + if not ( + years.dtype.kind == 'i' # integer dtype + and years.is_monotonic_increasing # rising + and years.is_unique + ): + raise ConversionError('Years must be a monotonically increasing and unique Index') + + if years.name != 'year': + years = years.rename('year') + + return years + @staticmethod def _create_timesteps_with_extra( timesteps: pd.DatetimeIndex, hours_of_last_timestep: Optional[float] @@ -235,10 +263,11 @@ def from_dataset(cls, ds: xr.Dataset) -> 'FlowSystem': # Create FlowSystem instance with constructor parameters flow_system = cls( timesteps=ds.indexes['time'], + years=ds.indexes.get('year'), scenarios=ds.indexes.get('scenario'), - scenario_weights=cls._resolve_dataarray_reference( - reference_structure['scenario_weights'], arrays_dict - ) if 'scenario_weights' in reference_structure else None, + weights=cls._resolve_dataarray_reference(reference_structure['weights'], arrays_dict) + if 'weights' in reference_structure + else None, hours_of_last_timestep=reference_structure.get('hours_of_last_timestep'), hours_of_previous_timesteps=reference_structure.get('hours_of_previous_timesteps'), ) @@ -380,12 +409,12 @@ def fit_effects_to_model_coords( def connect_and_transform(self): """Transform data for all elements using the new simplified approach.""" - self.scenario_weights = self.fit_to_model_coords( - 'scenario_weights', self.scenario_weights, has_time_dim=False + self.weights = self.fit_to_model_coords( + 'weights', self.weights, has_time_dim=False ) - if self.scenario_weights is not None and self.scenario_weights.sum() != 1: + if self.weights is not None and self.weights.sum() != 1: logger.warning(f'Scenario weights are not normalized to 1. This is reccomended for a better scaled model. ' - f'Sum of weights={self.scenario_weights.sum().item()}') + f'Sum of weights={self.weights.sum().item()}') if not self.connected_and_transformed: self._connect_network() @@ -621,6 +650,8 @@ def all_elements(self) -> Dict[str, Element]: @property def coords(self) -> Dict[str, pd.Index]: active_coords = {'time': self.timesteps} + if self.years is not None: + active_coords['year'] = self.years if self.scenarios is not None: active_coords['scenario'] = self.scenarios return active_coords @@ -629,13 +660,18 @@ def coords(self) -> Dict[str, pd.Index]: def used_in_calculation(self) -> bool: return self._used_in_calculation - def sel(self, time: Optional[Union[str, slice, List[str], pd.Timestamp, pd.DatetimeIndex]] = None, - scenario: Optional[Union[str, slice, List[str], pd.Index]] = None) -> 'FlowSystem': + def sel( + self, + time: Optional[Union[str, slice, List[str], pd.Timestamp, pd.DatetimeIndex]] = None, + year: Optional[Union[int, slice, List[int], pd.Index]] = None, + scenario: Optional[Union[str, slice, List[str], pd.Index]] = None, + ) -> 'FlowSystem': """ Select a subset of the flowsystem by the time coordinate. Args: time: Time selection (e.g., slice('2023-01-01', '2023-12-31'), '2023-06-15', or list of times) + year: Year selection (e.g., slice(2023, 2024), or list of years) scenario: Scenario selection (e.g., slice('scenario1', 'scenario2'), or list of scenarios) Returns: @@ -648,7 +684,8 @@ def sel(self, time: Optional[Union[str, slice, List[str], pd.Timestamp, pd.Datet indexers = {} if time is not None: indexers['time'] = time - + if year is not None: + indexers['year'] = year if scenario is not None: indexers['scenario'] = scenario @@ -658,12 +695,18 @@ def sel(self, time: Optional[Union[str, slice, List[str], pd.Timestamp, pd.Datet selected_dataset = self.to_dataset().sel(**indexers) return self.__class__.from_dataset(selected_dataset) - def isel(self, time: Optional[Union[int, slice, List[int]]] = None, scenario: Optional[Union[int, slice, List[int]]] = None) -> 'FlowSystem': + def isel( + self, + time: Optional[Union[int, slice, List[int]]] = None, + year: Optional[Union[int, slice, List[int]]] = None, + scenario: Optional[Union[int, slice, List[int]]] = None + ) -> 'FlowSystem': """ Select a subset of the flowsystem by integer indices. Args: time: Time selection by integer index (e.g., slice(0, 100), 50, or [0, 5, 10]) + year: Year selection by integer index (e.g., slice(0, 100), 50, or [0, 5, 10]) scenario: Scenario selection by integer index (e.g., slice(0, 3), 50, or [0, 5, 10]) Returns: @@ -676,7 +719,8 @@ def isel(self, time: Optional[Union[int, slice, List[int]]] = None, scenario: Op indexers = {} if time is not None: indexers['time'] = time - + if year is not None: + indexers['year'] = year if scenario is not None: indexers['scenario'] = scenario From 941d93f26fb3700b06619d40dbe5285dbca2ad17 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 8 Jul 2025 09:46:09 +0200 Subject: [PATCH 185/336] Improve dimension handling --- examples/04_Scenarios/scenario_example.py | 5 ++- flixopt/core.py | 2 +- flixopt/effects.py | 26 +++++------ flixopt/elements.py | 2 +- flixopt/features.py | 51 +++++++++++---------- flixopt/flow_system.py | 14 +++--- flixopt/structure.py | 28 ++++++------ tests/conftest.py | 2 +- tests/test_functional.py | 54 ----------------------- tests/test_scenarios.py | 33 +++++++------- 10 files changed, 80 insertions(+), 137 deletions(-) diff --git a/examples/04_Scenarios/scenario_example.py b/examples/04_Scenarios/scenario_example.py index ae53cc1ff..d1ab0cedd 100644 --- a/examples/04_Scenarios/scenario_example.py +++ b/examples/04_Scenarios/scenario_example.py @@ -12,14 +12,15 @@ # Create datetime array starting from '2020-01-01' for the given time period timesteps = pd.date_range('2020-01-01', periods=9, freq='h') scenarios = pd.Index(['Base Case', 'High Demand']) + years = pd.Index([2020, 2021, 2022]) # --- Create Time Series Data --- # Heat demand profile (e.g., kW) over time and corresponding power prices heat_demand_per_h = pd.DataFrame({'Base Case':[30, 0, 90, 110, 110, 20, 20, 20, 20], 'High Demand':[30, 0, 100, 118, 125, 20, 20, 20, 20]}, index=timesteps) - power_prices = np.array([0.08, 0.09]) + power_prices = np.array([0.08, 0.09, 0.10]) - flow_system = fx.FlowSystem(timesteps=timesteps, scenarios=scenarios, scenario_weights=np.array([0.5, 0.6])) + flow_system = fx.FlowSystem(timesteps=timesteps, years=years, scenarios=scenarios, weights=np.array([0.5, 0.6])) # --- Define Energy Buses --- # These represent nodes, where the used medias are balanced (electricity, heat, and gas) diff --git a/flixopt/core.py b/flixopt/core.py index ee0ef0540..fb0e4bae0 100644 --- a/flixopt/core.py +++ b/flixopt/core.py @@ -30,7 +30,7 @@ NonTemporalData = Union[Scalar, xr.DataArray] """Internally used datatypes for non-temporal data. Can be a Scalar or an xr.DataArray.""" -FlowSystemDimensions = Literal['time', 'scenario'] +FlowSystemDimensions = Literal['time', 'year', 'scenario'] """Possible dimensions of a FlowSystem.""" diff --git a/flixopt/effects.py b/flixopt/effects.py index 381b5a3de..639ae559b 100644 --- a/flixopt/effects.py +++ b/flixopt/effects.py @@ -145,8 +145,7 @@ def __init__(self, model: SystemModel, element: Effect): self.invest: ShareAllocationModel = self.add( ShareAllocationModel( model=self._model, - has_time_dim=False, - has_scenario_dim=True, + dims=['year', 'scenario'], label_of_element=self.label_of_element, label='invest', label_full=f'{self.label_full}(invest)', @@ -158,8 +157,7 @@ def __init__(self, model: SystemModel, element: Effect): self.operation: ShareAllocationModel = self.add( ShareAllocationModel( model=self._model, - has_time_dim=True, - has_scenario_dim=True, + dims=['time', 'year', 'scenario'], label_of_element=self.label_of_element, label='operation', label_full=f'{self.label_full}(operation)', @@ -182,7 +180,7 @@ def do_modeling(self): self._model.add_variables( lower=self.element.minimum_total if self.element.minimum_total is not None else -np.inf, upper=self.element.maximum_total if self.element.maximum_total is not None else np.inf, - coords=self._model.get_coords(time_dim=False), + coords=self._model.get_coords(['year', 'scenario']), name=f'{self.label_full}|total', ), 'total', @@ -406,15 +404,13 @@ def add_share_to_effects( self.effects[effect].model.operation.add_share( name, expression, - has_time_dim=True, - has_scenario_dim=True, + dims=['time', 'year', 'scenario'], ) elif target == 'invest': self.effects[effect].model.invest.add_share( name, expression, - has_time_dim=False, - has_scenario_dim=True, + dims=['year', 'scenario'], ) else: raise ValueError(f'Target {target} not supported!') @@ -422,13 +418,13 @@ def add_share_to_effects( def add_share_to_penalty(self, name: str, expression: linopy.LinearExpression) -> None: if expression.ndim != 0: raise TypeError(f'Penalty shares must be scalar expressions! ({expression.ndim=})') - self.penalty.add_share(name, expression, has_time_dim=False, has_scenario_dim=False) + self.penalty.add_share(name, expression, dims=[]) def do_modeling(self): for effect in self.effects: effect.create_model(self._model) self.penalty = self.add( - ShareAllocationModel(self._model, has_time_dim=False, has_scenario_dim=False, label_of_element='Penalty') + ShareAllocationModel(self._model, dims=[], label_of_element='Penalty') ) for model in [effect.model for effect in self.effects] + [self.penalty]: model.do_modeling() @@ -436,7 +432,7 @@ def do_modeling(self): self._add_share_between_effects() self._model.add_objective( - (self.effects.objective_effect.model.total * self._model.scenario_weights).sum() + (self.effects.objective_effect.model.total * self._model.weights).sum() + self.penalty.total.sum() ) @@ -447,16 +443,14 @@ def _add_share_between_effects(self): self.effects[target_effect].model.operation.add_share( origin_effect.model.operation.label_full, origin_effect.model.operation.total_per_timestep * time_series, - has_time_dim=True, - has_scenario_dim=True, + dims=['time', 'year', 'scenario'], ) # 2. invest: -> hier ist es Scalar (share) for target_effect, factor in origin_effect.specific_share_to_other_effects_invest.items(): self.effects[target_effect].model.invest.add_share( origin_effect.model.invest.label_full, origin_effect.model.invest.total * factor, - has_time_dim=False, - has_scenario_dim=True, + dims=['year', 'scenario'], ) diff --git a/flixopt/elements.py b/flixopt/elements.py index 1daadeb55..968b70ca5 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -364,7 +364,7 @@ def do_modeling(self): self._model.add_variables( lower=self.element.flow_hours_total_min if self.element.flow_hours_total_min is not None else 0, upper=self.element.flow_hours_total_max if self.element.flow_hours_total_max is not None else np.inf, - coords=self._model.get_coords(time_dim=False), + coords=self._model.get_coords(['year', 'scenario']), name=f'{self.label_full}|total_flow_hours', ), 'total_flow_hours', diff --git a/flixopt/features.py b/flixopt/features.py index 4e47ace7f..c002a8ff6 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -10,7 +10,7 @@ import numpy as np from .config import CONFIG -from .core import NonTemporalData, Scalar, TemporalData +from .core import NonTemporalData, Scalar, TemporalData, FlowSystemDimensions from .interface import InvestParameters, OnOffParameters, Piecewise from .structure import Model, SystemModel @@ -48,7 +48,7 @@ def do_modeling(self): lower=0 if self.parameters.optional else self.parameters.minimum_or_fixed_size, upper=self.parameters.maximum_or_fixed_size, name=f'{self.label_full}|size', - coords=self._model.get_coords(time_dim=False), + coords=self._model.get_coords(['year', 'scenario']), ), 'size', ) @@ -59,7 +59,7 @@ def do_modeling(self): self._model.add_variables( binary=True, name=f'{self.label_full}|is_invested', - coords=self._model.get_coords(time_dim=False), + coords=self._model.get_coords(['year', 'scenario']), ), 'is_invested', ) @@ -294,7 +294,7 @@ def do_modeling(self): self._model.add_variables( lower=self._on_hours_total_min, upper=self._on_hours_total_max, - coords=self._model.get_coords(time_dim=False), + coords=self._model.get_coords(['year', 'scenario']), name=f'{self.label_full}|on_hours_total', ), 'on_hours_total', @@ -952,8 +952,7 @@ class ShareAllocationModel(Model): def __init__( self, model: SystemModel, - has_time_dim: bool, - has_scenario_dim: bool, + dims: List[FlowSystemDimensions], label_of_element: Optional[str] = None, label: Optional[str] = None, label_full: Optional[str] = None, @@ -963,10 +962,11 @@ def __init__( min_per_hour: Optional[TemporalData] = None, ): super().__init__(model, label_of_element=label_of_element, label=label, label_full=label_full) - if not has_time_dim: # If the condition is True - assert max_per_hour is None and min_per_hour is None, ( - 'Both max_per_hour and min_per_hour cannot be used when has_time_dim is False' - ) + + if 'time' not in dims and max_per_hour is not None or min_per_hour is not None: + raise ValueError('Both max_per_hour and min_per_hour cannot be used when has_time_dim is False') + + self._dims = dims self.total_per_timestep: Optional[linopy.Variable] = None self.total: Optional[linopy.Variable] = None self.shares: Dict[str, linopy.Variable] = {} @@ -976,8 +976,6 @@ def __init__( self._eq_total: Optional[linopy.Constraint] = None # Parameters - self._has_time_dim = has_time_dim - self._has_scenario_dim = has_scenario_dim self._total_max = total_max if total_max is not None else np.inf self._total_min = total_min if total_min is not None else -np.inf self._max_per_hour = max_per_hour if max_per_hour is not None else np.inf @@ -988,7 +986,7 @@ def do_modeling(self): self._model.add_variables( lower=self._total_min, upper=self._total_max, - coords=self._model.get_coords(time_dim=False, scenario_dim=self._has_scenario_dim), + coords=self._model.get_coords([dim for dim in self._dims if dim != 'time']), name=f'{self.label_full}|total', ), 'total', @@ -998,12 +996,12 @@ def do_modeling(self): self._model.add_constraints(self.total == 0, name=f'{self.label_full}|total'), 'total' ) - if self._has_time_dim: + if 'time' in self._dims: self.total_per_timestep = self.add( self._model.add_variables( lower=-np.inf if (self._min_per_hour is None) else self._min_per_hour * self._model.hours_per_step, upper=np.inf if (self._max_per_hour is None) else self._max_per_hour * self._model.hours_per_step, - coords=self._model.get_coords(time_dim=True, scenario_dim=self._has_scenario_dim), + coords=self._model.get_coords(self._dims), name=f'{self.label_full}|total_per_timestep', ), 'total_per_timestep', @@ -1021,8 +1019,7 @@ def add_share( self, name: str, expression: linopy.LinearExpression, - has_time_dim: bool, - has_scenario_dim: bool, + dims: Optional[List[FlowSystemDimensions]] = None, ): """ Add a share to the share allocation model. If the share already exists, the expression is added to the existing share. @@ -1033,18 +1030,24 @@ def add_share( Args: name: The name of the share. expression: The expression of the share. Added to the right hand side of the constraint. + dims: The dimensions of the share. Defaults to all dimensions. Dims are ordered automatically """ - if has_time_dim and not self._has_time_dim: - raise ValueError('Cannot add share with time_dim=True to a model without time_dim') - if has_scenario_dim and not self._has_scenario_dim: - raise ValueError('Cannot add share with scenario_dim=True to a model without scenario_dim') + if dims is None: + dims = self._dims + else: + if 'time' in dims and 'time' not in self._dims: + raise ValueError('Cannot add share with time-dim to a model without time-dim') + if 'year' in dims and 'year' not in self._dims: + raise ValueError('Cannot add share with year-dim to a model without year-dim') + if 'scenario' in dims and 'scenario' not in self._dims: + raise ValueError('Cannot add share with scenario-dim to a model without scenario-dim') if name in self.shares: self.share_constraints[name].lhs -= expression else: self.shares[name] = self.add( self._model.add_variables( - coords=self._model.get_coords(time_dim=has_time_dim, scenario_dim=has_scenario_dim), + coords=self._model.get_coords(dims), name=f'{name}->{self.label_full}', ), name, @@ -1052,7 +1055,7 @@ def add_share( self.share_constraints[name] = self.add( self._model.add_constraints(self.shares[name] == expression, name=f'{name}->{self.label_full}'), name ) - if not has_time_dim: + if 'time' not in dims: self._eq_total.lhs -= self.shares[name] else: self._eq_total_per_timestep.lhs -= self.shares[name] @@ -1083,7 +1086,7 @@ def do_modeling(self): self.shares = { effect: self.add( self._model.add_variables( - coords=self._model.get_coords(time_dim=False), name=f'{self.label_full}|{effect}' + coords=self._model.get_coords(['year', 'scenario']), name=f'{self.label_full}|{effect}' ), f'{effect}', ) diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index ffb408369..0a7550fc6 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -376,12 +376,12 @@ def fit_to_model_coords( except ConversionError as e: raise ConversionError( f'Could not convert time series data "{name}" to DataArray:\n{data}\nOriginal Error: {e}') from e - else: - try: - return DataConverter.to_dataarray(data, coords=coords).rename(name) - except ConversionError as e: - raise ConversionError( - f'Could not convert data "{name}" to DataArray:\n{data}\nOriginal Error: {e}') from e + + try: + return DataConverter.to_dataarray(data, coords=coords).rename(name) + except ConversionError as e: + raise ConversionError( + f'Could not convert data "{name}" to DataArray:\n{data}\nOriginal Error: {e}') from e def fit_effects_to_model_coords( self, @@ -413,7 +413,7 @@ def connect_and_transform(self): 'weights', self.weights, has_time_dim=False ) if self.weights is not None and self.weights.sum() != 1: - logger.warning(f'Scenario weights are not normalized to 1. This is reccomended for a better scaled model. ' + logger.warning(f'Scenario weights are not normalized to 1. This is recomended for a better scaled model. ' f'Sum of weights={self.weights.sum().item()}') if not self.connected_and_transformed: diff --git a/flixopt/structure.py b/flixopt/structure.py index 5edee1bd3..3aad34489 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -19,7 +19,7 @@ from . import io as fx_io from .config import CONFIG -from .core import NonTemporalData, Scalar, TemporalDataUser, TimeSeriesData, get_dataarray_stats +from .core import NonTemporalData, Scalar, TemporalDataUser, TimeSeriesData, get_dataarray_stats, FlowSystemDimensions if TYPE_CHECKING: # for type checking and preventing circular imports from .effects import EffectCollectionModel @@ -103,28 +103,28 @@ def hours_of_previous_timesteps(self): return self.flow_system.hours_of_previous_timesteps def get_coords( - self, scenario_dim=True, time_dim=True, extra_timestep=False + self, + dims: Optional[FlowSystemDimensions] = None, + extra_timestep=False, ) -> Optional[Union[Tuple[pd.Index], Tuple[pd.Index, pd.Index]]]: """ Returns the coordinates of the model Args: - scenario_dim: If True, the scenario dimension is included in the coordinates - time_dim: If True, the time dimension is included in the coordinates + dims: The dimensions to include in the coordinates. Defaults to all dimensions. Coords are ordered automatically extra_timestep: If True, the extra timesteps are used instead of the regular timesteps Returns: The coordinates of the model. Might also be None if no scenarios are present and time_dim is False """ - if extra_timestep and not time_dim: - raise ValueError('extra_timestep=True requires time_dim=True') + if dims is not None and extra_timestep and 'time' not in dims: + raise ValueError('extra_timestep=True requires time to be included in dims') - coords = self.flow_system.coords + if dims is None: + coords = self.flow_system.coords + else: + coords = {k: v for k, v in self.flow_system.coords.items() if k in dims} - if not scenario_dim: - coords.pop('scenario', None) - if not time_dim: - coords.pop('time', None) if extra_timestep: coords['time'] = self.flow_system.timesteps_extra @@ -137,12 +137,12 @@ def get_coords( return tuple(coords.values()) @property - def scenario_weights(self) -> Union[int, xr.DataArray]: + def weights(self) -> Union[int, xr.DataArray]: """Returns the scenario weights of the FlowSystem.""" - if self.flow_system.scenario_weights is None: + if self.flow_system.weights is None: return 1 - return self.flow_system.scenario_weights + return self.flow_system.weights class Interface: diff --git a/tests/conftest.py b/tests/conftest.py index 9f247164f..7c1b08a5b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -191,7 +191,7 @@ def simple_flow_system_scenarios() -> fx.FlowSystem: ) # Create flow system - flow_system = fx.FlowSystem(base_timesteps, scenarios=pd.Index(['A', 'B', 'C']), scenario_weights=np.array([0.5, 0.25, 0.25])) + flow_system = fx.FlowSystem(base_timesteps, scenarios=pd.Index(['A', 'B', 'C']), weights=np.array([0.5, 0.25, 0.25])) flow_system.add_elements(fx.Bus('Strom'), fx.Bus('Fernwärme'), fx.Bus('Gas')) flow_system.add_elements(storage, costs, co2, boiler, heat_load, gas_tariff, electricity_feed_in, chp) diff --git a/tests/test_functional.py b/tests/test_functional.py index 5db83f656..9542d656b 100644 --- a/tests/test_functional.py +++ b/tests/test_functional.py @@ -325,60 +325,6 @@ def test_optional_invest(solver_fixture, time_steps_fixture): err_msg='"Boiler__Q_th__IsInvested" does not have the right value', ) - def test_fixed_relative_profile(self): - self.flow_system = self.create_model(self.datetime_array) - self.flow_system.add_elements( - fx.linear_converters.Boiler( - 'Boiler', - 0.5, - Q_fu=fx.Flow('Q_fu', bus=self.get_element('Gas')), - Q_th=fx.Flow( - 'Q_th', - bus=self.get_element('Fernwärme'), - size=fx.InvestParameters(optional=True, minimum_size=40, fix_effects=10, specific_effects=1), - ), - ), - fx.linear_converters.Boiler( - 'Boiler_optional', - 0.5, - Q_fu=fx.Flow('Q_fu', bus=self.get_element('Gas')), - Q_th=fx.Flow( - 'Q_th', - bus=self.get_element('Fernwärme'), - size=fx.InvestParameters(optional=True, minimum_size=50, fix_effects=10, specific_effects=1), - ), - ), - ) - self.flow_system.add_elements( - fx.Source( - 'Wärmequelle', - source=fx.Flow( - 'Q_th', - bus=self.get_element('Fernwärme'), - fixed_relative_profile=np.linspace(0, 5, len(self.datetime_array)), - size=fx.InvestParameters(optional=False, minimum_size=2, maximum_size=5), - ), - ) - ) - self.get_element('Fernwärme').excess_penalty_per_flow_hour = 1e5 - - self.solve_and_load(self.flow_system) - source = self.get_element('Wärmequelle') - assert_allclose( - source.source.model.flow_rate.result, - np.linspace(0, 5, len(self.datetime_array)) * source.source.model._investment.size.result, - rtol=self.mip_gap, - atol=1e-10, - err_msg='The total costs does not have the right value', - ) - assert_allclose( - source.source.model._investment.size.result, - 2, - rtol=self.mip_gap, - atol=1e-10, - err_msg='The total costs does not have the right value', - ) - def test_on(solver_fixture, time_steps_fixture): """Tests if the On Variable is correctly created and calculated in a Flow""" diff --git a/tests/test_scenarios.py b/tests/test_scenarios.py index cd3de4407..717d5919b 100644 --- a/tests/test_scenarios.py +++ b/tests/test_scenarios.py @@ -24,15 +24,14 @@ def test_system(): # Create two scenarios scenarios = pd.Index(["Scenario A", "Scenario B"], name="scenario") - # Create scenario weights as TimeSeriesData - # Using TimeSeriesData to avoid conversion issues - scenario_weights = TimeSeriesData(np.array([0.7, 0.3])) + # Create scenario weights + weights = np.array([0.7, 0.3]) # Create a flow system with scenarios flow_system = FlowSystem( timesteps=timesteps, scenarios=scenarios, - scenario_weights=scenario_weights # Use TimeSeriesData for weights + weights=weights # Use TimeSeriesData for weights ) # Create demand profiles that differ between scenarios @@ -254,27 +253,27 @@ def flow_system_piecewise_conversion_scenarios(flow_system_complex_scenarios) -> return flow_system -def test_scenario_weights(flow_system_piecewise_conversion_scenarios): +def test_weights(flow_system_piecewise_conversion_scenarios): """Test that scenario weights are correctly used in the model.""" scenarios = flow_system_piecewise_conversion_scenarios.scenarios weights = np.linspace(0.5, 1, len(scenarios)) - flow_system_piecewise_conversion_scenarios.scenario_weights = weights + flow_system_piecewise_conversion_scenarios.weights = weights model = create_linopy_model(flow_system_piecewise_conversion_scenarios) - np.testing.assert_allclose(model.scenario_weights.values, weights) + np.testing.assert_allclose(model.weights.values, weights) assert_linequal(model.objective.expression, (model.variables['costs|total'] * weights).sum() + model.variables['Penalty|total']) - assert np.isclose(model.scenario_weights.sum().item(), 2.25) + assert np.isclose(model.weights.sum().item(), 2.25) -def test_scenario_weights_io(flow_system_piecewise_conversion_scenarios): +def test_weights_io(flow_system_piecewise_conversion_scenarios): """Test that scenario weights are correctly used in the model.""" scenarios = flow_system_piecewise_conversion_scenarios.scenarios weights = np.linspace(0.5, 1, len(scenarios)) / np.sum(np.linspace(0.5, 1, len(scenarios))) - flow_system_piecewise_conversion_scenarios.scenario_weights = weights + flow_system_piecewise_conversion_scenarios.weights = weights model = create_linopy_model(flow_system_piecewise_conversion_scenarios) - np.testing.assert_allclose(model.scenario_weights.values, weights) + np.testing.assert_allclose(model.weights.values, weights) assert_linequal(model.objective.expression, (model.variables['costs|total'] * weights).sum() + model.variables['Penalty|total']) - assert np.isclose(model.scenario_weights.sum().item(), 1.0) + assert np.isclose(model.weights.sum().item(), 1.0) def test_scenario_dimensions_in_variables(flow_system_piecewise_conversion_scenarios): """Test that all time variables are correctly broadcasted to scenario dimensions.""" @@ -286,7 +285,7 @@ def test_full_scenario_optimization(flow_system_piecewise_conversion_scenarios): """Test a full optimization with scenarios and verify results.""" scenarios = flow_system_piecewise_conversion_scenarios.scenarios weights = np.linspace(0.5, 1, len(scenarios)) / np.sum(np.linspace(0.5, 1, len(scenarios))) - flow_system_piecewise_conversion_scenarios.scenario_weights = weights + flow_system_piecewise_conversion_scenarios.weights = weights calc = create_calculation_and_solve(flow_system_piecewise_conversion_scenarios, solver=fx.solvers.GurobiSolver(mip_gap=0.01, time_limit_seconds=60), name='test_full_scenario') @@ -305,7 +304,7 @@ def test_io_persistance(flow_system_piecewise_conversion_scenarios): """Test a full optimization with scenarios and verify results.""" scenarios = flow_system_piecewise_conversion_scenarios.scenarios weights = np.linspace(0.5, 1, len(scenarios)) / np.sum(np.linspace(0.5, 1, len(scenarios))) - flow_system_piecewise_conversion_scenarios.scenario_weights = weights + flow_system_piecewise_conversion_scenarios.weights = weights calc = create_calculation_and_solve(flow_system_piecewise_conversion_scenarios, solver=fx.solvers.HighsSolver(mip_gap=0.001, time_limit_seconds=60), name='test_full_scenario') @@ -326,12 +325,12 @@ def test_scenarios_selection(flow_system_piecewise_conversion_scenarios): flow_system_full = flow_system_piecewise_conversion_scenarios scenarios = flow_system_full.scenarios weights = np.linspace(0.5, 1, len(scenarios)) / np.sum(np.linspace(0.5, 1, len(scenarios))) - flow_system_full.scenario_weights = weights + flow_system_full.weights = weights flow_system = flow_system_full.sel(scenario=scenarios[0:2]) assert flow_system.scenarios.equals(flow_system_full.scenarios[0:2]) - np.testing.assert_allclose(flow_system.scenario_weights.values, flow_system_full.scenario_weights[0:2]) + np.testing.assert_allclose(flow_system.weights.values, flow_system_full.weights[0:2]) calc = fx.FullCalculation(flow_system=flow_system, name='test_full_scenario') @@ -340,6 +339,6 @@ def test_scenarios_selection(flow_system_piecewise_conversion_scenarios): calc.results.to_file() - np.testing.assert_allclose(calc.results.objective, ((calc.results.solution['costs|total'] * flow_system.scenario_weights).sum() + calc.results.solution['Penalty|total']).item()) ## Acount for rounding errors + np.testing.assert_allclose(calc.results.objective, ((calc.results.solution['costs|total'] * flow_system.weights).sum() + calc.results.solution['Penalty|total']).item()) ## Acount for rounding errors assert calc.results.solution.indexes['scenario'].equals(flow_system_full.scenarios[0:2]) From 745e88589888c7566664a628c24acc1cd009f037 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 8 Jul 2025 14:36:35 +0200 Subject: [PATCH 186/336] Change plotting to use an indexer instead --- flixopt/flow_system.py | 7 ++--- flixopt/results.py | 58 ++++++++++++++++++++++++++++++++++++------ 2 files changed, 52 insertions(+), 13 deletions(-) diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index 0a7550fc6..bbccbfce5 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -12,10 +12,7 @@ import numpy as np import pandas as pd import xarray as xr -from rich.console import Console -from rich.pretty import Pretty -from . import io as fx_io from .core import ( ConversionError, DataConverter, @@ -648,7 +645,7 @@ def all_elements(self) -> Dict[str, Element]: return {**self.components, **self.effects.effects, **self.flows, **self.buses} @property - def coords(self) -> Dict[str, pd.Index]: + def coords(self) -> Dict[FlowSystemDimensions, pd.Index]: active_coords = {'time': self.timesteps} if self.years is not None: active_coords['year'] = self.years @@ -665,7 +662,7 @@ def sel( time: Optional[Union[str, slice, List[str], pd.Timestamp, pd.DatetimeIndex]] = None, year: Optional[Union[int, slice, List[int], pd.Index]] = None, scenario: Optional[Union[str, slice, List[str], pd.Index]] = None, - ) -> 'FlowSystem': + ) -> 'FlowSystem': """ Select a subset of the flowsystem by the time coordinate. diff --git a/flixopt/results.py b/flixopt/results.py index 97dfc136a..96f099228 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -21,6 +21,7 @@ import pyvis from .calculation import Calculation, SegmentedCalculation + from .core import FlowSystemDimensions logger = logging.getLogger('flixopt') @@ -611,7 +612,7 @@ def plot_heatmap( save: Union[bool, pathlib.Path] = False, show: bool = True, engine: plotting.PlottingEngine = 'plotly', - scenario: Optional[Union[str, int]] = None, + indexer: Optional[Dict['FlowSystemDimensions', Any]] = None, ) -> Union[plotly.graph_objs.Figure, Tuple[plt.Figure, plt.Axes]]: """ Plots a heatmap of the solution of a variable. @@ -624,19 +625,60 @@ def plot_heatmap( save: Whether to save the plot or not. If a path is provided, the plot will be saved at that location. show: Whether to show the plot or not. engine: The engine to use for plotting. Can be either 'plotly' or 'matplotlib'. - scenario: The scenario to plot. Defaults to the first scenario. Has no effect without scenarios present + indexer: Optional selection dict, e.g., {'scenario': 'base', 'year': 2024}. + If None, uses first value for each dimension. + + Examples: + Basic usage (uses first scenario, first year, all time): + + >>> results.plot_heatmap('Battery|charge_state') + + Select specific scenario and year: + + >>> results.plot_heatmap('Boiler(Qth)|flow_rate', indexer={'scenario': 'base', 'year': 2024}) + + Time filtering (summer months only): + + >>> results.plot_heatmap('Boiler(Qth)|flow_rate', indexer={ + ... 'scenario': 'base', + ... 'time': results.solution.time[results.solution.time.dt.month.isin([6, 7, 8])] + ... }) + + Save to specific location: + + >>> results.plot_heatmap('Boiler(Qth)|flow_rate', + ... indexer={'scenario': 'base'}, + ... save='path/to/my_heatmap.html') """ dataarray = self.solution[variable_name] - scenario_suffix = '' - if 'scenario' in dataarray.indexes: - chosen_scenario = scenario or self.scenarios[0] - dataarray = dataarray.sel(scenario=chosen_scenario).drop_vars('scenario') - scenario_suffix = f'--{chosen_scenario}' + # Apply indexer or use first values + if indexer is not None: + # User provided custom indexer - apply it + dataarray = dataarray.sel(indexer) + suffix = '--' + '_'.join(f'{k}_{v}' for k, v in indexer.items()) + else: + # No indexer - use first value for each dimension except 'time' + selection = {} + suffix_parts = [] + + for dim in dataarray.dims: + if dim != 'time' and dim in dataarray.coords: + first_value = dataarray.coords[dim].values[0] + selection[dim] = first_value + suffix_parts.append(f'{dim}_{first_value}') + + if selection: + dataarray = dataarray.sel(selection) + + suffix = '--' + '_'.join(suffix_parts) if suffix_parts else '' + + # Create name + name = f'{variable_name}{suffix}' if suffix else variable_name return plot_heatmap( dataarray=dataarray, - name=f'{variable_name}{scenario_suffix}', + name=name, folder=self.folder, heatmap_timeframes=heatmap_timeframes, heatmap_timesteps_per_frame=heatmap_timesteps_per_frame, From c9bae2ae982d3fa844186eb1d87604e2a9b31d21 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 15 Jul 2025 11:07:52 +0200 Subject: [PATCH 187/336] Change plotting to use an indexer instead --- examples/04_Scenarios/scenario_example.py | 2 + flixopt/results.py | 61 ++++++++++++++--------- 2 files changed, 39 insertions(+), 24 deletions(-) diff --git a/examples/04_Scenarios/scenario_example.py b/examples/04_Scenarios/scenario_example.py index d1ab0cedd..5295d2820 100644 --- a/examples/04_Scenarios/scenario_example.py +++ b/examples/04_Scenarios/scenario_example.py @@ -111,6 +111,8 @@ # --- Solve the Calculation and Save Results --- calculation.solve(fx.solvers.HighsSolver(mip_gap=0, time_limit_seconds=30)) + calculation.results.plot_heatmap('CHP(Q_th)|flow_rate') + # --- Analyze Results --- calculation.results['Fernwärme'].plot_node_balance_pie() calculation.results['Fernwärme'].plot_node_balance(style='stacked_bar') diff --git a/flixopt/results.py b/flixopt/results.py index 96f099228..941d0d6dc 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -652,29 +652,11 @@ def plot_heatmap( """ dataarray = self.solution[variable_name] - # Apply indexer or use first values - if indexer is not None: - # User provided custom indexer - apply it - dataarray = dataarray.sel(indexer) - suffix = '--' + '_'.join(f'{k}_{v}' for k, v in indexer.items()) - else: - # No indexer - use first value for each dimension except 'time' - selection = {} - suffix_parts = [] - - for dim in dataarray.dims: - if dim != 'time' and dim in dataarray.coords: - first_value = dataarray.coords[dim].values[0] - selection[dim] = first_value - suffix_parts.append(f'{dim}_{first_value}') - - if selection: - dataarray = dataarray.sel(selection) - - suffix = '--' + '_'.join(suffix_parts) if suffix_parts else '' + dataarray, suffix_parts = _apply_indexer_to_data(dataarray, indexer) # Create name - name = f'{variable_name}{suffix}' if suffix else variable_name + suffix = '--' + '-'.join(suffix_parts) if suffix_parts else '' + name = variable_name if not suffix_parts else f'{variable_name}--{'-'.join(suffix_parts)}' if suffix else variable_name return plot_heatmap( dataarray=dataarray, @@ -854,7 +836,7 @@ def plot_node_balance( show: bool = True, colors: plotting.ColorType = 'viridis', engine: plotting.PlottingEngine = 'plotly', - scenario: Optional[Union[str, int]] = None, + indexer: Optional[Dict['FlowSystemDimensions', Any]] = None, mode: Literal['flow_rate', 'flow_hours'] = 'flow_rate', style: Literal['area', 'stacked_bar', 'line'] = 'stacked_bar', drop_suffix: bool = True, @@ -917,7 +899,7 @@ def plot_node_balance_pie( save: Union[bool, pathlib.Path] = False, show: bool = True, engine: plotting.PlottingEngine = 'plotly', - scenario: Optional[Union[str, int]] = None, + indexer: Optional[Dict['FlowSystemDimensions', Any]] = None, ) -> plotly.graph_objects.Figure: """ Plots a pie chart of the flow hours of the inputs and outputs of buses or components. @@ -1068,7 +1050,7 @@ def plot_charge_state( colors: plotting.ColorType = 'viridis', engine: plotting.PlottingEngine = 'plotly', style: Literal['area', 'stacked_bar', 'line'] = 'stacked_bar', - scenario: Optional[Union[str, int]] = None, + indexer: Optional[Dict['FlowSystemDimensions', Any]] = None, ) -> plotly.graph_objs.Figure: """ Plots the charge state of a Storage. @@ -1625,3 +1607,34 @@ def apply_filter(array, coord_name: str, coord_values: Union[Any, List[Any]]): raise ValueError(f"No edges match criteria: {filters}") return da + + +def _apply_indexer_to_data(data: xr.DataArray, indexer: Optional[Dict[str, Any]] = None): + """ + Apply indexer selection or auto-select first values for non-time dimensions. + + Args: + data: xarray Dataset or DataArray + indexer: Optional selection dict + + Returns: + Tuple of (selected_data, suffix_parts_list) + """ + suffix_parts = [] + + if indexer is not None: + # User provided indexer + data = data.sel(indexer) + suffix_parts.extend(f"{v}[{k}]" for k, v in indexer.items()) + else: + # Auto-select first value for each dimension except 'time' + selection = {} + for dim in data.dims: + if dim != 'time' and dim in data.coords: + first_value = data.coords[dim].values[0] + selection[dim] = first_value + suffix_parts.append(f"{first_value}[{dim}]") + if selection: + data = data.sel(selection) + + return data, suffix_parts From d1b55094f4c01b24de9eea4b8cc7a2ac01192c70 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 16 Jul 2025 16:05:09 +0200 Subject: [PATCH 188/336] Use tuples to set dimensions in Models --- flixopt/components.py | 4 ++-- flixopt/core.py | 4 ++-- flixopt/effects.py | 16 ++++++++-------- flixopt/features.py | 7 ++++--- flixopt/flow_system.py | 2 +- flixopt/structure.py | 2 +- tests/test_dataconverter.py | 14 +++++++------- tests/test_effects_shares_summation.py | 4 ++-- 8 files changed, 27 insertions(+), 26 deletions(-) diff --git a/flixopt/components.py b/flixopt/components.py index 00d0c073f..f34e2945c 100644 --- a/flixopt/components.py +++ b/flixopt/components.py @@ -635,7 +635,7 @@ def relative_charge_state_bounds(self) -> Tuple[xr.DataArray, xr.DataArray]: ).assign_coords(time=final_timestep) else: min_final = xr.DataArray( - [self.element.relative_minimum_final_charge_state], coords=final_coords, dims=['time'] + [self.element.relative_minimum_final_charge_state], coords=final_coords, dims='time' ) # Get final maximum charge state @@ -645,7 +645,7 @@ def relative_charge_state_bounds(self) -> Tuple[xr.DataArray, xr.DataArray]: ).assign_coords(time=final_timestep) else: max_final = xr.DataArray( - [self.element.relative_maximum_final_charge_state], coords=final_coords, dims=['time'] + [self.element.relative_maximum_final_charge_state], coords=final_coords, dims='time' ) # Concatenate with original bounds diff --git a/flixopt/core.py b/flixopt/core.py index fb0e4bae0..509cd8a91 100644 --- a/flixopt/core.py +++ b/flixopt/core.py @@ -191,7 +191,7 @@ def _match_series_to_dimension( # Try to match Series index to coordinates for dim_name in target_dims: if data.index.equals(coords[dim_name]): - return xr.DataArray(data.values.copy(), coords={dim_name: coords[dim_name]}, dims=[dim_name]) + return xr.DataArray(data.values.copy(), coords={dim_name: coords[dim_name]}, dims=dim_name) # If no index matches, raise error raise ConversionError(f'Series index does not match any target dimension coordinates: {target_dims}') @@ -237,7 +237,7 @@ def _match_array_to_dimension( # Match to the single matching dimension match_dim = matching_dims[0] - return xr.DataArray(data.copy(), coords={match_dim: coords[match_dim]}, dims=[match_dim]) + return xr.DataArray(data.copy(), coords={match_dim: coords[match_dim]}, dims=match_dim) @staticmethod def _match_multidim_array_to_dimensions( diff --git a/flixopt/effects.py b/flixopt/effects.py index 639ae559b..0b5c9f5eb 100644 --- a/flixopt/effects.py +++ b/flixopt/effects.py @@ -145,7 +145,7 @@ def __init__(self, model: SystemModel, element: Effect): self.invest: ShareAllocationModel = self.add( ShareAllocationModel( model=self._model, - dims=['year', 'scenario'], + dims=('year', 'scenario'), label_of_element=self.label_of_element, label='invest', label_full=f'{self.label_full}(invest)', @@ -157,7 +157,7 @@ def __init__(self, model: SystemModel, element: Effect): self.operation: ShareAllocationModel = self.add( ShareAllocationModel( model=self._model, - dims=['time', 'year', 'scenario'], + dims=('time', 'year', 'scenario'), label_of_element=self.label_of_element, label='operation', label_full=f'{self.label_full}(operation)', @@ -404,13 +404,13 @@ def add_share_to_effects( self.effects[effect].model.operation.add_share( name, expression, - dims=['time', 'year', 'scenario'], + dims=('time', 'year', 'scenario'), ) elif target == 'invest': self.effects[effect].model.invest.add_share( name, expression, - dims=['year', 'scenario'], + dims=('year', 'scenario'), ) else: raise ValueError(f'Target {target} not supported!') @@ -418,13 +418,13 @@ def add_share_to_effects( def add_share_to_penalty(self, name: str, expression: linopy.LinearExpression) -> None: if expression.ndim != 0: raise TypeError(f'Penalty shares must be scalar expressions! ({expression.ndim=})') - self.penalty.add_share(name, expression, dims=[]) + self.penalty.add_share(name, expression, dims=()) def do_modeling(self): for effect in self.effects: effect.create_model(self._model) self.penalty = self.add( - ShareAllocationModel(self._model, dims=[], label_of_element='Penalty') + ShareAllocationModel(self._model, dims=(), label_of_element='Penalty') ) for model in [effect.model for effect in self.effects] + [self.penalty]: model.do_modeling() @@ -443,14 +443,14 @@ def _add_share_between_effects(self): self.effects[target_effect].model.operation.add_share( origin_effect.model.operation.label_full, origin_effect.model.operation.total_per_timestep * time_series, - dims=['time', 'year', 'scenario'], + dims=('time', 'year', 'scenario'), ) # 2. invest: -> hier ist es Scalar (share) for target_effect, factor in origin_effect.specific_share_to_other_effects_invest.items(): self.effects[target_effect].model.invest.add_share( origin_effect.model.invest.label_full, origin_effect.model.invest.total * factor, - dims=['year', 'scenario'], + dims=('year', 'scenario'), ) diff --git a/flixopt/features.py b/flixopt/features.py index c002a8ff6..76842fc50 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -823,11 +823,12 @@ def __init__( self._as_time_series = as_time_series def do_modeling(self): + dims =('time', 'year','scenario') if self._as_time_series else ('year','scenario') self.inside_piece = self.add( self._model.add_variables( binary=True, name=f'{self.label_full}|inside_piece', - coords=self._model.get_coords(time_dim=self._as_time_series), + coords=self._model.get_coords(dims=dims), ), 'inside_piece', ) @@ -837,7 +838,7 @@ def do_modeling(self): lower=0, upper=1, name=f'{self.label_full}|lambda0', - coords=self._model.get_coords(time_dim=self._as_time_series), + coords=self._model.get_coords(dims=dims), ), 'lambda0', ) @@ -847,7 +848,7 @@ def do_modeling(self): lower=0, upper=1, name=f'{self.label_full}|lambda1', - coords=self._model.get_coords(time_dim=self._as_time_series), + coords=self._model.get_coords(dims=dims), ), 'lambda1', ) diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index bbccbfce5..26ce0c0e2 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -171,7 +171,7 @@ def calculate_hours_per_timestep(timesteps_extra: pd.DatetimeIndex) -> xr.DataAr """Calculate duration of each timestep as a 1D DataArray.""" hours_per_step = np.diff(timesteps_extra) / pd.Timedelta(hours=1) return xr.DataArray( - hours_per_step, coords={'time': timesteps_extra[:-1]}, dims=['time'], name='hours_per_timestep' + hours_per_step, coords={'time': timesteps_extra[:-1]}, dims='time', name='hours_per_timestep' ) @staticmethod diff --git a/flixopt/structure.py b/flixopt/structure.py index 3aad34489..5b95e0be4 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -104,7 +104,7 @@ def hours_of_previous_timesteps(self): def get_coords( self, - dims: Optional[FlowSystemDimensions] = None, + dims: Optional[Tuple[FlowSystemDimensions]] = None, extra_timestep=False, ) -> Optional[Union[Tuple[pd.Index], Tuple[pd.Index, pd.Index]]]: """ diff --git a/tests/test_dataconverter.py b/tests/test_dataconverter.py index 2fbad4a13..08c7d926c 100644 --- a/tests/test_dataconverter.py +++ b/tests/test_dataconverter.py @@ -447,7 +447,7 @@ class TestDataArrayConversion: def test_compatible_dataarray(self, time_coords): """Compatible DataArray should pass through.""" - original = xr.DataArray([10, 20, 30, 40, 50], coords={'time': time_coords}, dims=['time']) + original = xr.DataArray([10, 20, 30, 40, 50], coords={'time': time_coords}, dims='time') result = DataConverter.to_dataarray(original, coords={'time': time_coords}) assert result.shape == (5,) @@ -461,14 +461,14 @@ def test_compatible_dataarray(self, time_coords): def test_incompatible_dataarray_coords(self, time_coords): """DataArray with wrong coordinates should fail.""" wrong_times = pd.date_range('2025-01-01', periods=5, freq='D', name='time') - original = xr.DataArray([10, 20, 30, 40, 50], coords={'time': wrong_times}, dims=['time']) + original = xr.DataArray([10, 20, 30, 40, 50], coords={'time': wrong_times}, dims='time') with pytest.raises(ConversionError): DataConverter.to_dataarray(original, coords={'time': time_coords}) def test_incompatible_dataarray_dims(self, time_coords): """DataArray with wrong dimensions should fail.""" - original = xr.DataArray([10, 20, 30, 40, 50], coords={'wrong_dim': range(5)}, dims=['wrong_dim']) + original = xr.DataArray([10, 20, 30, 40, 50], coords={'wrong_dim': range(5)}, dims='wrong_dim') with pytest.raises(ConversionError): DataConverter.to_dataarray(original, coords={'time': time_coords}) @@ -476,7 +476,7 @@ def test_incompatible_dataarray_dims(self, time_coords): def test_dataarray_broadcast(self, time_coords, scenario_coords): """DataArray should broadcast to additional dimensions.""" # 1D time DataArray to 2D time+scenario - original = xr.DataArray([10, 20, 30, 40, 50], coords={'time': time_coords}, dims=['time']) + original = xr.DataArray([10, 20, 30, 40, 50], coords={'time': time_coords}, dims='time') result = DataConverter.to_dataarray(original, coords={'time': time_coords, 'scenario': scenario_coords}) assert result.shape == (5, 3) @@ -499,7 +499,7 @@ def test_2d_dataarray_broadcast_to_more_dimensions(self, standard_coords): original = xr.DataArray( [[10, 20, 30], [40, 50, 60], [70, 80, 90], [100, 110, 120], [130, 140, 150]], coords={'time': standard_coords['time'], 'scenario': standard_coords['scenario']}, - dims=['time', 'scenario'] + dims=('time', 'scenario') ) # Broadcast to 3D @@ -521,7 +521,7 @@ class TestTimeSeriesDataConversion: def test_timeseries_data_basic(self, time_coords): """TimeSeriesData should work like DataArray.""" - data_array = xr.DataArray([10, 20, 30, 40, 50], coords={'time': time_coords}, dims=['time']) + data_array = xr.DataArray([10, 20, 30, 40, 50], coords={'time': time_coords}, dims='time') ts_data = TimeSeriesData(data_array, aggregation_group='test') result = DataConverter.to_dataarray(ts_data, coords={'time': time_coords}) @@ -532,7 +532,7 @@ def test_timeseries_data_basic(self, time_coords): def test_timeseries_data_broadcast(self, time_coords, scenario_coords): """TimeSeriesData should broadcast to additional dimensions.""" - data_array = xr.DataArray([10, 20, 30, 40, 50], coords={'time': time_coords}, dims=['time']) + data_array = xr.DataArray([10, 20, 30, 40, 50], coords={'time': time_coords}, dims='time') ts_data = TimeSeriesData(data_array) result = DataConverter.to_dataarray(ts_data, coords={'time': time_coords, 'scenario': scenario_coords}) diff --git a/tests/test_effects_shares_summation.py b/tests/test_effects_shares_summation.py index e2dada7e9..b1ff5c3a3 100644 --- a/tests/test_effects_shares_summation.py +++ b/tests/test_effects_shares_summation.py @@ -46,8 +46,8 @@ def test_xarray_conversions(): """Test with xarray DataArrays that have dimensions.""" # Create DataArrays with a time dimension time_points = [1, 2, 3] - a_to_b = xr.DataArray([2.0, 2.1, 2.2], dims=['time'], coords={'time': time_points}) - b_to_c = xr.DataArray([3.0, 3.1, 3.2], dims=['time'], coords={'time': time_points}) + a_to_b = xr.DataArray([2.0, 2.1, 2.2], dims='time', coords={'time': time_points}) + b_to_c = xr.DataArray([3.0, 3.1, 3.2], dims='time', coords={'time': time_points}) conversion_dict = { 'A': {'B': a_to_b}, From c7568dd0ec63bb9941d9cdef7258c4b280a4924a Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 16 Jul 2025 16:18:44 +0200 Subject: [PATCH 189/336] Bugfix in validation logic and test --- flixopt/features.py | 2 +- tests/test_scenarios.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/flixopt/features.py b/flixopt/features.py index 76842fc50..f53ebecca 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -964,7 +964,7 @@ def __init__( ): super().__init__(model, label_of_element=label_of_element, label=label, label_full=label_full) - if 'time' not in dims and max_per_hour is not None or min_per_hour is not None: + if 'time' not in dims and (max_per_hour is not None or min_per_hour is not None): raise ValueError('Both max_per_hour and min_per_hour cannot be used when has_time_dim is False') self._dims = dims diff --git a/tests/test_scenarios.py b/tests/test_scenarios.py index 717d5919b..62f206e68 100644 --- a/tests/test_scenarios.py +++ b/tests/test_scenarios.py @@ -142,7 +142,7 @@ def flow_system_complex_scenarios() -> fx.FlowSystem: thermal_load = np.array([30, 0, 90, 110, 110, 20, 20, 20, 20]) electrical_load = np.array([40, 40, 40, 40, 40, 40, 40, 40, 40]) flow_system = fx.FlowSystem(pd.date_range('2020-01-01', periods=9, freq='h', name='time'), - pd.Index(['A', 'B', 'C'], name='scenario')) + scenarios=pd.Index(['A', 'B', 'C'], name='scenario')) # Define the components and flow_system flow_system.add_elements( fx.Effect('costs', '€', 'Kosten', is_standard=True, is_objective=True), From fc62634fdc6b9a000f76ac3ec2cb610d794332aa Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 16 Jul 2025 16:21:08 +0200 Subject: [PATCH 190/336] Improve Errors --- flixopt/core.py | 2 +- flixopt/flow_system.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/flixopt/core.py b/flixopt/core.py index 509cd8a91..c9eeb5c6a 100644 --- a/flixopt/core.py +++ b/flixopt/core.py @@ -185,7 +185,7 @@ def _match_series_to_dimension( """ if len(target_dims) == 0: if len(data) != 1: - raise ConversionError('Cannot convert multi-element Series without target dimensions') + raise ConversionError(f'Cannot convert multi-element Series without target dimensions. Got \n{data}\n and \n{coords}') return xr.DataArray(data.iloc[0]) # Try to match Series index to coordinates diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index 26ce0c0e2..7dab1bed0 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -141,14 +141,14 @@ def _validate_years(years: pd.Index) -> pd.Index: years: The year index to validate """ if not isinstance(years, pd.Index) or len(years) == 0: - raise ConversionError('Years must be a non-empty Index') + raise ConversionError(f'Years must be a non-empty Index. Got {years}') if not ( years.dtype.kind == 'i' # integer dtype and years.is_monotonic_increasing # rising and years.is_unique ): - raise ConversionError('Years must be a monotonically increasing and unique Index') + raise ConversionError(f'Years must be a monotonically increasing and unique Index. Got {years}') if years.name != 'year': years = years.rename('year') From e320b9fa6c377bdafcbd02541d41bf11f112ce4f Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 16 Jul 2025 16:38:09 +0200 Subject: [PATCH 191/336] Improve weights handling and rescaling if None --- flixopt/flow_system.py | 11 +++++++---- flixopt/structure.py | 6 ++++-- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index 7dab1bed0..f82568588 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -406,6 +406,10 @@ def fit_effects_to_model_coords( def connect_and_transform(self): """Transform data for all elements using the new simplified approach.""" + if self.connected_and_transformed: + logger.debug('FlowSystem already connected and transformed') + return + self.weights = self.fit_to_model_coords( 'weights', self.weights, has_time_dim=False ) @@ -413,10 +417,9 @@ def connect_and_transform(self): logger.warning(f'Scenario weights are not normalized to 1. This is recomended for a better scaled model. ' f'Sum of weights={self.weights.sum().item()}') - if not self.connected_and_transformed: - self._connect_network() - for element in list(self.components.values()) + list(self.effects.effects.values()) + list(self.buses.values()): - element.transform_data(self) + self._connect_network() + for element in list(self.components.values()) + list(self.effects.effects.values()) + list(self.buses.values()): + element.transform_data(self) self._connected_and_transformed = True def add_elements(self, *elements: Element) -> None: diff --git a/flixopt/structure.py b/flixopt/structure.py index 5b95e0be4..b0005cbd1 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -138,9 +138,11 @@ def get_coords( @property def weights(self) -> Union[int, xr.DataArray]: - """Returns the scenario weights of the FlowSystem.""" + """Returns the scenario weights of the FlowSystem. If None, return weights that are normalized to 1 (one)""" if self.flow_system.weights is None: - return 1 + weights = self.flow_system.fit_to_model_coords('weights', 1, has_time_dim=False) + + return weights / weights.sum() return self.flow_system.weights From 33a22aa56c13e467c171c18009c65baf4e41bb28 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 16 Jul 2025 16:38:17 +0200 Subject: [PATCH 192/336] Fix typehint --- flixopt/core.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flixopt/core.py b/flixopt/core.py index c9eeb5c6a..99b69b5ed 100644 --- a/flixopt/core.py +++ b/flixopt/core.py @@ -27,7 +27,7 @@ NonTemporalDataUser = Union[int, float, np.integer, np.floating, np.ndarray, pd.Series, pd.DataFrame, xr.DataArray] """User data which has no time dimension. Internally converted to a Scalar or an xr.DataArray without a time dimension.""" -NonTemporalData = Union[Scalar, xr.DataArray] +NonTemporalData = xr.DataArray """Internally used datatypes for non-temporal data. Can be a Scalar or an xr.DataArray.""" FlowSystemDimensions = Literal['time', 'year', 'scenario'] From 904211f7c8ed52fc07ce5b1590ef2800d7309eb2 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 16 Jul 2025 17:16:41 +0200 Subject: [PATCH 193/336] Update Broadcasting in Storage Bounds and improve type hints --- flixopt/components.py | 44 +++++++++++++++++++++---------------------- 1 file changed, 21 insertions(+), 23 deletions(-) diff --git a/flixopt/components.py b/flixopt/components.py index f34e2945c..03a45e377 100644 --- a/flixopt/components.py +++ b/flixopt/components.py @@ -129,14 +129,14 @@ def __init__( label: str, charging: Flow, discharging: Flow, - capacity_in_flow_hours: Union[Scalar, InvestParameters], + capacity_in_flow_hours: Union[NonTemporalDataUser, InvestParameters], relative_minimum_charge_state: TemporalDataUser = 0, relative_maximum_charge_state: TemporalDataUser = 1, - initial_charge_state: Union[Scalar, Literal['lastValueOfSim']] = 0, - minimal_final_charge_state: Optional[Scalar] = None, - maximal_final_charge_state: Optional[Scalar] = None, - relative_minimum_final_charge_state: Optional[Scalar] = None, - relative_maximum_final_charge_state: Optional[Scalar] = None, + initial_charge_state: Union[NonTemporalDataUser, Literal['lastValueOfSim']] = 0, + minimal_final_charge_state: Optional[NonTemporalDataUser] = None, + maximal_final_charge_state: Optional[NonTemporalDataUser] = None, + relative_minimum_final_charge_state: Optional[NonTemporalDataUser] = None, + relative_maximum_final_charge_state: Optional[NonTemporalDataUser] = None, eta_charge: TemporalDataUser = 1, eta_discharge: TemporalDataUser = 1, relative_loss_per_hour: TemporalDataUser = 0, @@ -187,8 +187,8 @@ def __init__( self.relative_minimum_charge_state: TemporalDataUser = relative_minimum_charge_state self.relative_maximum_charge_state: TemporalDataUser = relative_maximum_charge_state - self.relative_minimum_final_charge_state: Scalar = relative_minimum_final_charge_state - self.relative_maximum_final_charge_state: Scalar = relative_maximum_final_charge_state + self.relative_minimum_final_charge_state = relative_minimum_final_charge_state + self.relative_maximum_final_charge_state = relative_maximum_final_charge_state self.initial_charge_state = initial_charge_state self.minimal_final_charge_state = minimal_final_charge_state @@ -230,6 +230,12 @@ def transform_data(self, flow_system: 'FlowSystem') -> None: self.maximal_final_charge_state = flow_system.fit_to_model_coords( f'{self.label_full}|maximal_final_charge_state', self.maximal_final_charge_state, has_time_dim=False ) + self.relative_minimum_final_charge_state = flow_system.fit_to_model_coords( + f'{self.label_full}|relative_minimum_final_charge_state', self.relative_minimum_final_charge_state, has_time_dim=False + ) + self.relative_maximum_final_charge_state = flow_system.fit_to_model_coords( + f'{self.label_full}|relative_maximum_final_charge_state', self.relative_maximum_final_charge_state, has_time_dim=False + ) if isinstance(self.capacity_in_flow_hours, InvestParameters): self.capacity_in_flow_hours.transform_data(flow_system, f'{self.label_full}|InvestParameters') else: @@ -625,29 +631,21 @@ def relative_charge_state_bounds(self) -> Tuple[xr.DataArray, xr.DataArray]: Returns: Tuple of (minimum_bounds, maximum_bounds) DataArrays extending to final timestep """ - final_timestep = self._model.flow_system.timesteps_extra[-1] - final_coords = {'time': [final_timestep]} + final_coords = {'time': [self._model.flow_system.timesteps_extra[-1]]} # Get final minimum charge state if self.element.relative_minimum_final_charge_state is None: - min_final = self.element.relative_minimum_charge_state.isel( - time=-1, drop=True - ).assign_coords(time=final_timestep) + min_final = self.element.relative_minimum_charge_state.isel(time=-1, drop=True) else: - min_final = xr.DataArray( - [self.element.relative_minimum_final_charge_state], coords=final_coords, dims='time' - ) + min_final = self.element.relative_minimum_final_charge_state + min_final = min_final.expand_dims('time').assign_coords(time=final_coords['time']) # Get final maximum charge state if self.element.relative_maximum_final_charge_state is None: - max_final = self.element.relative_maximum_charge_state.isel( - time=-1, drop=True - ).assign_coords(time=final_timestep) + max_final = self.element.relative_maximum_charge_state.isel(time=-1, drop=True) else: - max_final = xr.DataArray( - [self.element.relative_maximum_final_charge_state], coords=final_coords, dims='time' - ) - + max_final = self.element.relative_maximum_final_charge_state + max_final = max_final.expand_dims('time').assign_coords(time=final_coords['time']) # Concatenate with original bounds min_bounds = xr.concat([self.element.relative_minimum_charge_state, min_final], dim='time') max_bounds = xr.concat([self.element.relative_maximum_charge_state, max_final], dim='time') From 3c6c08bfda16820573e192fb1a92c1d692d99e2c Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 16 Jul 2025 17:17:44 +0200 Subject: [PATCH 194/336] Make .get_model_coords() return an actual xr.Coordinates Object --- flixopt/structure.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/flixopt/structure.py b/flixopt/structure.py index b0005cbd1..9931a951f 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -106,7 +106,7 @@ def get_coords( self, dims: Optional[Tuple[FlowSystemDimensions]] = None, extra_timestep=False, - ) -> Optional[Union[Tuple[pd.Index], Tuple[pd.Index, pd.Index]]]: + ) -> Optional[xr.Coordinates]: """ Returns the coordinates of the model @@ -131,10 +131,7 @@ def get_coords( if not coords: return None - if len(coords) == 1: - return (coords.popitem()[1],) - - return tuple(coords.values()) + return xr.Coordinates(coords) @property def weights(self) -> Union[int, xr.DataArray]: From 22a1cef407ba086813294042c227c27668f80020 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 17 Jul 2025 10:42:53 +0200 Subject: [PATCH 195/336] Improve get_coords() --- flixopt/structure.py | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/flixopt/structure.py b/flixopt/structure.py index 9931a951f..6d1df20e4 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -8,7 +8,7 @@ import logging import pathlib from io import StringIO -from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Tuple, Union +from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Tuple, Union, Collection import linopy import numpy as np @@ -104,34 +104,34 @@ def hours_of_previous_timesteps(self): def get_coords( self, - dims: Optional[Tuple[FlowSystemDimensions]] = None, - extra_timestep=False, + dims: Optional[Collection[str]] = None, + extra_timestep: bool = False, ) -> Optional[xr.Coordinates]: """ Returns the coordinates of the model Args: - dims: The dimensions to include in the coordinates. Defaults to all dimensions. Coords are ordered automatically - extra_timestep: If True, the extra timesteps are used instead of the regular timesteps + dims: The dimensions to include in the coordinates. If None, includes all dimensions + extra_timestep: If True, uses extra timesteps instead of regular timesteps Returns: - The coordinates of the model. Might also be None if no scenarios are present and time_dim is False + The coordinates of the model, or None if no coordinates are available + + Raises: + ValueError: If extra_timestep=True but 'time' is not in dims """ - if dims is not None and extra_timestep and 'time' not in dims: - raise ValueError('extra_timestep=True requires time to be included in dims') + if extra_timestep and dims is not None and 'time' not in dims: + raise ValueError('extra_timestep=True requires "time" to be included in dims') if dims is None: - coords = self.flow_system.coords + coords = dict(self.flow_system.coords) else: coords = {k: v for k, v in self.flow_system.coords.items() if k in dims} - if extra_timestep: + if extra_timestep and coords: coords['time'] = self.flow_system.timesteps_extra - if not coords: - return None - - return xr.Coordinates(coords) + return xr.Coordinates(coords) if coords else None @property def weights(self) -> Union[int, xr.DataArray]: From 44dbefcce98c736b484b2acbe572435ba13350f4 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 17 Jul 2025 10:45:07 +0200 Subject: [PATCH 196/336] Rename SystemModel to FlowSystemModel --- flixopt/aggregation.py | 4 ++-- flixopt/calculation.py | 12 ++++++------ flixopt/components.py | 12 ++++++------ flixopt/effects.py | 10 +++++----- flixopt/elements.py | 14 +++++++------- flixopt/features.py | 30 +++++++++++++++--------------- flixopt/flow_system.py | 8 ++++---- flixopt/structure.py | 14 +++++++------- tests/conftest.py | 4 ++-- 9 files changed, 54 insertions(+), 54 deletions(-) diff --git a/flixopt/aggregation.py b/flixopt/aggregation.py index d47a42997..47ac1336d 100644 --- a/flixopt/aggregation.py +++ b/flixopt/aggregation.py @@ -28,7 +28,7 @@ from .structure import ( Element, Model, - SystemModel, + FlowSystemModel, ) if TYPE_CHECKING: @@ -292,7 +292,7 @@ class AggregationModel(Model): def __init__( self, - model: SystemModel, + model: FlowSystemModel, aggregation_parameters: AggregationParameters, flow_system: FlowSystem, aggregation_data: Aggregation, diff --git a/flixopt/calculation.py b/flixopt/calculation.py index 764961b78..6bf86bb20 100644 --- a/flixopt/calculation.py +++ b/flixopt/calculation.py @@ -1,11 +1,11 @@ """ This module contains the Calculation functionality for the flixopt framework. -It is used to calculate a SystemModel for a given FlowSystem through a solver. +It is used to calculate a FlowSystemModel for a given FlowSystem through a solver. There are three different Calculation types: - 1. FullCalculation: Calculates the SystemModel for the full FlowSystem - 2. AggregatedCalculation: Calculates the SystemModel for the full FlowSystem, but aggregates the TimeSeriesData. + 1. FullCalculation: Calculates the FlowSystemModel for the full FlowSystem + 2. AggregatedCalculation: Calculates the FlowSystemModel for the full FlowSystem, but aggregates the TimeSeriesData. This simplifies the mathematical model and usually speeds up the solving process. - 3. SegmentedCalculation: Solves a SystemModel for each individual Segment of the FlowSystem. + 3. SegmentedCalculation: Solves a FlowSystemModel for each individual Segment of the FlowSystem. """ import logging @@ -32,7 +32,7 @@ from .flow_system import FlowSystem from .results import CalculationResults, SegmentedCalculationResults from .solvers import _Solver -from .structure import SystemModel +from .structure import FlowSystemModel logger = logging.getLogger('flixopt') @@ -81,7 +81,7 @@ def __init__( flow_system._used_in_calculation = True self.flow_system = flow_system - self.model: Optional[SystemModel] = None + self.model: Optional[FlowSystemModel] = None self.durations = {'modeling': 0.0, 'solving': 0.0, 'saving': 0.0} self.folder = pathlib.Path.cwd() / 'results' if folder is None else pathlib.Path(folder) diff --git a/flixopt/components.py b/flixopt/components.py index 03a45e377..685928714 100644 --- a/flixopt/components.py +++ b/flixopt/components.py @@ -14,7 +14,7 @@ from .elements import Component, ComponentModel, Flow from .features import InvestmentModel, OnOffModel, PiecewiseModel from .interface import InvestParameters, OnOffParameters, PiecewiseConversion -from .structure import SystemModel, register_class_for_io +from .structure import FlowSystemModel, register_class_for_io if TYPE_CHECKING: from .flow_system import FlowSystem @@ -58,7 +58,7 @@ def __init__( self.conversion_factors = conversion_factors or [] self.piecewise_conversion = piecewise_conversion - def create_model(self, model: SystemModel) -> 'LinearConverterModel': + def create_model(self, model: FlowSystemModel) -> 'LinearConverterModel': self._plausibility_checks() self.model = LinearConverterModel(model, self) return self.model @@ -200,7 +200,7 @@ def __init__( self.prevent_simultaneous_charge_and_discharge = prevent_simultaneous_charge_and_discharge self.balanced = balanced - def create_model(self, model: SystemModel) -> 'StorageModel': + def create_model(self, model: FlowSystemModel) -> 'StorageModel': self._plausibility_checks() self.model = StorageModel(model, self) return self.model @@ -393,7 +393,7 @@ def transform_data(self, flow_system: 'FlowSystem') -> None: class TransmissionModel(ComponentModel): - def __init__(self, model: SystemModel, element: Transmission): + def __init__(self, model: FlowSystemModel, element: Transmission): super().__init__(model, element) self.element: Transmission = element self.on_off: Optional[OnOffModel] = None @@ -444,7 +444,7 @@ def create_transmission_equation(self, name: str, in_flow: Flow, out_flow: Flow) class LinearConverterModel(ComponentModel): - def __init__(self, model: SystemModel, element: LinearConverter): + def __init__(self, model: FlowSystemModel, element: LinearConverter): super().__init__(model, element) self.element: LinearConverter = element self.on_off: Optional[OnOffModel] = None @@ -494,7 +494,7 @@ def do_modeling(self): class StorageModel(ComponentModel): """Model of Storage""" - def __init__(self, model: SystemModel, element: Storage): + def __init__(self, model: FlowSystemModel, element: Storage): super().__init__(model, element) self.element: Storage = element self.charge_state: Optional[linopy.Variable] = None diff --git a/flixopt/effects.py b/flixopt/effects.py index 0b5c9f5eb..23943d16b 100644 --- a/flixopt/effects.py +++ b/flixopt/effects.py @@ -15,7 +15,7 @@ from .core import Scalar, TemporalData, TemporalDataUser from .features import ShareAllocationModel -from .structure import Element, ElementModel, Interface, Model, SystemModel, register_class_for_io +from .structure import Element, ElementModel, Interface, Model, FlowSystemModel, register_class_for_io if TYPE_CHECKING: from .flow_system import FlowSystem @@ -127,7 +127,7 @@ def transform_data(self, flow_system: 'FlowSystem'): has_time_dim=False ) - def create_model(self, model: SystemModel) -> 'EffectModel': + def create_model(self, model: FlowSystemModel) -> 'EffectModel': self._plausibility_checks() self.model = EffectModel(model, self) return self.model @@ -138,7 +138,7 @@ def _plausibility_checks(self) -> None: class EffectModel(ElementModel): - def __init__(self, model: SystemModel, element: Effect): + def __init__(self, model: FlowSystemModel, element: Effect): super().__init__(model, element) self.element: Effect = element self.total: Optional[linopy.Variable] = None @@ -222,7 +222,7 @@ def __init__(self, *effects: List[Effect]): self.model: Optional[EffectCollectionModel] = None self.add_effects(*effects) - def create_model(self, model: SystemModel) -> 'EffectCollectionModel': + def create_model(self, model: FlowSystemModel) -> 'EffectCollectionModel': self._plausibility_checks() self.model = EffectCollectionModel(model, self) return self.model @@ -388,7 +388,7 @@ class EffectCollectionModel(Model): Handling all Effects """ - def __init__(self, model: SystemModel, effects: EffectCollection): + def __init__(self, model: FlowSystemModel, effects: EffectCollection): super().__init__(model, label_of_element='Effects') self.effects = effects self.penalty: Optional[ShareAllocationModel] = None diff --git a/flixopt/elements.py b/flixopt/elements.py index 968b70ca5..a546b5e9c 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -14,7 +14,7 @@ from .effects import TemporalEffectsUser from .features import InvestmentModel, OnOffModel, PreventSimultaneousUsageModel from .interface import InvestParameters, OnOffParameters -from .structure import Element, ElementModel, SystemModel, register_class_for_io +from .structure import Element, ElementModel, FlowSystemModel, register_class_for_io if TYPE_CHECKING: from .flow_system import FlowSystem @@ -63,7 +63,7 @@ def __init__( self.flows: Dict[str, Flow] = {flow.label: flow for flow in self.inputs + self.outputs} - def create_model(self, model: SystemModel) -> 'ComponentModel': + def create_model(self, model: FlowSystemModel) -> 'ComponentModel': self._plausibility_checks() self.model = ComponentModel(model, self) return self.model @@ -108,7 +108,7 @@ def __init__( self.inputs: List[Flow] = [] self.outputs: List[Flow] = [] - def create_model(self, model: SystemModel) -> 'BusModel': + def create_model(self, model: FlowSystemModel) -> 'BusModel': self._plausibility_checks() self.model = BusModel(model, self) return self.model @@ -227,7 +227,7 @@ def __init__( self.bus = bus self._bus_object = None - def create_model(self, model: SystemModel) -> 'FlowModel': + def create_model(self, model: FlowSystemModel) -> 'FlowModel': self._plausibility_checks() self.model = FlowModel(model, self) return self.model @@ -308,7 +308,7 @@ def invest_is_optional(self) -> bool: class FlowModel(ElementModel): - def __init__(self, model: SystemModel, element: Flow): + def __init__(self, model: FlowSystemModel, element: Flow): super().__init__(model, element) self.element: Flow = element self.flow_rate: Optional[linopy.Variable] = None @@ -490,7 +490,7 @@ def flow_rate_upper_bound(self) -> TemporalData: class BusModel(ElementModel): - def __init__(self, model: SystemModel, element: Bus): + def __init__(self, model: FlowSystemModel, element: Bus): super().__init__(model, element) self.element: Bus = element self.excess_input: Optional[linopy.Variable] = None @@ -538,7 +538,7 @@ def results_structure(self): class ComponentModel(ElementModel): - def __init__(self, model: SystemModel, element: Component): + def __init__(self, model: FlowSystemModel, element: Component): super().__init__(model, element) self.element: Component = element self.on_off: Optional[OnOffModel] = None diff --git a/flixopt/features.py b/flixopt/features.py index f53ebecca..bc4bfb9b3 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -12,7 +12,7 @@ from .config import CONFIG from .core import NonTemporalData, Scalar, TemporalData, FlowSystemDimensions from .interface import InvestParameters, OnOffParameters, Piecewise -from .structure import Model, SystemModel +from .structure import Model, FlowSystemModel logger = logging.getLogger('flixopt') @@ -22,7 +22,7 @@ class InvestmentModel(Model): def __init__( self, - model: SystemModel, + model: FlowSystemModel, label_of_element: str, parameters: InvestParameters, defining_variable: [linopy.Variable], @@ -240,7 +240,7 @@ class StateModel(Model): def __init__( self, - model: SystemModel, + model: FlowSystemModel, label_of_element: str, defining_variables: List[linopy.Variable], defining_bounds: List[Tuple[TemporalData, TemporalData]], @@ -255,7 +255,7 @@ def __init__( Models binary state variables based on a continous variable. Args: - model: The SystemModel that is used to create the model. + model: The FlowSystemModel that is used to create the model. label_of_element: The label of the parent (Element). Used to construct the full label of the model. defining_variables: List of Variables that are used to define the state defining_bounds: List of Tuples, defining the absolute bounds of each defining variable @@ -404,7 +404,7 @@ class SwitchStateModel(Model): def __init__( self, - model: SystemModel, + model: FlowSystemModel, label_of_element: str, state_variable: linopy.Variable, previous_state=0, @@ -488,7 +488,7 @@ class ConsecutiveStateModel(Model): def __init__( self, - model: SystemModel, + model: FlowSystemModel, label_of_element: str, state_variable: linopy.Variable, minimum_duration: Optional[TemporalData] = None, @@ -500,7 +500,7 @@ def __init__( Model and constraint the consecutive duration of a state variable. Args: - model: The SystemModel that is used to create the model. + model: The FlowSystemModel that is used to create the model. label_of_element: The label of the parent (Element). Used to construct the full label of the model. state_variable: The state variable that is used to model the duration. state = {0, 1} minimum_duration: The minimum duration of the state variable. @@ -665,7 +665,7 @@ class OnOffModel(Model): def __init__( self, - model: SystemModel, + model: FlowSystemModel, on_off_parameters: OnOffParameters, label_of_element: str, defining_variables: List[linopy.Variable], @@ -677,7 +677,7 @@ def __init__( Constructor for OnOffModel Args: - model: Reference to the SystemModel + model: Reference to the FlowSystemModel on_off_parameters: Parameters for the OnOffModel label_of_element: Label of the Parent defining_variables: List of Variables that are used to define the OnOffModel @@ -811,7 +811,7 @@ class PieceModel(Model): def __init__( self, - model: SystemModel, + model: FlowSystemModel, label_of_element: str, label: str, as_time_series: bool = True, @@ -865,7 +865,7 @@ def do_modeling(self): class PiecewiseModel(Model): def __init__( self, - model: SystemModel, + model: FlowSystemModel, label_of_element: str, piecewise_variables: Dict[str, Piecewise], zero_point: Optional[Union[bool, linopy.Variable]], @@ -878,7 +878,7 @@ def __init__( Each Piece is a tuple of (start, end). Args: - model: The SystemModel that is used to create the model. + model: The FlowSystemModel that is used to create the model. label_of_element: The label of the parent (Element). Used to construct the full label of the model. label: The label of the model. Used to construct the full label of the model. piecewise_variables: The variables to which the Pieces are assigned. @@ -952,7 +952,7 @@ def do_modeling(self): class ShareAllocationModel(Model): def __init__( self, - model: SystemModel, + model: FlowSystemModel, dims: List[FlowSystemDimensions], label_of_element: Optional[str] = None, label: Optional[str] = None, @@ -1065,7 +1065,7 @@ def add_share( class PiecewiseEffectsModel(Model): def __init__( self, - model: SystemModel, + model: FlowSystemModel, label_of_element: str, piecewise_origin: Tuple[str, Piecewise], piecewise_shares: Dict[str, Piecewise], @@ -1143,7 +1143,7 @@ class PreventSimultaneousUsageModel(Model): def __init__( self, - model: SystemModel, + model: FlowSystemModel, variables: List[linopy.Variable], label_of_element: str, label: str = 'PreventSimultaneousUsage', diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index f82568588..877db6fdc 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -32,7 +32,7 @@ TemporalEffectsUser, ) from .elements import Bus, Component, Flow -from .structure import Element, Interface, SystemModel +from .structure import Element, Interface, FlowSystemModel if TYPE_CHECKING: import pyvis @@ -98,7 +98,7 @@ def __init__( self.components: Dict[str, Component] = {} self.buses: Dict[str, Bus] = {} self.effects: EffectCollection = EffectCollection() - self.model: Optional[SystemModel] = None + self.model: Optional[FlowSystemModel] = None self._connected_and_transformed = False self._used_in_calculation = False @@ -448,10 +448,10 @@ def add_elements(self, *elements: Element) -> None: f'Tried to add incompatible object to FlowSystem: {type(new_element)=}: {new_element=} ' ) - def create_model(self) -> SystemModel: + def create_model(self) -> FlowSystemModel: if not self.connected_and_transformed: raise RuntimeError('FlowSystem is not connected_and_transformed. Call FlowSystem.connect_and_transform() first.') - self.model = SystemModel(self) + self.model = FlowSystemModel(self) return self.model def plot_network( diff --git a/flixopt/structure.py b/flixopt/structure.py index 6d1df20e4..9566e303f 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -43,9 +43,9 @@ def register_class_for_io(cls): return cls -class SystemModel(linopy.Model): +class FlowSystemModel(linopy.Model): """ - The SystemModel is the linopy Model that is used to create the mathematical model of the flow_system. + The FlowSystemModel is the linopy Model that is used to create the mathematical model of the flow_system. It is used to create and store the variables and constraints for the flow_system. """ @@ -667,7 +667,7 @@ def _plausibility_checks(self) -> None: """This function is used to do some basic plausibility checks for each Element during initialization""" raise NotImplementedError('Every Element needs a _plausibility_checks() method') - def create_model(self, model: SystemModel) -> 'ElementModel': + def create_model(self, model: FlowSystemModel) -> 'ElementModel': raise NotImplementedError('Every Element needs a create_model() method') @property @@ -700,11 +700,11 @@ class Model: """Stores Variables and Constraints.""" def __init__( - self, model: SystemModel, label_of_element: str, label: str = '', label_full: Optional[str] = None + self, model: FlowSystemModel, label_of_element: str, label: str = '', label_full: Optional[str] = None ): """ Args: - model: The SystemModel that is used to create the model. + model: The FlowSystemModel that is used to create the model. label_of_element: The label of the parent (Element). Used to construct the full label of the model. label: The label of the model. Used to construct the full label of the model. label_full: The full label of the model. Can overwrite the full label constructed from the other labels. @@ -834,10 +834,10 @@ def all_sub_models(self) -> List['Model']: class ElementModel(Model): """Stores the mathematical Variables and Constraints for Elements""" - def __init__(self, model: SystemModel, element: Element): + def __init__(self, model: FlowSystemModel, element: Element): """ Args: - model: The SystemModel that is used to create the model. + model: The FlowSystemModel that is used to create the model. element: The element this model is created for. """ super().__init__(model, label_of_element=element.label_full, label=element.label, label_full=element.label_full) diff --git a/tests/conftest.py b/tests/conftest.py index 7c1b08a5b..5d98cdcb5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -13,7 +13,7 @@ import xarray as xr import flixopt as fx -from flixopt.structure import SystemModel +from flixopt.structure import FlowSystemModel @pytest.fixture() @@ -496,7 +496,7 @@ def create_calculation_and_solve(flow_system: fx.FlowSystem, solver, name: str, return calculation -def create_linopy_model(flow_system: fx.FlowSystem) -> SystemModel: +def create_linopy_model(flow_system: fx.FlowSystem) -> FlowSystemModel: calculation = fx.FullCalculation('GenericName', flow_system) calculation.do_modeling() return calculation.model From f82556aafaafa5dfb2f3e109bcdc705b5d348013 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 18 Jul 2025 10:23:03 +0200 Subject: [PATCH 197/336] First steps --- flixopt/features.py | 905 +++++++++++++++++++++++++++---------------- flixopt/structure.py | 21 + 2 files changed, 598 insertions(+), 328 deletions(-) diff --git a/flixopt/features.py b/flixopt/features.py index bc4bfb9b3..e495e2973 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -12,225 +12,623 @@ from .config import CONFIG from .core import NonTemporalData, Scalar, TemporalData, FlowSystemDimensions from .interface import InvestParameters, OnOffParameters, Piecewise -from .structure import Model, FlowSystemModel +from .structure import Model, FlowSystemModel, BaseFeatureModel logger = logging.getLogger('flixopt') -class InvestmentModel(Model): - """Class for modeling an investment""" +class ModelingPrimitives: + """Mathematical modeling primitives returning (variables, constraints) tuples""" - def __init__( - self, + @staticmethod + def binary_state_pair( + model: FlowSystemModel, name: str, coords: List[str] = None + ) -> Tuple[Dict[str, linopy.Variable], Dict[str, linopy.Constraint]]: + """ + Creates complementary binary variables with completeness constraint. + + Mathematical formulation: + on[t] + off[t] = 1 ∀t + on[t], off[t] ∈ {0, 1} + + Returns: + variables: {'on': binary_var, 'off': binary_var} + constraints: {'complementary': constraint} + """ + coords = coords or ['time'] + + on = model.add_variables(binary=True, name=f'{name}|on', coords=model.get_coords(coords)) + off = model.add_variables(binary=True, name=f'{name}|off', coords=model.get_coords(coords)) + + # Constraint: on + off = 1 + complementary = model.add_constraints(on + off == 1, name=f'{name}|complementary') + + variables = {'on': on, 'off': off} + constraints = {'complementary': complementary} + + return variables, constraints + + @staticmethod + def proportionally_bounded_variable( model: FlowSystemModel, - label_of_element: str, - parameters: InvestParameters, - defining_variable: [linopy.Variable], - relative_bounds_of_defining_variable: Tuple[TemporalData, TemporalData], - label: Optional[str] = None, - on_variable: Optional[linopy.Variable] = None, - ): - super().__init__(model, label_of_element, label) - self.size: Optional[Union[Scalar, linopy.Variable]] = None - self.is_invested: Optional[linopy.Variable] = None - self.scenario_of_investment: Optional[linopy.Variable] = None + name: str, + controlling_variable, + bounds: Tuple[TemporalData, TemporalData], + coords: List[str] = None, + ) -> Tuple[Dict[str, linopy.Variable], Dict[str, linopy.Constraint]]: + """ + Creates variable with bounds proportional to another variable. - self.piecewise_effects: Optional[PiecewiseEffectsModel] = None + Mathematical formulation: + lower_factor[t] * controller[t] ≤ variable[t] ≤ upper_factor[t] * controller[t] ∀t - self._on_variable = on_variable - self._defining_variable = defining_variable - self._relative_bounds_of_defining_variable = relative_bounds_of_defining_variable - self.parameters = parameters + Returns: + variables: {'variable': bounded_var} + constraints: {'lower_bound': constraint, 'upper_bound': constraint} + """ + coords = coords or ['time'] + variable = model.add_variables(name=f'{name}|bounded', coords=model.get_coords(coords)) - def do_modeling(self): - self.size = self.add( - self._model.add_variables( - lower=0 if self.parameters.optional else self.parameters.minimum_or_fixed_size, - upper=self.parameters.maximum_or_fixed_size, - name=f'{self.label_full}|size', - coords=self._model.get_coords(['year', 'scenario']), - ), - 'size', + lower_factor, upper_factor = bounds + + # Constraints: lower_factor * controller ≤ var ≤ upper_factor * controller + lower_bound = model.add_constraints( + variable >= controlling_variable * lower_factor, name=f'{name}|proportional_lb' + ) + upper_bound = model.add_constraints( + variable <= controlling_variable * upper_factor, name=f'{name}|proportional_ub' ) - # Optional - if self.parameters.optional: - self.is_invested = self.add( - self._model.add_variables( - binary=True, - name=f'{self.label_full}|is_invested', - coords=self._model.get_coords(['year', 'scenario']), - ), - 'is_invested', - ) + variables = {'variable': variable} + constraints = {'lower_bound': lower_bound, 'upper_bound': upper_bound} - self._create_bounds_for_optional_investment() + return variables, constraints - if self._model.flow_system.scenarios is not None: - self._create_bounds_for_scenarios() + @staticmethod + def expression_tracking_variable( + model: FlowSystemModel, + name: str, + tracked_expression, + bounds: Tuple[TemporalData, TemporalData] = None, + coords: List[str] = None, + ) -> Tuple[Dict[str, linopy.Variable], Dict[str, linopy.Constraint]]: + """ + Creates variable that equals a given expression. - # Bounds for defining variable - self._create_bounds_for_defining_variable() + Mathematical formulation: + tracker = expression + lower ≤ tracker ≤ upper (if bounds provided) - self._create_shares() + Returns: + variables: {'tracker': tracker_var} + constraints: {'tracking': constraint} + """ + coords = coords or ['year', 'scenario'] - def _create_shares(self): - # fix_effects: - fix_effects = self.parameters.fix_effects - if fix_effects != {}: - self._model.effects.add_share_to_effects( - name=self.label_of_element, - expressions={ - effect: self.is_invested * factor if self.is_invested is not None else factor - for effect, factor in fix_effects.items() - }, - target='invest', + if bounds: + tracker = model.add_variables( + lower=bounds[0], upper=bounds[1], name=f'{name}|tracker', coords=model.get_coords(coords) ) + else: + tracker = model.add_variables(name=f'{name}|tracker', coords=model.get_coords(coords)) - if self.parameters.divest_effects != {} and self.parameters.optional: - # share: divest_effects - isInvested * divest_effects - self._model.effects.add_share_to_effects( - name=self.label_of_element, - expressions={effect: -self.is_invested * factor + factor for effect, factor in self.parameters.divest_effects.items()}, - target='invest', - ) + # Constraint: tracker = expression + tracking = model.add_constraints(tracker == tracked_expression, name=f'{name}|tracking_eq') + + variables = {'tracker': tracker} + constraints = {'tracking': tracking} + + return variables, constraints + + @staticmethod + def state_transition_variables( + model: FlowSystemModel, name: str, state_variable, previous_state=0 + ) -> Tuple[Dict[str, linopy.Variable], Dict[str, linopy.Constraint]]: + """ + Creates switch-on/off variables with state transition logic. + + Mathematical formulation: + switch_on[t] - switch_off[t] = state[t] - state[t-1] ∀t > 0 + switch_on[0] - switch_off[0] = state[0] - previous_state + switch_on[t] + switch_off[t] ≤ 1 ∀t + switch_on[t], switch_off[t] ∈ {0, 1} + + Returns: + variables: {'switch_on': binary_var, 'switch_off': binary_var} + constraints: {'transition': constraint, 'initial': constraint, 'mutex': constraint} + """ + switch_on = model.add_variables(binary=True, name=f'{name}|switch_on', coords=model.get_coords(['time'])) + switch_off = model.add_variables(binary=True, name=f'{name}|switch_off', coords=model.get_coords(['time'])) + + # State transition constraints for t > 0 + transition = model.add_constraints( + switch_on.isel(time=slice(1, None)) - switch_off.isel(time=slice(1, None)) + == state_variable.isel(time=slice(1, None)) - state_variable.isel(time=slice(None, -1)), + name=f'{name}|state_transition', + ) + + # Initial state transition for t = 0 + initial = model.add_constraints( + switch_on.isel(time=0) - switch_off.isel(time=0) == state_variable.isel(time=0) - previous_state, + name=f'{name}|initial_transition', + ) + + # At most one switch per timestep + mutex = model.add_constraints(switch_on + switch_off <= 1, name=f'{name}|switch_mutex') + + variables = {'switch_on': switch_on, 'switch_off': switch_off} + constraints = {'transition': transition, 'initial': initial, 'mutex': mutex} + + return variables, constraints + + @staticmethod + def big_m_binary_bounds( + model: FlowSystemModel, + name: str, + variable, + binary_control, + size_variable, + relative_bounds: Tuple[TemporalData, TemporalData], + ) -> Tuple[Dict, Dict[str, linopy.Constraint]]: + """ + Creates bounds controlled by both binary and continuous variables. + + Mathematical formulation: + variable[t] ≤ size[t] * upper_factor[t] ∀t + + If binary_control provided: + variable[t] ≥ M * (binary[t] - 1) + size[t] * lower_factor[t] ∀t + where M = max(size) * max(upper_factor) + Else: + variable[t] ≥ size[t] * lower_factor[t] ∀t + + Returns: + variables: {} (no new variables created) + constraints: {'upper_bound': constraint, 'lower_bound': constraint} + """ + rel_lower, rel_upper = relative_bounds + + # Upper bound: variable ≤ size * upper_factor + upper_bound = model.add_constraints(variable <= size_variable * rel_upper, name=f'{name}|size_upper_bound') - if self.parameters.specific_effects != {}: - self._model.effects.add_share_to_effects( - name=self.label_of_element, - expressions={effect: self.size * factor for effect, factor in self.parameters.specific_effects.items()}, - target='invest', + if binary_control is not None: + # Big-M lower bound: variable ≥ M*(binary-1) + size*lower_factor + big_m = size_variable.max() * rel_upper.max() # Conservative big-M + lower_bound = model.add_constraints( + variable >= big_m * (binary_control - 1) + size_variable * rel_lower, + name=f'{name}|binary_controlled_lower_bound', ) + else: + # Simple lower bound: variable ≥ size * lower_factor + lower_bound = model.add_constraints(variable >= size_variable * rel_lower, name=f'{name}|size_lower_bound') - if self.parameters.piecewise_effects: - self.piecewise_effects = self.add( - PiecewiseEffectsModel( - model=self._model, - label_of_element=self.label_of_element, - piecewise_origin=(self.size.name, self.parameters.piecewise_effects.piecewise_origin), - piecewise_shares=self.parameters.piecewise_effects.piecewise_shares, - zero_point=self.is_invested, - ), - 'segments', + variables = {} # No new variables created + constraints = {'upper_bound': upper_bound, 'lower_bound': lower_bound} + + return variables, constraints + + @staticmethod + def consecutive_duration_tracking( + model: FlowSystemModel, + name: str, + state_variable: linopy.Variable, + minimum_duration: Optional[TemporalData] = None, + maximum_duration: Optional[TemporalData] = None, + previous_duration: TemporalData = 0, + ) -> Tuple[Dict[str, linopy.Variable], Dict[str, linopy.Constraint]]: + """ + Creates consecutive duration tracking for a binary state variable. + + Mathematical formulation: + duration[t] ≤ state[t] * M ∀t + duration[t+1] ≤ duration[t] + hours_per_step[t] ∀t + duration[t+1] ≥ duration[t] + hours_per_step[t] + (state[t+1] - 1) * M ∀t + duration[0] = (hours_per_step[0] + previous_duration) * state[0] + + If minimum_duration provided: + duration[t] ≥ (state[t-1] - state[t]) * minimum_duration[t-1] ∀t > 0 + + Args: + state_variable: Binary state variable to track duration for + minimum_duration: Optional minimum consecutive duration + maximum_duration: Optional maximum consecutive duration + previous_duration: Duration from before first timestep + + Returns: + variables: {'duration': duration_var} + constraints: {'upper_bound': constraint, 'forward': constraint, 'backward': constraint, ...} + """ + hours_per_step = model.hours_per_step + mega = hours_per_step.sum('time') + previous_duration # Big-M value + + # Duration variable + duration = model.add_variables( + lower=0, + upper=maximum_duration if maximum_duration is not None else mega, + coords=model.get_coords(['time']), + name=f'{name}|duration', + ) + + constraints = {} + + # Upper bound: duration[t] ≤ state[t] * M + constraints['upper_bound'] = model.add_constraints( + duration <= state_variable * mega, name=f'{name}|duration_upper_bound' + ) + + # Forward constraint: duration[t+1] ≤ duration[t] + hours_per_step[t] + constraints['forward'] = model.add_constraints( + duration.isel(time=slice(1, None)) + <= duration.isel(time=slice(None, -1)) + hours_per_step.isel(time=slice(None, -1)), + name=f'{name}|duration_forward', + ) + + # Backward constraint: duration[t+1] ≥ duration[t] + hours_per_step[t] + (state[t+1] - 1) * M + constraints['backward'] = model.add_constraints( + duration.isel(time=slice(1, None)) + >= duration.isel(time=slice(None, -1)) + + hours_per_step.isel(time=slice(None, -1)) + + (state_variable.isel(time=slice(1, None)) - 1) * mega, + name=f'{name}|duration_backward', + ) + + # Initial condition: duration[0] = (hours_per_step[0] + previous_duration) * state[0] + constraints['initial'] = model.add_constraints( + duration.isel(time=0) + == (hours_per_step.isel(time=0) + previous_duration) * state_variable.isel(time=0), + name=f'{name}|duration_initial', + ) + + # Minimum duration constraint if provided + if minimum_duration is not None: + constraints['minimum'] = model.add_constraints( + duration.isel(time=slice(1, None)) + >= (state_variable.isel(time=slice(None, -1)) - state_variable.isel(time=slice(1, None))) + * minimum_duration.isel(time=slice(None, -1)), + name=f'{name}|duration_minimum', ) - self.piecewise_effects.do_modeling() - def _create_bounds_for_optional_investment(self): - if self.parameters.fixed_size: - # eq: investment_size = isInvested * fixed_size - self.add( - self._model.add_constraints( - self.size == self.is_invested * self.parameters.fixed_size, name=f'{self.label_full}|is_invested' - ), - 'is_invested', + # Handle initial condition for minimum duration + if previous_duration > 0 and previous_duration < minimum_duration.isel(time=0).max(): + constraints['initial_minimum'] = model.add_constraints( + state_variable.isel(time=0) == 1, name=f'{name}|duration_initial_minimum' + ) + + variables = {'duration': duration} + + return variables, constraints + + +class ModelingPatterns: + """High-level patterns that compose primitives and return (variables, constraints) tuples""" + + @staticmethod + def investment_sizing_pattern( + model: FlowSystemModel, + name: str, + size_bounds: Tuple[TemporalData, TemporalData], + controlled_variables: List[linopy.Variable] = None, + control_factors: List[Tuple[TemporalData, TemporalData]] = None, + optional: bool = False, + ) -> Tuple[Dict[str, linopy.Variable], Dict[str, linopy.Constraint]]: + """ + Complete investment sizing pattern with optional binary decision. + + Returns: + variables: {'size': size_var, 'is_invested': binary_var (if optional)} + constraints: {'investment_upper_bound': constraint, 'investment_lower_bound': constraint, ...} + """ + variables = {} + constraints = {} + + # Investment size variable + size_min, size_max = size_bounds + variables['size'] = model.add_variables( + lower=size_min, + upper=size_max, + name=f'{name}|investment_size', + coords=model.get_coords(['year', 'scenario']), + ) + + # Optional binary investment decision + if optional: + variables['is_invested'] = model.add_variables( + binary=True, name=f'{name}|is_invested', coords=model.get_coords(['year', 'scenario']) ) + # Link size to investment decision + if abs(size_min - size_max) < 1e-10: # Fixed size case + constraints['fixed_investment_size'] = model.add_constraints( + variables['size'] == variables['is_invested'] * size_max, name=f'{name}|fixed_investment_size' + ) + else: # Variable size case + constraints['investment_upper_bound'] = model.add_constraints( + variables['size'] <= variables['is_invested'] * size_max, name=f'{name}|investment_upper_bound' + ) + constraints['investment_lower_bound'] = model.add_constraints( + variables['size'] >= variables['is_invested'] * max(CONFIG.modeling.EPSILON, size_min), + name=f'{name}|investment_lower_bound', + ) + + # Control dependent variables + if controlled_variables and control_factors: + for i, (var, factors) in enumerate(zip(controlled_variables, control_factors)): + _, control_constraints = ModelingPrimitives.big_m_binary_bounds( + model, f'{name}|control_{i}', var, variables.get('is_invested'), variables['size'], factors + ) + # Flatten control constraints with indexed names + constraints[f'control_{i}_upper_bound'] = control_constraints['upper_bound'] + constraints[f'control_{i}_lower_bound'] = control_constraints['lower_bound'] + + return variables, constraints + + @staticmethod + def operational_binary_control_pattern( + model: FlowSystemModel, + name: str, + controlled_variables: List[linopy.Variable], + variable_bounds: List[Tuple[TemporalData, TemporalData]], + use_complement: bool = False, + track_total_duration: bool = False, + track_switches: bool = False, + previous_state=0, + duration_bounds: Tuple[TemporalData, TemporalData] = None, + ) -> Tuple[Dict[str, linopy.Variable], Dict[str, linopy.Constraint]]: + """ + Operational binary control with optional features. + + Returns: + variables: {'on': binary_var, 'off': binary_var (optional), 'total_duration': var (optional), ...} + constraints: {'complementary': constraint, 'control_0_lower': constraint, ...} + """ + variables = {} + constraints = {} + + # Main binary state + if use_complement: + state_vars, state_constraints = ModelingPrimitives.binary_state_pair(model, name) + variables.update(state_vars) + constraints.update(state_constraints) else: - # eq1: P_invest <= isInvested * investSize_max - self.add( - self._model.add_constraints( - self.size <= self.is_invested * self.parameters.maximum_size, - name=f'{self.label_full}|is_invested_ub', - ), - 'is_invested_ub', + variables['on'] = model.add_variables(binary=True, name=f'{name}|on', coords=model.get_coords(['time'])) + + # Control variables with binary state + for i, (var, (lower_bound, upper_bound)) in enumerate(zip(controlled_variables, variable_bounds)): + # Lower bound constraint + constraints[f'control_{i}_lower'] = model.add_constraints( + variables['on'] * max(CONFIG.modeling.EPSILON, lower_bound) <= var, name=f'{name}|control_{i}_lower' + ) + # Upper bound constraint + constraints[f'control_{i}_upper'] = model.add_constraints( + var <= variables['on'] * upper_bound, name=f'{name}|control_{i}_upper' ) - # eq2: P_invest >= isInvested * max(epsilon, investSize_min) - self.add( - self._model.add_constraints( - self.size >= self.is_invested * np.maximum(CONFIG.modeling.EPSILON, self.parameters.minimum_or_fixed_size), - name=f'{self.label_full}|is_invested_lb', - ), - 'is_invested_lb', + # Total duration tracking + if track_total_duration: + duration_expr = (variables['on'] * model.hours_per_step).sum('time') + duration_vars, duration_constraints = ModelingPrimitives.expression_tracking_variable( + model, f'{name}|duration', duration_expr, duration_bounds ) + variables['total_duration'] = duration_vars['tracker'] + constraints['duration_tracking'] = duration_constraints['tracking'] - def _create_bounds_for_defining_variable(self): - variable = self._defining_variable - lb_relative, ub_relative = self._relative_bounds_of_defining_variable - if np.all(lb_relative == ub_relative): - self.add( - self._model.add_constraints( - variable == self.size * ub_relative, name=f'{self.label_full}|fix_{variable.name}' - ), - f'fix_{variable.name}', + # Switch tracking + if track_switches: + switch_vars, switch_constraints = ModelingPrimitives.state_transition_variables( + model, f'{name}|switches', variables['on'], previous_state ) - return + variables.update(switch_vars) + # Add switch constraints with prefixed names + for switch_name, switch_constraint in switch_constraints.items(): + constraints[f'switch_{switch_name}'] = switch_constraint - # eq: defining_variable(t) <= size * upper_bound(t) - self.add( - self._model.add_constraints( - variable <= self.size * ub_relative, name=f'{self.label_full}|ub_{variable.name}' - ), - f'ub_{variable.name}', - ) + return variables, constraints - if self._on_variable is None: - # eq: defining_variable(t) >= investment_size * relative_minimum(t) - self.add( - self._model.add_constraints( - variable >= self.size * lb_relative, name=f'{self.label_full}|lb_{variable.name}' - ), - f'lb_{variable.name}', - ) + @staticmethod + def operational_binary_control_pattern( + model: FlowSystemModel, + name: str, + controlled_variables: List[linopy.Variable], + variable_bounds: List[Tuple[TemporalData, TemporalData]], + use_complement: bool = False, + track_total_duration: bool = False, + track_switches: bool = False, + previous_state=0, + duration_bounds: Tuple[TemporalData, TemporalData] = None, + track_consecutive_on: bool = False, + consecutive_on_bounds: Tuple[Optional[TemporalData], Optional[TemporalData]] = (None, None), + previous_on_duration: TemporalData = 0, + track_consecutive_off: bool = False, + consecutive_off_bounds: Tuple[Optional[TemporalData], Optional[TemporalData]] = (None, None), + previous_off_duration: TemporalData = 0, + ) -> Tuple[Dict[str, linopy.Variable], Dict[str, linopy.Constraint]]: + """ + Enhanced operational binary control with consecutive duration tracking. + + New Args: + track_consecutive_on: Whether to track consecutive on duration + consecutive_on_bounds: (min_duration, max_duration) for consecutive on + previous_on_duration: Previous consecutive on duration + track_consecutive_off: Whether to track consecutive off duration + consecutive_off_bounds: (min_duration, max_duration) for consecutive off + previous_off_duration: Previous consecutive off duration + """ + variables = {} + constraints = {} + + # Main binary state (existing logic) + if use_complement: + state_vars, state_constraints = ModelingPrimitives.binary_state_pair(model, name) + variables.update(state_vars) + constraints.update(state_constraints) else: - ## 2. Gleichung: Minimum durch Investmentgröße und On - # eq: defining_variable(t) >= mega * (On(t)-1) + size * relative_minimum(t) - # ... mit mega = relative_maximum * maximum_size - # äquivalent zu:. - # eq: - defining_variable(t) + mega * On(t) + size * relative_minimum(t) <= + mega - mega = self.parameters.maximum_size * lb_relative - on = self._on_variable - self.add( - self._model.add_constraints( - variable >= mega * (on - 1) + self.size * lb_relative, name=f'{self.label_full}|lb_{variable.name}' - ), - f'lb_{variable.name}', + variables['on'] = model.add_variables(binary=True, name=f'{name}|on', coords=model.get_coords(['time'])) + + # Control variables (existing logic) + for i, (var, (lower_bound, upper_bound)) in enumerate(zip(controlled_variables, variable_bounds)): + constraints[f'control_{i}_lower'] = model.add_constraints( + variables['on'] * max(CONFIG.modeling.EPSILON, lower_bound) <= var, name=f'{name}|control_{i}_lower' + ) + constraints[f'control_{i}_upper'] = model.add_constraints( + var <= variables['on'] * upper_bound, name=f'{name}|control_{i}_upper' ) - # anmerkung: Glg bei Spezialfall relative_minimum = 0 redundant zu OnOff ?? - def _create_bounds_for_scenarios(self): - if isinstance(self.parameters.investment_scenarios, str): - if self.parameters.investment_scenarios == 'individual': - return - raise ValueError(f'Invalid value for investment_scenarios: {self.parameters.investment_scenarios}') + # Total duration tracking (existing logic) + if track_total_duration: + duration_expr = (variables['on'] * model.hours_per_step).sum('time') + duration_vars, duration_constraints = ModelingPrimitives.expression_tracking_variable( + model, f'{name}|duration', duration_expr, duration_bounds + ) + variables['total_duration'] = duration_vars['tracker'] + constraints['duration_tracking'] = duration_constraints['tracking'] - if self.parameters.investment_scenarios is None: - self.add( - self._model.add_constraints( - self.size.isel(scenario=slice(None, -1)) == self.size.isel(scenario=slice(1, None)), - name=f'{self.label_full}|equalize_size_per_scenario', - ), - 'equalize_size_per_scenario', + # Switch tracking (existing logic) + if track_switches: + switch_vars, switch_constraints = ModelingPrimitives.state_transition_variables( + model, f'{name}|switches', variables['on'], previous_state ) - return - if not isinstance(self.parameters.investment_scenarios, list): - raise ValueError(f'Invalid value for investment_scenarios: {self.parameters.investment_scenarios}') - if not all(scenario in self._model.time_series_collection.scenarios for scenario in self.parameters.investment_scenarios): - raise ValueError(f'Some scenarios in investment_scenarios are not present in the time_series_collection: ' - f'{self.parameters.investment_scenarios}. This might be due to selecting a subset of ' - f'all scenarios, which is not yet supported.') - - investment_scenarios = self._model.time_series_collection.scenarios.intersection(self.parameters.investment_scenarios) - no_investment_scenarios = self._model.time_series_collection.scenarios.difference(self.parameters.investment_scenarios) - - # eq: size(s) = size(s') for s, s' in investment_scenarios - if len(investment_scenarios) > 1: - self.add( - self._model.add_constraints( - self.size.sel(scenario=investment_scenarios[:-1]) == self.size.sel(scenario=investment_scenarios[1:]), - name=f'{self.label_full}|investment_scenarios', - ), - 'investment_scenarios', + variables.update(switch_vars) + for switch_name, switch_constraint in switch_constraints.items(): + constraints[f'switch_{switch_name}'] = switch_constraint + + # NEW: Consecutive on duration tracking + if track_consecutive_on: + min_on, max_on = consecutive_on_bounds + consecutive_on_vars, consecutive_on_constraints = ModelingPrimitives.consecutive_duration_tracking( + model, + f'{name}|consecutive_on', + variables['on'], + minimum_duration=min_on, + maximum_duration=max_on, + previous_duration=previous_on_duration, ) - - if len(no_investment_scenarios) >= 1: - self.add( - self._model.add_constraints( - self.size.sel(scenario=no_investment_scenarios) == 0, - name=f'{self.label_full}|no_investment_scenarios', - ), - 'no_investment_scenarios', + variables['consecutive_on_duration'] = consecutive_on_vars['duration'] + for cons_name, cons_constraint in consecutive_on_constraints.items(): + constraints[f'consecutive_on_{cons_name}'] = cons_constraint + + # NEW: Consecutive off duration tracking + if track_consecutive_off and 'off' in variables: + min_off, max_off = consecutive_off_bounds + consecutive_off_vars, consecutive_off_constraints = ModelingPrimitives.consecutive_duration_tracking( + model, + f'{name}|consecutive_off', + variables['off'], + minimum_duration=min_off, + maximum_duration=max_off, + previous_duration=previous_off_duration, ) + variables['consecutive_off_duration'] = consecutive_off_vars['duration'] + for cons_name, cons_constraint in consecutive_off_constraints.items(): + constraints[f'consecutive_off_{cons_name}'] = cons_constraint + + return variables, constraints + + +class InvestmentModel(BaseFeatureModel): + def create_variables(self): + # Clean tuple unpacking + variables, constraints = ModelingPatterns.investment_sizing_pattern( + model=self._model, + name=self.label_full, + size_bounds=( + 0 if self.parameters.optional else self.parameters.minimum_or_fixed_size, + self.parameters.maximum_or_fixed_size, + ), + controlled_variables=[self._defining_variable], + control_factors=[self._relative_bounds_of_defining_variable], + optional=self.parameters.optional, + ) + + # Register variables + self.size = self.add(variables['size'], 'size') + if 'is_invested' in variables: + self.is_invested = self.add(variables['is_invested'], 'is_invested') + + # Register all constraints + for constraint_name, constraint in constraints.items(): + self.add(constraint, constraint_name) + + +class OnOffModel(BaseFeatureModel): + """OnOff model using factory patterns""" + + def __init__( + self, + model: FlowSystemModel, + on_off_parameters: OnOffParameters, + label_of_element: str, + defining_variables: List[linopy.Variable], + defining_bounds: List[Tuple[TemporalData, TemporalData]], + previous_values: List[Optional[TemporalData]], + label: Optional[str] = None, + ): + super().__init__(model, label_of_element, on_off_parameters, label) + + self._defining_variables = defining_variables + self._defining_bounds = defining_bounds + self._previous_values = previous_values + + # All variables set by factory + self.on: Optional[linopy.Variable] = None + self.off: Optional[linopy.Variable] = None + self.total_on_hours: Optional[linopy.Variable] = None + self.switch_on: Optional[linopy.Variable] = None + self.switch_off: Optional[linopy.Variable] = None + self.consecutive_on_hours: Optional[linopy.Variable] = None + self.consecutive_off_hours: Optional[linopy.Variable] = None + + def create_variables_and_constraints(self): + # Use enhanced factory pattern + variables, constraints = ModelingPatterns.operational_binary_control_pattern( + model=self._model, + name=self.label_full, + controlled_variables=self._defining_variables, + variable_bounds=self._defining_bounds, + use_complement=self.parameters.use_off, + track_total_duration=True, + track_switches=self.parameters.use_switch_on, + previous_state=self._get_previous_state(), + duration_bounds=(self.parameters.on_hours_total_min, self.parameters.on_hours_total_max), + track_consecutive_on=self.parameters.use_consecutive_on_hours, + consecutive_on_bounds=(self.parameters.consecutive_on_hours_min, self.parameters.consecutive_on_hours_max), + previous_on_duration=self._get_previous_on_duration(), + track_consecutive_off=self.parameters.use_consecutive_off_hours, + consecutive_off_bounds=( + self.parameters.consecutive_off_hours_min, + self.parameters.consecutive_off_hours_max, + ), + previous_off_duration=self._get_previous_off_duration(), + ) + + # Register all variables + self.on = self.add(variables['on'], 'on') + if 'off' in variables: + self.off = self.add(variables['off'], 'off') + if 'total_duration' in variables: + self.total_on_hours = self.add(variables['total_duration'], 'total_duration') + if 'switch_on' in variables: + self.switch_on = self.add(variables['switch_on'], 'switch_on') + self.switch_off = self.add(variables['switch_off'], 'switch_off') + if 'consecutive_on_duration' in variables: + self.consecutive_on_hours = self.add(variables['consecutive_on_duration'], 'consecutive_on_hours') + if 'consecutive_off_duration' in variables: + self.consecutive_off_hours = self.add(variables['consecutive_off_duration'], 'consecutive_off_hours') + + # Register all constraints + for constraint_name, constraint in constraints.items(): + self.add(constraint, constraint_name) + + def _get_previous_on_duration(self): + """Calculate previous consecutive on duration""" + # Implementation based on _previous_values + return 0 # Placeholder + + def _get_previous_off_duration(self): + """Calculate previous consecutive off duration""" + # Implementation based on _previous_values + return 0 # Placeholder + + # Remove the old placeholder methods - no longer needed! class StateModel(Model): @@ -657,155 +1055,6 @@ def compute_consecutive_hours_in_state( return np.sum(binary_values[-nr_of_indexes_with_consecutive_ones:] * hours_per_timestep[-nr_of_indexes_with_consecutive_ones:]) -class OnOffModel(Model): - """ - Class for modeling the on and off state of a variable - Uses component models to create a modular implementation - """ - - def __init__( - self, - model: FlowSystemModel, - on_off_parameters: OnOffParameters, - label_of_element: str, - defining_variables: List[linopy.Variable], - defining_bounds: List[Tuple[TemporalData, TemporalData]], - previous_values: List[Optional[TemporalData]], - label: Optional[str] = None, - ): - """ - Constructor for OnOffModel - - Args: - model: Reference to the FlowSystemModel - on_off_parameters: Parameters for the OnOffModel - label_of_element: Label of the Parent - defining_variables: List of Variables that are used to define the OnOffModel - defining_bounds: List of Tuples, defining the absolute bounds of each defining variable - previous_values: List of previous values of the defining variables - label: Label of the OnOffModel - """ - super().__init__(model, label_of_element, label) - self.parameters = on_off_parameters - self._defining_variables = defining_variables - self._defining_bounds = defining_bounds - self._previous_values = previous_values - - self.state_model = None - self.switch_state_model = None - self.consecutive_on_model = None - self.consecutive_off_model = None - - def do_modeling(self): - """Create all variables and constraints for the OnOffModel""" - - # Create binary state component - self.state_model = StateModel( - model=self._model, - label_of_element=self.label_of_element, - defining_variables=self._defining_variables, - defining_bounds=self._defining_bounds, - previous_values=self._previous_values, - use_off=self.parameters.use_off, - on_hours_total_min=self.parameters.on_hours_total_min, - on_hours_total_max=self.parameters.on_hours_total_max, - effects_per_running_hour=self.parameters.effects_per_running_hour, - ) - self.add(self.state_model) - self.state_model.do_modeling() - - # Create switch component if needed - if self.parameters.use_switch_on: - self.switch_state_model = SwitchStateModel( - model=self._model, - label_of_element=self.label_of_element, - state_variable=self.state_model.on, - previous_state=self.state_model.previous_on_states[-1], - switch_on_max=self.parameters.switch_on_total_max, - ) - self.add(self.switch_state_model) - self.switch_state_model.do_modeling() - - # Create consecutive on hours component if needed - if self.parameters.use_consecutive_on_hours: - self.consecutive_on_model = ConsecutiveStateModel( - model=self._model, - label_of_element=self.label_of_element, - state_variable=self.state_model.on, - minimum_duration=self.parameters.consecutive_on_hours_min, - maximum_duration=self.parameters.consecutive_on_hours_max, - previous_states=self.state_model.previous_on_states, - label='ConsecutiveOn', - ) - self.add(self.consecutive_on_model) - self.consecutive_on_model.do_modeling() - - # Create consecutive off hours component if needed - if self.parameters.use_consecutive_off_hours: - self.consecutive_off_model = ConsecutiveStateModel( - model=self._model, - label_of_element=self.label_of_element, - state_variable=self.state_model.off, - minimum_duration=self.parameters.consecutive_off_hours_min, - maximum_duration=self.parameters.consecutive_off_hours_max, - previous_states=self.state_model.previous_off_states, - label='ConsecutiveOff', - ) - self.add(self.consecutive_off_model) - self.consecutive_off_model.do_modeling() - - self._create_shares() - - def _create_shares(self): - if self.parameters.effects_per_running_hour: - self._model.effects.add_share_to_effects( - name=self.label_of_element, - expressions={ - effect: self.state_model.on * factor * self._model.hours_per_step - for effect, factor in self.parameters.effects_per_running_hour.items() - }, - target='operation', - ) - - if self.parameters.effects_per_switch_on: - self._model.effects.add_share_to_effects( - name=self.label_of_element, - expressions={ - effect: self.switch_state_model.switch_on * factor - for effect, factor in self.parameters.effects_per_switch_on.items() - }, - target='operation', - ) - - @property - def on(self): - return self.state_model.on - - @property - def off(self): - return self.state_model.off - - @property - def switch_on(self): - return self.switch_state_model.switch_on - - @property - def switch_off(self): - return self.switch_state_model.switch_off - - @property - def switch_on_nr(self): - return self.switch_state_model.switch_on_nr - - @property - def consecutive_on_hours(self): - return self.consecutive_on_model.duration - - @property - def consecutive_off_hours(self): - return self.consecutive_off_model.duration - - class PieceModel(Model): """Class for modeling a linear piece of one or more variables in parallel""" diff --git a/flixopt/structure.py b/flixopt/structure.py index 9566e303f..fed5bed94 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -831,6 +831,27 @@ def all_sub_models(self) -> List['Model']: return [model for sub_model in self.sub_models for model in [sub_model] + sub_model.all_sub_models] +class BaseFeatureModel(Model): + """Minimal base class for feature models that use factory patterns""" + + def __init__(self, model: FlowSystemModel, label_of_element: str, parameters, label: Optional[str] = None): + super().__init__(model, label_of_element, label or self.__class__.__name__) + self.parameters = parameters + + def do_modeling(self): + """Template method - creates variables and constraints, then effects""" + self.create_variables_and_constraints() + self.add_effects() + + def create_variables_and_constraints(self): + """Override in subclasses to create variables and constraints""" + raise NotImplementedError('Subclasses must implement create_variables_and_constraints()') + + def add_effects(self): + """Override in subclasses to add effects""" + pass # Default: no effects + + class ElementModel(Model): """Stores the mathematical Variables and Constraints for Elements""" From 33460a0220d3d415113334433c552cbae2c23ab1 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 18 Jul 2025 11:02:50 +0200 Subject: [PATCH 198/336] Improve Feature Patterns --- flixopt/features.py | 452 ++++++++++++++++++++++++++++++++++---------- 1 file changed, 353 insertions(+), 99 deletions(-) diff --git a/flixopt/features.py b/flixopt/features.py index e495e2973..3986f7b49 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -17,6 +17,140 @@ logger = logging.getLogger('flixopt') +class ModelingUtilities: + """Utility functions for modeling calculations - used across different classes""" + + @staticmethod + def compute_consecutive_hours_in_state( + binary_values: TemporalData, hours_per_timestep: Union[int, float, np.ndarray] + ) -> Scalar: + """ + Computes the final consecutive duration in state 'on' (=1) in hours, from a binary array. + + Args: + binary_values: An int or 1D binary array containing only `0`s and `1`s. + hours_per_timestep: The duration of each timestep in hours. + If a scalar is provided, it is used for all timesteps. + If an array is provided, it must be as long as the last consecutive duration in binary_values. + + Returns: + The duration of the binary variable in hours. + + Raises + ------ + TypeError + If the length of binary_values and dt_in_hours is not equal, but None is a scalar. + """ + if np.isscalar(binary_values) and np.isscalar(hours_per_timestep): + return binary_values * hours_per_timestep + elif np.isscalar(binary_values) and not np.isscalar(hours_per_timestep): + return binary_values * hours_per_timestep[-1] + + if np.isclose(binary_values[-1], 0, atol=CONFIG.modeling.EPSILON): + return 0 + + if np.isscalar(hours_per_timestep): + hours_per_timestep = np.ones(len(binary_values)) * hours_per_timestep + hours_per_timestep: np.ndarray + + indexes_with_zero_values = np.where(np.isclose(binary_values, 0, atol=CONFIG.modeling.EPSILON))[0] + if len(indexes_with_zero_values) == 0: + nr_of_indexes_with_consecutive_ones = len(binary_values) + else: + nr_of_indexes_with_consecutive_ones = len(binary_values) - indexes_with_zero_values[-1] - 1 + + if len(hours_per_timestep) < nr_of_indexes_with_consecutive_ones: + raise ValueError( + f'When trying to calculate the consecutive duration, the length of the last duration ' + f'({nr_of_indexes_with_consecutive_ones}) is longer than the provided hours_per_timestep ({len(hours_per_timestep)}), ' + f'as {binary_values=}' + ) + + return np.sum( + binary_values[-nr_of_indexes_with_consecutive_ones:] + * hours_per_timestep[-nr_of_indexes_with_consecutive_ones:] + ) + + @staticmethod + def compute_previous_states(previous_values: List[TemporalData], epsilon: float = None) -> np.ndarray: + """ + Computes the previous states {0, 1} of defining variables as a binary array from their previous values. + + Args: + previous_values: List of previous values for variables + epsilon: Tolerance for zero detection (uses CONFIG.modeling.EPSILON if None) + + Returns: + Binary array of previous states + """ + if epsilon is None: + epsilon = CONFIG.modeling.EPSILON + + if not previous_values or all(val is None for val in previous_values): + return np.array([0]) + + # Convert to 2D-array and compute binary on/off states + previous_values = np.array([values for values in previous_values if values is not None]) # Filter out None + if previous_values.ndim > 1: + return np.any(~np.isclose(previous_values, 0, atol=epsilon), axis=0).astype(int) + + return (~np.isclose(previous_values, 0, atol=epsilon)).astype(int) + + @staticmethod + def compute_previous_on_duration(previous_values: List[TemporalData], hours_per_step: Union[int, float]) -> Scalar: + """ + Convenience method to compute previous consecutive 'on' duration. + + Args: + previous_values: List of previous values for variables + hours_per_step: Duration of each timestep in hours + + Returns: + Previous consecutive on duration in hours + """ + if not previous_values: + return 0 + + previous_states = ModelingUtilities.compute_previous_states(previous_values) + return ModelingUtilities.compute_consecutive_hours_in_state(previous_states, hours_per_step) + + @staticmethod + def compute_previous_off_duration(previous_values: List[TemporalData], hours_per_step: Union[int, float]) -> Scalar: + """ + Convenience method to compute previous consecutive 'off' duration. + + Args: + previous_values: List of previous values for variables + hours_per_step: Duration of each timestep in hours + + Returns: + Previous consecutive off duration in hours + """ + if not previous_values: + return 0 + + previous_states = ModelingUtilities.compute_previous_states(previous_values) + previous_off_states = 1 - previous_states + return ModelingUtilities.compute_consecutive_hours_in_state(previous_off_states, hours_per_step) + + @staticmethod + def get_most_recent_state(previous_values: List[TemporalData]) -> int: + """ + Get the most recent binary state from previous values. + + Args: + previous_values: List of previous values for variables + + Returns: + Most recent binary state (0 or 1) + """ + if not previous_values: + return 0 + + previous_states = ModelingUtilities.compute_previous_states(previous_values) + return int(previous_states[-1]) + + class ModelingPrimitives: """Mathematical modeling primitives returning (variables, constraints) tuples""" @@ -105,12 +239,15 @@ def expression_tracking_variable( """ coords = coords or ['year', 'scenario'] - if bounds: + if not bounds: + tracker = model.add_variables(name=f'{name}|tracker', coords=model.get_coords(coords)) + else: tracker = model.add_variables( - lower=bounds[0], upper=bounds[1], name=f'{name}|tracker', coords=model.get_coords(coords) + lower=bounds[0] if bounds[0] is not None else -np.inf, + upper=bounds[1] if bounds[1] is not None else np.inf, + name=f'{name}|tracker', + coords=model.get_coords(coords), ) - else: - tracker = model.add_variables(name=f'{name}|tracker', coords=model.get_coords(coords)) # Constraint: tracker = expression tracking = model.add_constraints(tracker == tracked_expression, name=f'{name}|tracking_eq') @@ -298,6 +435,49 @@ def consecutive_duration_tracking( return variables, constraints + @staticmethod + def mutual_exclusivity_constraint( + model: FlowSystemModel, name: str, binary_variables: List[linopy.Variable], tolerance: float = 1.1 + ) -> Tuple[Dict, Dict[str, linopy.Constraint]]: + """ + Creates mutual exclusivity constraint for binary variables. + + Mathematical formulation: + Σ(binary_vars[i]) ≤ tolerance ∀t + + Ensures at most one binary variable can be 1 at any time. + Tolerance > 1.0 accounts for binary variable numerical precision. + + Args: + binary_variables: List of binary variables that should be mutually exclusive + tolerance: Upper bound (typically 1.1 for numerical stability) + + Returns: + variables: {} (no new variables created) + constraints: {'mutual_exclusivity': constraint} + + Raises: + AssertionError: If fewer than 2 variables provided or variables aren't binary + """ + assert len(binary_variables) >= 2, ( + f'Mutual exclusivity requires at least 2 variables, got {len(binary_variables)}' + ) + + for var in binary_variables: + assert var.attrs.get('binary', False), ( + f'Variable {var.name} must be binary for mutual exclusivity constraint' + ) + + # Create mutual exclusivity constraint + mutual_exclusivity = model.add_constraints( + sum(binary_variables) <= tolerance, name=f'{name}|mutual_exclusivity' + ) + + variables = {} # No new variables created + constraints = {'mutual_exclusivity': mutual_exclusivity} + + return variables, constraints + class ModelingPatterns: """High-level patterns that compose primitives and return (variables, constraints) tuples""" @@ -362,68 +542,6 @@ def investment_sizing_pattern( return variables, constraints - @staticmethod - def operational_binary_control_pattern( - model: FlowSystemModel, - name: str, - controlled_variables: List[linopy.Variable], - variable_bounds: List[Tuple[TemporalData, TemporalData]], - use_complement: bool = False, - track_total_duration: bool = False, - track_switches: bool = False, - previous_state=0, - duration_bounds: Tuple[TemporalData, TemporalData] = None, - ) -> Tuple[Dict[str, linopy.Variable], Dict[str, linopy.Constraint]]: - """ - Operational binary control with optional features. - - Returns: - variables: {'on': binary_var, 'off': binary_var (optional), 'total_duration': var (optional), ...} - constraints: {'complementary': constraint, 'control_0_lower': constraint, ...} - """ - variables = {} - constraints = {} - - # Main binary state - if use_complement: - state_vars, state_constraints = ModelingPrimitives.binary_state_pair(model, name) - variables.update(state_vars) - constraints.update(state_constraints) - else: - variables['on'] = model.add_variables(binary=True, name=f'{name}|on', coords=model.get_coords(['time'])) - - # Control variables with binary state - for i, (var, (lower_bound, upper_bound)) in enumerate(zip(controlled_variables, variable_bounds)): - # Lower bound constraint - constraints[f'control_{i}_lower'] = model.add_constraints( - variables['on'] * max(CONFIG.modeling.EPSILON, lower_bound) <= var, name=f'{name}|control_{i}_lower' - ) - # Upper bound constraint - constraints[f'control_{i}_upper'] = model.add_constraints( - var <= variables['on'] * upper_bound, name=f'{name}|control_{i}_upper' - ) - - # Total duration tracking - if track_total_duration: - duration_expr = (variables['on'] * model.hours_per_step).sum('time') - duration_vars, duration_constraints = ModelingPrimitives.expression_tracking_variable( - model, f'{name}|duration', duration_expr, duration_bounds - ) - variables['total_duration'] = duration_vars['tracker'] - constraints['duration_tracking'] = duration_constraints['tracking'] - - # Switch tracking - if track_switches: - switch_vars, switch_constraints = ModelingPrimitives.state_transition_variables( - model, f'{name}|switches', variables['on'], previous_state - ) - variables.update(switch_vars) - # Add switch constraints with prefixed names - for switch_name, switch_constraint in switch_constraints.items(): - constraints[f'switch_{switch_name}'] = switch_constraint - - return variables, constraints - @staticmethod def operational_binary_control_pattern( model: FlowSystemModel, @@ -467,7 +585,7 @@ def operational_binary_control_pattern( # Control variables (existing logic) for i, (var, (lower_bound, upper_bound)) in enumerate(zip(controlled_variables, variable_bounds)): constraints[f'control_{i}_lower'] = model.add_constraints( - variables['on'] * max(CONFIG.modeling.EPSILON, lower_bound) <= var, name=f'{name}|control_{i}_lower' + variables['on'] * np.maximum(lower_bound, CONFIG.modeling.EPSILON) <= var, name=f'{name}|control_{i}_lower' ) constraints[f'control_{i}_upper'] = model.add_constraints( var <= variables['on'] * upper_bound, name=f'{name}|control_{i}_upper' @@ -525,8 +643,30 @@ def operational_binary_control_pattern( class InvestmentModel(BaseFeatureModel): - def create_variables(self): - # Clean tuple unpacking + """Investment model using factory patterns but keeping old interface""" + + def __init__( + self, + model: FlowSystemModel, + label_of_element: str, + parameters: InvestParameters, + defining_variable: linopy.Variable, + relative_bounds_of_defining_variable: Tuple[TemporalData, TemporalData], + label: Optional[str] = None, + on_variable: Optional[linopy.Variable] = None, + ): + super().__init__(model, label_of_element, parameters, label) + + self._defining_variable = defining_variable + self._relative_bounds_of_defining_variable = relative_bounds_of_defining_variable + self._on_variable = on_variable + + # Only keep non-variable attributes + self.scenario_of_investment: Optional[linopy.Variable] = None + self.piecewise_effects: Optional[PiecewiseEffectsModel] = None + + def create_variables_and_constraints(self): + # Use factory patterns variables, constraints = ModelingPatterns.investment_sizing_pattern( model=self._model, name=self.label_full, @@ -539,15 +679,76 @@ def create_variables(self): optional=self.parameters.optional, ) - # Register variables - self.size = self.add(variables['size'], 'size') + # Register variables (stored in Model's variable tracking) + self.add(variables['size'], 'size') if 'is_invested' in variables: - self.is_invested = self.add(variables['is_invested'], 'is_invested') + self.add(variables['is_invested'], 'is_invested') - # Register all constraints + # Register constraints for constraint_name, constraint in constraints.items(): self.add(constraint, constraint_name) + # Handle scenarios and piecewise effects... + if self._model.flow_system.scenarios is not None: + self._create_bounds_for_scenarios() + + if self.parameters.piecewise_effects: + self.piecewise_effects = self.add( + PiecewiseEffectsModel( + model=self._model, + label_of_element=self.label_of_element, + piecewise_origin=(self.size.name, self.parameters.piecewise_effects.piecewise_origin), + piecewise_shares=self.parameters.piecewise_effects.piecewise_shares, + zero_point=self.is_invested, + ), + 'segments', + ) + self.piecewise_effects.do_modeling() + + # Properties access variables from Model's tracking system + @property + def size(self) -> Optional[linopy.Variable]: + """Investment size variable""" + return self.get_variable_by_short_name('size') + + @property + def is_invested(self) -> Optional[linopy.Variable]: + """Binary investment decision variable""" + return self.get_variable_by_short_name('is_invested') + + def add_effects(self): + """Add investment effects""" + if self.parameters.fix_effects: + self._model.effects.add_share_to_effects( + name=self.label_of_element, + expressions={ + effect: self.is_invested * factor if self.is_invested is not None else factor + for effect, factor in self.parameters.fix_effects.items() + }, + target='invest', + ) + + if self.parameters.divest_effects and self.parameters.optional: + self._model.effects.add_share_to_effects( + name=self.label_of_element, + expressions={ + effect: -self.is_invested * factor + factor + for effect, factor in self.parameters.divest_effects.items() + }, + target='invest', + ) + + if self.parameters.specific_effects: + self._model.effects.add_share_to_effects( + name=self.label_of_element, + expressions={effect: self.size * factor for effect, factor in self.parameters.specific_effects.items()}, + target='invest', + ) + + def _create_bounds_for_scenarios(self): + """Keep existing scenario logic""" + pass + class OnOffModel(BaseFeatureModel): """OnOff model using factory patterns""" @@ -555,8 +756,8 @@ class OnOffModel(BaseFeatureModel): def __init__( self, model: FlowSystemModel, - on_off_parameters: OnOffParameters, label_of_element: str, + on_off_parameters: OnOffParameters, defining_variables: List[linopy.Variable], defining_bounds: List[Tuple[TemporalData, TemporalData]], previous_values: List[Optional[TemporalData]], @@ -568,17 +769,8 @@ def __init__( self._defining_bounds = defining_bounds self._previous_values = previous_values - # All variables set by factory - self.on: Optional[linopy.Variable] = None - self.off: Optional[linopy.Variable] = None - self.total_on_hours: Optional[linopy.Variable] = None - self.switch_on: Optional[linopy.Variable] = None - self.switch_off: Optional[linopy.Variable] = None - self.consecutive_on_hours: Optional[linopy.Variable] = None - self.consecutive_off_hours: Optional[linopy.Variable] = None - def create_variables_and_constraints(self): - # Use enhanced factory pattern + # Use factory patterns variables, constraints = ModelingPatterns.operational_binary_control_pattern( model=self._model, name=self.label_full, @@ -600,35 +792,97 @@ def create_variables_and_constraints(self): previous_off_duration=self._get_previous_off_duration(), ) - # Register all variables - self.on = self.add(variables['on'], 'on') + # Register all variables (stored in Model's variable tracking) + self.add(variables['on'], 'on') if 'off' in variables: - self.off = self.add(variables['off'], 'off') + self.add(variables['off'], 'off') if 'total_duration' in variables: - self.total_on_hours = self.add(variables['total_duration'], 'total_duration') + self.add(variables['total_duration'], 'total_duration') if 'switch_on' in variables: - self.switch_on = self.add(variables['switch_on'], 'switch_on') - self.switch_off = self.add(variables['switch_off'], 'switch_off') + self.add(variables['switch_on'], 'switch_on') + self.add(variables['switch_off'], 'switch_off') if 'consecutive_on_duration' in variables: - self.consecutive_on_hours = self.add(variables['consecutive_on_duration'], 'consecutive_on_hours') + self.add(variables['consecutive_on_duration'], 'consecutive_on_hours') if 'consecutive_off_duration' in variables: - self.consecutive_off_hours = self.add(variables['consecutive_off_duration'], 'consecutive_off_hours') + self.add(variables['consecutive_off_duration'], 'consecutive_off_hours') # Register all constraints for constraint_name, constraint in constraints.items(): self.add(constraint, constraint_name) + # Properties access variables from Model's tracking system + @property + def on(self) -> Optional[linopy.Variable]: + """Binary on state variable""" + return self.get_variable_by_short_name('on') + + @property + def off(self) -> Optional[linopy.Variable]: + """Binary off state variable""" + return self.get_variable_by_short_name('off') + + @property + def total_on_hours(self) -> Optional[linopy.Variable]: + """Total on hours variable""" + return self.get_variable_by_short_name('total_duration') + + @property + def switch_on(self) -> Optional[linopy.Variable]: + """Switch on variable""" + return self.get_variable_by_short_name('switch_on') + + @property + def switch_off(self) -> Optional[linopy.Variable]: + """Switch off variable""" + return self.get_variable_by_short_name('switch_off') + + @property + def switch_on_nr(self) -> Optional[linopy.Variable]: + """Number of switch-ons variable""" + # This could be added to factory if needed + return None + + @property + def consecutive_on_hours(self) -> Optional[linopy.Variable]: + """Consecutive on hours variable""" + return self.get_variable_by_short_name('consecutive_on_hours') + + @property + def consecutive_off_hours(self) -> Optional[linopy.Variable]: + """Consecutive off hours variable""" + return self.get_variable_by_short_name('consecutive_off_hours') + + def add_effects(self): + """Add operational effects""" + if self.parameters.effects_per_running_hour: + self._model.effects.add_share_to_effects( + name=self.label_of_element, + expressions={ + effect: self.on * factor * self._model.hours_per_step + for effect, factor in self.parameters.effects_per_running_hour.items() + }, + target='operation', + ) + + if self.parameters.effects_per_switch_on and self.switch_on: + self._model.effects.add_share_to_effects( + name=self.label_of_element, + expressions={ + effect: self.switch_on * factor for effect, factor in self.parameters.effects_per_switch_on.items() + }, + target='operation', + ) + def _get_previous_on_duration(self): - """Calculate previous consecutive on duration""" - # Implementation based on _previous_values - return 0 # Placeholder + hours_per_step = self._model.hours_per_step.isel(time=0).values.flatten()[0] + return ModelingUtilities.compute_previous_on_duration(self._previous_values, hours_per_step) def _get_previous_off_duration(self): - """Calculate previous consecutive off duration""" - # Implementation based on _previous_values - return 0 # Placeholder + hours_per_step = self._model.hours_per_step.isel(time=0).values.flatten()[0] + return ModelingUtilities.compute_previous_off_duration(self._previous_values, hours_per_step) - # Remove the old placeholder methods - no longer needed! + def _get_previous_state(self): + return ModelingUtilities.get_most_recent_state(self._previous_values) class StateModel(Model): From ff70674ac7425f7b3a1ecb6e0ae2cac8599332cf Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 18 Jul 2025 11:18:00 +0200 Subject: [PATCH 199/336] Improve acess to variables via short names --- flixopt/structure.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/flixopt/structure.py b/flixopt/structure.py index fed5bed94..5a4b016ce 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -739,10 +739,10 @@ def add( # TODO: Check uniquenes of short names if isinstance(item, linopy.Variable): self._variables_direct.append(item.name) - self._variables_short[item.name] = short_name or item.name + self._variables_short[short_name] = item.name elif isinstance(item, linopy.Constraint): self._constraints_direct.append(item.name) - self._constraints_short[item.name] = short_name or item.name + self._constraints_short[short_name] = item.name elif isinstance(item, Model): self.sub_models.append(item) self._sub_models_short[item.label_full] = short_name or item.label_full @@ -830,6 +830,18 @@ def constraints(self) -> linopy.Constraints: def all_sub_models(self) -> List['Model']: return [model for sub_model in self.sub_models for model in [sub_model] + sub_model.all_sub_models] + def get_variable_by_short_name(self, short_name: str, default_return = None) -> Optional[linopy.Variable]: + """Get variable by short name""" + if short_name not in self._variables_short: + return default_return + return self._model.variables[self._variables_short.get(short_name)] + + def get_constraint_by_short_name(self, short_name: str, default_return = None) -> Optional[linopy.Constraint]: + """Get variable by short name""" + if short_name not in self._constraints_short: + return default_return + return self._model.constraints[self._constraints_short.get(short_name)] + class BaseFeatureModel(Model): """Minimal base class for feature models that use factory patterns""" From fa5e30a11e11d6a564228ce6a1408b2613d6aed5 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 18 Jul 2025 11:52:35 +0200 Subject: [PATCH 200/336] Improve --- flixopt/effects.py | 6 +- flixopt/elements.py | 96 ++-- flixopt/features.py | 1193 +++--------------------------------------- flixopt/modeling.py | 636 ++++++++++++++++++++++ flixopt/structure.py | 31 +- 5 files changed, 777 insertions(+), 1185 deletions(-) create mode 100644 flixopt/modeling.py diff --git a/flixopt/effects.py b/flixopt/effects.py index 23943d16b..2fc2aae37 100644 --- a/flixopt/effects.py +++ b/flixopt/effects.py @@ -147,8 +147,7 @@ def __init__(self, model: FlowSystemModel, element: Effect): model=self._model, dims=('year', 'scenario'), label_of_element=self.label_of_element, - label='invest', - label_full=f'{self.label_full}(invest)', + label_of_model=f'{self.label_of_model}(invest)', total_max=self.element.maximum_invest, total_min=self.element.minimum_invest, ) @@ -159,8 +158,7 @@ def __init__(self, model: FlowSystemModel, element: Effect): model=self._model, dims=('time', 'year', 'scenario'), label_of_element=self.label_of_element, - label='operation', - label_full=f'{self.label_full}(operation)', + label_of_model=f'{self.label_of_model}|(operation)', total_max=self.element.maximum_operation, total_min=self.element.minimum_operation, min_per_hour=self.element.minimum_operation_per_hour diff --git a/flixopt/elements.py b/flixopt/elements.py index a546b5e9c..43907b07a 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -12,7 +12,7 @@ from .config import CONFIG from .core import PlausibilityError, Scalar, TemporalData, TemporalDataUser from .effects import TemporalEffectsUser -from .features import InvestmentModel, OnOffModel, PreventSimultaneousUsageModel +from .features import InvestmentModel, OnOffModel, PreventSimultaneousUsageModel, ModelingPatterns, ModelingPrimitives from .interface import InvestParameters, OnOffParameters from .structure import Element, ElementModel, FlowSystemModel, register_class_for_io @@ -311,15 +311,14 @@ class FlowModel(ElementModel): def __init__(self, model: FlowSystemModel, element: Flow): super().__init__(model, element) self.element: Flow = element - self.flow_rate: Optional[linopy.Variable] = None - self.total_flow_hours: Optional[linopy.Variable] = None + # Feature models (set by do_modeling) self.on_off: Optional[OnOffModel] = None self._investment: Optional[InvestmentModel] = None def do_modeling(self): - # eq relative_minimum(t) * size <= flow_rate(t) <= relative_maximum(t) * size - self.flow_rate: linopy.Variable = self.add( + # Main flow rate variable + self.add( self._model.add_variables( lower=self.flow_rate_lower_bound, upper=self.flow_rate_upper_bound, @@ -329,7 +328,7 @@ def do_modeling(self): 'flow_rate', ) - # OnOff + # OnOff feature if self.element.on_off_parameters is not None: self.on_off: OnOffModel = self.add( OnOffModel( @@ -344,46 +343,57 @@ def do_modeling(self): ) self.on_off.do_modeling() - # Investment + # Investment feature if isinstance(self.element.size, InvestParameters): self._investment: InvestmentModel = self.add( InvestmentModel( model=self._model, label_of_element=self.label_of_element, + label_of_model=self.label_of_element, parameters=self.element.size, defining_variable=self.flow_rate, - relative_bounds_of_defining_variable=(self.flow_rate_lower_bound_relative, - self.flow_rate_upper_bound_relative), + relative_bounds_of_defining_variable=( + self.flow_rate_lower_bound_relative, + self.flow_rate_upper_bound_relative, + ), on_variable=self.on_off.on if self.on_off is not None else None, ), 'investment', ) self._investment.do_modeling() - self.total_flow_hours = self.add( - self._model.add_variables( - lower=self.element.flow_hours_total_min if self.element.flow_hours_total_min is not None else 0, - upper=self.element.flow_hours_total_max if self.element.flow_hours_total_max is not None else np.inf, - coords=self._model.get_coords(['year', 'scenario']), - name=f'{self.label_full}|total_flow_hours', + # Total flow hours tracking (could use factory pattern) + variables, constraints = ModelingPrimitives.expression_tracking_variable( + model=self._model, + name=f'{self.label_full}|total_flow_hours', + tracked_expression=(self.flow_rate * self._model.hours_per_step).sum('time'), + bounds=( + self.element.flow_hours_total_min if self.element.flow_hours_total_min is not None else 0, + self.element.flow_hours_total_max if self.element.flow_hours_total_max is not None else None, ), - 'total_flow_hours', + coords=['year', 'scenario'], ) - self.add( - self._model.add_constraints( - self.total_flow_hours == (self.flow_rate * self._model.hours_per_step).sum('time'), - name=f'{self.label_full}|total_flow_hours', - ), - 'total_flow_hours', - ) + self.add(variables['tracker'], 'total_flow_hours') + self.add(constraints['tracking'], 'total_flow_hours_tracking') - # Load factor + # Load factor constraints self._create_bounds_for_load_factor() - # Shares + # Effects self._create_shares() + # Properties for clean access to variables + @property + def flow_rate(self) -> Optional[linopy.Variable]: + """Main flow rate variable""" + return self.get_variable_by_short_name('flow_rate') + + @property + def total_flow_hours(self) -> Optional[linopy.Variable]: + """Total flow hours variable""" + return self.get_variable_by_short_name('total_flow_hours') + def results_structure(self): return { **super().results_structure(), @@ -393,10 +403,10 @@ def results_structure(self): } def _create_shares(self): - # Arbeitskosten: - if self.element.effects_per_flow_hour != {}: + # Effects per flow hour + if self.element.effects_per_flow_hour: self._model.effects.add_share_to_effects( - name=self.label_full, # Use the full label of the element + name=self.label_full, expressions={ effect: self.flow_rate * self._model.hours_per_step * factor for effect, factor in self.element.effects_per_flow_hour.items() @@ -405,39 +415,35 @@ def _create_shares(self): ) def _create_bounds_for_load_factor(self): - # TODO: Add Variable load_factor for better evaluation? + """Create load factor constraints using current approach""" + # Get the size (either from element or investment) + size = self.element.size if self._investment is None else self._investment.size - # eq: var_sumFlowHours <= size * dt_tot * load_factor_max + # Maximum load factor constraint if self.element.load_factor_max is not None: - name_short = 'load_factor_max' flow_hours_per_size_max = self._model.hours_per_step.sum('time') * self.element.load_factor_max - size = self.element.size if self._investment is None else self._investment.size - self.add( self._model.add_constraints( self.total_flow_hours <= size * flow_hours_per_size_max, - name=f'{self.label_full}|{name_short}', + name=f'{self.label_full}|load_factor_max', ), - name_short, + 'load_factor_max', ) - # eq: size * sum(dt)* load_factor_min <= var_sumFlowHours + # Minimum load factor constraint if self.element.load_factor_min is not None: - name_short = 'load_factor_min' flow_hours_per_size_min = self._model.hours_per_step.sum('time') * self.element.load_factor_min - size = self.element.size if self._investment is None else self._investment.size - self.add( self._model.add_constraints( self.total_flow_hours >= size * flow_hours_per_size_min, - name=f'{self.label_full}|{name_short}', + name=f'{self.label_full}|load_factor_min', ), - name_short, + 'load_factor_min', ) @property def flow_rate_bounds_on(self) -> Tuple[TemporalData, TemporalData]: - """Returns absolute flow rate bounds. Important for OnOffModel""" + """Returns absolute flow rate bounds for OnOffModel""" relative_minimum, relative_maximum = self.flow_rate_lower_bound_relative, self.flow_rate_upper_bound_relative size = self.element.size if not isinstance(size, InvestParameters): @@ -458,7 +464,7 @@ def flow_rate_lower_bound_relative(self) -> TemporalData: @property def flow_rate_upper_bound_relative(self) -> TemporalData: - """ Returns the upper bound of the flow_rate relative to its size""" + """Returns the upper bound of the flow_rate relative to its size""" fixed_profile = self.element.fixed_relative_profile if fixed_profile is None: return self.element.relative_maximum @@ -566,8 +572,8 @@ def do_modeling(self): self.on_off = self.add( OnOffModel( self._model, - self.element.on_off_parameters, - self.label_of_element, + on_off_parameters=self.element.on_off_parameters, + label_of_element=self.label_of_element, defining_variables=[flow.model.flow_rate for flow in all_flows], defining_bounds=[flow.model.flow_rate_bounds_on for flow in all_flows], previous_values=[flow.previous_flow_rate for flow in all_flows], diff --git a/flixopt/features.py b/flixopt/features.py index 3986f7b49..e08d94cb1 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -11,637 +11,13 @@ from .config import CONFIG from .core import NonTemporalData, Scalar, TemporalData, FlowSystemDimensions -from .interface import InvestParameters, OnOffParameters, Piecewise +from .interface import InvestParameters, OnOffParameters, Piecewise, PiecewiseEffects from .structure import Model, FlowSystemModel, BaseFeatureModel +from .modeling import ModelingPatterns, ModelingUtilities, ModelingPrimitives logger = logging.getLogger('flixopt') -class ModelingUtilities: - """Utility functions for modeling calculations - used across different classes""" - - @staticmethod - def compute_consecutive_hours_in_state( - binary_values: TemporalData, hours_per_timestep: Union[int, float, np.ndarray] - ) -> Scalar: - """ - Computes the final consecutive duration in state 'on' (=1) in hours, from a binary array. - - Args: - binary_values: An int or 1D binary array containing only `0`s and `1`s. - hours_per_timestep: The duration of each timestep in hours. - If a scalar is provided, it is used for all timesteps. - If an array is provided, it must be as long as the last consecutive duration in binary_values. - - Returns: - The duration of the binary variable in hours. - - Raises - ------ - TypeError - If the length of binary_values and dt_in_hours is not equal, but None is a scalar. - """ - if np.isscalar(binary_values) and np.isscalar(hours_per_timestep): - return binary_values * hours_per_timestep - elif np.isscalar(binary_values) and not np.isscalar(hours_per_timestep): - return binary_values * hours_per_timestep[-1] - - if np.isclose(binary_values[-1], 0, atol=CONFIG.modeling.EPSILON): - return 0 - - if np.isscalar(hours_per_timestep): - hours_per_timestep = np.ones(len(binary_values)) * hours_per_timestep - hours_per_timestep: np.ndarray - - indexes_with_zero_values = np.where(np.isclose(binary_values, 0, atol=CONFIG.modeling.EPSILON))[0] - if len(indexes_with_zero_values) == 0: - nr_of_indexes_with_consecutive_ones = len(binary_values) - else: - nr_of_indexes_with_consecutive_ones = len(binary_values) - indexes_with_zero_values[-1] - 1 - - if len(hours_per_timestep) < nr_of_indexes_with_consecutive_ones: - raise ValueError( - f'When trying to calculate the consecutive duration, the length of the last duration ' - f'({nr_of_indexes_with_consecutive_ones}) is longer than the provided hours_per_timestep ({len(hours_per_timestep)}), ' - f'as {binary_values=}' - ) - - return np.sum( - binary_values[-nr_of_indexes_with_consecutive_ones:] - * hours_per_timestep[-nr_of_indexes_with_consecutive_ones:] - ) - - @staticmethod - def compute_previous_states(previous_values: List[TemporalData], epsilon: float = None) -> np.ndarray: - """ - Computes the previous states {0, 1} of defining variables as a binary array from their previous values. - - Args: - previous_values: List of previous values for variables - epsilon: Tolerance for zero detection (uses CONFIG.modeling.EPSILON if None) - - Returns: - Binary array of previous states - """ - if epsilon is None: - epsilon = CONFIG.modeling.EPSILON - - if not previous_values or all(val is None for val in previous_values): - return np.array([0]) - - # Convert to 2D-array and compute binary on/off states - previous_values = np.array([values for values in previous_values if values is not None]) # Filter out None - if previous_values.ndim > 1: - return np.any(~np.isclose(previous_values, 0, atol=epsilon), axis=0).astype(int) - - return (~np.isclose(previous_values, 0, atol=epsilon)).astype(int) - - @staticmethod - def compute_previous_on_duration(previous_values: List[TemporalData], hours_per_step: Union[int, float]) -> Scalar: - """ - Convenience method to compute previous consecutive 'on' duration. - - Args: - previous_values: List of previous values for variables - hours_per_step: Duration of each timestep in hours - - Returns: - Previous consecutive on duration in hours - """ - if not previous_values: - return 0 - - previous_states = ModelingUtilities.compute_previous_states(previous_values) - return ModelingUtilities.compute_consecutive_hours_in_state(previous_states, hours_per_step) - - @staticmethod - def compute_previous_off_duration(previous_values: List[TemporalData], hours_per_step: Union[int, float]) -> Scalar: - """ - Convenience method to compute previous consecutive 'off' duration. - - Args: - previous_values: List of previous values for variables - hours_per_step: Duration of each timestep in hours - - Returns: - Previous consecutive off duration in hours - """ - if not previous_values: - return 0 - - previous_states = ModelingUtilities.compute_previous_states(previous_values) - previous_off_states = 1 - previous_states - return ModelingUtilities.compute_consecutive_hours_in_state(previous_off_states, hours_per_step) - - @staticmethod - def get_most_recent_state(previous_values: List[TemporalData]) -> int: - """ - Get the most recent binary state from previous values. - - Args: - previous_values: List of previous values for variables - - Returns: - Most recent binary state (0 or 1) - """ - if not previous_values: - return 0 - - previous_states = ModelingUtilities.compute_previous_states(previous_values) - return int(previous_states[-1]) - - -class ModelingPrimitives: - """Mathematical modeling primitives returning (variables, constraints) tuples""" - - @staticmethod - def binary_state_pair( - model: FlowSystemModel, name: str, coords: List[str] = None - ) -> Tuple[Dict[str, linopy.Variable], Dict[str, linopy.Constraint]]: - """ - Creates complementary binary variables with completeness constraint. - - Mathematical formulation: - on[t] + off[t] = 1 ∀t - on[t], off[t] ∈ {0, 1} - - Returns: - variables: {'on': binary_var, 'off': binary_var} - constraints: {'complementary': constraint} - """ - coords = coords or ['time'] - - on = model.add_variables(binary=True, name=f'{name}|on', coords=model.get_coords(coords)) - off = model.add_variables(binary=True, name=f'{name}|off', coords=model.get_coords(coords)) - - # Constraint: on + off = 1 - complementary = model.add_constraints(on + off == 1, name=f'{name}|complementary') - - variables = {'on': on, 'off': off} - constraints = {'complementary': complementary} - - return variables, constraints - - @staticmethod - def proportionally_bounded_variable( - model: FlowSystemModel, - name: str, - controlling_variable, - bounds: Tuple[TemporalData, TemporalData], - coords: List[str] = None, - ) -> Tuple[Dict[str, linopy.Variable], Dict[str, linopy.Constraint]]: - """ - Creates variable with bounds proportional to another variable. - - Mathematical formulation: - lower_factor[t] * controller[t] ≤ variable[t] ≤ upper_factor[t] * controller[t] ∀t - - Returns: - variables: {'variable': bounded_var} - constraints: {'lower_bound': constraint, 'upper_bound': constraint} - """ - coords = coords or ['time'] - variable = model.add_variables(name=f'{name}|bounded', coords=model.get_coords(coords)) - - lower_factor, upper_factor = bounds - - # Constraints: lower_factor * controller ≤ var ≤ upper_factor * controller - lower_bound = model.add_constraints( - variable >= controlling_variable * lower_factor, name=f'{name}|proportional_lb' - ) - upper_bound = model.add_constraints( - variable <= controlling_variable * upper_factor, name=f'{name}|proportional_ub' - ) - - variables = {'variable': variable} - constraints = {'lower_bound': lower_bound, 'upper_bound': upper_bound} - - return variables, constraints - - @staticmethod - def expression_tracking_variable( - model: FlowSystemModel, - name: str, - tracked_expression, - bounds: Tuple[TemporalData, TemporalData] = None, - coords: List[str] = None, - ) -> Tuple[Dict[str, linopy.Variable], Dict[str, linopy.Constraint]]: - """ - Creates variable that equals a given expression. - - Mathematical formulation: - tracker = expression - lower ≤ tracker ≤ upper (if bounds provided) - - Returns: - variables: {'tracker': tracker_var} - constraints: {'tracking': constraint} - """ - coords = coords or ['year', 'scenario'] - - if not bounds: - tracker = model.add_variables(name=f'{name}|tracker', coords=model.get_coords(coords)) - else: - tracker = model.add_variables( - lower=bounds[0] if bounds[0] is not None else -np.inf, - upper=bounds[1] if bounds[1] is not None else np.inf, - name=f'{name}|tracker', - coords=model.get_coords(coords), - ) - - # Constraint: tracker = expression - tracking = model.add_constraints(tracker == tracked_expression, name=f'{name}|tracking_eq') - - variables = {'tracker': tracker} - constraints = {'tracking': tracking} - - return variables, constraints - - @staticmethod - def state_transition_variables( - model: FlowSystemModel, name: str, state_variable, previous_state=0 - ) -> Tuple[Dict[str, linopy.Variable], Dict[str, linopy.Constraint]]: - """ - Creates switch-on/off variables with state transition logic. - - Mathematical formulation: - switch_on[t] - switch_off[t] = state[t] - state[t-1] ∀t > 0 - switch_on[0] - switch_off[0] = state[0] - previous_state - switch_on[t] + switch_off[t] ≤ 1 ∀t - switch_on[t], switch_off[t] ∈ {0, 1} - - Returns: - variables: {'switch_on': binary_var, 'switch_off': binary_var} - constraints: {'transition': constraint, 'initial': constraint, 'mutex': constraint} - """ - switch_on = model.add_variables(binary=True, name=f'{name}|switch_on', coords=model.get_coords(['time'])) - switch_off = model.add_variables(binary=True, name=f'{name}|switch_off', coords=model.get_coords(['time'])) - - # State transition constraints for t > 0 - transition = model.add_constraints( - switch_on.isel(time=slice(1, None)) - switch_off.isel(time=slice(1, None)) - == state_variable.isel(time=slice(1, None)) - state_variable.isel(time=slice(None, -1)), - name=f'{name}|state_transition', - ) - - # Initial state transition for t = 0 - initial = model.add_constraints( - switch_on.isel(time=0) - switch_off.isel(time=0) == state_variable.isel(time=0) - previous_state, - name=f'{name}|initial_transition', - ) - - # At most one switch per timestep - mutex = model.add_constraints(switch_on + switch_off <= 1, name=f'{name}|switch_mutex') - - variables = {'switch_on': switch_on, 'switch_off': switch_off} - constraints = {'transition': transition, 'initial': initial, 'mutex': mutex} - - return variables, constraints - - @staticmethod - def big_m_binary_bounds( - model: FlowSystemModel, - name: str, - variable, - binary_control, - size_variable, - relative_bounds: Tuple[TemporalData, TemporalData], - ) -> Tuple[Dict, Dict[str, linopy.Constraint]]: - """ - Creates bounds controlled by both binary and continuous variables. - - Mathematical formulation: - variable[t] ≤ size[t] * upper_factor[t] ∀t - - If binary_control provided: - variable[t] ≥ M * (binary[t] - 1) + size[t] * lower_factor[t] ∀t - where M = max(size) * max(upper_factor) - Else: - variable[t] ≥ size[t] * lower_factor[t] ∀t - - Returns: - variables: {} (no new variables created) - constraints: {'upper_bound': constraint, 'lower_bound': constraint} - """ - rel_lower, rel_upper = relative_bounds - - # Upper bound: variable ≤ size * upper_factor - upper_bound = model.add_constraints(variable <= size_variable * rel_upper, name=f'{name}|size_upper_bound') - - if binary_control is not None: - # Big-M lower bound: variable ≥ M*(binary-1) + size*lower_factor - big_m = size_variable.max() * rel_upper.max() # Conservative big-M - lower_bound = model.add_constraints( - variable >= big_m * (binary_control - 1) + size_variable * rel_lower, - name=f'{name}|binary_controlled_lower_bound', - ) - else: - # Simple lower bound: variable ≥ size * lower_factor - lower_bound = model.add_constraints(variable >= size_variable * rel_lower, name=f'{name}|size_lower_bound') - - variables = {} # No new variables created - constraints = {'upper_bound': upper_bound, 'lower_bound': lower_bound} - - return variables, constraints - - @staticmethod - def consecutive_duration_tracking( - model: FlowSystemModel, - name: str, - state_variable: linopy.Variable, - minimum_duration: Optional[TemporalData] = None, - maximum_duration: Optional[TemporalData] = None, - previous_duration: TemporalData = 0, - ) -> Tuple[Dict[str, linopy.Variable], Dict[str, linopy.Constraint]]: - """ - Creates consecutive duration tracking for a binary state variable. - - Mathematical formulation: - duration[t] ≤ state[t] * M ∀t - duration[t+1] ≤ duration[t] + hours_per_step[t] ∀t - duration[t+1] ≥ duration[t] + hours_per_step[t] + (state[t+1] - 1) * M ∀t - duration[0] = (hours_per_step[0] + previous_duration) * state[0] - - If minimum_duration provided: - duration[t] ≥ (state[t-1] - state[t]) * minimum_duration[t-1] ∀t > 0 - - Args: - state_variable: Binary state variable to track duration for - minimum_duration: Optional minimum consecutive duration - maximum_duration: Optional maximum consecutive duration - previous_duration: Duration from before first timestep - - Returns: - variables: {'duration': duration_var} - constraints: {'upper_bound': constraint, 'forward': constraint, 'backward': constraint, ...} - """ - hours_per_step = model.hours_per_step - mega = hours_per_step.sum('time') + previous_duration # Big-M value - - # Duration variable - duration = model.add_variables( - lower=0, - upper=maximum_duration if maximum_duration is not None else mega, - coords=model.get_coords(['time']), - name=f'{name}|duration', - ) - - constraints = {} - - # Upper bound: duration[t] ≤ state[t] * M - constraints['upper_bound'] = model.add_constraints( - duration <= state_variable * mega, name=f'{name}|duration_upper_bound' - ) - - # Forward constraint: duration[t+1] ≤ duration[t] + hours_per_step[t] - constraints['forward'] = model.add_constraints( - duration.isel(time=slice(1, None)) - <= duration.isel(time=slice(None, -1)) + hours_per_step.isel(time=slice(None, -1)), - name=f'{name}|duration_forward', - ) - - # Backward constraint: duration[t+1] ≥ duration[t] + hours_per_step[t] + (state[t+1] - 1) * M - constraints['backward'] = model.add_constraints( - duration.isel(time=slice(1, None)) - >= duration.isel(time=slice(None, -1)) - + hours_per_step.isel(time=slice(None, -1)) - + (state_variable.isel(time=slice(1, None)) - 1) * mega, - name=f'{name}|duration_backward', - ) - - # Initial condition: duration[0] = (hours_per_step[0] + previous_duration) * state[0] - constraints['initial'] = model.add_constraints( - duration.isel(time=0) - == (hours_per_step.isel(time=0) + previous_duration) * state_variable.isel(time=0), - name=f'{name}|duration_initial', - ) - - # Minimum duration constraint if provided - if minimum_duration is not None: - constraints['minimum'] = model.add_constraints( - duration.isel(time=slice(1, None)) - >= (state_variable.isel(time=slice(None, -1)) - state_variable.isel(time=slice(1, None))) - * minimum_duration.isel(time=slice(None, -1)), - name=f'{name}|duration_minimum', - ) - - # Handle initial condition for minimum duration - if previous_duration > 0 and previous_duration < minimum_duration.isel(time=0).max(): - constraints['initial_minimum'] = model.add_constraints( - state_variable.isel(time=0) == 1, name=f'{name}|duration_initial_minimum' - ) - - variables = {'duration': duration} - - return variables, constraints - - @staticmethod - def mutual_exclusivity_constraint( - model: FlowSystemModel, name: str, binary_variables: List[linopy.Variable], tolerance: float = 1.1 - ) -> Tuple[Dict, Dict[str, linopy.Constraint]]: - """ - Creates mutual exclusivity constraint for binary variables. - - Mathematical formulation: - Σ(binary_vars[i]) ≤ tolerance ∀t - - Ensures at most one binary variable can be 1 at any time. - Tolerance > 1.0 accounts for binary variable numerical precision. - - Args: - binary_variables: List of binary variables that should be mutually exclusive - tolerance: Upper bound (typically 1.1 for numerical stability) - - Returns: - variables: {} (no new variables created) - constraints: {'mutual_exclusivity': constraint} - - Raises: - AssertionError: If fewer than 2 variables provided or variables aren't binary - """ - assert len(binary_variables) >= 2, ( - f'Mutual exclusivity requires at least 2 variables, got {len(binary_variables)}' - ) - - for var in binary_variables: - assert var.attrs.get('binary', False), ( - f'Variable {var.name} must be binary for mutual exclusivity constraint' - ) - - # Create mutual exclusivity constraint - mutual_exclusivity = model.add_constraints( - sum(binary_variables) <= tolerance, name=f'{name}|mutual_exclusivity' - ) - - variables = {} # No new variables created - constraints = {'mutual_exclusivity': mutual_exclusivity} - - return variables, constraints - - -class ModelingPatterns: - """High-level patterns that compose primitives and return (variables, constraints) tuples""" - - @staticmethod - def investment_sizing_pattern( - model: FlowSystemModel, - name: str, - size_bounds: Tuple[TemporalData, TemporalData], - controlled_variables: List[linopy.Variable] = None, - control_factors: List[Tuple[TemporalData, TemporalData]] = None, - optional: bool = False, - ) -> Tuple[Dict[str, linopy.Variable], Dict[str, linopy.Constraint]]: - """ - Complete investment sizing pattern with optional binary decision. - - Returns: - variables: {'size': size_var, 'is_invested': binary_var (if optional)} - constraints: {'investment_upper_bound': constraint, 'investment_lower_bound': constraint, ...} - """ - variables = {} - constraints = {} - - # Investment size variable - size_min, size_max = size_bounds - variables['size'] = model.add_variables( - lower=size_min, - upper=size_max, - name=f'{name}|investment_size', - coords=model.get_coords(['year', 'scenario']), - ) - - # Optional binary investment decision - if optional: - variables['is_invested'] = model.add_variables( - binary=True, name=f'{name}|is_invested', coords=model.get_coords(['year', 'scenario']) - ) - - # Link size to investment decision - if abs(size_min - size_max) < 1e-10: # Fixed size case - constraints['fixed_investment_size'] = model.add_constraints( - variables['size'] == variables['is_invested'] * size_max, name=f'{name}|fixed_investment_size' - ) - else: # Variable size case - constraints['investment_upper_bound'] = model.add_constraints( - variables['size'] <= variables['is_invested'] * size_max, name=f'{name}|investment_upper_bound' - ) - constraints['investment_lower_bound'] = model.add_constraints( - variables['size'] >= variables['is_invested'] * max(CONFIG.modeling.EPSILON, size_min), - name=f'{name}|investment_lower_bound', - ) - - # Control dependent variables - if controlled_variables and control_factors: - for i, (var, factors) in enumerate(zip(controlled_variables, control_factors)): - _, control_constraints = ModelingPrimitives.big_m_binary_bounds( - model, f'{name}|control_{i}', var, variables.get('is_invested'), variables['size'], factors - ) - # Flatten control constraints with indexed names - constraints[f'control_{i}_upper_bound'] = control_constraints['upper_bound'] - constraints[f'control_{i}_lower_bound'] = control_constraints['lower_bound'] - - return variables, constraints - - @staticmethod - def operational_binary_control_pattern( - model: FlowSystemModel, - name: str, - controlled_variables: List[linopy.Variable], - variable_bounds: List[Tuple[TemporalData, TemporalData]], - use_complement: bool = False, - track_total_duration: bool = False, - track_switches: bool = False, - previous_state=0, - duration_bounds: Tuple[TemporalData, TemporalData] = None, - track_consecutive_on: bool = False, - consecutive_on_bounds: Tuple[Optional[TemporalData], Optional[TemporalData]] = (None, None), - previous_on_duration: TemporalData = 0, - track_consecutive_off: bool = False, - consecutive_off_bounds: Tuple[Optional[TemporalData], Optional[TemporalData]] = (None, None), - previous_off_duration: TemporalData = 0, - ) -> Tuple[Dict[str, linopy.Variable], Dict[str, linopy.Constraint]]: - """ - Enhanced operational binary control with consecutive duration tracking. - - New Args: - track_consecutive_on: Whether to track consecutive on duration - consecutive_on_bounds: (min_duration, max_duration) for consecutive on - previous_on_duration: Previous consecutive on duration - track_consecutive_off: Whether to track consecutive off duration - consecutive_off_bounds: (min_duration, max_duration) for consecutive off - previous_off_duration: Previous consecutive off duration - """ - variables = {} - constraints = {} - - # Main binary state (existing logic) - if use_complement: - state_vars, state_constraints = ModelingPrimitives.binary_state_pair(model, name) - variables.update(state_vars) - constraints.update(state_constraints) - else: - variables['on'] = model.add_variables(binary=True, name=f'{name}|on', coords=model.get_coords(['time'])) - - # Control variables (existing logic) - for i, (var, (lower_bound, upper_bound)) in enumerate(zip(controlled_variables, variable_bounds)): - constraints[f'control_{i}_lower'] = model.add_constraints( - variables['on'] * np.maximum(lower_bound, CONFIG.modeling.EPSILON) <= var, name=f'{name}|control_{i}_lower' - ) - constraints[f'control_{i}_upper'] = model.add_constraints( - var <= variables['on'] * upper_bound, name=f'{name}|control_{i}_upper' - ) - - # Total duration tracking (existing logic) - if track_total_duration: - duration_expr = (variables['on'] * model.hours_per_step).sum('time') - duration_vars, duration_constraints = ModelingPrimitives.expression_tracking_variable( - model, f'{name}|duration', duration_expr, duration_bounds - ) - variables['total_duration'] = duration_vars['tracker'] - constraints['duration_tracking'] = duration_constraints['tracking'] - - # Switch tracking (existing logic) - if track_switches: - switch_vars, switch_constraints = ModelingPrimitives.state_transition_variables( - model, f'{name}|switches', variables['on'], previous_state - ) - variables.update(switch_vars) - for switch_name, switch_constraint in switch_constraints.items(): - constraints[f'switch_{switch_name}'] = switch_constraint - - # NEW: Consecutive on duration tracking - if track_consecutive_on: - min_on, max_on = consecutive_on_bounds - consecutive_on_vars, consecutive_on_constraints = ModelingPrimitives.consecutive_duration_tracking( - model, - f'{name}|consecutive_on', - variables['on'], - minimum_duration=min_on, - maximum_duration=max_on, - previous_duration=previous_on_duration, - ) - variables['consecutive_on_duration'] = consecutive_on_vars['duration'] - for cons_name, cons_constraint in consecutive_on_constraints.items(): - constraints[f'consecutive_on_{cons_name}'] = cons_constraint - - # NEW: Consecutive off duration tracking - if track_consecutive_off and 'off' in variables: - min_off, max_off = consecutive_off_bounds - consecutive_off_vars, consecutive_off_constraints = ModelingPrimitives.consecutive_duration_tracking( - model, - f'{name}|consecutive_off', - variables['off'], - minimum_duration=min_off, - maximum_duration=max_off, - previous_duration=previous_off_duration, - ) - variables['consecutive_off_duration'] = consecutive_off_vars['duration'] - for cons_name, cons_constraint in consecutive_off_constraints.items(): - constraints[f'consecutive_off_{cons_name}'] = cons_constraint - - return variables, constraints - - class InvestmentModel(BaseFeatureModel): """Investment model using factory patterns but keeping old interface""" @@ -652,10 +28,10 @@ def __init__( parameters: InvestParameters, defining_variable: linopy.Variable, relative_bounds_of_defining_variable: Tuple[TemporalData, TemporalData], - label: Optional[str] = None, + label_of_model: Optional[str] = None, on_variable: Optional[linopy.Variable] = None, ): - super().__init__(model, label_of_element, parameters, label) + super().__init__(model, label_of_element=label_of_element, parameters=parameters, label_of_model=label_of_model) self._defining_variable = defining_variable self._relative_bounds_of_defining_variable = relative_bounds_of_defining_variable @@ -885,430 +261,6 @@ def _get_previous_state(self): return ModelingUtilities.get_most_recent_state(self._previous_values) -class StateModel(Model): - """ - Handles basic on/off binary states for defining variables - """ - - def __init__( - self, - model: FlowSystemModel, - label_of_element: str, - defining_variables: List[linopy.Variable], - defining_bounds: List[Tuple[TemporalData, TemporalData]], - previous_values: List[Optional[TemporalData]] = None, - use_off: bool = True, - on_hours_total_min: Optional[TemporalData] = 0, - on_hours_total_max: Optional[TemporalData] = None, - effects_per_running_hour: Dict[str, TemporalData] = None, - label: Optional[str] = None, - ): - """ - Models binary state variables based on a continous variable. - - Args: - model: The FlowSystemModel that is used to create the model. - label_of_element: The label of the parent (Element). Used to construct the full label of the model. - defining_variables: List of Variables that are used to define the state - defining_bounds: List of Tuples, defining the absolute bounds of each defining variable - previous_values: List of previous values of the defining variables - use_off: Whether to use the off state or not - on_hours_total_min: min. overall sum of operating hours. - on_hours_total_max: max. overall sum of operating hours. - effects_per_running_hour: Costs per operating hours - label: Label of the OnOffModel - """ - super().__init__(model, label_of_element, label) - assert len(defining_variables) == len(defining_bounds), 'Every defining Variable needs bounds to Model OnOff' - self._defining_variables = defining_variables - self._defining_bounds = defining_bounds - self._previous_values = previous_values or [] - self._on_hours_total_min = on_hours_total_min if on_hours_total_min is not None else 0 - self._on_hours_total_max = on_hours_total_max if on_hours_total_max is not None else np.inf - self._use_off = use_off - self._effects_per_running_hour = effects_per_running_hour if effects_per_running_hour is not None else {} - - self.on = None - self.total_on_hours: Optional[linopy.Variable] = None - self.off = None - - def do_modeling(self): - self.on = self.add( - self._model.add_variables( - name=f'{self.label_full}|on', - binary=True, - coords=self._model.get_coords(), - ), - 'on', - ) - - self.total_on_hours = self.add( - self._model.add_variables( - lower=self._on_hours_total_min, - upper=self._on_hours_total_max, - coords=self._model.get_coords(['year', 'scenario']), - name=f'{self.label_full}|on_hours_total', - ), - 'on_hours_total', - ) - - self.add( - self._model.add_constraints( - self.total_on_hours == (self.on * self._model.hours_per_step).sum('time'), - name=f'{self.label_full}|on_hours_total', - ), - 'on_hours_total', - ) - - # Add defining constraints for each variable - self._add_defining_constraints() - - if self._use_off: - self.off = self.add( - self._model.add_variables( - name=f'{self.label_full}|off', - binary=True, - coords=self._model.get_coords(), - ), - 'off', - ) - - # Constraint: on + off = 1 - self.add(self._model.add_constraints(self.on + self.off == 1, name=f'{self.label_full}|off'), 'off') - - return self - - def _add_defining_constraints(self): - """Add constraints that link defining variables to the on state""" - nr_of_def_vars = len(self._defining_variables) - - if nr_of_def_vars == 1: - # Case for a single defining variable - def_var = self._defining_variables[0] - lb, ub = self._defining_bounds[0] - - # Constraint: on * lower_bound <= def_var - self.add( - self._model.add_constraints( - self.on * np.maximum(CONFIG.modeling.EPSILON, lb) <= def_var, name=f'{self.label_full}|on_con1' - ), - 'on_con1', - ) - - # Constraint: on * upper_bound >= def_var - self.add( - self._model.add_constraints(self.on * ub >= def_var, name=f'{self.label_full}|on_con2'), 'on_con2' - ) - else: - # Case for multiple defining variables - ub = sum(bound[1] for bound in self._defining_bounds) / nr_of_def_vars - lb = CONFIG.modeling.EPSILON #TODO: Can this be a bigger value? (maybe the smallest bound?) - - # Constraint: on * epsilon <= sum(all_defining_variables) - self.add( - self._model.add_constraints( - self.on * lb <= sum(self._defining_variables), name=f'{self.label_full}|on_con1' - ), - 'on_con1', - ) - - # Constraint to ensure all variables are zero when off. - # Divide by nr_of_def_vars to improve numerical stability (smaller factors) - self.add( - self._model.add_constraints( - self.on * ub >= sum([def_var / nr_of_def_vars for def_var in self._defining_variables]), - name=f'{self.label_full}|on_con2', - ), - 'on_con2', - ) - - @property - def previous_states(self) -> np.ndarray: - """Computes the previous states {0, 1} of defining variables as a binary array from their previous values.""" - return StateModel.compute_previous_states(self._previous_values, epsilon=CONFIG.modeling.EPSILON) - - @property - def previous_on_states(self) -> np.ndarray: - return self.previous_states - - @property - def previous_off_states(self): - return 1 - self.previous_states - - @staticmethod - def compute_previous_states(previous_values: List[TemporalData], epsilon: float = 1e-5) -> np.ndarray: - """Computes the previous states {0, 1} of defining variables as a binary array from their previous values.""" - if not previous_values or all([val is None for val in previous_values]): - return np.array([0]) - - # Convert to 2D-array and compute binary on/off states - previous_values = np.array([values for values in previous_values if values is not None]) # Filter out None - if previous_values.ndim > 1: - return np.any(~np.isclose(previous_values, 0, atol=epsilon), axis=0).astype(int) - - return (~np.isclose(previous_values, 0, atol=epsilon)).astype(int) - - -class SwitchStateModel(Model): - """ - Handles switch on/off transitions - """ - - def __init__( - self, - model: FlowSystemModel, - label_of_element: str, - state_variable: linopy.Variable, - previous_state=0, - switch_on_max: Optional[Scalar] = None, - label: Optional[str] = None, - ): - super().__init__(model, label_of_element, label) - self._state_variable = state_variable - self.previous_state = previous_state - self._switch_on_max = switch_on_max if switch_on_max is not None else np.inf - - self.switch_on = None - self.switch_off = None - self.switch_on_nr = None - - def do_modeling(self): - """Create switch variables and constraints""" - - # Create switch variables - self.switch_on = self.add( - self._model.add_variables(binary=True, name=f'{self.label_full}|switch_on', coords=self._model.get_coords()), - 'switch_on', - ) - - self.switch_off = self.add( - self._model.add_variables(binary=True, name=f'{self.label_full}|switch_off', coords=self._model.get_coords()), - 'switch_off', - ) - - # Create count variable for number of switches - self.switch_on_nr = self.add( - self._model.add_variables( - upper=self._switch_on_max, - lower=0, - name=f'{self.label_full}|switch_on_nr', - ), - 'switch_on_nr', - ) - - # Add switch constraints for all entries after the first timestep - self.add( - self._model.add_constraints( - self.switch_on.isel(time=slice(1, None)) - self.switch_off.isel(time=slice(1, None)) - == self._state_variable.isel(time=slice(1, None)) - self._state_variable.isel(time=slice(None, -1)), - name=f'{self.label_full}|switch_con', - ), - 'switch_con', - ) - - # Initial switch constraint - self.add( - self._model.add_constraints( - self.switch_on.isel(time=0) - self.switch_off.isel(time=0) - == self._state_variable.isel(time=0) - self.previous_state, - name=f'{self.label_full}|initial_switch_con', - ), - 'initial_switch_con', - ) - - # Mutual exclusivity constraint - self.add( - self._model.add_constraints(self.switch_on + self.switch_off <= 1.1, name=f'{self.label_full}|switch_on_or_off'), - 'switch_on_or_off', - ) - - # Total switch-on count constraint - self.add( - self._model.add_constraints( - self.switch_on_nr == self.switch_on.sum('time'), name=f'{self.label_full}|switch_on_nr' - ), - 'switch_on_nr', - ) - - return self - - -class ConsecutiveStateModel(Model): - """ - Handles tracking consecutive durations in a state - """ - - def __init__( - self, - model: FlowSystemModel, - label_of_element: str, - state_variable: linopy.Variable, - minimum_duration: Optional[TemporalData] = None, - maximum_duration: Optional[TemporalData] = None, - previous_states: Optional[TemporalData] = None, - label: Optional[str] = None, - ): - """ - Model and constraint the consecutive duration of a state variable. - - Args: - model: The FlowSystemModel that is used to create the model. - label_of_element: The label of the parent (Element). Used to construct the full label of the model. - state_variable: The state variable that is used to model the duration. state = {0, 1} - minimum_duration: The minimum duration of the state variable. - maximum_duration: The maximum duration of the state variable. - previous_states: The previous states of the state variable. - label: The label of the model. Used to construct the full label of the model. - """ - super().__init__(model, label_of_element, label) - self._state_variable = state_variable - self._previous_states = previous_states - self._minimum_duration = minimum_duration - self._maximum_duration = maximum_duration - - self.duration = None - - def do_modeling(self): - """Create consecutive duration variables and constraints""" - # Get the hours per step - hours_per_step = self._model.hours_per_step - mega = hours_per_step.sum('time') + self.previous_duration - - # Create the duration variable - self.duration = self.add( - self._model.add_variables( - lower=0, - upper=self._maximum_duration if self._maximum_duration is not None else mega, - coords=self._model.get_coords(), - name=f'{self.label_full}|hours', - ), - 'hours', - ) - - # Add constraints - - # Upper bound constraint - self.add( - self._model.add_constraints( - self.duration <= self._state_variable * mega, name=f'{self.label_full}|con1' - ), - 'con1', - ) - - # Forward constraint - self.add( - self._model.add_constraints( - self.duration.isel(time=slice(1, None)) - <= self.duration.isel(time=slice(None, -1)) + hours_per_step.isel(time=slice(None, -1)), - name=f'{self.label_full}|con2a', - ), - 'con2a', - ) - - # Backward constraint - self.add( - self._model.add_constraints( - self.duration.isel(time=slice(1, None)) - >= self.duration.isel(time=slice(None, -1)) - + hours_per_step.isel(time=slice(None, -1)) - + (self._state_variable.isel(time=slice(1, None)) - 1) * mega, - name=f'{self.label_full}|con2b', - ), - 'con2b', - ) - - # Add minimum duration constraints if specified - if self._minimum_duration is not None: - self.add( - self._model.add_constraints( - self.duration - >= ( - self._state_variable.isel(time=slice(None, -1)) - self._state_variable.isel(time=slice(1, None)) - ) - * self._minimum_duration.isel(time=slice(None, -1)), - name=f'{self.label_full}|minimum', - ), - 'minimum', - ) - - # Handle initial condition - if 0 < self.previous_duration < self._minimum_duration.isel(time=0).max(): - self.add( - self._model.add_constraints( - self._state_variable.isel(time=0) == 1, name=f'{self.label_full}|initial_minimum' - ), - 'initial_minimum', - ) - - # Set initial value - self.add( - self._model.add_constraints( - self.duration.isel(time=0) == - (hours_per_step.isel(time=0) + self.previous_duration) * self._state_variable.isel(time=0), - name=f'{self.label_full}|initial', - ), - 'initial', - ) - - return self - - @property - def previous_duration(self) -> Scalar: - """Computes the previous duration of the state variable""" - #TODO: Allow for other/dynamic timestep resolutions - return ConsecutiveStateModel.compute_consecutive_hours_in_state( - self._previous_states, self._model.hours_per_step.isel(time=0).values.flatten()[0] - ) - - @staticmethod - def compute_consecutive_hours_in_state( - binary_values: TemporalData, hours_per_timestep: Union[int, float, np.ndarray] - ) -> Scalar: - """ - Computes the final consecutive duration in state 'on' (=1) in hours, from a binary array. - - Args: - binary_values: An int or 1D binary array containing only `0`s and `1`s. - hours_per_timestep: The duration of each timestep in hours. - If a scalar is provided, it is used for all timesteps. - If an array is provided, it must be as long as the last consecutive duration in binary_values. - - Returns: - The duration of the binary variable in hours. - - Raises - ------ - TypeError - If the length of binary_values and dt_in_hours is not equal, but None is a scalar. - """ - if np.isscalar(binary_values) and np.isscalar(hours_per_timestep): - return binary_values * hours_per_timestep - elif np.isscalar(binary_values) and not np.isscalar(hours_per_timestep): - return binary_values * hours_per_timestep[-1] - - if np.isclose(binary_values[-1], 0, atol=CONFIG.modeling.EPSILON): - return 0 - - if np.isscalar(hours_per_timestep): - hours_per_timestep = np.ones(len(binary_values)) * hours_per_timestep - hours_per_timestep: np.ndarray - - indexes_with_zero_values = np.where(np.isclose(binary_values, 0, atol=CONFIG.modeling.EPSILON))[0] - if len(indexes_with_zero_values) == 0: - nr_of_indexes_with_consecutive_ones = len(binary_values) - else: - nr_of_indexes_with_consecutive_ones = len(binary_values) - indexes_with_zero_values[-1] - 1 - - if len(hours_per_timestep) < nr_of_indexes_with_consecutive_ones: - raise ValueError( - f'When trying to calculate the consecutive duration, the length of the last duration ' - f'({len(nr_of_indexes_with_consecutive_ones)}) is longer than the provided hours_per_timestep ({len(hours_per_timestep)}), ' - f'as {binary_values=}' - ) - - return np.sum(binary_values[-nr_of_indexes_with_consecutive_ones:] * hours_per_timestep[-nr_of_indexes_with_consecutive_ones:]) - - class PieceModel(Model): """Class for modeling a linear piece of one or more variables in parallel""" @@ -1316,10 +268,10 @@ def __init__( self, model: FlowSystemModel, label_of_element: str, - label: str, + label_of_model: str, as_time_series: bool = True, ): - super().__init__(model, label_of_element, label) + super().__init__(model, label_of_element, label_of_model) self.inside_piece: Optional[linopy.Variable] = None self.lambda0: Optional[linopy.Variable] = None self.lambda1: Optional[linopy.Variable] = None @@ -1373,7 +325,7 @@ def __init__( piecewise_variables: Dict[str, Piecewise], zero_point: Optional[Union[bool, linopy.Variable]], as_time_series: bool, - label: str = '', + label_of_model: str = '', ): """ Modeling a Piecewise relation between miultiple variables. @@ -1388,7 +340,7 @@ def __init__( zero_point: A variable that can be used to define a zero point for the Piecewise relation. If None or False, no zero point is defined. as_time_series: Whether the Piecewise relation is defined for a TimeSeries or a single variable. """ - super().__init__(model, label_of_element, label) + super().__init__(model, label_of_element=label_of_element, label_of_model=label_of_model) self._piecewise_variables = piecewise_variables self._zero_point = zero_point self._as_time_series = as_time_series @@ -1402,7 +354,7 @@ def do_modeling(self): PieceModel( model=self._model, label_of_element=self.label_of_element, - label=f'Piece_{i}', + label_of_model=f'{self.label_of_element}|Piece_{i}', as_time_series=self._as_time_series, ) ) @@ -1452,20 +404,80 @@ def do_modeling(self): ) +class PiecewiseEffectsModel(Model): + def __init__( + self, + model: FlowSystemModel, + label_of_element: str, + piecewise_origin: Tuple[str, Piecewise], + piecewise_shares: Dict[str, Piecewise], + zero_point: Optional[Union[bool, linopy.Variable]], + label: str = 'PiecewiseEffects', + ): + super().__init__(model, label_of_element, label) + assert len(piecewise_origin[1]) == len(list(piecewise_shares.values())[0]), ( + 'Piece length of variable_segments and share_segments must be equal' + ) + self._zero_point = zero_point + self._piecewise_origin = piecewise_origin + self._piecewise_shares = piecewise_shares + self.shares: Dict[str, linopy.Variable] = {} + + self.piecewise_model: Optional[PiecewiseModel] = None + + def do_modeling(self): + self.shares = { + effect: self.add( + self._model.add_variables( + coords=self._model.get_coords(['year', 'scenario']), name=f'{self.label_full}|{effect}' + ), + f'{effect}', + ) + for effect in self._piecewise_shares + } + + piecewise_variables = { + self._piecewise_origin[0]: self._piecewise_origin[1], + **{ + self.shares[effect_label].name: self._piecewise_shares[effect_label] + for effect_label in self._piecewise_shares + }, + } + + self.piecewise_model = self.add( + PiecewiseModel( + model=self._model, + label_of_element=self.label_of_element, + piecewise_variables=piecewise_variables, + zero_point=self._zero_point, + as_time_series=False, + label_of_model=f'{self.label_of_element}|PiecewiseEffects', + ) + ) + + self.piecewise_model.do_modeling() + + # Shares + self._model.effects.add_share_to_effects( + name=self.label_of_element, + expressions={effect: variable * 1 for effect, variable in self.shares.items()}, + target='invest', + ) + + class ShareAllocationModel(Model): def __init__( self, model: FlowSystemModel, dims: List[FlowSystemDimensions], label_of_element: Optional[str] = None, - label: Optional[str] = None, - label_full: Optional[str] = None, + label_of_model: Optional[str] = None, total_max: Optional[Scalar] = None, total_min: Optional[Scalar] = None, max_per_hour: Optional[TemporalData] = None, min_per_hour: Optional[TemporalData] = None, ): - super().__init__(model, label_of_element=label_of_element, label=label, label_full=label_full) + super().__init__(model, label_of_element=label_of_element, label_of_model=label_of_model) if 'time' not in dims and (max_per_hour is not None or min_per_hour is not None): raise ValueError('Both max_per_hour and min_per_hour cannot be used when has_time_dim is False') @@ -1565,67 +577,6 @@ def add_share( self._eq_total_per_timestep.lhs -= self.shares[name] -class PiecewiseEffectsModel(Model): - def __init__( - self, - model: FlowSystemModel, - label_of_element: str, - piecewise_origin: Tuple[str, Piecewise], - piecewise_shares: Dict[str, Piecewise], - zero_point: Optional[Union[bool, linopy.Variable]], - label: str = 'PiecewiseEffects', - ): - super().__init__(model, label_of_element, label) - assert len(piecewise_origin[1]) == len(list(piecewise_shares.values())[0]), ( - 'Piece length of variable_segments and share_segments must be equal' - ) - self._zero_point = zero_point - self._piecewise_origin = piecewise_origin - self._piecewise_shares = piecewise_shares - self.shares: Dict[str, linopy.Variable] = {} - - self.piecewise_model: Optional[PiecewiseModel] = None - - def do_modeling(self): - self.shares = { - effect: self.add( - self._model.add_variables( - coords=self._model.get_coords(['year', 'scenario']), name=f'{self.label_full}|{effect}' - ), - f'{effect}', - ) - for effect in self._piecewise_shares - } - - piecewise_variables = { - self._piecewise_origin[0]: self._piecewise_origin[1], - **{ - self.shares[effect_label].name: self._piecewise_shares[effect_label] - for effect_label in self._piecewise_shares - }, - } - - self.piecewise_model = self.add( - PiecewiseModel( - model=self._model, - label_of_element=self.label_of_element, - piecewise_variables=piecewise_variables, - zero_point=self._zero_point, - as_time_series=False, - label='PiecewiseEffects', - ) - ) - - self.piecewise_model.do_modeling() - - # Shares - self._model.effects.add_share_to_effects( - name=self.label_of_element, - expressions={effect: variable * 1 for effect, variable in self.shares.items()}, - target='invest', - ) - - class PreventSimultaneousUsageModel(Model): """ Prevents multiple Multiple Binary variables from being 1 at the same time diff --git a/flixopt/modeling.py b/flixopt/modeling.py new file mode 100644 index 000000000..2b5445a3c --- /dev/null +++ b/flixopt/modeling.py @@ -0,0 +1,636 @@ +import logging +from typing import Dict, List, Optional, Tuple, Union + +import linopy +import numpy as np + +from .config import CONFIG +from .core import NonTemporalData, Scalar, TemporalData, FlowSystemDimensions +from .structure import Model, FlowSystemModel, BaseFeatureModel + +logger = logging.getLogger('flixopt') + + +class ModelingUtilities: + """Utility functions for modeling calculations - used across different classes""" + + @staticmethod + def compute_consecutive_hours_in_state( + binary_values: TemporalData, hours_per_timestep: Union[int, float, np.ndarray] + ) -> Scalar: + """ + Computes the final consecutive duration in state 'on' (=1) in hours, from a binary array. + + Args: + binary_values: An int or 1D binary array containing only `0`s and `1`s. + hours_per_timestep: The duration of each timestep in hours. + If a scalar is provided, it is used for all timesteps. + If an array is provided, it must be as long as the last consecutive duration in binary_values. + + Returns: + The duration of the binary variable in hours. + + Raises + ------ + TypeError + If the length of binary_values and dt_in_hours is not equal, but None is a scalar. + """ + if np.isscalar(binary_values) and np.isscalar(hours_per_timestep): + return binary_values * hours_per_timestep + elif np.isscalar(binary_values) and not np.isscalar(hours_per_timestep): + return binary_values * hours_per_timestep[-1] + + if np.isclose(binary_values[-1], 0, atol=CONFIG.modeling.EPSILON): + return 0 + + if np.isscalar(hours_per_timestep): + hours_per_timestep = np.ones(len(binary_values)) * hours_per_timestep + hours_per_timestep: np.ndarray + + indexes_with_zero_values = np.where(np.isclose(binary_values, 0, atol=CONFIG.modeling.EPSILON))[0] + if len(indexes_with_zero_values) == 0: + nr_of_indexes_with_consecutive_ones = len(binary_values) + else: + nr_of_indexes_with_consecutive_ones = len(binary_values) - indexes_with_zero_values[-1] - 1 + + if len(hours_per_timestep) < nr_of_indexes_with_consecutive_ones: + raise ValueError( + f'When trying to calculate the consecutive duration, the length of the last duration ' + f'({nr_of_indexes_with_consecutive_ones}) is longer than the provided hours_per_timestep ({len(hours_per_timestep)}), ' + f'as {binary_values=}' + ) + + return np.sum( + binary_values[-nr_of_indexes_with_consecutive_ones:] + * hours_per_timestep[-nr_of_indexes_with_consecutive_ones:] + ) + + @staticmethod + def compute_previous_states(previous_values: List[TemporalData], epsilon: float = None) -> np.ndarray: + """ + Computes the previous states {0, 1} of defining variables as a binary array from their previous values. + + Args: + previous_values: List of previous values for variables + epsilon: Tolerance for zero detection (uses CONFIG.modeling.EPSILON if None) + + Returns: + Binary array of previous states + """ + if epsilon is None: + epsilon = CONFIG.modeling.EPSILON + + if not previous_values or all(val is None for val in previous_values): + return np.array([0]) + + # Convert to 2D-array and compute binary on/off states + previous_values = np.array([values for values in previous_values if values is not None]) # Filter out None + if previous_values.ndim > 1: + return np.any(~np.isclose(previous_values, 0, atol=epsilon), axis=0).astype(int) + + return (~np.isclose(previous_values, 0, atol=epsilon)).astype(int) + + @staticmethod + def compute_previous_on_duration(previous_values: List[TemporalData], hours_per_step: Union[int, float]) -> Scalar: + """ + Convenience method to compute previous consecutive 'on' duration. + + Args: + previous_values: List of previous values for variables + hours_per_step: Duration of each timestep in hours + + Returns: + Previous consecutive on duration in hours + """ + if not previous_values: + return 0 + + previous_states = ModelingUtilities.compute_previous_states(previous_values) + return ModelingUtilities.compute_consecutive_hours_in_state(previous_states, hours_per_step) + + @staticmethod + def compute_previous_off_duration(previous_values: List[TemporalData], hours_per_step: Union[int, float]) -> Scalar: + """ + Convenience method to compute previous consecutive 'off' duration. + + Args: + previous_values: List of previous values for variables + hours_per_step: Duration of each timestep in hours + + Returns: + Previous consecutive off duration in hours + """ + if not previous_values: + return 0 + + previous_states = ModelingUtilities.compute_previous_states(previous_values) + previous_off_states = 1 - previous_states + return ModelingUtilities.compute_consecutive_hours_in_state(previous_off_states, hours_per_step) + + @staticmethod + def get_most_recent_state(previous_values: List[TemporalData]) -> int: + """ + Get the most recent binary state from previous values. + + Args: + previous_values: List of previous values for variables + + Returns: + Most recent binary state (0 or 1) + """ + if not previous_values: + return 0 + + previous_states = ModelingUtilities.compute_previous_states(previous_values) + return int(previous_states[-1]) + + +class ModelingPrimitives: + """Mathematical modeling primitives returning (variables, constraints) tuples""" + + @staticmethod + def binary_state_pair( + model: FlowSystemModel, name: str, coords: List[str] = None + ) -> Tuple[Dict[str, linopy.Variable], Dict[str, linopy.Constraint]]: + """ + Creates complementary binary variables with completeness constraint. + + Mathematical formulation: + on[t] + off[t] = 1 ∀t + on[t], off[t] ∈ {0, 1} + + Returns: + variables: {'on': binary_var, 'off': binary_var} + constraints: {'complementary': constraint} + """ + coords = coords or ['time'] + + on = model.add_variables(binary=True, name=f'{name}|on', coords=model.get_coords(coords)) + off = model.add_variables(binary=True, name=f'{name}|off', coords=model.get_coords(coords)) + + # Constraint: on + off = 1 + complementary = model.add_constraints(on + off == 1, name=f'{name}|complementary') + + variables = {'on': on, 'off': off} + constraints = {'complementary': complementary} + + return variables, constraints + + @staticmethod + def proportionally_bounded_variable( + model: FlowSystemModel, + name: str, + controlling_variable, + bounds: Tuple[TemporalData, TemporalData], + coords: List[str] = None, + ) -> Tuple[Dict[str, linopy.Variable], Dict[str, linopy.Constraint]]: + """ + Creates variable with bounds proportional to another variable. + + Mathematical formulation: + lower_factor[t] * controller[t] ≤ variable[t] ≤ upper_factor[t] * controller[t] ∀t + + Returns: + variables: {'variable': bounded_var} + constraints: {'lower_bound': constraint, 'upper_bound': constraint} + """ + coords = coords or ['time'] + variable = model.add_variables(name=f'{name}|bounded', coords=model.get_coords(coords)) + + lower_factor, upper_factor = bounds + + # Constraints: lower_factor * controller ≤ var ≤ upper_factor * controller + lower_bound = model.add_constraints( + variable >= controlling_variable * lower_factor, name=f'{name}|proportional_lb' + ) + upper_bound = model.add_constraints( + variable <= controlling_variable * upper_factor, name=f'{name}|proportional_ub' + ) + + variables = {'variable': variable} + constraints = {'lower_bound': lower_bound, 'upper_bound': upper_bound} + + return variables, constraints + + @staticmethod + def expression_tracking_variable( + model: FlowSystemModel, + name: str, + tracked_expression, + bounds: Tuple[TemporalData, TemporalData] = None, + coords: List[str] = None, + ) -> Tuple[Dict[str, linopy.Variable], Dict[str, linopy.Constraint]]: + """ + Creates variable that equals a given expression. + + Mathematical formulation: + tracker = expression + lower ≤ tracker ≤ upper (if bounds provided) + + Returns: + variables: {'tracker': tracker_var} + constraints: {'tracking': constraint} + """ + coords = coords or ['year', 'scenario'] + + if not bounds: + tracker = model.add_variables(name=f'{name}', coords=model.get_coords(coords)) + else: + tracker = model.add_variables( + lower=bounds[0] if bounds[0] is not None else -np.inf, + upper=bounds[1] if bounds[1] is not None else np.inf, + name=f'{name}', + coords=model.get_coords(coords), + ) + + # Constraint: tracker = expression + tracking = model.add_constraints(tracker == tracked_expression, name=f'{name}') + + variables = {'tracker': tracker} + constraints = {'tracking': tracking} + + return variables, constraints + + @staticmethod + def state_transition_variables( + model: FlowSystemModel, name: str, state_variable, previous_state=0 + ) -> Tuple[Dict[str, linopy.Variable], Dict[str, linopy.Constraint]]: + """ + Creates switch-on/off variables with state transition logic. + + Mathematical formulation: + switch_on[t] - switch_off[t] = state[t] - state[t-1] ∀t > 0 + switch_on[0] - switch_off[0] = state[0] - previous_state + switch_on[t] + switch_off[t] ≤ 1 ∀t + switch_on[t], switch_off[t] ∈ {0, 1} + + Returns: + variables: {'switch_on': binary_var, 'switch_off': binary_var} + constraints: {'transition': constraint, 'initial': constraint, 'mutex': constraint} + """ + switch_on = model.add_variables(binary=True, name=f'{name}|switch_on', coords=model.get_coords(['time'])) + switch_off = model.add_variables(binary=True, name=f'{name}|switch_off', coords=model.get_coords(['time'])) + + # State transition constraints for t > 0 + transition = model.add_constraints( + switch_on.isel(time=slice(1, None)) - switch_off.isel(time=slice(1, None)) + == state_variable.isel(time=slice(1, None)) - state_variable.isel(time=slice(None, -1)), + name=f'{name}|state_transition', + ) + + # Initial state transition for t = 0 + initial = model.add_constraints( + switch_on.isel(time=0) - switch_off.isel(time=0) == state_variable.isel(time=0) - previous_state, + name=f'{name}|initial_transition', + ) + + # At most one switch per timestep + mutex = model.add_constraints(switch_on + switch_off <= 1, name=f'{name}|switch_mutex') + + variables = {'switch_on': switch_on, 'switch_off': switch_off} + constraints = {'transition': transition, 'initial': initial, 'mutex': mutex} + + return variables, constraints + + @staticmethod + def big_m_binary_bounds( + model: FlowSystemModel, + name: str, + variable, + binary_control, + size_variable, + relative_bounds: Tuple[TemporalData, TemporalData], + ) -> Tuple[Dict, Dict[str, linopy.Constraint]]: + """ + Creates bounds controlled by both binary and continuous variables. + + Mathematical formulation: + variable[t] ≤ size[t] * upper_factor[t] ∀t + + If binary_control provided: + variable[t] ≥ M * (binary[t] - 1) + size[t] * lower_factor[t] ∀t + where M = max(size) * max(upper_factor) + Else: + variable[t] ≥ size[t] * lower_factor[t] ∀t + + Returns: + variables: {} (no new variables created) + constraints: {'upper_bound': constraint, 'lower_bound': constraint} + """ + rel_lower, rel_upper = relative_bounds + + # Upper bound: variable ≤ size * upper_factor + upper_bound = model.add_constraints(variable <= size_variable * rel_upper, name=f'{name}|size_upper_bound') + + if binary_control is not None: + # Big-M lower bound: variable ≥ M*(binary-1) + size*lower_factor + big_m = size_variable.max() * rel_upper.max() # Conservative big-M + lower_bound = model.add_constraints( + variable >= big_m * (binary_control - 1) + size_variable * rel_lower, + name=f'{name}|binary_controlled_lower_bound', + ) + else: + # Simple lower bound: variable ≥ size * lower_factor + lower_bound = model.add_constraints(variable >= size_variable * rel_lower, name=f'{name}|size_lower_bound') + + variables = {} # No new variables created + constraints = {'upper_bound': upper_bound, 'lower_bound': lower_bound} + + return variables, constraints + + @staticmethod + def consecutive_duration_tracking( + model: FlowSystemModel, + name: str, + state_variable: linopy.Variable, + minimum_duration: Optional[TemporalData] = None, + maximum_duration: Optional[TemporalData] = None, + previous_duration: TemporalData = 0, + ) -> Tuple[Dict[str, linopy.Variable], Dict[str, linopy.Constraint]]: + """ + Creates consecutive duration tracking for a binary state variable. + + Mathematical formulation: + duration[t] ≤ state[t] * M ∀t + duration[t+1] ≤ duration[t] + hours_per_step[t] ∀t + duration[t+1] ≥ duration[t] + hours_per_step[t] + (state[t+1] - 1) * M ∀t + duration[0] = (hours_per_step[0] + previous_duration) * state[0] + + If minimum_duration provided: + duration[t] ≥ (state[t-1] - state[t]) * minimum_duration[t-1] ∀t > 0 + + Args: + state_variable: Binary state variable to track duration for + minimum_duration: Optional minimum consecutive duration + maximum_duration: Optional maximum consecutive duration + previous_duration: Duration from before first timestep + + Returns: + variables: {'duration': duration_var} + constraints: {'upper_bound': constraint, 'forward': constraint, 'backward': constraint, ...} + """ + hours_per_step = model.hours_per_step + mega = hours_per_step.sum('time') + previous_duration # Big-M value + + # Duration variable + duration = model.add_variables( + lower=0, + upper=maximum_duration if maximum_duration is not None else mega, + coords=model.get_coords(['time']), + name=f'{name}|duration', + ) + + constraints = {} + + # Upper bound: duration[t] ≤ state[t] * M + constraints['upper_bound'] = model.add_constraints( + duration <= state_variable * mega, name=f'{name}|duration_upper_bound' + ) + + # Forward constraint: duration[t+1] ≤ duration[t] + hours_per_step[t] + constraints['forward'] = model.add_constraints( + duration.isel(time=slice(1, None)) + <= duration.isel(time=slice(None, -1)) + hours_per_step.isel(time=slice(None, -1)), + name=f'{name}|duration_forward', + ) + + # Backward constraint: duration[t+1] ≥ duration[t] + hours_per_step[t] + (state[t+1] - 1) * M + constraints['backward'] = model.add_constraints( + duration.isel(time=slice(1, None)) + >= duration.isel(time=slice(None, -1)) + + hours_per_step.isel(time=slice(None, -1)) + + (state_variable.isel(time=slice(1, None)) - 1) * mega, + name=f'{name}|duration_backward', + ) + + # Initial condition: duration[0] = (hours_per_step[0] + previous_duration) * state[0] + constraints['initial'] = model.add_constraints( + duration.isel(time=0) + == (hours_per_step.isel(time=0) + previous_duration) * state_variable.isel(time=0), + name=f'{name}|duration_initial', + ) + + # Minimum duration constraint if provided + if minimum_duration is not None: + constraints['minimum'] = model.add_constraints( + duration.isel(time=slice(1, None)) + >= (state_variable.isel(time=slice(None, -1)) - state_variable.isel(time=slice(1, None))) + * minimum_duration.isel(time=slice(None, -1)), + name=f'{name}|duration_minimum', + ) + + # Handle initial condition for minimum duration + if previous_duration > 0 and previous_duration < minimum_duration.isel(time=0).max(): + constraints['initial_minimum'] = model.add_constraints( + state_variable.isel(time=0) == 1, name=f'{name}|duration_initial_minimum' + ) + + variables = {'duration': duration} + + return variables, constraints + + @staticmethod + def mutual_exclusivity_constraint( + model: FlowSystemModel, name: str, binary_variables: List[linopy.Variable], tolerance: float = 1.1 + ) -> Tuple[Dict, Dict[str, linopy.Constraint]]: + """ + Creates mutual exclusivity constraint for binary variables. + + Mathematical formulation: + Σ(binary_vars[i]) ≤ tolerance ∀t + + Ensures at most one binary variable can be 1 at any time. + Tolerance > 1.0 accounts for binary variable numerical precision. + + Args: + binary_variables: List of binary variables that should be mutually exclusive + tolerance: Upper bound (typically 1.1 for numerical stability) + + Returns: + variables: {} (no new variables created) + constraints: {'mutual_exclusivity': constraint} + + Raises: + AssertionError: If fewer than 2 variables provided or variables aren't binary + """ + assert len(binary_variables) >= 2, ( + f'Mutual exclusivity requires at least 2 variables, got {len(binary_variables)}' + ) + + for var in binary_variables: + assert var.attrs.get('binary', False), ( + f'Variable {var.name} must be binary for mutual exclusivity constraint' + ) + + # Create mutual exclusivity constraint + mutual_exclusivity = model.add_constraints( + sum(binary_variables) <= tolerance, name=f'{name}|mutual_exclusivity' + ) + + variables = {} # No new variables created + constraints = {'mutual_exclusivity': mutual_exclusivity} + + return variables, constraints + + +class ModelingPatterns: + """High-level patterns that compose primitives and return (variables, constraints) tuples""" + + @staticmethod + def investment_sizing_pattern( + model: FlowSystemModel, + name: str, + size_bounds: Tuple[TemporalData, TemporalData], + controlled_variables: List[linopy.Variable] = None, + control_factors: List[Tuple[TemporalData, TemporalData]] = None, + optional: bool = False, + ) -> Tuple[Dict[str, linopy.Variable], Dict[str, linopy.Constraint]]: + """ + Complete investment sizing pattern with optional binary decision. + + Returns: + variables: {'size': size_var, 'is_invested': binary_var (if optional)} + constraints: {'upper_bound': constraint, 'lower_bound': constraint, ...} + """ + variables = {} + constraints = {} + + # Investment size variable + size_min, size_max = size_bounds + variables['size'] = model.add_variables( + lower=size_min, + upper=size_max, + name=f'{name}|size', + coords=model.get_coords(['year', 'scenario']), + ) + + # Optional binary investment decision + if optional: + variables['is_invested'] = model.add_variables( + binary=True, name=f'{name}|is_invested', coords=model.get_coords(['year', 'scenario']) + ) + + # Link size to investment decision + if abs(size_min - size_max) < 1e-10: # Fixed size case + constraints['fixed_size'] = model.add_constraints( + variables['size'] == variables['is_invested'] * size_max, name=f'{name}|fixed_size' + ) + else: # Variable size case + constraints['upper_bound'] = model.add_constraints( + variables['size'] <= variables['is_invested'] * size_max, name=f'{name}|upper_bound' + ) + constraints['lower_bound'] = model.add_constraints( + variables['size'] >= variables['is_invested'] * max(CONFIG.modeling.EPSILON, size_min), + name=f'{name}|lower_bound', + ) + + # Control dependent variables + if controlled_variables and control_factors: + for i, (var, factors) in enumerate(zip(controlled_variables, control_factors)): + _, control_constraints = ModelingPrimitives.big_m_binary_bounds( + model, f'{name}|control_{i}', var, variables.get('is_invested'), variables['size'], factors + ) + # Flatten control constraints with indexed names + constraints[f'control_{i}_upper_bound'] = control_constraints['upper_bound'] + constraints[f'control_{i}_lower_bound'] = control_constraints['lower_bound'] + + return variables, constraints + + @staticmethod + def operational_binary_control_pattern( + model: FlowSystemModel, + name: str, + controlled_variables: List[linopy.Variable], + variable_bounds: List[Tuple[TemporalData, TemporalData]], + use_complement: bool = False, + track_total_duration: bool = False, + track_switches: bool = False, + previous_state=0, + duration_bounds: Tuple[TemporalData, TemporalData] = None, + track_consecutive_on: bool = False, + consecutive_on_bounds: Tuple[Optional[TemporalData], Optional[TemporalData]] = (None, None), + previous_on_duration: TemporalData = 0, + track_consecutive_off: bool = False, + consecutive_off_bounds: Tuple[Optional[TemporalData], Optional[TemporalData]] = (None, None), + previous_off_duration: TemporalData = 0, + ) -> Tuple[Dict[str, linopy.Variable], Dict[str, linopy.Constraint]]: + """ + Enhanced operational binary control with consecutive duration tracking. + + New Args: + track_consecutive_on: Whether to track consecutive on duration + consecutive_on_bounds: (min_duration, max_duration) for consecutive on + previous_on_duration: Previous consecutive on duration + track_consecutive_off: Whether to track consecutive off duration + consecutive_off_bounds: (min_duration, max_duration) for consecutive off + previous_off_duration: Previous consecutive off duration + """ + variables = {} + constraints = {} + + # Main binary state (existing logic) + if use_complement: + state_vars, state_constraints = ModelingPrimitives.binary_state_pair(model, name) + variables.update(state_vars) + constraints.update(state_constraints) + else: + variables['on'] = model.add_variables(binary=True, name=f'{name}|on', coords=model.get_coords(['time'])) + + # Control variables (existing logic) + for i, (var, (lower_bound, upper_bound)) in enumerate(zip(controlled_variables, variable_bounds)): + constraints[f'control_{i}_lower'] = model.add_constraints( + variables['on'] * np.maximum(lower_bound, CONFIG.modeling.EPSILON) <= var, name=f'{name}|control_{i}_lower' + ) + constraints[f'control_{i}_upper'] = model.add_constraints( + var <= variables['on'] * upper_bound, name=f'{name}|control_{i}_upper' + ) + + # Total duration tracking (existing logic) + if track_total_duration: + duration_expr = (variables['on'] * model.hours_per_step).sum('time') + duration_vars, duration_constraints = ModelingPrimitives.expression_tracking_variable( + model, f'{name}|duration', duration_expr, duration_bounds + ) + variables['total_duration'] = duration_vars['tracker'] + constraints['duration_tracking'] = duration_constraints['tracking'] + + # Switch tracking (existing logic) + if track_switches: + switch_vars, switch_constraints = ModelingPrimitives.state_transition_variables( + model, f'{name}|switches', variables['on'], previous_state + ) + variables.update(switch_vars) + for switch_name, switch_constraint in switch_constraints.items(): + constraints[f'switch_{switch_name}'] = switch_constraint + + # NEW: Consecutive on duration tracking + if track_consecutive_on: + min_on, max_on = consecutive_on_bounds + consecutive_on_vars, consecutive_on_constraints = ModelingPrimitives.consecutive_duration_tracking( + model, + f'{name}|consecutive_on', + variables['on'], + minimum_duration=min_on, + maximum_duration=max_on, + previous_duration=previous_on_duration, + ) + variables['consecutive_on_duration'] = consecutive_on_vars['duration'] + for cons_name, cons_constraint in consecutive_on_constraints.items(): + constraints[f'consecutive_on_{cons_name}'] = cons_constraint + + # NEW: Consecutive off duration tracking + if track_consecutive_off and 'off' in variables: + min_off, max_off = consecutive_off_bounds + consecutive_off_vars, consecutive_off_constraints = ModelingPrimitives.consecutive_duration_tracking( + model, + f'{name}|consecutive_off', + variables['off'], + minimum_duration=min_off, + maximum_duration=max_off, + previous_duration=previous_off_duration, + ) + variables['consecutive_off_duration'] = consecutive_off_vars['duration'] + for cons_name, cons_constraint in consecutive_off_constraints.items(): + constraints[f'consecutive_off_{cons_name}'] = cons_constraint + + return variables, constraints diff --git a/flixopt/structure.py b/flixopt/structure.py index 5a4b016ce..ec594ca6e 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -700,19 +700,17 @@ class Model: """Stores Variables and Constraints.""" def __init__( - self, model: FlowSystemModel, label_of_element: str, label: str = '', label_full: Optional[str] = None + self, model: FlowSystemModel, label_of_element: str, label_of_model = None ): """ Args: model: The FlowSystemModel that is used to create the model. label_of_element: The label of the parent (Element). Used to construct the full label of the model. - label: The label of the model. Used to construct the full label of the model. - label_full: The full label of the model. Can overwrite the full label constructed from the other labels. + label_of_model: The label of the model. Used as a prefix in all variables and constraints. """ self._model = model self.label_of_element = label_of_element - self._label = label - self._label_full = label_full + self.label_of_model = label_of_model if label_of_model is not None else self.label_of_element self._variables_direct: List[str] = [] self._constraints_direct: List[str] = [] @@ -777,16 +775,11 @@ def filter_variables( @property def label(self) -> str: - return self._label if self._label else self.label_of_element + return self.label_of_model @property def label_full(self) -> str: - """Used to construct the names of variables and constraints""" - if self._label_full: - return self._label_full - elif self._label: - return f'{self.label_of_element}|{self.label}' - return self.label_of_element + return self.label_of_model @property def variables_direct(self) -> linopy.Variables: @@ -846,8 +839,16 @@ def get_constraint_by_short_name(self, short_name: str, default_return = None) - class BaseFeatureModel(Model): """Minimal base class for feature models that use factory patterns""" - def __init__(self, model: FlowSystemModel, label_of_element: str, parameters, label: Optional[str] = None): - super().__init__(model, label_of_element, label or self.__class__.__name__) + def __init__(self, model: FlowSystemModel, label_of_element: str, parameters, label_of_model: Optional[str] = None): + """Initialize the BaseFeatureModel. + Args: + model: The FlowSystemModel that is used to create the model. + label_of_element: The label of the parent (Element). Used to create shares. + label_of_model: The label of the model. Used as a prefix in all variables and constraints. + Defaults to {label_of_element}|{self.__class__.__name__} + parameters: The parameters of the feature model. + """ + super().__init__(model, label_of_element, label_of_model or f'{label_of_element}|{self.__class__.__name__}') self.parameters = parameters def do_modeling(self): @@ -873,7 +874,7 @@ def __init__(self, model: FlowSystemModel, element: Element): model: The FlowSystemModel that is used to create the model. element: The element this model is created for. """ - super().__init__(model, label_of_element=element.label_full, label=element.label, label_full=element.label_full) + super().__init__(model, label_of_element=element.label_full) self.element = element def results_structure(self): From a3511f91aae37d6d50cf04643f6bb275b4078387 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 18 Jul 2025 11:55:02 +0200 Subject: [PATCH 201/336] Add naming options to big_m_binary_bounds() --- flixopt/modeling.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/flixopt/modeling.py b/flixopt/modeling.py index 2b5445a3c..1651cb12b 100644 --- a/flixopt/modeling.py +++ b/flixopt/modeling.py @@ -295,11 +295,12 @@ def state_transition_variables( @staticmethod def big_m_binary_bounds( model: FlowSystemModel, - name: str, variable, binary_control, size_variable, relative_bounds: Tuple[TemporalData, TemporalData], + upper_bound_name: str, + lower_bound_name: str, ) -> Tuple[Dict, Dict[str, linopy.Constraint]]: """ Creates bounds controlled by both binary and continuous variables. @@ -320,18 +321,16 @@ def big_m_binary_bounds( rel_lower, rel_upper = relative_bounds # Upper bound: variable ≤ size * upper_factor - upper_bound = model.add_constraints(variable <= size_variable * rel_upper, name=f'{name}|size_upper_bound') + upper_bound = model.add_constraints(variable <= size_variable * rel_upper, name=upper_bound_name) if binary_control is not None: # Big-M lower bound: variable ≥ M*(binary-1) + size*lower_factor big_m = size_variable.max() * rel_upper.max() # Conservative big-M lower_bound = model.add_constraints( - variable >= big_m * (binary_control - 1) + size_variable * rel_lower, - name=f'{name}|binary_controlled_lower_bound', + variable >= big_m * (binary_control - 1) + size_variable * rel_lower, name=lower_bound_name ) else: - # Simple lower bound: variable ≥ size * lower_factor - lower_bound = model.add_constraints(variable >= size_variable * rel_lower, name=f'{name}|size_lower_bound') + lower_bound = model.add_constraints(variable >= size_variable * rel_lower, name=lower_bound_name) variables = {} # No new variables created constraints = {'upper_bound': upper_bound, 'lower_bound': lower_bound} From 404dc033fc9de602c955c58cd8a083d08bd515dc Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 18 Jul 2025 12:36:39 +0200 Subject: [PATCH 202/336] Fix and improve FLowModeling with Investment --- flixopt/effects.py | 2 +- flixopt/features.py | 6 ++-- flixopt/modeling.py | 43 ++++++++++++++++++++-------- tests/test_flow.py | 68 ++++++++++++++++++++++----------------------- 4 files changed, 68 insertions(+), 51 deletions(-) diff --git a/flixopt/effects.py b/flixopt/effects.py index 2fc2aae37..0e4236076 100644 --- a/flixopt/effects.py +++ b/flixopt/effects.py @@ -158,7 +158,7 @@ def __init__(self, model: FlowSystemModel, element: Effect): model=self._model, dims=('time', 'year', 'scenario'), label_of_element=self.label_of_element, - label_of_model=f'{self.label_of_model}|(operation)', + label_of_model=f'{self.label_of_model}(operation)', total_max=self.element.maximum_operation, total_min=self.element.minimum_operation, min_per_hour=self.element.minimum_operation_per_hour diff --git a/flixopt/features.py b/flixopt/features.py index e08d94cb1..49635ad3d 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -46,12 +46,10 @@ def create_variables_and_constraints(self): variables, constraints = ModelingPatterns.investment_sizing_pattern( model=self._model, name=self.label_full, - size_bounds=( - 0 if self.parameters.optional else self.parameters.minimum_or_fixed_size, - self.parameters.maximum_or_fixed_size, - ), + size_bounds=(self.parameters.minimum_or_fixed_size, self.parameters.maximum_or_fixed_size,), controlled_variables=[self._defining_variable], control_factors=[self._relative_bounds_of_defining_variable], + state_variables=[self._on_variable], optional=self.parameters.optional, ) diff --git a/flixopt/modeling.py b/flixopt/modeling.py index 1651cb12b..f7aca8755 100644 --- a/flixopt/modeling.py +++ b/flixopt/modeling.py @@ -323,14 +323,15 @@ def big_m_binary_bounds( # Upper bound: variable ≤ size * upper_factor upper_bound = model.add_constraints(variable <= size_variable * rel_upper, name=upper_bound_name) - if binary_control is not None: + if binary_control is None: + lower_bound = model.add_constraints(variable >= size_variable * rel_lower, name=lower_bound_name) + else: # Big-M lower bound: variable ≥ M*(binary-1) + size*lower_factor - big_m = size_variable.max() * rel_upper.max() # Conservative big-M + big_m = CONFIG.modeling.BIG #size_variable.max() * rel_upper.max() # Conservative big-M lower_bound = model.add_constraints( variable >= big_m * (binary_control - 1) + size_variable * rel_lower, name=lower_bound_name ) - else: - lower_bound = model.add_constraints(variable >= size_variable * rel_lower, name=lower_bound_name) + variables = {} # No new variables created constraints = {'upper_bound': upper_bound, 'lower_bound': lower_bound} @@ -482,11 +483,21 @@ def investment_sizing_pattern( size_bounds: Tuple[TemporalData, TemporalData], controlled_variables: List[linopy.Variable] = None, control_factors: List[Tuple[TemporalData, TemporalData]] = None, + state_variables: List[linopy.Variable] = None, optional: bool = False, ) -> Tuple[Dict[str, linopy.Variable], Dict[str, linopy.Constraint]]: """ Complete investment sizing pattern with optional binary decision. + Args: + model: The model to add the variables to. + name: The name of the investment variable. + size_bounds: The minimum and maximum investment size. + controlled_variables: The variables that are controlled by the investment decision. + control_factors: The control factors for the controlled variables. + state_variables: State variable defining the state of the controlled variables. + optional: Whether the investment decision is optional. + Returns: variables: {'size': size_var, 'is_invested': binary_var (if optional)} constraints: {'upper_bound': constraint, 'lower_bound': constraint, ...} @@ -497,7 +508,7 @@ def investment_sizing_pattern( # Investment size variable size_min, size_max = size_bounds variables['size'] = model.add_variables( - lower=size_min, + lower=0 if optional else size_min, upper=size_max, name=f'{name}|size', coords=model.get_coords(['year', 'scenario']), @@ -516,22 +527,30 @@ def investment_sizing_pattern( ) else: # Variable size case constraints['upper_bound'] = model.add_constraints( - variables['size'] <= variables['is_invested'] * size_max, name=f'{name}|upper_bound' + variables['size'] <= variables['is_invested'] * size_max, name=f'{name}|size|upper_bound' ) constraints['lower_bound'] = model.add_constraints( - variables['size'] >= variables['is_invested'] * max(CONFIG.modeling.EPSILON, size_min), - name=f'{name}|lower_bound', + variables['size'] >= variables['is_invested'] * np.maximum(CONFIG.modeling.EPSILON, size_min), + name=f'{name}|size|lower_bound', ) # Control dependent variables if controlled_variables and control_factors: - for i, (var, factors) in enumerate(zip(controlled_variables, control_factors)): + for i, (var, factors, state_variable) in enumerate(zip(controlled_variables, control_factors, state_variables)): + upper_bound_name = f'{var.name}|upper_bound' + lower_bound_name = f'{var.name}|lower_bound' _, control_constraints = ModelingPrimitives.big_m_binary_bounds( - model, f'{name}|control_{i}', var, variables.get('is_invested'), variables['size'], factors + model=model, + variable=var, + binary_control=state_variable, + size_variable=variables['size'], + relative_bounds=factors, + upper_bound_name=upper_bound_name, + lower_bound_name=lower_bound_name, ) # Flatten control constraints with indexed names - constraints[f'control_{i}_upper_bound'] = control_constraints['upper_bound'] - constraints[f'control_{i}_lower_bound'] = control_constraints['lower_bound'] + constraints[upper_bound_name] = control_constraints['upper_bound'] + constraints[lower_bound_name] = control_constraints['lower_bound'] return variables, constraints diff --git a/tests/test_flow.py b/tests/test_flow.py index cce10b21a..9038af1c7 100644 --- a/tests/test_flow.py +++ b/tests/test_flow.py @@ -143,8 +143,8 @@ def test_flow_invest(self, basic_flow_system_linopy): assert set(flow.model.constraints) == set( [ 'Sink(Wärme)|total_flow_hours', - 'Sink(Wärme)|lb_Sink(Wärme)|flow_rate', - 'Sink(Wärme)|ub_Sink(Wärme)|flow_rate', + 'Sink(Wärme)|flow_rate|upper_bound', + 'Sink(Wärme)|flow_rate|lower_bound', ] ) @@ -161,13 +161,13 @@ def test_flow_invest(self, basic_flow_system_linopy): ), ) assert_conequal( - model.constraints['Sink(Wärme)|lb_Sink(Wärme)|flow_rate'], + model.constraints['Sink(Wärme)|flow_rate|lower_bound'], flow.model.variables['Sink(Wärme)|flow_rate'] >= flow.model.variables['Sink(Wärme)|size'] * xr.DataArray(np.linspace(0.1, 0.5, timesteps.size), coords=(timesteps,)), ) assert_conequal( - model.constraints['Sink(Wärme)|ub_Sink(Wärme)|flow_rate'], + model.constraints['Sink(Wärme)|flow_rate|upper_bound'], flow.model.variables['Sink(Wärme)|flow_rate'] <= flow.model.variables['Sink(Wärme)|size'] * xr.DataArray(np.linspace(0.5, 1, timesteps.size), coords=(timesteps,)), @@ -194,10 +194,10 @@ def test_flow_invest_optional(self, basic_flow_system_linopy): assert set(flow.model.constraints) == set( [ 'Sink(Wärme)|total_flow_hours', - 'Sink(Wärme)|is_invested_ub', - 'Sink(Wärme)|is_invested_lb', - 'Sink(Wärme)|lb_Sink(Wärme)|flow_rate', - 'Sink(Wärme)|ub_Sink(Wärme)|flow_rate', + 'Sink(Wärme)|size|lower_bound', + 'Sink(Wärme)|size|upper_bound', + 'Sink(Wärme)|flow_rate|lower_bound', + 'Sink(Wärme)|flow_rate|upper_bound', ] ) @@ -215,13 +215,13 @@ def test_flow_invest_optional(self, basic_flow_system_linopy): ), ) assert_conequal( - model.constraints['Sink(Wärme)|lb_Sink(Wärme)|flow_rate'], + model.constraints['Sink(Wärme)|flow_rate|lower_bound'], flow.model.variables['Sink(Wärme)|flow_rate'] >= flow.model.variables['Sink(Wärme)|size'] * xr.DataArray(np.linspace(0.1, 0.5, timesteps.size), coords=(timesteps,)), ) assert_conequal( - model.constraints['Sink(Wärme)|ub_Sink(Wärme)|flow_rate'], + model.constraints['Sink(Wärme)|flow_rate|upper_bound'], flow.model.variables['Sink(Wärme)|flow_rate'] <= flow.model.variables['Sink(Wärme)|size'] * xr.DataArray(np.linspace(0.5, 1, timesteps.size), coords=(timesteps,)), @@ -229,11 +229,11 @@ def test_flow_invest_optional(self, basic_flow_system_linopy): # Is invested assert_conequal( - model.constraints['Sink(Wärme)|is_invested_ub'], + model.constraints['Sink(Wärme)|size|upper_bound'], flow.model.variables['Sink(Wärme)|size'] <= flow.model.variables['Sink(Wärme)|is_invested'] * 100, ) assert_conequal( - model.constraints['Sink(Wärme)|is_invested_lb'], + model.constraints['Sink(Wärme)|size|lower_bound'], flow.model.variables['Sink(Wärme)|size'] >= flow.model.variables['Sink(Wärme)|is_invested'] * 20, ) @@ -258,10 +258,10 @@ def test_flow_invest_optional_wo_min_size(self, basic_flow_system_linopy): assert set(flow.model.constraints) == set( [ 'Sink(Wärme)|total_flow_hours', - 'Sink(Wärme)|is_invested_ub', - 'Sink(Wärme)|is_invested_lb', - 'Sink(Wärme)|lb_Sink(Wärme)|flow_rate', - 'Sink(Wärme)|ub_Sink(Wärme)|flow_rate', + 'Sink(Wärme)|size|upper_bound', + 'Sink(Wärme)|size|lower_bound', + 'Sink(Wärme)|flow_rate|lower_bound', + 'Sink(Wärme)|flow_rate|upper_bound', ] ) @@ -279,13 +279,13 @@ def test_flow_invest_optional_wo_min_size(self, basic_flow_system_linopy): ), ) assert_conequal( - model.constraints['Sink(Wärme)|lb_Sink(Wärme)|flow_rate'], + model.constraints['Sink(Wärme)|flow_rate|lower_bound'], flow.model.variables['Sink(Wärme)|flow_rate'] >= flow.model.variables['Sink(Wärme)|size'] * xr.DataArray(np.linspace(0.1, 0.5, timesteps.size), coords=(timesteps,)), ) assert_conequal( - model.constraints['Sink(Wärme)|ub_Sink(Wärme)|flow_rate'], + model.constraints['Sink(Wärme)|flow_rate|upper_bound'], flow.model.variables['Sink(Wärme)|flow_rate'] <= flow.model.variables['Sink(Wärme)|size'] * xr.DataArray(np.linspace(0.5, 1, timesteps.size), coords=(timesteps,)), @@ -293,11 +293,11 @@ def test_flow_invest_optional_wo_min_size(self, basic_flow_system_linopy): # Is invested assert_conequal( - model.constraints['Sink(Wärme)|is_invested_ub'], + model.constraints['Sink(Wärme)|size|upper_bound'], flow.model.variables['Sink(Wärme)|size'] <= flow.model.variables['Sink(Wärme)|is_invested'] * 100, ) assert_conequal( - model.constraints['Sink(Wärme)|is_invested_lb'], + model.constraints['Sink(Wärme)|size|lower_bound'], flow.model.variables['Sink(Wärme)|size'] >= flow.model.variables['Sink(Wärme)|is_invested'] * 1e-5, ) @@ -322,8 +322,8 @@ def test_flow_invest_wo_min_size_non_optional(self, basic_flow_system_linopy): assert set(flow.model.constraints) == set( [ 'Sink(Wärme)|total_flow_hours', - 'Sink(Wärme)|lb_Sink(Wärme)|flow_rate', - 'Sink(Wärme)|ub_Sink(Wärme)|flow_rate', + 'Sink(Wärme)|flow_rate|lower_bound', + 'Sink(Wärme)|flow_rate|upper_bound', ] ) @@ -339,13 +339,13 @@ def test_flow_invest_wo_min_size_non_optional(self, basic_flow_system_linopy): ), ) assert_conequal( - model.constraints['Sink(Wärme)|lb_Sink(Wärme)|flow_rate'], + model.constraints['Sink(Wärme)|flow_rate|lower_bound'], flow.model.variables['Sink(Wärme)|flow_rate'] >= flow.model.variables['Sink(Wärme)|size'] * xr.DataArray(np.linspace(0.1, 0.5, timesteps.size), coords=(timesteps,)), ) assert_conequal( - model.constraints['Sink(Wärme)|ub_Sink(Wärme)|flow_rate'], + model.constraints['Sink(Wärme)|flow_rate|upper_bound'], flow.model.variables['Sink(Wärme)|flow_rate'] <= flow.model.variables['Sink(Wärme)|size'] * xr.DataArray(np.linspace(0.5, 1, timesteps.size), coords=(timesteps,)), @@ -935,10 +935,10 @@ def test_flow_on_invest_optional(self, basic_flow_system_linopy): 'Sink(Wärme)|on_hours_total', 'Sink(Wärme)|on_con1', 'Sink(Wärme)|on_con2', - 'Sink(Wärme)|is_invested_lb', - 'Sink(Wärme)|is_invested_ub', - 'Sink(Wärme)|lb_Sink(Wärme)|flow_rate', - 'Sink(Wärme)|ub_Sink(Wärme)|flow_rate', + 'Sink(Wärme)|size|lower_bound', + 'Sink(Wärme)|size|upper_bound', + 'Sink(Wärme)|flow_rate|lower_bound', + 'Sink(Wärme)|flow_rate|upper_bound', ] ) @@ -980,12 +980,12 @@ def test_flow_on_invest_optional(self, basic_flow_system_linopy): mega = 0.2 * 200 # Relative minimum * maximum size assert_conequal( - model.constraints['Sink(Wärme)|lb_Sink(Wärme)|flow_rate'], + model.constraints['Sink(Wärme)|flow_rate|lower_bound'], flow.model.variables['Sink(Wärme)|flow_rate'] >= flow.model.variables['Sink(Wärme)|on'] * mega + flow.model.variables['Sink(Wärme)|size'] * 0.2 - mega, ) assert_conequal( - model.constraints['Sink(Wärme)|ub_Sink(Wärme)|flow_rate'], + model.constraints['Sink(Wärme)|flow_rate|upper_bound'], flow.model.variables['Sink(Wärme)|flow_rate'] <= flow.model.variables['Sink(Wärme)|size'] * 0.8, ) @@ -1019,8 +1019,8 @@ def test_flow_on_invest_non_optional(self, basic_flow_system_linopy): 'Sink(Wärme)|on_hours_total', 'Sink(Wärme)|on_con1', 'Sink(Wärme)|on_con2', - 'Sink(Wärme)|lb_Sink(Wärme)|flow_rate', - 'Sink(Wärme)|ub_Sink(Wärme)|flow_rate', + 'Sink(Wärme)|flow_rate|lower_bound', + 'Sink(Wärme)|flow_rate|upper_bound', ] ) @@ -1062,12 +1062,12 @@ def test_flow_on_invest_non_optional(self, basic_flow_system_linopy): mega = 0.2 * 200 # Relative minimum * maximum size assert_conequal( - model.constraints['Sink(Wärme)|lb_Sink(Wärme)|flow_rate'], + model.constraints['Sink(Wärme)|flow_rate|lower_bound'], flow.model.variables['Sink(Wärme)|flow_rate'] >= flow.model.variables['Sink(Wärme)|on'] * mega + flow.model.variables['Sink(Wärme)|size'] * 0.2 - mega, ) assert_conequal( - model.constraints['Sink(Wärme)|ub_Sink(Wärme)|flow_rate'], + model.constraints['Sink(Wärme)|flow_rate|upper_bound'], flow.model.variables['Sink(Wärme)|flow_rate'] <= flow.model.variables['Sink(Wärme)|size'] * 0.8, ) From 1ad74ce43b507872bfe16533a3fe3d56f942e2ab Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 18 Jul 2025 13:17:45 +0200 Subject: [PATCH 203/336] Improve --- flixopt/elements.py | 9 +-- flixopt/features.py | 133 ++++++++++++++++++++++++++++---------------- flixopt/modeling.py | 57 ++++++++++--------- 3 files changed, 118 insertions(+), 81 deletions(-) diff --git a/flixopt/elements.py b/flixopt/elements.py index 43907b07a..440ac6de4 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -334,10 +334,11 @@ def do_modeling(self): OnOffModel( model=self._model, label_of_element=self.label_of_element, - on_off_parameters=self.element.on_off_parameters, - defining_variables=[self.flow_rate], - defining_bounds=[self.flow_rate_bounds_on], - previous_values=[self.element.previous_flow_rate], + parameters=self.element.on_off_parameters, + flow_rates=[self.flow_rate], + flow_rate_bounds=[self.flow_rate_bounds_on], + previous_flow_rates=[self.element.previous_flow_rate], + label_of_model=self.label_of_element, ), 'on_off', ) diff --git a/flixopt/features.py b/flixopt/features.py index 49635ad3d..52c1302c2 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -131,58 +131,95 @@ def __init__( self, model: FlowSystemModel, label_of_element: str, - on_off_parameters: OnOffParameters, - defining_variables: List[linopy.Variable], - defining_bounds: List[Tuple[TemporalData, TemporalData]], - previous_values: List[Optional[TemporalData]], - label: Optional[str] = None, + parameters: OnOffParameters, + flow_rates: List[linopy.Variable], + flow_rate_bounds: List[Tuple[TemporalData, TemporalData]], + previous_flow_rates: List[Optional[TemporalData]], + label_of_model: Optional[str] = None, ): - super().__init__(model, label_of_element, on_off_parameters, label) - - self._defining_variables = defining_variables - self._defining_bounds = defining_bounds - self._previous_values = previous_values + super().__init__(model, label_of_element, parameters=parameters, label_of_model=label_of_model) + self._flow_rates = flow_rates + self._flow_rate_bounds = flow_rate_bounds + self._previous_flow_rates = previous_flow_rates def create_variables_and_constraints(self): - # Use factory patterns - variables, constraints = ModelingPatterns.operational_binary_control_pattern( - model=self._model, - name=self.label_full, - controlled_variables=self._defining_variables, - variable_bounds=self._defining_bounds, - use_complement=self.parameters.use_off, - track_total_duration=True, - track_switches=self.parameters.use_switch_on, - previous_state=self._get_previous_state(), - duration_bounds=(self.parameters.on_hours_total_min, self.parameters.on_hours_total_max), - track_consecutive_on=self.parameters.use_consecutive_on_hours, - consecutive_on_bounds=(self.parameters.consecutive_on_hours_min, self.parameters.consecutive_on_hours_max), - previous_on_duration=self._get_previous_on_duration(), - track_consecutive_off=self.parameters.use_consecutive_off_hours, - consecutive_off_bounds=( - self.parameters.consecutive_off_hours_min, - self.parameters.consecutive_off_hours_max, - ), - previous_off_duration=self._get_previous_off_duration(), + variables = {} + constraints = {} + + # 1. Main binary state using existing pattern + state_vars, state_constraints = ModelingPrimitives.binary_state_pair(self._model, self.label_of_model, use_complement=self.parameters.use_off) + variables.update(state_vars) + constraints.update(state_constraints) + + # 2. Control variables - use big_m_binary_bounds pattern for consistency + for i, (flow_rate, (lower_bound, upper_bound)) in enumerate(zip(self._flow_rates, self._flow_rate_bounds)): + suffix = f'_{i}' if len(self._flow_rates) > 1 else '' + # Use the big_m pattern but without binary control (None) + _, control_constraints = ModelingPrimitives.big_m_binary_bounds( + model=self._model, + variable=flow_rate, + binary_control=None, + size_variable=variables['on'], + relative_bounds=(lower_bound, upper_bound), + upper_bound_name=f'{variables['on'].name}|ub{suffix}', + lower_bound_name=f'{variables['on'].name}|lb{suffix}', + ) + constraints[f'ub_{i}'] = control_constraints['upper_bound'] + constraints[f'lb_{i}'] = control_constraints['lower_bound'] + + # 3. Total duration tracking using existing pattern + duration_expr = (variables['on'] * self._model.hours_per_step).sum('time') + duration_vars, duration_constraints = ModelingPrimitives.expression_tracking_variable( + self._model, f'{self.label_of_model}|on_hours_total', duration_expr, + (self.parameters.on_hours_total_min if self.parameters.on_hours_total_min is not None else 0, + self.parameters.on_hours_total_max if self.parameters.on_hours_total_max is not None else np.inf),#TODO: self._model.hours_per_step.sum('time').item() + self._get_previous_on_duration()) ) + variables['on_hours_total'] = duration_vars['tracker'] + constraints['on_hours_total'] = duration_constraints['tracking'] + + # 4. Switch tracking using existing pattern + if self.parameters.use_switch_on: + switch_vars, switch_constraints = ModelingPrimitives.state_transition_variables( + self._model, f'{self.label_of_model}|switches', variables['on'], + previous_state=ModelingUtilities.get_most_recent_state(self._previous_flow_rates) + ) + variables.update(switch_vars) + for switch_name, switch_constraint in switch_constraints.items(): + constraints[f'switch_{switch_name}'] = switch_constraint + + # 5. Consecutive on duration using existing pattern + if self.parameters.use_consecutive_on_hours: + consecutive_on_vars, consecutive_on_constraints = ModelingPrimitives.consecutive_duration_tracking( + self._model, + f'{self.label_of_model}|consecutive_on', + variables['on'], + minimum_duration=self.parameters.consecutive_on_hours_min, + maximum_duration=self.parameters.consecutive_on_hours_max, + previous_duration=ModelingUtilities.compute_previous_on_duration(self._previous_flow_rates, self._model.hours_per_step), + ) + variables['consecutive_on_duration'] = consecutive_on_vars['duration'] + for cons_name, cons_constraint in consecutive_on_constraints.items(): + constraints[f'consecutive_on_{cons_name}'] = cons_constraint + + # 6. Consecutive off duration using existing pattern + if self.parameters.use_consecutive_off_hours: + consecutive_off_vars, consecutive_off_constraints = ModelingPrimitives.consecutive_duration_tracking( + self._model, + f'{self.label_of_model}|consecutive_off', + variables['off'], + minimum_duration=self.parameters.consecutive_off_hours_min, + maximum_duration=self.parameters.consecutive_off_hours_max, + previous_duration=ModelingUtilities.compute_previous_off_duration(self._previous_flow_rates, self._model.hours_per_step), + ) + variables['consecutive_off_duration'] = consecutive_off_vars['duration'] + for cons_name, cons_constraint in consecutive_off_constraints.items(): + constraints[f'consecutive_off_{cons_name}'] = cons_constraint - # Register all variables (stored in Model's variable tracking) - self.add(variables['on'], 'on') - if 'off' in variables: - self.add(variables['off'], 'off') - if 'total_duration' in variables: - self.add(variables['total_duration'], 'total_duration') - if 'switch_on' in variables: - self.add(variables['switch_on'], 'switch_on') - self.add(variables['switch_off'], 'switch_off') - if 'consecutive_on_duration' in variables: - self.add(variables['consecutive_on_duration'], 'consecutive_on_hours') - if 'consecutive_off_duration' in variables: - self.add(variables['consecutive_off_duration'], 'consecutive_off_hours') - - # Register all constraints + # Register all constraints and variables for constraint_name, constraint in constraints.items(): self.add(constraint, constraint_name) + for variable_name, variable in variables.items(): + self.add(variable, variable_name) # Properties access variables from Model's tracking system @property @@ -249,14 +286,14 @@ def add_effects(self): def _get_previous_on_duration(self): hours_per_step = self._model.hours_per_step.isel(time=0).values.flatten()[0] - return ModelingUtilities.compute_previous_on_duration(self._previous_values, hours_per_step) + return ModelingUtilities.compute_previous_on_duration(self._previous_flow_rates, hours_per_step) def _get_previous_off_duration(self): hours_per_step = self._model.hours_per_step.isel(time=0).values.flatten()[0] - return ModelingUtilities.compute_previous_off_duration(self._previous_values, hours_per_step) + return ModelingUtilities.compute_previous_off_duration(self._previous_flow_rates, hours_per_step) def _get_previous_state(self): - return ModelingUtilities.get_most_recent_state(self._previous_values) + return ModelingUtilities.get_most_recent_state(self._previous_flow_rates) class PieceModel(Model): diff --git a/flixopt/modeling.py b/flixopt/modeling.py index f7aca8755..64f0164d6 100644 --- a/flixopt/modeling.py +++ b/flixopt/modeling.py @@ -150,7 +150,7 @@ class ModelingPrimitives: @staticmethod def binary_state_pair( - model: FlowSystemModel, name: str, coords: List[str] = None + model: FlowSystemModel, name: str, coords: List[str] = None, use_complement: bool = True ) -> Tuple[Dict[str, linopy.Variable], Dict[str, linopy.Constraint]]: """ Creates complementary binary variables with completeness constraint. @@ -166,15 +166,16 @@ def binary_state_pair( coords = coords or ['time'] on = model.add_variables(binary=True, name=f'{name}|on', coords=model.get_coords(coords)) - off = model.add_variables(binary=True, name=f'{name}|off', coords=model.get_coords(coords)) - - # Constraint: on + off = 1 - complementary = model.add_constraints(on + off == 1, name=f'{name}|complementary') + if use_complement: + off = model.add_variables(binary=True, name=f'{name}|off', coords=model.get_coords(coords)) - variables = {'on': on, 'off': off} - constraints = {'complementary': complementary} + # Constraint: on + off = 1 + complementary = model.add_constraints(on + off == 1, name=f'{name}|complementary') - return variables, constraints + variables = {'on': on, 'off': off} + constraints = {'complementary': complementary} + return variables, constraints + return {'on': on}, {} @staticmethod def proportionally_bounded_variable( @@ -573,20 +574,12 @@ def operational_binary_control_pattern( previous_off_duration: TemporalData = 0, ) -> Tuple[Dict[str, linopy.Variable], Dict[str, linopy.Constraint]]: """ - Enhanced operational binary control with consecutive duration tracking. - - New Args: - track_consecutive_on: Whether to track consecutive on duration - consecutive_on_bounds: (min_duration, max_duration) for consecutive on - previous_on_duration: Previous consecutive on duration - track_consecutive_off: Whether to track consecutive off duration - consecutive_off_bounds: (min_duration, max_duration) for consecutive off - previous_off_duration: Previous consecutive off duration + Enhanced operational binary control using composable patterns. """ variables = {} constraints = {} - # Main binary state (existing logic) + # 1. Main binary state using existing pattern if use_complement: state_vars, state_constraints = ModelingPrimitives.binary_state_pair(model, name) variables.update(state_vars) @@ -594,25 +587,31 @@ def operational_binary_control_pattern( else: variables['on'] = model.add_variables(binary=True, name=f'{name}|on', coords=model.get_coords(['time'])) - # Control variables (existing logic) + # 2. Control variables - use big_m_binary_bounds pattern for consistency for i, (var, (lower_bound, upper_bound)) in enumerate(zip(controlled_variables, variable_bounds)): - constraints[f'control_{i}_lower'] = model.add_constraints( - variables['on'] * np.maximum(lower_bound, CONFIG.modeling.EPSILON) <= var, name=f'{name}|control_{i}_lower' - ) - constraints[f'control_{i}_upper'] = model.add_constraints( - var <= variables['on'] * upper_bound, name=f'{name}|control_{i}_upper' + # Use the big_m pattern but without binary control (None) + _, control_constraints = ModelingPrimitives.big_m_binary_bounds( + model=model, + variable=var, + binary_control=variables['on'], # The on state controls the variables + size_variable=1, # No size scaling, just on/off + relative_bounds=(lower_bound, upper_bound), + upper_bound_name=f'{name}|control_{i}_upper', + lower_bound_name=f'{name}|control_{i}_lower', ) + constraints[f'control_{i}_upper'] = control_constraints['upper_bound'] + constraints[f'control_{i}_lower'] = control_constraints['lower_bound'] - # Total duration tracking (existing logic) + # 3. Total duration tracking using existing pattern if track_total_duration: duration_expr = (variables['on'] * model.hours_per_step).sum('time') duration_vars, duration_constraints = ModelingPrimitives.expression_tracking_variable( - model, f'{name}|duration', duration_expr, duration_bounds + model, f'{name}|on_hours_total', duration_expr, duration_bounds ) variables['total_duration'] = duration_vars['tracker'] constraints['duration_tracking'] = duration_constraints['tracking'] - # Switch tracking (existing logic) + # 4. Switch tracking using existing pattern if track_switches: switch_vars, switch_constraints = ModelingPrimitives.state_transition_variables( model, f'{name}|switches', variables['on'], previous_state @@ -621,7 +620,7 @@ def operational_binary_control_pattern( for switch_name, switch_constraint in switch_constraints.items(): constraints[f'switch_{switch_name}'] = switch_constraint - # NEW: Consecutive on duration tracking + # 5. Consecutive on duration using existing pattern if track_consecutive_on: min_on, max_on = consecutive_on_bounds consecutive_on_vars, consecutive_on_constraints = ModelingPrimitives.consecutive_duration_tracking( @@ -636,7 +635,7 @@ def operational_binary_control_pattern( for cons_name, cons_constraint in consecutive_on_constraints.items(): constraints[f'consecutive_on_{cons_name}'] = cons_constraint - # NEW: Consecutive off duration tracking + # 6. Consecutive off duration using existing pattern if track_consecutive_off and 'off' in variables: min_off, max_off = consecutive_off_bounds consecutive_off_vars, consecutive_off_constraints = ModelingPrimitives.consecutive_duration_tracking( From d1408a48e323bf06f6412508748bfc55143f873f Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 18 Jul 2025 13:56:58 +0200 Subject: [PATCH 204/336] Tyring to improve the Methods for bounding variables in different scenarios --- flixopt/modeling.py | 395 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 386 insertions(+), 9 deletions(-) diff --git a/flixopt/modeling.py b/flixopt/modeling.py index 64f0164d6..019652a0b 100644 --- a/flixopt/modeling.py +++ b/flixopt/modeling.py @@ -293,15 +293,70 @@ def state_transition_variables( return variables, constraints + @staticmethod + def proportional_bounds_with_binary_control( + model: FlowSystemModel, + bounded_variable, + binary_gate: linopy.Variable, + gate_bounds: Tuple[TemporalData, TemporalData], + upper_bound_name: str, + lower_bound_name: str, + scaling_variable=None, + relative_gate_bounds: Tuple[float, float] = None, + ) -> Tuple[Dict, Dict[str, linopy.Constraint]]: + """ + Creates proportional bounds with optional scaling and binary control. + + Args: + bounded_variable: Variable to apply bounds to + relative_bounds: (min_factor, max_factor) - either absolute bounds or factors for scaling + upper_bound_name: Name for the upper bound constraint + lower_bound_name: Name for the lower bound constraint + scaling_variable: Optional variable to scale bounds by (e.g., investment_size) + binary_gate: Optional binary variable that can disable lower bound when 0 + scaling_bounds: Optional (min_value, max_value) of scaling_variable for tighter big-M + + Returns: + variables: {} (no new variables created) + constraints: {'upper_bound': constraint, 'lower_bound': constraint} + """ + + # Determine base expressions for bounds + if scaling_variable is not None: + upper_expr = scaling_variable * relative_gate_bounds[1] + lower_expr = scaling_variable * relative_gate_bounds[0] + else: + upper_expr = gate_bounds[1] + lower_expr = gate_bounds[0] + + # Upper bound constraint + upper_bound = model.add_constraints(bounded_variable <= upper_expr, name=upper_bound_name) + + # Lower bound constraint + if binary_gate is None: + lower_bound = model.add_constraints(bounded_variable >= lower_expr, name=lower_bound_name) + else: + # Calculate tight big-M using scaling bounds if provided + big_m = np.minimum(absolute_bounds[1], CONFIG.modeling.BIG) if absolute_bounds is not None else CONFIG.modeling.BIG + + lower_bound = model.add_constraints( + bounded_variable >= big_m * (binary_gate - 1) + lower_expr, name=lower_bound_name + ) + + variables = {} + constraints = {'upper_bound': upper_bound, 'lower_bound': lower_bound} + return variables, constraints + @staticmethod def big_m_binary_bounds( model: FlowSystemModel, - variable, - binary_control, - size_variable, + bounded_variable: linopy.Variable, + scaling_variable: linopy.Variable, + binary_gate: linopy.Variable, relative_bounds: Tuple[TemporalData, TemporalData], upper_bound_name: str, lower_bound_name: str, + big_m: float = CONFIG.modeling.BIG, ) -> Tuple[Dict, Dict[str, linopy.Constraint]]: """ Creates bounds controlled by both binary and continuous variables. @@ -322,23 +377,345 @@ def big_m_binary_bounds( rel_lower, rel_upper = relative_bounds # Upper bound: variable ≤ size * upper_factor - upper_bound = model.add_constraints(variable <= size_variable * rel_upper, name=upper_bound_name) + upper_bound = model.add_constraints(bounded_variable <= scaling_variable * rel_upper, name=upper_bound_name) - if binary_control is None: - lower_bound = model.add_constraints(variable >= size_variable * rel_lower, name=lower_bound_name) + if binary_gate is None: + lower_bound = model.add_constraints(bounded_variable >= scaling_variable * rel_lower, name=lower_bound_name) else: # Big-M lower bound: variable ≥ M*(binary-1) + size*lower_factor - big_m = CONFIG.modeling.BIG #size_variable.max() * rel_upper.max() # Conservative big-M lower_bound = model.add_constraints( - variable >= big_m * (binary_control - 1) + size_variable * rel_lower, name=lower_bound_name + bounded_variable >= big_m * (binary_gate - 1) + scaling_variable * rel_lower, name=lower_bound_name ) - variables = {} # No new variables created constraints = {'upper_bound': upper_bound, 'lower_bound': lower_bound} return variables, constraints + @staticmethod + def binary_controlled_bounds( + model: FlowSystemModel, + variable: linopy.Variable, + bounds: Tuple[TemporalData, TemporalData], + binary_control: linopy.Variable, + upper_bound_name: str, + lower_bound_name: str, + ) -> Tuple[Dict, Dict[str, linopy.Constraint]]: + """ + Creates bounds controlled by a binary variable with epsilon handling. + + Mathematical formulation: + binary * max(ε, lower_bound) ≤ variable ≤ binary * upper_bound + + When binary = 1: normal bounds apply + When binary = 0: variable is forced to 0 + + Example use case - Investment bounds: + β_inv * max(ε, V^L) ≤ V ≤ β_inv * V^U + where β_inv is investment decision, V is investment size + + Args: + variable: Variable to be bounded + bounds: (lower_bound, upper_bound) absolute bounds + binary_control: Binary variable controlling the bounds + upper_bound_name: Name for upper bound constraint + lower_bound_name: Name for lower bound constraint + + Returns: + variables: {} (no new variables created) + constraints: {'upper_bound': constraint, 'lower_bound': constraint} + """ + lower_bound, upper_bound = bounds + + # Apply epsilon to lower bound to distinguish 0 from "very small positive" + epsilon_lower = np.maximum(CONFIG.modeling.EPSILON, lower_bound) + + upper_constraint = model.add_constraints(variable <= binary_control * upper_bound, name=upper_bound_name) + lower_constraint = model.add_constraints(variable >= binary_control * epsilon_lower, name=lower_bound_name) + + variables = {} + constraints = {'upper_bound': upper_constraint, 'lower_bound': lower_constraint} + return variables, constraints + + @staticmethod + def binary_scaled_bounds( + model: FlowSystemModel, + variable: linopy.Variable, + scaling_variable: linopy.Variable, + relative_bounds: Tuple[TemporalData, TemporalData], + binary_control: linopy.Variable, + upper_bound_name: str, + lower_bound_name: str, + ) -> Tuple[Dict, Dict[str, linopy.Constraint]]: + """ + Creates scaled bounds controlled by a binary variable. + + Mathematical formulation: + binary * max(ε, scaling * lower_factor) ≤ variable ≤ binary * scaling * upper_factor + + When binary = 1: variable bounded by scaled factors + When binary = 0: variable forced to 0 + + Example use case - Fixed size with on/off control: + β_on(t) * max(ε, P * p_rel^L(t)) ≤ p(t) ≤ β_on(t) * P * p_rel^U(t) + where β_on is on/off state, P is fixed size, p is flow rate + + Args: + variable: Variable to be bounded + scaling_variable: Variable to scale the bounds by + relative_bounds: (lower_factor, upper_factor) relative to scaling variable + binary_control: Binary variable controlling the bounds + upper_bound_name: Name for upper bound constraint + lower_bound_name: Name for lower bound constraint + + Returns: + variables: {} (no new variables created) + constraints: {'upper_bound': constraint, 'lower_bound': constraint} + """ + rel_lower, rel_upper = relative_bounds + + # Calculate scaled expressions + upper_expr = scaling_variable * rel_upper + lower_expr = scaling_variable * rel_lower + + # Apply epsilon to lower expression + epsilon_lower = np.maximum(CONFIG.modeling.EPSILON, lower_expr) + + upper_constraint = model.add_constraints(variable <= binary_control * upper_expr, name=upper_bound_name) + lower_constraint = model.add_constraints(variable >= binary_control * epsilon_lower, name=lower_bound_name) + + variables = {} + constraints = {'upper_bound': upper_constraint, 'lower_bound': lower_constraint} + return variables, constraints + + @staticmethod + def big_m_dual_control_bounds( + model: FlowSystemModel, + variable: linopy.Variable, + scaling_variable: linopy.Variable, + relative_bounds: Tuple[TemporalData, TemporalData], + binary_control: linopy.Variable, + scaling_bounds: Tuple[TemporalData, TemporalData], + constraint_name_prefix: str, + ) -> Tuple[Dict, Dict[str, linopy.Constraint]]: + """ + Creates bounds with both binary and continuous variable control using big-M formulation. + + Mathematical formulation: + # Binary control with big-M bounds: + binary * max(ε, scaling_min * lower_factor) ≤ variable ≤ binary * M + # Continuous scaling bounds: + M * (binary - 1) + scaling * lower_factor ≤ variable ≤ scaling * upper_factor + + Where M = scaling_max * upper_factor + + This maintains linearity when both binary and continuous controls are present. + + Example use case - Variable investment size with on/off control: + β_on(t) * max(ε, P^L * p_rel^L(t)) ≤ p(t) ≤ β_on(t) * M(t) + M(t) * (β_on(t) - 1) + P * p_rel^L(t) ≤ p(t) ≤ P * p_rel^U(t) + where β_on is on/off state, P is variable investment size, p is flow rate + + Args: + variable: Variable to be bounded + scaling_variable: Continuous variable that scales the bounds + relative_bounds: (lower_factor, upper_factor) relative to scaling variable + binary_control: Binary variable for on/off control + scaling_bounds: (scaling_min, scaling_max) bounds of the scaling variable + constraint_name_prefix: Prefix for constraint names + + Returns: + variables: {} (no new variables created) + constraints: { + 'binary_lower': binary-controlled lower bound, + 'binary_upper': binary-controlled upper bound, + 'scaling_lower': scaling-controlled lower bound, + 'scaling_upper': scaling-controlled upper bound + } + """ + rel_lower, rel_upper = relative_bounds + scaling_min, scaling_max = scaling_bounds + + # Calculate big-M as maximum possible value + big_m = rel_upper * scaling_max + + # Binary-controlled lower bound with epsilon + epsilon_lower = np.maximum(CONFIG.modeling.EPSILON, rel_lower * scaling_min) + binary_lower = model.add_constraints( + binary_control * epsilon_lower <= variable, name=f'{constraint_name_prefix}|binary_lower' + ) + + # Binary-controlled upper bound with big-M + binary_upper = model.add_constraints( + variable <= binary_control * big_m, name=f'{constraint_name_prefix}|binary_upper' + ) + + # Scaling-controlled lower bound with big-M relaxation + scaling_lower = model.add_constraints( + big_m * (binary_control - 1) + scaling_variable * rel_lower <= variable, + name=f'{constraint_name_prefix}|scaling_lower', + ) + + # Scaling-controlled upper bound + scaling_upper = model.add_constraints( + variable <= scaling_variable * rel_upper, name=f'{constraint_name_prefix}|scaling_upper' + ) + + variables = {} + constraints = { + 'binary_lower': binary_lower, + 'binary_upper': binary_upper, + 'scaling_lower': scaling_lower, + 'scaling_upper': scaling_upper, + } + return variables, constraints + + @staticmethod + def scaled_bounds( + model: FlowSystemModel, + variable: linopy.Variable, + scaling_variable: linopy.Variable, + relative_bounds: Tuple[TemporalData, TemporalData], + upper_bound_name: str, + lower_bound_name: str, + ) -> Tuple[Dict, Dict[str, linopy.Constraint]]: + """ + Creates simple bounds scaled by another variable. + + Mathematical formulation: + scaling * lower_factor ≤ variable ≤ scaling * upper_factor + + Example use case - Flow rate bounded by size: + P * p_rel^L(t) ≤ p(t) ≤ P * p_rel^U(t) + where P is size, p is flow rate + + Args: + variable: Variable to be bounded + scaling_variable: Variable to scale the bounds by + relative_bounds: (lower_factor, upper_factor) relative to scaling variable + upper_bound_name: Name for upper bound constraint + lower_bound_name: Name for lower bound constraint + + Returns: + variables: {} (no new variables created) + constraints: {'upper_bound': constraint, 'lower_bound': constraint} + """ + rel_lower, rel_upper = relative_bounds + + upper_constraint = model.add_constraints(variable <= scaling_variable * rel_upper, name=upper_bound_name) + lower_constraint = model.add_constraints(variable >= scaling_variable * rel_lower, name=lower_bound_name) + + variables = {} + constraints = {'upper_bound': upper_constraint, 'lower_bound': lower_constraint} + return variables, constraints + + @staticmethod + def auto_bounds( + model: FlowSystemModel, + variable: linopy.Variable, + bounds: Tuple[TemporalData, TemporalData], + upper_bound_name: str, + lower_bound_name: str, + scaling_variable: linopy.Variable = None, + binary_control: linopy.Variable = None, + scaling_bounds: Tuple[TemporalData, TemporalData] = None, + constraint_name_prefix: str = None, + ) -> Tuple[Dict, Dict[str, linopy.Constraint]]: + """ + Automatically selects the appropriate bounds method based on provided parameters. + + Parameter combinations and resulting method calls: + + 1. Only bounds → Simple absolute bounds: + lower_bound ≤ variable ≤ upper_bound + + 2. bounds + scaling_variable → scaled_bounds(): + scaling * lower_factor ≤ variable ≤ scaling * upper_factor + + 3. bounds + binary_control → binary_controlled_bounds(): + binary * max(ε, lower_bound) ≤ variable ≤ binary * upper_bound + + 4. bounds + scaling_variable + binary_control → binary_scaled_bounds(): + binary * max(ε, scaling * lower_factor) ≤ variable ≤ binary * scaling * upper_factor + + 5. All parameters → big_m_dual_control_bounds(): + Complex big-M formulation for binary + variable scaling control + + Args: + variable: Variable to be bounded + bounds: (lower, upper) - absolute bounds or relative factors if scaling + upper_bound_name: Name for upper bound constraint + lower_bound_name: Name for lower bound constraint + scaling_variable: Optional variable to scale bounds by + binary_control: Optional binary variable for on/off control + scaling_bounds: Required if using big-M (case 5), bounds of scaling variable + constraint_name_prefix: Required if using big-M (case 5) + + Returns: + Same as the underlying primitive method + + Raises: + ValueError: If big-M case is detected but required parameters are missing + """ + + # Case 5: Big-M dual control (most complex) + if scaling_variable is not None and binary_control is not None and scaling_bounds is not None: + if constraint_name_prefix is None: + raise ValueError('constraint_name_prefix is required when using big-M dual control') + + return ModelingPrimitives.big_m_dual_control_bounds( + model=model, + variable=variable, + scaling_variable=scaling_variable, + relative_bounds=bounds, + binary_control=binary_control, + scaling_bounds=scaling_bounds, + constraint_name_prefix=constraint_name_prefix, + ) + + # Case 4: Binary + scaling (fixed size with on/off) + elif scaling_variable is not None and binary_control is not None: + return ModelingPrimitives.binary_scaled_bounds( + model=model, + variable=variable, + scaling_variable=scaling_variable, + relative_bounds=bounds, + binary_control=binary_control, + upper_bound_name=upper_bound_name, + lower_bound_name=lower_bound_name, + ) + + # Case 3: Binary only (investment decision) + elif binary_control is not None: + return ModelingPrimitives.binary_controlled_bounds( + model=model, + variable=variable, + bounds=bounds, + binary_control=binary_control, + upper_bound_name=upper_bound_name, + lower_bound_name=lower_bound_name, + ) + + # Case 2: Scaling only (size-dependent bounds) + elif scaling_variable is not None: + return ModelingPrimitives.scaled_bounds( + model=model, + variable=variable, + scaling_variable=scaling_variable, + relative_bounds=bounds, + upper_bound_name=upper_bound_name, + lower_bound_name=lower_bound_name, + ) + + # Case 1: Simple absolute bounds + else: + upper_constraint = model.add_constraints(variable <= bounds[1], name=upper_bound_name) + lower_constraint = model.add_constraints(variable >= bounds[0], name=lower_bound_name) + + variables = {} + constraints = {'upper_bound': upper_constraint, 'lower_bound': lower_constraint} + return variables, constraints + @staticmethod def consecutive_duration_tracking( model: FlowSystemModel, From ab000ca804d31f1aa9ef938db56a4fdd6536aeb5 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 18 Jul 2025 17:33:21 +0200 Subject: [PATCH 205/336] Improve BoundingPatterns --- flixopt/modeling.py | 908 +++++++++++++++++++++++--------------------- 1 file changed, 484 insertions(+), 424 deletions(-) diff --git a/flixopt/modeling.py b/flixopt/modeling.py index 019652a0b..a443c3a74 100644 --- a/flixopt/modeling.py +++ b/flixopt/modeling.py @@ -193,7 +193,7 @@ def proportionally_bounded_variable( Returns: variables: {'variable': bounded_var} - constraints: {'lower_bound': constraint, 'upper_bound': constraint} + constraints: {'lb': constraint, 'ub': constraint} """ coords = coords or ['time'] variable = model.add_variables(name=f'{name}|bounded', coords=model.get_coords(coords)) @@ -209,7 +209,7 @@ def proportionally_bounded_variable( ) variables = {'variable': variable} - constraints = {'lower_bound': lower_bound, 'upper_bound': upper_bound} + constraints = {'lb': lower_bound, 'ub': upper_bound} return variables, constraints @@ -294,561 +294,639 @@ def state_transition_variables( return variables, constraints @staticmethod - def proportional_bounds_with_binary_control( + def consecutive_duration_tracking( model: FlowSystemModel, - bounded_variable, - binary_gate: linopy.Variable, - gate_bounds: Tuple[TemporalData, TemporalData], - upper_bound_name: str, - lower_bound_name: str, - scaling_variable=None, - relative_gate_bounds: Tuple[float, float] = None, - ) -> Tuple[Dict, Dict[str, linopy.Constraint]]: + name: str, + state_variable: linopy.Variable, + minimum_duration: Optional[TemporalData] = None, + maximum_duration: Optional[TemporalData] = None, + previous_duration: TemporalData = 0, + ) -> Tuple[Dict[str, linopy.Variable], Dict[str, linopy.Constraint]]: """ - Creates proportional bounds with optional scaling and binary control. + Creates consecutive duration tracking for a binary state variable. + + Mathematical formulation: + duration[t] ≤ state[t] * M ∀t + duration[t+1] ≤ duration[t] + hours_per_step[t] ∀t + duration[t+1] ≥ duration[t] + hours_per_step[t] + (state[t+1] - 1) * M ∀t + duration[0] = (hours_per_step[0] + previous_duration) * state[0] + + If minimum_duration provided: + duration[t] ≥ (state[t-1] - state[t]) * minimum_duration[t-1] ∀t > 0 Args: - bounded_variable: Variable to apply bounds to - relative_bounds: (min_factor, max_factor) - either absolute bounds or factors for scaling - upper_bound_name: Name for the upper bound constraint - lower_bound_name: Name for the lower bound constraint - scaling_variable: Optional variable to scale bounds by (e.g., investment_size) - binary_gate: Optional binary variable that can disable lower bound when 0 - scaling_bounds: Optional (min_value, max_value) of scaling_variable for tighter big-M + state_variable: Binary state variable to track duration for + minimum_duration: Optional minimum consecutive duration + maximum_duration: Optional maximum consecutive duration + previous_duration: Duration from before first timestep Returns: - variables: {} (no new variables created) - constraints: {'upper_bound': constraint, 'lower_bound': constraint} + variables: {'duration': duration_var} + constraints: {'ub': constraint, 'forward': constraint, 'backward': constraint, ...} """ + hours_per_step = model.hours_per_step + mega = hours_per_step.sum('time') + previous_duration # Big-M value - # Determine base expressions for bounds - if scaling_variable is not None: - upper_expr = scaling_variable * relative_gate_bounds[1] - lower_expr = scaling_variable * relative_gate_bounds[0] - else: - upper_expr = gate_bounds[1] - lower_expr = gate_bounds[0] + # Duration variable + duration = model.add_variables( + lower=0, + upper=maximum_duration if maximum_duration is not None else mega, + coords=model.get_coords(['time']), + name=f'{name}|duration', + ) - # Upper bound constraint - upper_bound = model.add_constraints(bounded_variable <= upper_expr, name=upper_bound_name) + constraints = {} - # Lower bound constraint - if binary_gate is None: - lower_bound = model.add_constraints(bounded_variable >= lower_expr, name=lower_bound_name) - else: - # Calculate tight big-M using scaling bounds if provided - big_m = np.minimum(absolute_bounds[1], CONFIG.modeling.BIG) if absolute_bounds is not None else CONFIG.modeling.BIG + # Upper bound: duration[t] ≤ state[t] * M + constraints['ub'] = model.add_constraints( + duration <= state_variable * mega, name=f'{name}|duration_upper_bound' + ) + + # Forward constraint: duration[t+1] ≤ duration[t] + hours_per_step[t] + constraints['forward'] = model.add_constraints( + duration.isel(time=slice(1, None)) + <= duration.isel(time=slice(None, -1)) + hours_per_step.isel(time=slice(None, -1)), + name=f'{name}|duration_forward', + ) - lower_bound = model.add_constraints( - bounded_variable >= big_m * (binary_gate - 1) + lower_expr, name=lower_bound_name + # Backward constraint: duration[t+1] ≥ duration[t] + hours_per_step[t] + (state[t+1] - 1) * M + constraints['backward'] = model.add_constraints( + duration.isel(time=slice(1, None)) + >= duration.isel(time=slice(None, -1)) + + hours_per_step.isel(time=slice(None, -1)) + + (state_variable.isel(time=slice(1, None)) - 1) * mega, + name=f'{name}|duration_backward', + ) + + # Initial condition: duration[0] = (hours_per_step[0] + previous_duration) * state[0] + constraints['initial'] = model.add_constraints( + duration.isel(time=0) + == (hours_per_step.isel(time=0) + previous_duration) * state_variable.isel(time=0), + name=f'{name}|duration_initial', + ) + + # Minimum duration constraint if provided + if minimum_duration is not None: + constraints['minimum'] = model.add_constraints( + duration.isel(time=slice(1, None)) + >= (state_variable.isel(time=slice(None, -1)) - state_variable.isel(time=slice(1, None))) + * minimum_duration.isel(time=slice(None, -1)), + name=f'{name}|duration_minimum', ) - variables = {} - constraints = {'upper_bound': upper_bound, 'lower_bound': lower_bound} + # Handle initial condition for minimum duration + if previous_duration > 0 and previous_duration < minimum_duration.isel(time=0).max(): + constraints['initial_minimum'] = model.add_constraints( + state_variable.isel(time=0) == 1, name=f'{name}|duration_initial_minimum' + ) + + variables = {'duration': duration} + return variables, constraints @staticmethod - def big_m_binary_bounds( - model: FlowSystemModel, - bounded_variable: linopy.Variable, - scaling_variable: linopy.Variable, - binary_gate: linopy.Variable, - relative_bounds: Tuple[TemporalData, TemporalData], - upper_bound_name: str, - lower_bound_name: str, - big_m: float = CONFIG.modeling.BIG, + def mutual_exclusivity_constraint( + model: FlowSystemModel, name: str, binary_variables: List[linopy.Variable], tolerance: float = 1.1 ) -> Tuple[Dict, Dict[str, linopy.Constraint]]: """ - Creates bounds controlled by both binary and continuous variables. + Creates mutual exclusivity constraint for binary variables. Mathematical formulation: - variable[t] ≤ size[t] * upper_factor[t] ∀t + Σ(binary_vars[i]) ≤ tolerance ∀t - If binary_control provided: - variable[t] ≥ M * (binary[t] - 1) + size[t] * lower_factor[t] ∀t - where M = max(size) * max(upper_factor) - Else: - variable[t] ≥ size[t] * lower_factor[t] ∀t + Ensures at most one binary variable can be 1 at any time. + Tolerance > 1.0 accounts for binary variable numerical precision. + + Args: + binary_variables: List of binary variables that should be mutually exclusive + tolerance: Upper bound (typically 1.1 for numerical stability) Returns: variables: {} (no new variables created) - constraints: {'upper_bound': constraint, 'lower_bound': constraint} - """ - rel_lower, rel_upper = relative_bounds + constraints: {'mutual_exclusivity': constraint} - # Upper bound: variable ≤ size * upper_factor - upper_bound = model.add_constraints(bounded_variable <= scaling_variable * rel_upper, name=upper_bound_name) + Raises: + AssertionError: If fewer than 2 variables provided or variables aren't binary + """ + assert len(binary_variables) >= 2, ( + f'Mutual exclusivity requires at least 2 variables, got {len(binary_variables)}' + ) - if binary_gate is None: - lower_bound = model.add_constraints(bounded_variable >= scaling_variable * rel_lower, name=lower_bound_name) - else: - # Big-M lower bound: variable ≥ M*(binary-1) + size*lower_factor - lower_bound = model.add_constraints( - bounded_variable >= big_m * (binary_gate - 1) + scaling_variable * rel_lower, name=lower_bound_name + for var in binary_variables: + assert var.attrs.get('binary', False), ( + f'Variable {var.name} must be binary for mutual exclusivity constraint' ) + # Create mutual exclusivity constraint + mutual_exclusivity = model.add_constraints( + sum(binary_variables) <= tolerance, name=f'{name}|mutual_exclusivity' + ) + variables = {} # No new variables created - constraints = {'upper_bound': upper_bound, 'lower_bound': lower_bound} + constraints = {'mutual_exclusivity': mutual_exclusivity} return variables, constraints + +class BoundingPatterns: + """High-level patterns that compose primitives and return (variables, constraints) tuples""" + + @staticmethod + def basic_bounds( + model: FlowSystemModel, + variable: linopy.Variable, + bounds: Tuple[TemporalData, TemporalData], + ): + """Create simple bounds. + + Args: + model: The optimization model instance + variable: Variable to be bounded + bounds: Tuple of (lower_bound, upper_bound) absolute bounds + + Returns: + Tuple containing: + - variables (Dict): Empty dict (no new variables created) + - constraints (Dict[str, linopy.Constraint]): Dictionary with keys: + - 'ub': Upper bound constraint + - 'lb': Lower bound constraint + """ + lower_bound, upper_bound = bounds + + upper_constraint = model.add_constraints(variable <= upper_bound, name=f'{variable.name}|ub') + lower_constraint = model.add_constraints(variable >= lower_bound, name=f'{variable.name}|lb') + + return {}, {'ub': upper_constraint, 'lb': lower_constraint} + @staticmethod def binary_controlled_bounds( model: FlowSystemModel, variable: linopy.Variable, bounds: Tuple[TemporalData, TemporalData], binary_control: linopy.Variable, - upper_bound_name: str, - lower_bound_name: str, ) -> Tuple[Dict, Dict[str, linopy.Constraint]]: - """ - Creates bounds controlled by a binary variable with epsilon handling. + """Create bounds controlled by a binary variable with epsilon handling. - Mathematical formulation: + This method implements binary-controlled bounds where a binary variable acts as an on/off + switch for the bounded variable. When the binary is 1, normal bounds apply; when 0, the + variable is forced to zero. + + Mathematical Formulation: binary * max(ε, lower_bound) ≤ variable ≤ binary * upper_bound - When binary = 1: normal bounds apply - When binary = 0: variable is forced to 0 + Where: + - binary ∈ {0, 1}: Control variable + - ε: Small positive constant (CONFIG.modeling.EPSILON) + - When binary = 1: Normal bounds apply + - When binary = 0: Variable is forced to 0 + + Use Cases: + - Investment decisions (invest or don't invest) + - Unit commitment (on/off operational states) + - Feature selection in optimization models - Example use case - Investment bounds: + Example: + Investment bounds where β_inv controls whether investment occurs: β_inv * max(ε, V^L) ≤ V ≤ β_inv * V^U - where β_inv is investment decision, V is investment size Args: + model: The optimization model instance variable: Variable to be bounded - bounds: (lower_bound, upper_bound) absolute bounds + bounds: Tuple of (lower_bound, upper_bound) absolute bounds binary_control: Binary variable controlling the bounds - upper_bound_name: Name for upper bound constraint - lower_bound_name: Name for lower bound constraint Returns: - variables: {} (no new variables created) - constraints: {'upper_bound': constraint, 'lower_bound': constraint} + Tuple containing: + - variables (Dict): Empty dict (no new variables created) + - constraints (Dict[str, linopy.Constraint]): Dictionary with keys: + - 'ub': Upper bound constraint + - 'lb': Lower bound constraint + - 'fix': Fix constraint, if upper bound is equal to lower bound + + Note: + The epsilon value is applied to the lower bound to distinguish between + zero and "very small positive" values, which is important for numerical + stability in optimization solvers. """ lower_bound, upper_bound = bounds + if np.all(lower_bound - upper_bound) < 1e-10: + fix_constraint = model.add_constraints( + variable == binary_control * upper_bound, name=f'{variable.name}|fixed_size' + ) + return {}, {'ub': fix_constraint, 'lb': fix_constraint} + # Apply epsilon to lower bound to distinguish 0 from "very small positive" - epsilon_lower = np.maximum(CONFIG.modeling.EPSILON, lower_bound) + epsilon = np.maximum(CONFIG.modeling.EPSILON, lower_bound) - upper_constraint = model.add_constraints(variable <= binary_control * upper_bound, name=upper_bound_name) - lower_constraint = model.add_constraints(variable >= binary_control * epsilon_lower, name=lower_bound_name) + upper_constraint = model.add_constraints(variable <= binary_control * upper_bound, name=f'{variable.name}|ub') + lower_constraint = model.add_constraints(variable >= binary_control * epsilon, name=f'{variable.name}|lb') - variables = {} - constraints = {'upper_bound': upper_constraint, 'lower_bound': lower_constraint} - return variables, constraints + return {}, {'ub': upper_constraint, 'lb': lower_constraint} @staticmethod - def binary_scaled_bounds( + def scaled_bounds( model: FlowSystemModel, variable: linopy.Variable, scaling_variable: linopy.Variable, relative_bounds: Tuple[TemporalData, TemporalData], - binary_control: linopy.Variable, - upper_bound_name: str, - lower_bound_name: str, ) -> Tuple[Dict, Dict[str, linopy.Constraint]]: - """ - Creates scaled bounds controlled by a binary variable. + """Create simple bounds scaled by another variable. - Mathematical formulation: - binary * max(ε, scaling * lower_factor) ≤ variable ≤ binary * scaling * upper_factor + This method creates proportional bounds where the actual bounds are determined + by multiplying relative factors with a scaling variable. This is useful for + capacity-dependent constraints. - When binary = 1: variable bounded by scaled factors - When binary = 0: variable forced to 0 + Mathematical Formulation: + scaling * lower_factor ≤ variable ≤ scaling * upper_factor + + Where: + - scaling: Continuous scaling variable (e.g., capacity, size) + - lower_factor, upper_factor: Relative bound multipliers + + Use Cases: + - Flow rates bounded by equipment capacity + - Production levels scaled by plant size + - Resource consumption proportional to activity level - Example use case - Fixed size with on/off control: - β_on(t) * max(ε, P * p_rel^L(t)) ≤ p(t) ≤ β_on(t) * P * p_rel^U(t) - where β_on is on/off state, P is fixed size, p is flow rate + Example: + Flow rate bounded by equipment size: + P * p_rel^L(t) ≤ p(t) ≤ P * p_rel^U(t) + where P is equipment size, p is flow rate Args: + model: The optimization model instance variable: Variable to be bounded - scaling_variable: Variable to scale the bounds by - relative_bounds: (lower_factor, upper_factor) relative to scaling variable - binary_control: Binary variable controlling the bounds - upper_bound_name: Name for upper bound constraint - lower_bound_name: Name for lower bound constraint + scaling_variable: Variable that scales the bound factors + relative_bounds: Tuple of (lower_factor, upper_factor) relative to scaling variable Returns: - variables: {} (no new variables created) - constraints: {'upper_bound': constraint, 'lower_bound': constraint} + Tuple containing: + - variables (Dict): Empty dict (no new variables created) + - constraints (Dict[str, linopy.Constraint]): Dictionary with keys: + - 'ub': Upper bound constraint + - 'lb': Lower bound constraint + + Note: + This method assumes the scaling variable is always non-negative. + For negative scaling variables, the inequality directions would need adjustment. """ rel_lower, rel_upper = relative_bounds - # Calculate scaled expressions - upper_expr = scaling_variable * rel_upper - lower_expr = scaling_variable * rel_lower - - # Apply epsilon to lower expression - epsilon_lower = np.maximum(CONFIG.modeling.EPSILON, lower_expr) - - upper_constraint = model.add_constraints(variable <= binary_control * upper_expr, name=upper_bound_name) - lower_constraint = model.add_constraints(variable >= binary_control * epsilon_lower, name=lower_bound_name) + upper_constraint = model.add_constraints(variable <= scaling_variable * rel_upper, name=f'{variable}|ub') + lower_constraint = model.add_constraints(variable >= scaling_variable * rel_lower, name=f'{variable}|lb') variables = {} - constraints = {'upper_bound': upper_constraint, 'lower_bound': lower_constraint} + constraints = {'ub': upper_constraint, 'lb': lower_constraint} return variables, constraints @staticmethod - def big_m_dual_control_bounds( + def binary_scaled_bounds( model: FlowSystemModel, variable: linopy.Variable, scaling_variable: linopy.Variable, relative_bounds: Tuple[TemporalData, TemporalData], binary_control: linopy.Variable, scaling_bounds: Tuple[TemporalData, TemporalData], - constraint_name_prefix: str, ) -> Tuple[Dict, Dict[str, linopy.Constraint]]: - """ - Creates bounds with both binary and continuous variable control using big-M formulation. + """Create scaled bounds controlled by a binary variable using linear big-M formulation. - Mathematical formulation: - # Binary control with big-M bounds: - binary * max(ε, scaling_min * lower_factor) ≤ variable ≤ binary * M - # Continuous scaling bounds: - M * (binary - 1) + scaling * lower_factor ≤ variable ≤ scaling * upper_factor + Desired (Non-Linear) Formulation: + binary * max(ε, scaling * lower_factor) ≤ variable ≤ binary * scaling * upper_factor - Where M = scaling_max * upper_factor + Actual Linear Big-M Formulation: + # When binary = 1: scaling bounds apply + scaling * lower_factor ≤ variable ≤ scaling * upper_factor + + # When binary = 0: variable forced to 0 + variable ≤ binary * M_upper + variable ≥ binary * M_lower + + Where: + M_upper = scaling_max * upper_factor + M_lower = max(ε, scaling_min * lower_factor) - This maintains linearity when both binary and continuous controls are present. + Behavior: + - When binary = 1: Variable bounded by scaling * factors (normal operation) + - When binary = 0: Variable forced to 0 (off state) - Example use case - Variable investment size with on/off control: - β_on(t) * max(ε, P^L * p_rel^L(t)) ≤ p(t) ≤ β_on(t) * M(t) - M(t) * (β_on(t) - 1) + P * p_rel^L(t) ≤ p(t) ≤ P * p_rel^U(t) - where β_on is on/off state, P is variable investment size, p is flow rate + Use Cases: + - Fixed-size units with on/off control + - Capacity-scaled operations with binary states + - Process units with binary operational modes + + Example: + Power plant with capacity P ∈ [P_min, P_max] and on/off control β_on: + Linear formulation replaces: β_on(t) * P * p_rel^L(t) ≤ p(t) ≤ β_on(t) * P * p_rel^U(t) Args: + model: The optimization model instance variable: Variable to be bounded - scaling_variable: Continuous variable that scales the bounds - relative_bounds: (lower_factor, upper_factor) relative to scaling variable + scaling_variable: Variable that scales the bound factors + relative_bounds: Tuple of (lower_factor, upper_factor) relative to scaling variable binary_control: Binary variable for on/off control - scaling_bounds: (scaling_min, scaling_max) bounds of the scaling variable - constraint_name_prefix: Prefix for constraint names + scaling_bounds: Tuple of (scaling_min, scaling_max) bounds of the scaling variable Returns: - variables: {} (no new variables created) - constraints: { - 'binary_lower': binary-controlled lower bound, - 'binary_upper': binary-controlled upper bound, - 'scaling_lower': scaling-controlled lower bound, - 'scaling_upper': scaling-controlled upper bound - } + Tuple containing: + - variables (Dict): Empty dict (no new variables created) + - constraints (Dict[str, linopy.Constraint]): Dictionary with keys: + - 'ub': Upper bound constraint + - 'lb': Lower bound constraint + + Note: + This method now requires scaling_bounds to compute appropriate big-M values. + The big-M formulation maintains linearity while preserving the intended behavior. """ rel_lower, rel_upper = relative_bounds scaling_min, scaling_max = scaling_bounds - # Calculate big-M as maximum possible value - big_m = rel_upper * scaling_max + # Calculate big-M values for upper and lower bounds + big_m_upper = scaling_max * rel_upper + big_m_lower = np.maximum(CONFIG.modeling.EPSILON, scaling_min * rel_lower) - # Binary-controlled lower bound with epsilon - epsilon_lower = np.maximum(CONFIG.modeling.EPSILON, rel_lower * scaling_min) - binary_lower = model.add_constraints( - binary_control * epsilon_lower <= variable, name=f'{constraint_name_prefix}|binary_lower' - ) + # Linear constraints using big-M technique: + # When binary = 1: normal scaling bounds apply + # When binary = 0: variable forced to 0 - # Binary-controlled upper bound with big-M + # Upper bound: variable ≤ min(scaling * rel_upper, binary * big_m_upper) + # Implemented as two constraints: + scaling_upper = model.add_constraints( + variable <= scaling_variable * rel_upper, name=f'{scaling_variable.name}|ub' + ) binary_upper = model.add_constraints( - variable <= binary_control * big_m, name=f'{constraint_name_prefix}|binary_upper' + variable <= binary_control * big_m_upper, name=f'{variable.name}|ub' ) - # Scaling-controlled lower bound with big-M relaxation + # Lower bound: variable ≥ max(scaling * rel_lower, binary * big_m_lower) + # When binary = 0: second constraint gives variable ≥ 0 + # When binary = 1: first constraint is active scaling_lower = model.add_constraints( - big_m * (binary_control - 1) + scaling_variable * rel_lower <= variable, - name=f'{constraint_name_prefix}|scaling_lower', + variable >= scaling_variable * rel_lower, name=f'{scaling_variable.name}|ub' ) - - # Scaling-controlled upper bound - scaling_upper = model.add_constraints( - variable <= scaling_variable * rel_upper, name=f'{constraint_name_prefix}|scaling_upper' + binary_lower = model.add_constraints( + variable >= binary_control * big_m_lower, name=f'{variable.name}|lb' ) variables = {} constraints = { - 'binary_lower': binary_lower, - 'binary_upper': binary_upper, - 'scaling_lower': scaling_lower, - 'scaling_upper': scaling_upper, + 'ub': scaling_upper, # Primary upper bound constraint + 'lb': scaling_lower, # Primary lower bound constraint + 'binary_upper': binary_upper, # Binary control upper bound + 'binary_lower': binary_lower, # Binary control lower bound } return variables, constraints @staticmethod - def scaled_bounds( + def dual_binary_scaled_bounds( model: FlowSystemModel, variable: linopy.Variable, scaling_variable: linopy.Variable, relative_bounds: Tuple[TemporalData, TemporalData], - upper_bound_name: str, - lower_bound_name: str, + scaling_binary: linopy.Variable, + secondary_binary: linopy.Variable, + scaling_bounds: Tuple[TemporalData, TemporalData], ) -> Tuple[Dict, Dict[str, linopy.Constraint]]: - """ - Creates simple bounds scaled by another variable. + """Create bounds with dual binary control over a scaled variable. - Mathematical formulation: - scaling * lower_factor ≤ variable ≤ scaling * upper_factor + This method implements the most complex bounding case where you have two binary variables + controlling a scaled relationship between variables. This is commonly used for investment + and operational control scenarios. - Example use case - Flow rate bounded by size: - P * p_rel^L(t) ≤ p(t) ≤ P * p_rel^U(t) - where P is size, p is flow rate + Hierarchical Control: + 1. scaling_binary: Controls whether the scaling variable can be non-zero + 2. Secondary binary: Controls whether the main variable can be non-zero (given scaling exists) - Args: - variable: Variable to be bounded - scaling_variable: Variable to scale the bounds by - relative_bounds: (lower_factor, upper_factor) relative to scaling variable - upper_bound_name: Name for upper bound constraint - lower_bound_name: Name for lower bound constraint + Mathematical Formulation: - Returns: - variables: {} (no new variables created) - constraints: {'upper_bound': constraint, 'lower_bound': constraint} - """ - rel_lower, rel_upper = relative_bounds + Scaling variable bounds: + scaling_binary * max(ε, scaling_min) ≤ scaling_variable ≤ scaling_binary * scaling_max - upper_constraint = model.add_constraints(variable <= scaling_variable * rel_upper, name=upper_bound_name) - lower_constraint = model.add_constraints(variable >= scaling_variable * rel_lower, name=lower_bound_name) + Main variable bounds with dual control: + secondary_binary * max(ε, rel_lower * scaling_min) ≤ variable ≤ secondary_binary * M + M * (secondary_binary - 1) + scaling_variable * rel_lower ≤ variable ≤ scaling_variable * rel_upper - variables = {} - constraints = {'upper_bound': upper_constraint, 'lower_bound': lower_constraint} - return variables, constraints + Where: M = rel_upper * scaling_max - @staticmethod - def auto_bounds( - model: FlowSystemModel, - variable: linopy.Variable, - bounds: Tuple[TemporalData, TemporalData], - upper_bound_name: str, - lower_bound_name: str, - scaling_variable: linopy.Variable = None, - binary_control: linopy.Variable = None, - scaling_bounds: Tuple[TemporalData, TemporalData] = None, - constraint_name_prefix: str = None, - ) -> Tuple[Dict, Dict[str, linopy.Constraint]]: - """ - Automatically selects the appropriate bounds method based on provided parameters. - - Parameter combinations and resulting method calls: - - 1. Only bounds → Simple absolute bounds: - lower_bound ≤ variable ≤ upper_bound - - 2. bounds + scaling_variable → scaled_bounds(): - scaling * lower_factor ≤ variable ≤ scaling * upper_factor + Logical Behavior: + - scaling_binary = 0: No scaling capacity (scaling_variable = 0), no main variable (variable = 0) + - scaling_binary = 1, secondary_binary = 0: Scaling exists but main variable is off (variable = 0) + - scaling_binary = 1, secondary_binary = 1: Normal scaled operation - 3. bounds + binary_control → binary_controlled_bounds(): - binary * max(ε, lower_bound) ≤ variable ≤ binary * upper_bound + Use Cases: + - Investment + operational control (capacity sizing + on/off dispatch) + - Resource allocation + activation (budget + spending) + - Equipment sizing + utilization (capacity + operation) + - Feature selection + intensity (enable + level) - 4. bounds + scaling_variable + binary_control → binary_scaled_bounds(): - binary * max(ε, scaling * lower_factor) ≤ variable ≤ binary * scaling * upper_factor - - 5. All parameters → big_m_dual_control_bounds(): - Complex big-M formulation for binary + variable scaling control + Examples: + - Power plant: Build capacity? (primary) How big? (scaling) When to run? (secondary) + - Marketing: Enter market? (primary) Budget size? (scaling) Campaign active? (secondary) + - Production: Install line? (primary) Line capacity? (scaling) Line running? (secondary) Args: - variable: Variable to be bounded - bounds: (lower, upper) - absolute bounds or relative factors if scaling - upper_bound_name: Name for upper bound constraint - lower_bound_name: Name for lower bound constraint - scaling_variable: Optional variable to scale bounds by - binary_control: Optional binary variable for on/off control - scaling_bounds: Required if using big-M (case 5), bounds of scaling variable - constraint_name_prefix: Required if using big-M (case 5) + model: The optimization model instance + variable: Main variable to be bounded + scaling_variable: Variable that scales the bounds (e.g., capacity, size, budget) + relative_bounds: Tuple of (rel_lower, rel_upper) relative bound multipliers + scaling_binary: Binary controlling scaling_variable existence (e.g., investment decision) + secondary_binary: Binary controlling variable operation (e.g., operational on/off) + scaling_bounds: Tuple of (scaling_min, scaling_max) bounds for scaling_variable Returns: - Same as the underlying primitive method - - Raises: - ValueError: If big-M case is detected but required parameters are missing + Tuple containing: + - variables (Dict): Empty dict (no new variables created) + - constraints (Dict[str, linopy.Constraint]): Dictionary with keys: + - 'primary_scaling_ub': Primary control upper bound for scaling variable + - 'primary_scaling_lb': Primary control lower bound for scaling variable + - 'secondary_variable_ub': Secondary control upper bound for main variable + - 'secondary_variable_lb': Secondary control lower bound for main variable + - 'scaling_variable_ub': Scaling-dependent upper bound for main variable + - 'scaling_variable_lb': Scaling-dependent lower bound for main variable + + Note: + This implements hierarchical binary control where the primary binary enables the scaling + variable, and the secondary binary controls the main variable's operation within the + scaled bounds. Both binaries must be active for normal operation. """ + rel_lower, rel_upper = relative_bounds + scaling_min, scaling_max = scaling_bounds - # Case 5: Big-M dual control (most complex) - if scaling_variable is not None and binary_control is not None and scaling_bounds is not None: - if constraint_name_prefix is None: - raise ValueError('constraint_name_prefix is required when using big-M dual control') - - return ModelingPrimitives.big_m_dual_control_bounds( - model=model, - variable=variable, - scaling_variable=scaling_variable, - relative_bounds=bounds, - binary_control=binary_control, - scaling_bounds=scaling_bounds, - constraint_name_prefix=constraint_name_prefix, - ) + # Calculate big-M value for secondary control constraints + # M = rel_upper * scaling_max (maximum possible variable value) + big_m = rel_upper * scaling_max - # Case 4: Binary + scaling (fixed size with on/off) - elif scaling_variable is not None and binary_control is not None: - return ModelingPrimitives.binary_scaled_bounds( - model=model, - variable=variable, - scaling_variable=scaling_variable, - relative_bounds=bounds, - binary_control=binary_control, - upper_bound_name=upper_bound_name, - lower_bound_name=lower_bound_name, - ) + # 1. PRIMARY BINARY CONSTRAINTS FOR SCALING VARIABLE + # scaling_binary * max(ε, scaling_min) ≤ scaling_variable ≤ scaling_binary * scaling_max + epsilon_scaling = np.maximum(CONFIG.modeling.EPSILON, scaling_min) - # Case 3: Binary only (investment decision) - elif binary_control is not None: - return ModelingPrimitives.binary_controlled_bounds( - model=model, - variable=variable, - bounds=bounds, - binary_control=binary_control, - upper_bound_name=upper_bound_name, - lower_bound_name=lower_bound_name, - ) + primary_scaling_ub = model.add_constraints( + scaling_variable <= scaling_binary * scaling_max, name=f'{scaling_variable.name}|primary_ub' + ) - # Case 2: Scaling only (size-dependent bounds) - elif scaling_variable is not None: - return ModelingPrimitives.scaled_bounds( - model=model, - variable=variable, - scaling_variable=scaling_variable, - relative_bounds=bounds, - upper_bound_name=upper_bound_name, - lower_bound_name=lower_bound_name, - ) + primary_scaling_lb = model.add_constraints( + scaling_variable >= scaling_binary * epsilon_scaling, name=f'{scaling_variable.name}|primary_lb' + ) - # Case 1: Simple absolute bounds - else: - upper_constraint = model.add_constraints(variable <= bounds[1], name=upper_bound_name) - lower_constraint = model.add_constraints(variable >= bounds[0], name=lower_bound_name) + # 2. SECONDARY BINARY CONSTRAINTS FOR MAIN VARIABLE + # secondary_binary * max(ε, rel_lower * scaling_min) ≤ variable ≤ secondary_binary * M + epsilon_variable = np.maximum(CONFIG.modeling.EPSILON, rel_lower * scaling_min) - variables = {} - constraints = {'upper_bound': upper_constraint, 'lower_bound': lower_constraint} - return variables, constraints + secondary_variable_ub = model.add_constraints( + variable <= secondary_binary * big_m, name=f'{variable.name}|secondary_ub' + ) - @staticmethod - def consecutive_duration_tracking( - model: FlowSystemModel, - name: str, - state_variable: linopy.Variable, - minimum_duration: Optional[TemporalData] = None, - maximum_duration: Optional[TemporalData] = None, - previous_duration: TemporalData = 0, - ) -> Tuple[Dict[str, linopy.Variable], Dict[str, linopy.Constraint]]: - """ - Creates consecutive duration tracking for a binary state variable. + secondary_variable_lb = model.add_constraints( + variable >= secondary_binary * epsilon_variable, name=f'{variable.name}|secondary_lb' + ) - Mathematical formulation: - duration[t] ≤ state[t] * M ∀t - duration[t+1] ≤ duration[t] + hours_per_step[t] ∀t - duration[t+1] ≥ duration[t] + hours_per_step[t] + (state[t+1] - 1) * M ∀t - duration[0] = (hours_per_step[0] + previous_duration) * state[0] + # 3. SCALING-DEPENDENT CONSTRAINTS FOR MAIN VARIABLE + # M * (secondary_binary - 1) + scaling_variable * rel_lower ≤ variable ≤ scaling_variable * rel_upper - If minimum_duration provided: - duration[t] ≥ (state[t-1] - state[t]) * minimum_duration[t-1] ∀t > 0 + scaling_variable_ub = model.add_constraints( + variable <= scaling_variable * rel_upper, name=f'{variable.name}|scaling_ub' + ) - Args: - state_variable: Binary state variable to track duration for - minimum_duration: Optional minimum consecutive duration - maximum_duration: Optional maximum consecutive duration - previous_duration: Duration from before first timestep + scaling_variable_lb = model.add_constraints( + big_m * (secondary_binary - 1) + scaling_variable * rel_lower <= variable, + name=f'{variable.name}|scaling_lb', + ) - Returns: - variables: {'duration': duration_var} - constraints: {'upper_bound': constraint, 'forward': constraint, 'backward': constraint, ...} - """ - hours_per_step = model.hours_per_step - mega = hours_per_step.sum('time') + previous_duration # Big-M value + variables = {} + constraints = { + 'primary_scaling_ub': primary_scaling_ub, + 'primary_scaling_lb': primary_scaling_lb, + 'secondary_variable_ub': secondary_variable_ub, + 'secondary_variable_lb': secondary_variable_lb, + 'scaling_variable_ub': scaling_variable_ub, + 'scaling_variable_lb': scaling_variable_lb, + } - # Duration variable - duration = model.add_variables( - lower=0, - upper=maximum_duration if maximum_duration is not None else mega, - coords=model.get_coords(['time']), - name=f'{name}|duration', - ) + return variables, constraints - constraints = {} + @staticmethod + def auto_bounds( + model: FlowSystemModel, + variable: linopy.Variable, + variable_bounds: Tuple[TemporalData, TemporalData], + scaling_variable: linopy.Variable = None, + scaling_state: linopy.Variable = None, + scaling_bounds: Tuple[TemporalData, TemporalData] = None, + variable_state: linopy.Variable = None, + ) -> Tuple[Dict, Dict[str, linopy.Constraint]]: + """Automatically select the appropriate bounds method based on provided parameters. - # Upper bound: duration[t] ≤ state[t] * M - constraints['upper_bound'] = model.add_constraints( - duration <= state_variable * mega, name=f'{name}|duration_upper_bound' - ) + This intelligent dispatcher analyzes the provided parameters and automatically + selects the most appropriate bounding method. It simplifies the API by providing + a single entry point for all bounding scenarios. - # Forward constraint: duration[t+1] ≤ duration[t] + hours_per_step[t] - constraints['forward'] = model.add_constraints( - duration.isel(time=slice(1, None)) - <= duration.isel(time=slice(None, -1)) + hours_per_step.isel(time=slice(None, -1)), - name=f'{name}|duration_forward', - ) + Parameter Combinations and Method Selection: - # Backward constraint: duration[t+1] ≥ duration[t] + hours_per_step[t] + (state[t+1] - 1) * M - constraints['backward'] = model.add_constraints( - duration.isel(time=slice(1, None)) - >= duration.isel(time=slice(None, -1)) - + hours_per_step.isel(time=slice(None, -1)) - + (state_variable.isel(time=slice(1, None)) - 1) * mega, - name=f'{name}|duration_backward', - ) + 1. **Simple Bounds**: Only `bounds` provided + → Creates: lower_bound ≤ variable ≤ upper_bound - # Initial condition: duration[0] = (hours_per_step[0] + previous_duration) * state[0] - constraints['initial'] = model.add_constraints( - duration.isel(time=0) - == (hours_per_step.isel(time=0) + previous_duration) * state_variable.isel(time=0), - name=f'{name}|duration_initial', - ) + 2. **Scaled Bounds**: `bounds` + `scaling_variable` + → Calls: scaled_bounds() + → Creates: scaling * lower_factor ≤ variable ≤ scaling * upper_factor - # Minimum duration constraint if provided - if minimum_duration is not None: - constraints['minimum'] = model.add_constraints( - duration.isel(time=slice(1, None)) - >= (state_variable.isel(time=slice(None, -1)) - state_variable.isel(time=slice(1, None))) - * minimum_duration.isel(time=slice(None, -1)), - name=f'{name}|duration_minimum', - ) + 3. **Binary Controlled**: `bounds` + `binary_control` + → Calls: binary_controlled_bounds() + → Creates: binary * max(ε, lower_bound) ≤ variable ≤ binary * upper_bound - # Handle initial condition for minimum duration - if previous_duration > 0 and previous_duration < minimum_duration.isel(time=0).max(): - constraints['initial_minimum'] = model.add_constraints( - state_variable.isel(time=0) == 1, name=f'{name}|duration_initial_minimum' - ) + 4. **Binary + Scaling**: `bounds` + `scaling_variable` + `binary_control` + → Calls: binary_scaled_bounds() + → Creates: binary * max(ε, scaling * lower_factor) ≤ variable ≤ binary * scaling * upper_factor - variables = {'duration': duration} + 5. **Big-M Dual Control**: All parameters provided + → Calls: big_m_dual_control_bounds() + → Creates: Complex big-M formulation for binary + variable scaling control - return variables, constraints + Usage Examples: + ```python + # Simple bounds + auto_bounds(model, var, (0, 100), 'upper', 'lower') - @staticmethod - def mutual_exclusivity_constraint( - model: FlowSystemModel, name: str, binary_variables: List[linopy.Variable], tolerance: float = 1.1 - ) -> Tuple[Dict, Dict[str, linopy.Constraint]]: - """ - Creates mutual exclusivity constraint for binary variables. + # Capacity-scaled bounds + auto_bounds(model, flow_var, (0.2, 0.8), 'upper', 'lower', scaling_variable=capacity_var) - Mathematical formulation: - Σ(binary_vars[i]) ≤ tolerance ∀t + # Binary on/off control + auto_bounds(model, var, (10, 100), 'upper', 'lower', binary_control=on_off_var) - Ensures at most one binary variable can be 1 at any time. - Tolerance > 1.0 accounts for binary variable numerical precision. + # Full dual control + auto_bounds( + model, + var, + (0.1, 0.9), + 'upper', + 'lower', + scaling_variable=size_var, + binary_control=on_var, + scaling_bounds=(0, 1000), + constraint_name_prefix='dual', + ) + ``` Args: - binary_variables: List of binary variables that should be mutually exclusive - tolerance: Upper bound (typically 1.1 for numerical stability) + model: The optimization model instance + variable: Variable to be bounded + variable_bounds: Tuple of (lower, upper) - absolute bounds or relative factors if scaling + scaling_variable: Optional variable to scale bounds by + scaling_state: Optional binary variable for the state of the scaling variable + scaling_bounds: Required for big-M case - bounds of scaling variable + variable_state: Optional variable that controls the variable state (e.g., on/off) Returns: - variables: {} (no new variables created) - constraints: {'mutual_exclusivity': constraint} + Tuple containing: + - variables (Dict): Variable dictionary from the selected method + - constraints (Dict[str, linopy.Constraint]): Constraint dictionary from the selected method Raises: - AssertionError: If fewer than 2 variables provided or variables aren't binary + ValueError: If big-M dual control is detected but required parameters are missing + + Note: + The method prioritizes more complex formulations when multiple options are available. + Parameter validation ensures all required arguments are provided for each case. """ - assert len(binary_variables) >= 2, ( - f'Mutual exclusivity requires at least 2 variables, got {len(binary_variables)}' - ) + # Case 1: Scaled bounds with state and a state for the variable + if variable_state is not None and scaling_variable is None and scaling_state is None: + return BoundingPatterns.dual_binary_scaled_bounds( + model=model, + variable=variable, + scaling_variable=variable_state, + relative_bounds=variable_bounds, + scaling_binary=variable_state, + secondary_binary=variable_state, + scaling_bounds=scaling_bounds, + ) - for var in binary_variables: - assert var.attrs.get('binary', False), ( - f'Variable {var.name} must be binary for mutual exclusivity constraint' + # Case 2: Scaled Bounds with state for the scaled variable + if variable_state is not None and scaling_variable is not None: + if scaling_bounds is None: + raise ValueError('scaling_bounds is required when using binary_scaled_bounds to compute big-M values') + + return BoundingPatterns.binary_scaled_bounds( + model=model, + variable=variable, + scaling_variable=scaling_variable, + relative_bounds=variable_bounds, + binary_control=variable_state, + scaling_bounds=scaling_bounds, ) - # Create mutual exclusivity constraint - mutual_exclusivity = model.add_constraints( - sum(binary_variables) <= tolerance, name=f'{name}|mutual_exclusivity' - ) + # Case 3: Binary controlled variable with fixed bounds + if variable_state is not None and scaling_variable is None: + return BoundingPatterns.binary_controlled_bounds( + model=model, + variable=variable, + bounds=variable_bounds, + binary_control=variable_state, + ) - variables = {} # No new variables created - constraints = {'mutual_exclusivity': mutual_exclusivity} + # Case 4: Simple absolute bounds + if scaling_variable is None and variable_state is None: + return BoundingPatterns.basic_bounds(model, variable, variable_bounds) - return variables, constraints + raise ValueError('Invalid combination of arguments') class ModelingPatterns: @@ -859,9 +937,9 @@ def investment_sizing_pattern( model: FlowSystemModel, name: str, size_bounds: Tuple[TemporalData, TemporalData], - controlled_variables: List[linopy.Variable] = None, - control_factors: List[Tuple[TemporalData, TemporalData]] = None, - state_variables: List[linopy.Variable] = None, + controlled_variable: linopy.Variable, + control_factors: Tuple[TemporalData, TemporalData], + state_variable: List[linopy.Variable] = None, optional: bool = False, ) -> Tuple[Dict[str, linopy.Variable], Dict[str, linopy.Constraint]]: """ @@ -878,7 +956,7 @@ def investment_sizing_pattern( Returns: variables: {'size': size_var, 'is_invested': binary_var (if optional)} - constraints: {'upper_bound': constraint, 'lower_bound': constraint, ...} + constraints: {'ub': constraint, 'lb': constraint, ...} """ variables = {} constraints = {} @@ -898,37 +976,19 @@ def investment_sizing_pattern( binary=True, name=f'{name}|is_invested', coords=model.get_coords(['year', 'scenario']) ) - # Link size to investment decision - if abs(size_min - size_max) < 1e-10: # Fixed size case - constraints['fixed_size'] = model.add_constraints( - variables['size'] == variables['is_invested'] * size_max, name=f'{name}|fixed_size' - ) - else: # Variable size case - constraints['upper_bound'] = model.add_constraints( - variables['size'] <= variables['is_invested'] * size_max, name=f'{name}|size|upper_bound' - ) - constraints['lower_bound'] = model.add_constraints( - variables['size'] >= variables['is_invested'] * np.maximum(CONFIG.modeling.EPSILON, size_min), - name=f'{name}|size|lower_bound', - ) + _, new_cons = BoundingPatterns.auto_bounds( + model=model, + variable=controlled_variable, + bounds=control_factors, + upper_bound_name=f'{controlled_variable.name}|ub', + lower_bound_name=f'{controlled_variable.name}|lb', + scaling_variable=variables['size'], + binary_control=variables['is_invested'] if optional else None, + scaling_bounds=(size_min, size_max), + constraint_name_prefix=name, + ) - # Control dependent variables - if controlled_variables and control_factors: - for i, (var, factors, state_variable) in enumerate(zip(controlled_variables, control_factors, state_variables)): - upper_bound_name = f'{var.name}|upper_bound' - lower_bound_name = f'{var.name}|lower_bound' - _, control_constraints = ModelingPrimitives.big_m_binary_bounds( - model=model, - variable=var, - binary_control=state_variable, - size_variable=variables['size'], - relative_bounds=factors, - upper_bound_name=upper_bound_name, - lower_bound_name=lower_bound_name, - ) - # Flatten control constraints with indexed names - constraints[upper_bound_name] = control_constraints['upper_bound'] - constraints[lower_bound_name] = control_constraints['lower_bound'] + constraints.update(new_cons) return variables, constraints @@ -967,7 +1027,7 @@ def operational_binary_control_pattern( # 2. Control variables - use big_m_binary_bounds pattern for consistency for i, (var, (lower_bound, upper_bound)) in enumerate(zip(controlled_variables, variable_bounds)): # Use the big_m pattern but without binary control (None) - _, control_constraints = ModelingPrimitives.big_m_binary_bounds( + _, control_constraints = BoundingPatterns.big_m_binary_bounds( model=model, variable=var, binary_control=variables['on'], # The on state controls the variables @@ -976,8 +1036,8 @@ def operational_binary_control_pattern( upper_bound_name=f'{name}|control_{i}_upper', lower_bound_name=f'{name}|control_{i}_lower', ) - constraints[f'control_{i}_upper'] = control_constraints['upper_bound'] - constraints[f'control_{i}_lower'] = control_constraints['lower_bound'] + constraints[f'control_{i}_upper'] = control_constraints['ub'] + constraints[f'control_{i}_lower'] = control_constraints['lb'] # 3. Total duration tracking using existing pattern if track_total_duration: From 2afc24e15b0b16d30d5e06ec0ffde221b645177a Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 18 Jul 2025 17:35:29 +0200 Subject: [PATCH 206/336] Improve BoundingPatterns --- flixopt/modeling.py | 384 +++++++++++++------------------------------- 1 file changed, 110 insertions(+), 274 deletions(-) diff --git a/flixopt/modeling.py b/flixopt/modeling.py index a443c3a74..359ae24e1 100644 --- a/flixopt/modeling.py +++ b/flixopt/modeling.py @@ -439,6 +439,9 @@ def basic_bounds( ): """Create simple bounds. + Mathematical Formulation: + lower_bound ≤ variable ≤ upper_bound + Args: model: The optimization model instance variable: Variable to be bounded @@ -446,10 +449,8 @@ def basic_bounds( Returns: Tuple containing: - - variables (Dict): Empty dict (no new variables created) - - constraints (Dict[str, linopy.Constraint]): Dictionary with keys: - - 'ub': Upper bound constraint - - 'lb': Lower bound constraint + - variables (Dict): Empty dict + - constraints (Dict[str, linopy.Constraint]): 'ub', 'lb' """ lower_bound, upper_bound = bounds @@ -463,64 +464,40 @@ def binary_controlled_bounds( model: FlowSystemModel, variable: linopy.Variable, bounds: Tuple[TemporalData, TemporalData], - binary_control: linopy.Variable, + variable_state: linopy.Variable, ) -> Tuple[Dict, Dict[str, linopy.Constraint]]: - """Create bounds controlled by a binary variable with epsilon handling. - - This method implements binary-controlled bounds where a binary variable acts as an on/off - switch for the bounded variable. When the binary is 1, normal bounds apply; when 0, the - variable is forced to zero. + """Create bounds controlled by a binary variable. Mathematical Formulation: - binary * max(ε, lower_bound) ≤ variable ≤ binary * upper_bound - - Where: - - binary ∈ {0, 1}: Control variable - - ε: Small positive constant (CONFIG.modeling.EPSILON) - - When binary = 1: Normal bounds apply - - When binary = 0: Variable is forced to 0 + variable_state * max(ε, lower_bound) ≤ variable ≤ variable_state * upper_bound Use Cases: - - Investment decisions (invest or don't invest) - - Unit commitment (on/off operational states) - - Feature selection in optimization models - - Example: - Investment bounds where β_inv controls whether investment occurs: - β_inv * max(ε, V^L) ≤ V ≤ β_inv * V^U + - Investment decisions + - Unit commitment (on/off states) Args: model: The optimization model instance variable: Variable to be bounded bounds: Tuple of (lower_bound, upper_bound) absolute bounds - binary_control: Binary variable controlling the bounds + variable_state: Binary variable controlling the bounds Returns: Tuple containing: - - variables (Dict): Empty dict (no new variables created) - - constraints (Dict[str, linopy.Constraint]): Dictionary with keys: - - 'ub': Upper bound constraint - - 'lb': Lower bound constraint - - 'fix': Fix constraint, if upper bound is equal to lower bound - - Note: - The epsilon value is applied to the lower bound to distinguish between - zero and "very small positive" values, which is important for numerical - stability in optimization solvers. + - variables (Dict): Empty dict + - constraints (Dict[str, linopy.Constraint]): 'ub', 'lb' """ lower_bound, upper_bound = bounds if np.all(lower_bound - upper_bound) < 1e-10: fix_constraint = model.add_constraints( - variable == binary_control * upper_bound, name=f'{variable.name}|fixed_size' + variable == variable_state * upper_bound, name=f'{variable.name}|fixed_size' ) return {}, {'ub': fix_constraint, 'lb': fix_constraint} - # Apply epsilon to lower bound to distinguish 0 from "very small positive" epsilon = np.maximum(CONFIG.modeling.EPSILON, lower_bound) - upper_constraint = model.add_constraints(variable <= binary_control * upper_bound, name=f'{variable.name}|ub') - lower_constraint = model.add_constraints(variable >= binary_control * epsilon, name=f'{variable.name}|lb') + upper_constraint = model.add_constraints(variable <= variable_state * upper_bound, name=f'{variable.name}|ub') + lower_constraint = model.add_constraints(variable >= variable_state * epsilon, name=f'{variable.name}|lb') return {}, {'ub': upper_constraint, 'lb': lower_constraint} @@ -531,28 +508,14 @@ def scaled_bounds( scaling_variable: linopy.Variable, relative_bounds: Tuple[TemporalData, TemporalData], ) -> Tuple[Dict, Dict[str, linopy.Constraint]]: - """Create simple bounds scaled by another variable. - - This method creates proportional bounds where the actual bounds are determined - by multiplying relative factors with a scaling variable. This is useful for - capacity-dependent constraints. + """Create bounds scaled by another variable. Mathematical Formulation: - scaling * lower_factor ≤ variable ≤ scaling * upper_factor - - Where: - - scaling: Continuous scaling variable (e.g., capacity, size) - - lower_factor, upper_factor: Relative bound multipliers + scaling_variable * lower_factor ≤ variable ≤ scaling_variable * upper_factor Use Cases: - Flow rates bounded by equipment capacity - Production levels scaled by plant size - - Resource consumption proportional to activity level - - Example: - Flow rate bounded by equipment size: - P * p_rel^L(t) ≤ p(t) ≤ P * p_rel^U(t) - where P is equipment size, p is flow rate Args: model: The optimization model instance @@ -562,19 +525,13 @@ def scaled_bounds( Returns: Tuple containing: - - variables (Dict): Empty dict (no new variables created) - - constraints (Dict[str, linopy.Constraint]): Dictionary with keys: - - 'ub': Upper bound constraint - - 'lb': Lower bound constraint - - Note: - This method assumes the scaling variable is always non-negative. - For negative scaling variables, the inequality directions would need adjustment. + - variables (Dict): Empty dict + - constraints (Dict[str, linopy.Constraint]): 'ub', 'lb' """ rel_lower, rel_upper = relative_bounds - upper_constraint = model.add_constraints(variable <= scaling_variable * rel_upper, name=f'{variable}|ub') - lower_constraint = model.add_constraints(variable >= scaling_variable * rel_lower, name=f'{variable}|lb') + upper_constraint = model.add_constraints(variable <= scaling_variable * rel_upper, name=f'{variable.name}|ub') + lower_constraint = model.add_constraints(variable >= scaling_variable * rel_lower, name=f'{variable.name}|lb') variables = {} constraints = {'ub': upper_constraint, 'lb': lower_constraint} @@ -586,94 +543,57 @@ def binary_scaled_bounds( variable: linopy.Variable, scaling_variable: linopy.Variable, relative_bounds: Tuple[TemporalData, TemporalData], - binary_control: linopy.Variable, + variable_state: linopy.Variable, scaling_bounds: Tuple[TemporalData, TemporalData], ) -> Tuple[Dict, Dict[str, linopy.Constraint]]: - """Create scaled bounds controlled by a binary variable using linear big-M formulation. - - Desired (Non-Linear) Formulation: - binary * max(ε, scaling * lower_factor) ≤ variable ≤ binary * scaling * upper_factor - - Actual Linear Big-M Formulation: - # When binary = 1: scaling bounds apply - scaling * lower_factor ≤ variable ≤ scaling * upper_factor + """Create scaled bounds controlled by a binary variable. - # When binary = 0: variable forced to 0 - variable ≤ binary * M_upper - variable ≥ binary * M_lower + Mathematical Formulation (Big-M): + scaling_variable * lower_factor ≤ variable ≤ scaling_variable * upper_factor + variable ≤ variable_state * M_upper + variable ≥ variable_state * M_lower - Where: - M_upper = scaling_max * upper_factor - M_lower = max(ε, scaling_min * lower_factor) - - Behavior: - - When binary = 1: Variable bounded by scaling * factors (normal operation) - - When binary = 0: Variable forced to 0 (off state) + Where: M_upper = scaling_max * upper_factor, M_lower = max(ε, scaling_min * lower_factor) Use Cases: - - Fixed-size units with on/off control - - Capacity-scaled operations with binary states - - Process units with binary operational modes - - Example: - Power plant with capacity P ∈ [P_min, P_max] and on/off control β_on: - Linear formulation replaces: β_on(t) * P * p_rel^L(t) ≤ p(t) ≤ β_on(t) * P * p_rel^U(t) + - Equipment with capacity and on/off control + - Variable-size units with operational states Args: model: The optimization model instance variable: Variable to be bounded scaling_variable: Variable that scales the bound factors relative_bounds: Tuple of (lower_factor, upper_factor) relative to scaling variable - binary_control: Binary variable for on/off control + variable_state: Binary variable for on/off control scaling_bounds: Tuple of (scaling_min, scaling_max) bounds of the scaling variable Returns: Tuple containing: - - variables (Dict): Empty dict (no new variables created) - - constraints (Dict[str, linopy.Constraint]): Dictionary with keys: - - 'ub': Upper bound constraint - - 'lb': Lower bound constraint - - Note: - This method now requires scaling_bounds to compute appropriate big-M values. - The big-M formulation maintains linearity while preserving the intended behavior. + - variables (Dict): Empty dict + - constraints (Dict[str, linopy.Constraint]): 'ub', 'lb', 'binary_upper', 'binary_lower' """ rel_lower, rel_upper = relative_bounds scaling_min, scaling_max = scaling_bounds - # Calculate big-M values for upper and lower bounds big_m_upper = scaling_max * rel_upper big_m_lower = np.maximum(CONFIG.modeling.EPSILON, scaling_min * rel_lower) - # Linear constraints using big-M technique: - # When binary = 1: normal scaling bounds apply - # When binary = 0: variable forced to 0 - - # Upper bound: variable ≤ min(scaling * rel_upper, binary * big_m_upper) - # Implemented as two constraints: scaling_upper = model.add_constraints( - variable <= scaling_variable * rel_upper, name=f'{scaling_variable.name}|ub' - ) - binary_upper = model.add_constraints( - variable <= binary_control * big_m_upper, name=f'{variable.name}|ub' + variable <= scaling_variable * rel_upper, name=f'{variable.name}|scaling_ub' ) + binary_upper = model.add_constraints(variable <= variable_state * big_m_upper, name=f'{variable.name}|ub') - # Lower bound: variable ≥ max(scaling * rel_lower, binary * big_m_lower) - # When binary = 0: second constraint gives variable ≥ 0 - # When binary = 1: first constraint is active scaling_lower = model.add_constraints( - variable >= scaling_variable * rel_lower, name=f'{scaling_variable.name}|ub' - ) - binary_lower = model.add_constraints( - variable >= binary_control * big_m_lower, name=f'{variable.name}|lb' + variable >= scaling_variable * rel_lower, name=f'{variable.name}|scaling_lb' ) + binary_lower = model.add_constraints(variable >= variable_state * big_m_lower, name=f'{variable.name}|lb') variables = {} constraints = { - 'ub': scaling_upper, # Primary upper bound constraint - 'lb': scaling_lower, # Primary lower bound constraint - 'binary_upper': binary_upper, # Binary control upper bound - 'binary_lower': binary_lower, # Binary control lower bound + 'ub': scaling_upper, + 'lb': scaling_lower, + 'binary_upper': binary_upper, + 'binary_lower': binary_lower, } return variables, constraints @@ -683,121 +603,76 @@ def dual_binary_scaled_bounds( variable: linopy.Variable, scaling_variable: linopy.Variable, relative_bounds: Tuple[TemporalData, TemporalData], - scaling_binary: linopy.Variable, - secondary_binary: linopy.Variable, + scaling_state: linopy.Variable, + variable_state: linopy.Variable, scaling_bounds: Tuple[TemporalData, TemporalData], ) -> Tuple[Dict, Dict[str, linopy.Constraint]]: """Create bounds with dual binary control over a scaled variable. - This method implements the most complex bounding case where you have two binary variables - controlling a scaled relationship between variables. This is commonly used for investment - and operational control scenarios. - - Hierarchical Control: - 1. scaling_binary: Controls whether the scaling variable can be non-zero - 2. Secondary binary: Controls whether the main variable can be non-zero (given scaling exists) - Mathematical Formulation: - - Scaling variable bounds: - scaling_binary * max(ε, scaling_min) ≤ scaling_variable ≤ scaling_binary * scaling_max - - Main variable bounds with dual control: - secondary_binary * max(ε, rel_lower * scaling_min) ≤ variable ≤ secondary_binary * M - M * (secondary_binary - 1) + scaling_variable * rel_lower ≤ variable ≤ scaling_variable * rel_upper + scaling_state * max(ε, scaling_min) ≤ scaling_variable ≤ scaling_state * scaling_max + variable_state * max(ε, rel_lower * scaling_min) ≤ variable ≤ variable_state * M + M * (variable_state - 1) + scaling_variable * rel_lower ≤ variable ≤ scaling_variable * rel_upper Where: M = rel_upper * scaling_max - Logical Behavior: - - scaling_binary = 0: No scaling capacity (scaling_variable = 0), no main variable (variable = 0) - - scaling_binary = 1, secondary_binary = 0: Scaling exists but main variable is off (variable = 0) - - scaling_binary = 1, secondary_binary = 1: Normal scaled operation - Use Cases: - - Investment + operational control (capacity sizing + on/off dispatch) - - Resource allocation + activation (budget + spending) - - Equipment sizing + utilization (capacity + operation) - - Feature selection + intensity (enable + level) - - Examples: - - Power plant: Build capacity? (primary) How big? (scaling) When to run? (secondary) - - Marketing: Enter market? (primary) Budget size? (scaling) Campaign active? (secondary) - - Production: Install line? (primary) Line capacity? (scaling) Line running? (secondary) + - Investment + operational control (capacity sizing + on/off dispatch) + - Equipment sizing + utilization Args: model: The optimization model instance - variable: Main variable to be bounded - scaling_variable: Variable that scales the bounds (e.g., capacity, size, budget) + variable: Variable to be bounded + scaling_variable: Variable that scales the bounds relative_bounds: Tuple of (rel_lower, rel_upper) relative bound multipliers - scaling_binary: Binary controlling scaling_variable existence (e.g., investment decision) - secondary_binary: Binary controlling variable operation (e.g., operational on/off) + scaling_state: Binary controlling scaling_variable existence + variable_state: Binary controlling variable operation scaling_bounds: Tuple of (scaling_min, scaling_max) bounds for scaling_variable Returns: Tuple containing: - - variables (Dict): Empty dict (no new variables created) - - constraints (Dict[str, linopy.Constraint]): Dictionary with keys: - - 'primary_scaling_ub': Primary control upper bound for scaling variable - - 'primary_scaling_lb': Primary control lower bound for scaling variable - - 'secondary_variable_ub': Secondary control upper bound for main variable - - 'secondary_variable_lb': Secondary control lower bound for main variable - - 'scaling_variable_ub': Scaling-dependent upper bound for main variable - - 'scaling_variable_lb': Scaling-dependent lower bound for main variable - - Note: - This implements hierarchical binary control where the primary binary enables the scaling - variable, and the secondary binary controls the main variable's operation within the - scaled bounds. Both binaries must be active for normal operation. + - variables (Dict): Empty dict + - constraints (Dict[str, linopy.Constraint]): Multiple constraint keys """ rel_lower, rel_upper = relative_bounds scaling_min, scaling_max = scaling_bounds - # Calculate big-M value for secondary control constraints - # M = rel_upper * scaling_max (maximum possible variable value) big_m = rel_upper * scaling_max - # 1. PRIMARY BINARY CONSTRAINTS FOR SCALING VARIABLE - # scaling_binary * max(ε, scaling_min) ≤ scaling_variable ≤ scaling_binary * scaling_max + # 1. SCALING VARIABLE CONSTRAINTS epsilon_scaling = np.maximum(CONFIG.modeling.EPSILON, scaling_min) - primary_scaling_ub = model.add_constraints( - scaling_variable <= scaling_binary * scaling_max, name=f'{scaling_variable.name}|primary_ub' + scaling_ub = model.add_constraints( + scaling_variable <= scaling_state * scaling_max, name=f'{scaling_variable.name}|ub' ) - primary_scaling_lb = model.add_constraints( - scaling_variable >= scaling_binary * epsilon_scaling, name=f'{scaling_variable.name}|primary_lb' + scaling_lb = model.add_constraints( + scaling_variable >= scaling_state * epsilon_scaling, name=f'{scaling_variable.name}|lb' ) - # 2. SECONDARY BINARY CONSTRAINTS FOR MAIN VARIABLE - # secondary_binary * max(ε, rel_lower * scaling_min) ≤ variable ≤ secondary_binary * M + # 2. VARIABLE STATE CONSTRAINTS epsilon_variable = np.maximum(CONFIG.modeling.EPSILON, rel_lower * scaling_min) - secondary_variable_ub = model.add_constraints( - variable <= secondary_binary * big_m, name=f'{variable.name}|secondary_ub' - ) + variable_ub = model.add_constraints(variable <= variable_state * big_m, name=f'{variable.name}|ub') - secondary_variable_lb = model.add_constraints( - variable >= secondary_binary * epsilon_variable, name=f'{variable.name}|secondary_lb' - ) - - # 3. SCALING-DEPENDENT CONSTRAINTS FOR MAIN VARIABLE - # M * (secondary_binary - 1) + scaling_variable * rel_lower ≤ variable ≤ scaling_variable * rel_upper + variable_lb = model.add_constraints(variable >= variable_state * epsilon_variable, name=f'{variable.name}|lb') + # 3. SCALING-DEPENDENT CONSTRAINTS scaling_variable_ub = model.add_constraints( variable <= scaling_variable * rel_upper, name=f'{variable.name}|scaling_ub' ) scaling_variable_lb = model.add_constraints( - big_m * (secondary_binary - 1) + scaling_variable * rel_lower <= variable, + big_m * (variable_state - 1) + scaling_variable * rel_lower <= variable, name=f'{variable.name}|scaling_lb', ) variables = {} constraints = { - 'primary_scaling_ub': primary_scaling_ub, - 'primary_scaling_lb': primary_scaling_lb, - 'secondary_variable_ub': secondary_variable_ub, - 'secondary_variable_lb': secondary_variable_lb, + 'scaling_ub': scaling_ub, + 'scaling_lb': scaling_lb, + 'variable_ub': variable_ub, + 'variable_lb': variable_lb, 'scaling_variable_ub': scaling_variable_ub, 'scaling_variable_lb': scaling_variable_lb, } @@ -808,123 +683,84 @@ def dual_binary_scaled_bounds( def auto_bounds( model: FlowSystemModel, variable: linopy.Variable, - variable_bounds: Tuple[TemporalData, TemporalData], + bounds: Tuple[TemporalData, TemporalData], scaling_variable: linopy.Variable = None, scaling_state: linopy.Variable = None, scaling_bounds: Tuple[TemporalData, TemporalData] = None, variable_state: linopy.Variable = None, ) -> Tuple[Dict, Dict[str, linopy.Constraint]]: - """Automatically select the appropriate bounds method based on provided parameters. - - This intelligent dispatcher analyzes the provided parameters and automatically - selects the most appropriate bounding method. It simplifies the API by providing - a single entry point for all bounding scenarios. - - Parameter Combinations and Method Selection: - - 1. **Simple Bounds**: Only `bounds` provided - → Creates: lower_bound ≤ variable ≤ upper_bound - - 2. **Scaled Bounds**: `bounds` + `scaling_variable` - → Calls: scaled_bounds() - → Creates: scaling * lower_factor ≤ variable ≤ scaling * upper_factor - - 3. **Binary Controlled**: `bounds` + `binary_control` - → Calls: binary_controlled_bounds() - → Creates: binary * max(ε, lower_bound) ≤ variable ≤ binary * upper_bound - - 4. **Binary + Scaling**: `bounds` + `scaling_variable` + `binary_control` - → Calls: binary_scaled_bounds() - → Creates: binary * max(ε, scaling * lower_factor) ≤ variable ≤ binary * scaling * upper_factor - - 5. **Big-M Dual Control**: All parameters provided - → Calls: big_m_dual_control_bounds() - → Creates: Complex big-M formulation for binary + variable scaling control - - Usage Examples: - ```python - # Simple bounds - auto_bounds(model, var, (0, 100), 'upper', 'lower') - - # Capacity-scaled bounds - auto_bounds(model, flow_var, (0.2, 0.8), 'upper', 'lower', scaling_variable=capacity_var) - - # Binary on/off control - auto_bounds(model, var, (10, 100), 'upper', 'lower', binary_control=on_off_var) + """Automatically select the appropriate bounds method. - # Full dual control - auto_bounds( - model, - var, - (0.1, 0.9), - 'upper', - 'lower', - scaling_variable=size_var, - binary_control=on_var, - scaling_bounds=(0, 1000), - constraint_name_prefix='dual', - ) - ``` + Parameter Combinations: + 1. Only bounds → basic_bounds() + 2. bounds + scaling_variable → scaled_bounds() + 3. bounds + variable_state → binary_controlled_bounds() + 4. bounds + scaling_variable + variable_state → binary_scaled_bounds() + 5. bounds + scaling_variable + scaling_state + variable_state → dual_binary_scaled_bounds() Args: model: The optimization model instance variable: Variable to be bounded - variable_bounds: Tuple of (lower, upper) - absolute bounds or relative factors if scaling + bounds: Tuple of (lower, upper) bounds or relative factors scaling_variable: Optional variable to scale bounds by - scaling_state: Optional binary variable for the state of the scaling variable - scaling_bounds: Required for big-M case - bounds of scaling variable - variable_state: Optional variable that controls the variable state (e.g., on/off) + scaling_state: Optional binary variable for scaling_variable state + scaling_bounds: Required for cases 4,5 - bounds of scaling variable + variable_state: Optional binary variable for variable state Returns: - Tuple containing: - - variables (Dict): Variable dictionary from the selected method - - constraints (Dict[str, linopy.Constraint]): Constraint dictionary from the selected method + Tuple from the selected method Raises: - ValueError: If big-M dual control is detected but required parameters are missing - - Note: - The method prioritizes more complex formulations when multiple options are available. - Parameter validation ensures all required arguments are provided for each case. + ValueError: If required parameters are missing """ - # Case 1: Scaled bounds with state and a state for the variable - if variable_state is not None and scaling_variable is None and scaling_state is None: + # Case 5: Dual binary control + if scaling_variable is not None and scaling_state is not None and variable_state is not None: + if scaling_bounds is None: + raise ValueError('scaling_bounds is required for dual binary control') return BoundingPatterns.dual_binary_scaled_bounds( model=model, variable=variable, - scaling_variable=variable_state, - relative_bounds=variable_bounds, - scaling_binary=variable_state, - secondary_binary=variable_state, + scaling_variable=scaling_variable, + relative_bounds=bounds, + scaling_state=scaling_state, + variable_state=variable_state, scaling_bounds=scaling_bounds, ) - # Case 2: Scaled Bounds with state for the scaled variable - if variable_state is not None and scaling_variable is not None: + # Case 4: Binary scaled bounds + if scaling_variable is not None and variable_state is not None: if scaling_bounds is None: - raise ValueError('scaling_bounds is required when using binary_scaled_bounds to compute big-M values') - + raise ValueError('scaling_bounds is required for binary scaled bounds') return BoundingPatterns.binary_scaled_bounds( model=model, variable=variable, scaling_variable=scaling_variable, - relative_bounds=variable_bounds, - binary_control=variable_state, + relative_bounds=bounds, + variable_state=variable_state, scaling_bounds=scaling_bounds, ) - # Case 3: Binary controlled variable with fixed bounds + # Case 3: Binary controlled bounds if variable_state is not None and scaling_variable is None: return BoundingPatterns.binary_controlled_bounds( model=model, variable=variable, - bounds=variable_bounds, - binary_control=variable_state, + bounds=bounds, + variable_state=variable_state, + ) + + # Case 2: Scaled bounds + if scaling_variable is not None and variable_state is None: + return BoundingPatterns.scaled_bounds( + model=model, + variable=variable, + scaling_variable=scaling_variable, + relative_bounds=bounds, ) - # Case 4: Simple absolute bounds + # Case 1: Basic bounds if scaling_variable is None and variable_state is None: - return BoundingPatterns.basic_bounds(model, variable, variable_bounds) + return BoundingPatterns.basic_bounds(model, variable, bounds) raise ValueError('Invalid combination of arguments') From b248f5872b285f32ba9a96a1d38d08eafe36c8d6 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 18 Jul 2025 19:32:07 +0200 Subject: [PATCH 207/336] Improve BoundingPatterns --- flixopt/elements.py | 2 +- flixopt/features.py | 114 +++++++++++++++++++++++--------- flixopt/modeling.py | 158 ++++++++++++-------------------------------- 3 files changed, 125 insertions(+), 149 deletions(-) diff --git a/flixopt/elements.py b/flixopt/elements.py index 440ac6de4..09fd07fe9 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -357,7 +357,7 @@ def do_modeling(self): self.flow_rate_lower_bound_relative, self.flow_rate_upper_bound_relative, ), - on_variable=self.on_off.on if self.on_off is not None else None, + state_variable=self.on_off.on if self.on_off is not None else None, ), 'investment', ) diff --git a/flixopt/features.py b/flixopt/features.py index 52c1302c2..884665e9b 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -13,7 +13,7 @@ from .core import NonTemporalData, Scalar, TemporalData, FlowSystemDimensions from .interface import InvestParameters, OnOffParameters, Piecewise, PiecewiseEffects from .structure import Model, FlowSystemModel, BaseFeatureModel -from .modeling import ModelingPatterns, ModelingUtilities, ModelingPrimitives +from .modeling import ModelingPatterns, ModelingUtilities, ModelingPrimitives, BoundingPatterns logger = logging.getLogger('flixopt') @@ -29,42 +29,82 @@ def __init__( defining_variable: linopy.Variable, relative_bounds_of_defining_variable: Tuple[TemporalData, TemporalData], label_of_model: Optional[str] = None, - on_variable: Optional[linopy.Variable] = None, + state_variable: Optional[linopy.Variable] = None, ): + """ + This feature model is used to model the investment of a variable. + It applies the corresponding bounds to the variable and the on/off state of the variable. + + Args: + model: The optimization model instance + label_of_element: The label of the parent (Element). Used to construct the full label of the model. + parameters: The parameters of the feature model. + defining_variable: The variable to be invested + relative_bounds_of_defining_variable: The bounds of the variable, with respect to the minimum/maximum investment sizes + label_of_model: The label of the model. This is needed to construct the full label of the model. + state_variable: The variable tracking the state of the variable + """ super().__init__(model, label_of_element=label_of_element, parameters=parameters, label_of_model=label_of_model) self._defining_variable = defining_variable self._relative_bounds_of_defining_variable = relative_bounds_of_defining_variable - self._on_variable = on_variable + self._state_variable = state_variable # Only keep non-variable attributes self.scenario_of_investment: Optional[linopy.Variable] = None self.piecewise_effects: Optional[PiecewiseEffectsModel] = None def create_variables_and_constraints(self): - # Use factory patterns - variables, constraints = ModelingPatterns.investment_sizing_pattern( - model=self._model, - name=self.label_full, - size_bounds=(self.parameters.minimum_or_fixed_size, self.parameters.maximum_or_fixed_size,), - controlled_variables=[self._defining_variable], - control_factors=[self._relative_bounds_of_defining_variable], - state_variables=[self._on_variable], - optional=self.parameters.optional, + constraints = [] + size_min, size_max = (self.parameters.minimum_or_fixed_size, self.parameters.maximum_or_fixed_size) + size = self.add( + self._model.add_variables( + lower=0 if self.parameters.optional else size_min, + upper=size_max, + name=f'{self.label_of_model}|size', + coords=self._model.get_coords(['year', 'scenario']), + ), + 'size', ) - # Register variables (stored in Model's variable tracking) - self.add(variables['size'], 'size') - if 'is_invested' in variables: - self.add(variables['is_invested'], 'is_invested') + constraints += BoundingPatterns.scaled_bounds( + self._model, + variable=self._defining_variable, + scaling_variable=size, + relative_bounds=self._relative_bounds_of_defining_variable, + ) - # Register constraints - for constraint_name, constraint in constraints.items(): - self.add(constraint, constraint_name) + # Optional binary investment decision + if self.parameters.optional: + is_invested = self.add( + self._model.add_variables( + binary=True, name=f'{self.label_of_model}|is_invested', coords=self._model.get_coords(['year', 'scenario']) + ), + 'is_invested', + ) - # Handle scenarios and piecewise effects... - if self._model.flow_system.scenarios is not None: - self._create_bounds_for_scenarios() + if self._state_variable is None: + constraints += BoundingPatterns.bounds_with_state( + self._model, + variable=size, + variable_state=is_invested, + bounds=(self.parameters.minimum_or_fixed_size, self.parameters.maximum_or_fixed_size), + ) + + else: + constraints += BoundingPatterns.scaled_bounds_with_state( + self._model, + variable=self._defining_variable, + variable_state=self._state_variable, + scaling_variable=size, + relative_bounds=self._relative_bounds_of_defining_variable, + scaling_bounds=(self.parameters.minimum_or_fixed_size, self.parameters.maximum_or_fixed_size), + scaling_state=is_invested, + ) + + # Register constraints + for constraint in constraints: + self.add(constraint) if self.parameters.piecewise_effects: self.piecewise_effects = self.add( @@ -137,6 +177,19 @@ def __init__( previous_flow_rates: List[Optional[TemporalData]], label_of_model: Optional[str] = None, ): + """ + This feature model is used to model the on/off state of flow_rate(s). It does not matter of the flow_rates are + bounded by a size variable or by a hard bound. THe used bound here is the absolute highest/lowest bound! + + Args: + model: The optimization model instance + label_of_element: The label of the parent (Element). Used to construct the full label of the model. + parameters: The parameters of the feature model. + flow_rates: The flow_rates to be modeled + flow_rate_bounds: The bounds of the flow_rates, with respect to the minimum/maximum investment sizes + previous_flow_rates: The previous flow_rates + label_of_model: The label of the model. This is needed to construct the full label of the model. + """ super().__init__(model, label_of_element, parameters=parameters, label_of_model=label_of_model) self._flow_rates = flow_rates self._flow_rate_bounds = flow_rate_bounds @@ -155,17 +208,14 @@ def create_variables_and_constraints(self): for i, (flow_rate, (lower_bound, upper_bound)) in enumerate(zip(self._flow_rates, self._flow_rate_bounds)): suffix = f'_{i}' if len(self._flow_rates) > 1 else '' # Use the big_m pattern but without binary control (None) - _, control_constraints = ModelingPrimitives.big_m_binary_bounds( + _, control_constraints = BoundingPatterns.binary_controlled_bounds( model=self._model, variable=flow_rate, - binary_control=None, - size_variable=variables['on'], - relative_bounds=(lower_bound, upper_bound), - upper_bound_name=f'{variables['on'].name}|ub{suffix}', - lower_bound_name=f'{variables['on'].name}|lb{suffix}', + bounds=(lower_bound, upper_bound), + variable_state=variables['on'], ) - constraints[f'ub_{i}'] = control_constraints['upper_bound'] - constraints[f'lb_{i}'] = control_constraints['lower_bound'] + constraints[f'ub{suffix}'] = control_constraints['ub'] + constraints[f'lb{suffix}'] = control_constraints['lb'] # 3. Total duration tracking using existing pattern duration_expr = (variables['on'] * self._model.hours_per_step).sum('time') @@ -216,8 +266,8 @@ def create_variables_and_constraints(self): constraints[f'consecutive_off_{cons_name}'] = cons_constraint # Register all constraints and variables - for constraint_name, constraint in constraints.items(): - self.add(constraint, constraint_name) + for constraint in constraints: + self.add(constraint) for variable_name, variable in variables.items(): self.add(variable, variable_name) diff --git a/flixopt/modeling.py b/flixopt/modeling.py index 359ae24e1..7380dcdec 100644 --- a/flixopt/modeling.py +++ b/flixopt/modeling.py @@ -438,6 +438,7 @@ def basic_bounds( bounds: Tuple[TemporalData, TemporalData], ): """Create simple bounds. + variable ∈ [lower_bound, upper_bound] Mathematical Formulation: lower_bound ≤ variable ≤ upper_bound @@ -457,19 +458,20 @@ def basic_bounds( upper_constraint = model.add_constraints(variable <= upper_bound, name=f'{variable.name}|ub') lower_constraint = model.add_constraints(variable >= lower_bound, name=f'{variable.name}|lb') - return {}, {'ub': upper_constraint, 'lb': lower_constraint} + return [lower_constraint, upper_constraint] @staticmethod - def binary_controlled_bounds( + def bounds_with_state( model: FlowSystemModel, variable: linopy.Variable, bounds: Tuple[TemporalData, TemporalData], variable_state: linopy.Variable, - ) -> Tuple[Dict, Dict[str, linopy.Constraint]]: - """Create bounds controlled by a binary variable. + ) -> List[linopy.Constraint]: + """Constraint a variable to bounds, that can be escaped from to 0 by a binary variable. + variable ∈ {0, [max(ε, lower_bound), upper_bound]} Mathematical Formulation: - variable_state * max(ε, lower_bound) ≤ variable ≤ variable_state * upper_bound + - variable_state * max(ε, lower_bound) ≤ variable ≤ variable_state * upper_bound Use Cases: - Investment decisions @@ -490,16 +492,16 @@ def binary_controlled_bounds( if np.all(lower_bound - upper_bound) < 1e-10: fix_constraint = model.add_constraints( - variable == variable_state * upper_bound, name=f'{variable.name}|fixed_size' + variable == variable_state * upper_bound, name=f'{variable.name}|fix' ) - return {}, {'ub': fix_constraint, 'lb': fix_constraint} + return [fix_constraint] epsilon = np.maximum(CONFIG.modeling.EPSILON, lower_bound) upper_constraint = model.add_constraints(variable <= variable_state * upper_bound, name=f'{variable.name}|ub') lower_constraint = model.add_constraints(variable >= variable_state * epsilon, name=f'{variable.name}|lb') - return {}, {'ub': upper_constraint, 'lb': lower_constraint} + return [lower_constraint, upper_constraint] @staticmethod def scaled_bounds( @@ -507,8 +509,9 @@ def scaled_bounds( variable: linopy.Variable, scaling_variable: linopy.Variable, relative_bounds: Tuple[TemporalData, TemporalData], - ) -> Tuple[Dict, Dict[str, linopy.Constraint]]: - """Create bounds scaled by another variable. + ) -> List[linopy.Constraint]: + """Constraint a variable by scaling bounds, dependent on another variable. + variable ∈ [lower_bound * scaling_variable, upper_bound * scaling_variable] Mathematical Formulation: scaling_variable * lower_factor ≤ variable ≤ scaling_variable * upper_factor @@ -533,20 +536,24 @@ def scaled_bounds( upper_constraint = model.add_constraints(variable <= scaling_variable * rel_upper, name=f'{variable.name}|ub') lower_constraint = model.add_constraints(variable >= scaling_variable * rel_lower, name=f'{variable.name}|lb') - variables = {} - constraints = {'ub': upper_constraint, 'lb': lower_constraint} - return variables, constraints + return [lower_constraint, upper_constraint] @staticmethod - def binary_scaled_bounds( + def scaled_bounds_with_state( model: FlowSystemModel, variable: linopy.Variable, scaling_variable: linopy.Variable, relative_bounds: Tuple[TemporalData, TemporalData], - variable_state: linopy.Variable, scaling_bounds: Tuple[TemporalData, TemporalData], - ) -> Tuple[Dict, Dict[str, linopy.Constraint]]: - """Create scaled bounds controlled by a binary variable. + variable_state: linopy.Variable, + scaling_state: linopy.Variable, + ) -> List[linopy.Constraint]: + """Constraint a variable by scaling bounds, dependent on another variable. + The bounds only apply if variable_state is 1. + + variable ∈ {0, + [max(ε, lower_relative_bound) * scaling_variable, upper_relative_bound * scaling_variable] + } Mathematical Formulation (Big-M): scaling_variable * lower_factor ≤ variable ≤ scaling_variable * upper_factor @@ -572,112 +579,31 @@ def binary_scaled_bounds( - variables (Dict): Empty dict - constraints (Dict[str, linopy.Constraint]): 'ub', 'lb', 'binary_upper', 'binary_lower' """ + rel_lower, rel_upper = relative_bounds scaling_min, scaling_max = scaling_bounds big_m_upper = scaling_max * rel_upper big_m_lower = np.maximum(CONFIG.modeling.EPSILON, scaling_min * rel_lower) - scaling_upper = model.add_constraints( - variable <= scaling_variable * rel_upper, name=f'{variable.name}|scaling_ub' - ) - binary_upper = model.add_constraints(variable <= variable_state * big_m_upper, name=f'{variable.name}|ub') - - scaling_lower = model.add_constraints( - variable >= scaling_variable * rel_lower, name=f'{variable.name}|scaling_lb' - ) - binary_lower = model.add_constraints(variable >= variable_state * big_m_lower, name=f'{variable.name}|lb') - - variables = {} - constraints = { - 'ub': scaling_upper, - 'lb': scaling_lower, - 'binary_upper': binary_upper, - 'binary_lower': binary_lower, - } - return variables, constraints - - @staticmethod - def dual_binary_scaled_bounds( - model: FlowSystemModel, - variable: linopy.Variable, - scaling_variable: linopy.Variable, - relative_bounds: Tuple[TemporalData, TemporalData], - scaling_state: linopy.Variable, - variable_state: linopy.Variable, - scaling_bounds: Tuple[TemporalData, TemporalData], - ) -> Tuple[Dict, Dict[str, linopy.Constraint]]: - """Create bounds with dual binary control over a scaled variable. - - Mathematical Formulation: - scaling_state * max(ε, scaling_min) ≤ scaling_variable ≤ scaling_state * scaling_max - variable_state * max(ε, rel_lower * scaling_min) ≤ variable ≤ variable_state * M - M * (variable_state - 1) + scaling_variable * rel_lower ≤ variable ≤ scaling_variable * rel_upper - - Where: M = rel_upper * scaling_max - - Use Cases: - - Investment + operational control (capacity sizing + on/off dispatch) - - Equipment sizing + utilization - - Args: - model: The optimization model instance - variable: Variable to be bounded - scaling_variable: Variable that scales the bounds - relative_bounds: Tuple of (rel_lower, rel_upper) relative bound multipliers - scaling_state: Binary controlling scaling_variable existence - variable_state: Binary controlling variable operation - scaling_bounds: Tuple of (scaling_min, scaling_max) bounds for scaling_variable - - Returns: - Tuple containing: - - variables (Dict): Empty dict - - constraints (Dict[str, linopy.Constraint]): Multiple constraint keys - """ - rel_lower, rel_upper = relative_bounds - scaling_min, scaling_max = scaling_bounds - - big_m = rel_upper * scaling_max - - # 1. SCALING VARIABLE CONSTRAINTS - epsilon_scaling = np.maximum(CONFIG.modeling.EPSILON, scaling_min) - - scaling_ub = model.add_constraints( - scaling_variable <= scaling_state * scaling_max, name=f'{scaling_variable.name}|ub' + _, constraints = BoundingPatterns.bounds_with_state( + model, + variable=scaling_variable, + bounds=scaling_bounds, + variable_state=scaling_state, ) - scaling_lb = model.add_constraints( - scaling_variable >= scaling_state * epsilon_scaling, name=f'{scaling_variable.name}|lb' - ) - - # 2. VARIABLE STATE CONSTRAINTS - epsilon_variable = np.maximum(CONFIG.modeling.EPSILON, rel_lower * scaling_min) - - variable_ub = model.add_constraints(variable <= variable_state * big_m, name=f'{variable.name}|ub') - - variable_lb = model.add_constraints(variable >= variable_state * epsilon_variable, name=f'{variable.name}|lb') - - # 3. SCALING-DEPENDENT CONSTRAINTS - scaling_variable_ub = model.add_constraints( - variable <= scaling_variable * rel_upper, name=f'{variable.name}|scaling_ub' + scaling_upper = model.add_constraints( + variable <= scaling_variable * rel_upper, name=f'{variable.name}|ub' ) + binary_upper = model.add_constraints(variable <= variable_state * big_m_upper, name=f'{variable_state.name}|ub') - scaling_variable_lb = model.add_constraints( - big_m * (variable_state - 1) + scaling_variable * rel_lower <= variable, - name=f'{variable.name}|scaling_lb', + scaling_lower = model.add_constraints( + variable >= scaling_variable * rel_lower, name=f'{variable.name}|lb' ) + binary_lower = model.add_constraints(variable >= variable_state * big_m_lower, name=f'{variable_state.name}|lb') - variables = {} - constraints = { - 'scaling_ub': scaling_ub, - 'scaling_lb': scaling_lb, - 'variable_ub': variable_ub, - 'variable_lb': variable_lb, - 'scaling_variable_ub': scaling_variable_ub, - 'scaling_variable_lb': scaling_variable_lb, - } - - return variables, constraints + return [scaling_lower, scaling_upper, binary_lower, binary_upper] @staticmethod def auto_bounds( @@ -688,15 +614,15 @@ def auto_bounds( scaling_state: linopy.Variable = None, scaling_bounds: Tuple[TemporalData, TemporalData] = None, variable_state: linopy.Variable = None, - ) -> Tuple[Dict, Dict[str, linopy.Constraint]]: + ) -> List[linopy.Constraint]: """Automatically select the appropriate bounds method. Parameter Combinations: 1. Only bounds → basic_bounds() 2. bounds + scaling_variable → scaled_bounds() - 3. bounds + variable_state → binary_controlled_bounds() + 3. bounds + variable_state → bounds_with_state() 4. bounds + scaling_variable + variable_state → binary_scaled_bounds() - 5. bounds + scaling_variable + scaling_state + variable_state → dual_binary_scaled_bounds() + 5. bounds + scaling_variable + scaling_state + variable_state → scaled_bounds_with_state_on_both_scaling_and_variable() Args: model: The optimization model instance @@ -717,7 +643,7 @@ def auto_bounds( if scaling_variable is not None and scaling_state is not None and variable_state is not None: if scaling_bounds is None: raise ValueError('scaling_bounds is required for dual binary control') - return BoundingPatterns.dual_binary_scaled_bounds( + return BoundingPatterns.scaled_bounds_with_state_on_both_scaling_and_variable( model=model, variable=variable, scaling_variable=scaling_variable, @@ -742,7 +668,7 @@ def auto_bounds( # Case 3: Binary controlled bounds if variable_state is not None and scaling_variable is None: - return BoundingPatterns.binary_controlled_bounds( + return BoundingPatterns.bounds_with_state( model=model, variable=variable, bounds=bounds, From d34445cd59b18838d810b677a852bcd5f3bf4b7b Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 18 Jul 2025 20:06:30 +0200 Subject: [PATCH 208/336] Fix duration Modeling --- flixopt/modeling.py | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/flixopt/modeling.py b/flixopt/modeling.py index 7380dcdec..083ba8c54 100644 --- a/flixopt/modeling.py +++ b/flixopt/modeling.py @@ -315,6 +315,7 @@ def consecutive_duration_tracking( duration[t] ≥ (state[t-1] - state[t]) * minimum_duration[t-1] ∀t > 0 Args: + name: Name of the duration variable state_variable: Binary state variable to track duration for minimum_duration: Optional minimum consecutive duration maximum_duration: Optional maximum consecutive duration @@ -332,21 +333,21 @@ def consecutive_duration_tracking( lower=0, upper=maximum_duration if maximum_duration is not None else mega, coords=model.get_coords(['time']), - name=f'{name}|duration', + name=name, ) constraints = {} # Upper bound: duration[t] ≤ state[t] * M constraints['ub'] = model.add_constraints( - duration <= state_variable * mega, name=f'{name}|duration_upper_bound' + duration <= state_variable * mega, name=f'{duration.name}|ub' ) # Forward constraint: duration[t+1] ≤ duration[t] + hours_per_step[t] constraints['forward'] = model.add_constraints( duration.isel(time=slice(1, None)) <= duration.isel(time=slice(None, -1)) + hours_per_step.isel(time=slice(None, -1)), - name=f'{name}|duration_forward', + name=f'{duration.name}|forward', ) # Backward constraint: duration[t+1] ≥ duration[t] + hours_per_step[t] + (state[t+1] - 1) * M @@ -355,29 +356,29 @@ def consecutive_duration_tracking( >= duration.isel(time=slice(None, -1)) + hours_per_step.isel(time=slice(None, -1)) + (state_variable.isel(time=slice(1, None)) - 1) * mega, - name=f'{name}|duration_backward', + name=f'{duration.name}|backward', ) # Initial condition: duration[0] = (hours_per_step[0] + previous_duration) * state[0] constraints['initial'] = model.add_constraints( duration.isel(time=0) == (hours_per_step.isel(time=0) + previous_duration) * state_variable.isel(time=0), - name=f'{name}|duration_initial', + name=f'{duration.name}|initial', ) # Minimum duration constraint if provided if minimum_duration is not None: - constraints['minimum'] = model.add_constraints( - duration.isel(time=slice(1, None)) + constraints['lb'] = model.add_constraints( + duration >= (state_variable.isel(time=slice(None, -1)) - state_variable.isel(time=slice(1, None))) * minimum_duration.isel(time=slice(None, -1)), - name=f'{name}|duration_minimum', + name=f'{duration.name}|lb', ) # Handle initial condition for minimum duration if previous_duration > 0 and previous_duration < minimum_duration.isel(time=0).max(): - constraints['initial_minimum'] = model.add_constraints( - state_variable.isel(time=0) == 1, name=f'{name}|duration_initial_minimum' + constraints['initial_lb'] = model.add_constraints( + state_variable.isel(time=0) == 1, name=f'{duration.name}|initial_lb' ) variables = {'duration': duration} From bde07b471b44fc401bc3af13da26a5ca2b783b51 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 18 Jul 2025 23:20:19 +0200 Subject: [PATCH 209/336] Fix On + Size --- flixopt/features.py | 83 +++++++++++++++++++++--------------- flixopt/modeling.py | 101 +++++++++++++++++++++++--------------------- 2 files changed, 101 insertions(+), 83 deletions(-) diff --git a/flixopt/features.py b/flixopt/features.py index 884665e9b..9ec91fec6 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -67,15 +67,25 @@ def create_variables_and_constraints(self): 'size', ) - constraints += BoundingPatterns.scaled_bounds( - self._model, - variable=self._defining_variable, - scaling_variable=size, - relative_bounds=self._relative_bounds_of_defining_variable, - ) + if self._state_variable is None and not self.parameters.optional: + constraints += BoundingPatterns.scaled_bounds( + self._model, + variable=self._defining_variable, + scaling_variable=size, + relative_bounds=self._relative_bounds_of_defining_variable, + name=self._defining_variable.name if self._state_variable is None else f'{self._defining_variable.name}_state', + ) - # Optional binary investment decision - if self.parameters.optional: + elif self._state_variable is not None and not self.parameters.optional: + constraints += BoundingPatterns.bounds_with_state( + self._model, + variable=self._defining_variable, + variable_state=self._state_variable, + bounds=self._relative_bounds_of_defining_variable, + name=self._defining_variable.name if self._state_variable is None else f'{self._defining_variable.name}_state', + ) + + elif self.parameters.optional: is_invested = self.add( self._model.add_variables( binary=True, name=f'{self.label_of_model}|is_invested', coords=self._model.get_coords(['year', 'scenario']) @@ -91,6 +101,13 @@ def create_variables_and_constraints(self): bounds=(self.parameters.minimum_or_fixed_size, self.parameters.maximum_or_fixed_size), ) + constraints += BoundingPatterns.scaled_bounds( + self._model, + variable=self._defining_variable, + scaling_variable=size, + relative_bounds=self._relative_bounds_of_defining_variable, + ) + else: constraints += BoundingPatterns.scaled_bounds_with_state( self._model, @@ -99,7 +116,7 @@ def create_variables_and_constraints(self): scaling_variable=size, relative_bounds=self._relative_bounds_of_defining_variable, scaling_bounds=(self.parameters.minimum_or_fixed_size, self.parameters.maximum_or_fixed_size), - scaling_state=is_invested, + name=f'{self.label_of_model}|size+on', ) # Register constraints @@ -197,25 +214,23 @@ def __init__( def create_variables_and_constraints(self): variables = {} - constraints = {} + constraints = [] # 1. Main binary state using existing pattern state_vars, state_constraints = ModelingPrimitives.binary_state_pair(self._model, self.label_of_model, use_complement=self.parameters.use_off) variables.update(state_vars) - constraints.update(state_constraints) + constraints += list(state_constraints.values()) # 2. Control variables - use big_m_binary_bounds pattern for consistency for i, (flow_rate, (lower_bound, upper_bound)) in enumerate(zip(self._flow_rates, self._flow_rate_bounds)): - suffix = f'_{i}' if len(self._flow_rates) > 1 else '' - # Use the big_m pattern but without binary control (None) - _, control_constraints = BoundingPatterns.binary_controlled_bounds( + # TODO: Add suffix options + constraints += BoundingPatterns.bounds_with_state( model=self._model, variable=flow_rate, bounds=(lower_bound, upper_bound), variable_state=variables['on'], + name=flow_rate.name, ) - constraints[f'ub{suffix}'] = control_constraints['ub'] - constraints[f'lb{suffix}'] = control_constraints['lb'] # 3. Total duration tracking using existing pattern duration_expr = (variables['on'] * self._model.hours_per_step).sum('time') @@ -225,45 +240,43 @@ def create_variables_and_constraints(self): self.parameters.on_hours_total_max if self.parameters.on_hours_total_max is not None else np.inf),#TODO: self._model.hours_per_step.sum('time').item() + self._get_previous_on_duration()) ) variables['on_hours_total'] = duration_vars['tracker'] - constraints['on_hours_total'] = duration_constraints['tracking'] + constraints += [duration_constraints['tracking']] # 4. Switch tracking using existing pattern if self.parameters.use_switch_on: switch_vars, switch_constraints = ModelingPrimitives.state_transition_variables( - self._model, f'{self.label_of_model}|switches', variables['on'], - previous_state=ModelingUtilities.get_most_recent_state(self._previous_flow_rates) + self._model, f'{self.label_of_model}|switch', variables['on'], + previous_state=ModelingUtilities.get_most_recent_state(self._previous_flow_rates), + max_count=self.parameters.switch_on_total_max, ) - variables.update(switch_vars) - for switch_name, switch_constraint in switch_constraints.items(): - constraints[f'switch_{switch_name}'] = switch_constraint + variables.update({'switch|on': switch_vars['on'], 'switch|off': switch_vars['off'], 'switch|count': switch_vars['count']}) + constraints += list(switch_constraints.values()) # 5. Consecutive on duration using existing pattern if self.parameters.use_consecutive_on_hours: consecutive_on_vars, consecutive_on_constraints = ModelingPrimitives.consecutive_duration_tracking( self._model, - f'{self.label_of_model}|consecutive_on', + f'{self.label_of_model}|consecutive_on_hours', #TODO: Change name variables['on'], minimum_duration=self.parameters.consecutive_on_hours_min, maximum_duration=self.parameters.consecutive_on_hours_max, previous_duration=ModelingUtilities.compute_previous_on_duration(self._previous_flow_rates, self._model.hours_per_step), ) - variables['consecutive_on_duration'] = consecutive_on_vars['duration'] - for cons_name, cons_constraint in consecutive_on_constraints.items(): - constraints[f'consecutive_on_{cons_name}'] = cons_constraint + variables['consecutive_on_hours'] = consecutive_on_vars['duration'] + constraints += list(consecutive_on_constraints.values()) # 6. Consecutive off duration using existing pattern if self.parameters.use_consecutive_off_hours: consecutive_off_vars, consecutive_off_constraints = ModelingPrimitives.consecutive_duration_tracking( self._model, - f'{self.label_of_model}|consecutive_off', + f'{self.label_of_model}|consecutive_off_hours', variables['off'], minimum_duration=self.parameters.consecutive_off_hours_min, maximum_duration=self.parameters.consecutive_off_hours_max, previous_duration=ModelingUtilities.compute_previous_off_duration(self._previous_flow_rates, self._model.hours_per_step), ) - variables['consecutive_off_duration'] = consecutive_off_vars['duration'] - for cons_name, cons_constraint in consecutive_off_constraints.items(): - constraints[f'consecutive_off_{cons_name}'] = cons_constraint + variables['consecutive_off_hours'] = consecutive_off_vars['duration'] + constraints += list(consecutive_off_constraints.values()) # Register all constraints and variables for constraint in constraints: @@ -285,23 +298,23 @@ def off(self) -> Optional[linopy.Variable]: @property def total_on_hours(self) -> Optional[linopy.Variable]: """Total on hours variable""" - return self.get_variable_by_short_name('total_duration') + return self.get_variable_by_short_name('total_on_hours') @property def switch_on(self) -> Optional[linopy.Variable]: """Switch on variable""" - return self.get_variable_by_short_name('switch_on') + return self.get_variable_by_short_name('switch|on') @property def switch_off(self) -> Optional[linopy.Variable]: """Switch off variable""" - return self.get_variable_by_short_name('switch_off') + return self.get_variable_by_short_name('switch|off') @property def switch_on_nr(self) -> Optional[linopy.Variable]: """Number of switch-ons variable""" # This could be added to factory if needed - return None + return self.get_variable_by_short_name('switch|count') @property def consecutive_on_hours(self) -> Optional[linopy.Variable]: @@ -325,7 +338,7 @@ def add_effects(self): target='operation', ) - if self.parameters.effects_per_switch_on and self.switch_on: + if self.parameters.effects_per_switch_on: self._model.effects.add_share_to_effects( name=self.label_of_element, expressions={ diff --git a/flixopt/modeling.py b/flixopt/modeling.py index 083ba8c54..b8e00a723 100644 --- a/flixopt/modeling.py +++ b/flixopt/modeling.py @@ -254,7 +254,8 @@ def expression_tracking_variable( @staticmethod def state_transition_variables( - model: FlowSystemModel, name: str, state_variable, previous_state=0 + model: FlowSystemModel, name: str, state_variable, previous_state=0, + max_count: Optional[int] = None, ) -> Tuple[Dict[str, linopy.Variable], Dict[str, linopy.Constraint]]: """ Creates switch-on/off variables with state transition logic. @@ -269,27 +270,36 @@ def state_transition_variables( variables: {'switch_on': binary_var, 'switch_off': binary_var} constraints: {'transition': constraint, 'initial': constraint, 'mutex': constraint} """ - switch_on = model.add_variables(binary=True, name=f'{name}|switch_on', coords=model.get_coords(['time'])) - switch_off = model.add_variables(binary=True, name=f'{name}|switch_off', coords=model.get_coords(['time'])) + switch_on = model.add_variables(binary=True, name=f'{name}|on', coords=model.get_coords(['time'])) + switch_off = model.add_variables(binary=True, name=f'{name}|off', coords=model.get_coords(['time'])) # State transition constraints for t > 0 transition = model.add_constraints( switch_on.isel(time=slice(1, None)) - switch_off.isel(time=slice(1, None)) == state_variable.isel(time=slice(1, None)) - state_variable.isel(time=slice(None, -1)), - name=f'{name}|state_transition', + name=name, ) # Initial state transition for t = 0 initial = model.add_constraints( switch_on.isel(time=0) - switch_off.isel(time=0) == state_variable.isel(time=0) - previous_state, - name=f'{name}|initial_transition', + name=f'{name}|initial', ) # At most one switch per timestep - mutex = model.add_constraints(switch_on + switch_off <= 1, name=f'{name}|switch_mutex') + mutex = model.add_constraints(switch_on + switch_off <= 1, name=f'{name}|mutex') + + count = model.add_variables( + lower=0, + upper=max_count if max_count is not None else np.inf, + coords=model.get_coords(['year', 'scenario']), + name=f'{name}|count', + ) + + count_constraint = model.add_constraints(count == switch_on.sum('time'), name=f'{name}|count') - variables = {'switch_on': switch_on, 'switch_off': switch_off} - constraints = {'transition': transition, 'initial': initial, 'mutex': mutex} + variables = {'on': switch_on, 'off': switch_off, 'count': count} + constraints = {'transition': transition, 'initial': initial, 'mutex': mutex, 'count': count_constraint} return variables, constraints @@ -437,6 +447,7 @@ def basic_bounds( model: FlowSystemModel, variable: linopy.Variable, bounds: Tuple[TemporalData, TemporalData], + name: str = None, ): """Create simple bounds. variable ∈ [lower_bound, upper_bound] @@ -455,9 +466,10 @@ def basic_bounds( - constraints (Dict[str, linopy.Constraint]): 'ub', 'lb' """ lower_bound, upper_bound = bounds + name = name or f'{variable.name}' - upper_constraint = model.add_constraints(variable <= upper_bound, name=f'{variable.name}|ub') - lower_constraint = model.add_constraints(variable >= lower_bound, name=f'{variable.name}|lb') + upper_constraint = model.add_constraints(variable <= upper_bound, name=f'{name}|ub') + lower_constraint = model.add_constraints(variable >= lower_bound, name=f'{name}|lb') return [lower_constraint, upper_constraint] @@ -467,6 +479,7 @@ def bounds_with_state( variable: linopy.Variable, bounds: Tuple[TemporalData, TemporalData], variable_state: linopy.Variable, + name: str = None, ) -> List[linopy.Constraint]: """Constraint a variable to bounds, that can be escaped from to 0 by a binary variable. variable ∈ {0, [max(ε, lower_bound), upper_bound]} @@ -490,17 +503,18 @@ def bounds_with_state( - constraints (Dict[str, linopy.Constraint]): 'ub', 'lb' """ lower_bound, upper_bound = bounds + name = name or f'{variable.name}' if np.all(lower_bound - upper_bound) < 1e-10: fix_constraint = model.add_constraints( - variable == variable_state * upper_bound, name=f'{variable.name}|fix' + variable == variable_state * upper_bound, name=f'{name}|fix' ) return [fix_constraint] epsilon = np.maximum(CONFIG.modeling.EPSILON, lower_bound) - upper_constraint = model.add_constraints(variable <= variable_state * upper_bound, name=f'{variable.name}|ub') - lower_constraint = model.add_constraints(variable >= variable_state * epsilon, name=f'{variable.name}|lb') + upper_constraint = model.add_constraints(variable <= variable_state * upper_bound, name=f'{name}|ub') + lower_constraint = model.add_constraints(variable >= variable_state * epsilon, name=f'{name}|lb') return [lower_constraint, upper_constraint] @@ -510,6 +524,7 @@ def scaled_bounds( variable: linopy.Variable, scaling_variable: linopy.Variable, relative_bounds: Tuple[TemporalData, TemporalData], + name: str = None, ) -> List[linopy.Constraint]: """Constraint a variable by scaling bounds, dependent on another variable. variable ∈ [lower_bound * scaling_variable, upper_bound * scaling_variable] @@ -533,9 +548,10 @@ def scaled_bounds( - constraints (Dict[str, linopy.Constraint]): 'ub', 'lb' """ rel_lower, rel_upper = relative_bounds + name = name or f'{variable.name}' - upper_constraint = model.add_constraints(variable <= scaling_variable * rel_upper, name=f'{variable.name}|ub') - lower_constraint = model.add_constraints(variable >= scaling_variable * rel_lower, name=f'{variable.name}|lb') + upper_constraint = model.add_constraints(variable <= scaling_variable * rel_upper, name=f'{name}|ub') + lower_constraint = model.add_constraints(variable >= scaling_variable * rel_lower, name=f'{name}|lb') return [lower_constraint, upper_constraint] @@ -547,62 +563,51 @@ def scaled_bounds_with_state( relative_bounds: Tuple[TemporalData, TemporalData], scaling_bounds: Tuple[TemporalData, TemporalData], variable_state: linopy.Variable, - scaling_state: linopy.Variable, + name: str = None, ) -> List[linopy.Constraint]: - """Constraint a variable by scaling bounds, dependent on another variable. - The bounds only apply if variable_state is 1. + """Constraint a variable by scaling bounds with binary state control. - variable ∈ {0, - [max(ε, lower_relative_bound) * scaling_variable, upper_relative_bound * scaling_variable] - } + variable ∈ {0, [max(ε, lower_relative_bound) * scaling_variable, upper_relative_bound * scaling_variable]} Mathematical Formulation (Big-M): - scaling_variable * lower_factor ≤ variable ≤ scaling_variable * upper_factor - variable ≤ variable_state * M_upper - variable ≥ variable_state * M_lower - - Where: M_upper = scaling_max * upper_factor, M_lower = max(ε, scaling_min * lower_factor) + (variable_state - 1) * M_misc + scaling_variable * rel_lower ≤ variable ≤ scaling_variable * rel_upper + variable_state * big_m_lower ≤ variable ≤ variable_state * big_m_upper - Use Cases: - - Equipment with capacity and on/off control - - Variable-size units with operational states + Where: + M_misc = scaling_max * rel_lower + big_m_upper = scaling_max * rel_upper + big_m_lower = max(ε, scaling_min * rel_lower) Args: model: The optimization model instance variable: Variable to be bounded scaling_variable: Variable that scales the bound factors relative_bounds: Tuple of (lower_factor, upper_factor) relative to scaling variable - variable_state: Binary variable for on/off control scaling_bounds: Tuple of (scaling_min, scaling_max) bounds of the scaling variable + variable_state: Binary variable for on/off control + name: Optional name prefix for constraints Returns: - Tuple containing: - - variables (Dict): Empty dict - - constraints (Dict[str, linopy.Constraint]): 'ub', 'lb', 'binary_upper', 'binary_lower' + List[linopy.Constraint]: List of constraint objects """ - rel_lower, rel_upper = relative_bounds scaling_min, scaling_max = scaling_bounds + name = name or f'{variable.name}' - big_m_upper = scaling_max * rel_upper - big_m_lower = np.maximum(CONFIG.modeling.EPSILON, scaling_min * rel_lower) + big_m_misc = scaling_max * rel_lower - _, constraints = BoundingPatterns.bounds_with_state( - model, - variable=scaling_variable, - bounds=scaling_bounds, - variable_state=scaling_state, + scaling_lower = model.add_constraints( + variable >= (variable_state - 1) * big_m_misc + scaling_variable * rel_lower, name=f'{name}|lb2' ) - scaling_upper = model.add_constraints( - variable <= scaling_variable * rel_upper, name=f'{variable.name}|ub' + variable <= scaling_variable * rel_upper, name=f'{name}|ub2' ) - binary_upper = model.add_constraints(variable <= variable_state * big_m_upper, name=f'{variable_state.name}|ub') - scaling_lower = model.add_constraints( - variable >= scaling_variable * rel_lower, name=f'{variable.name}|lb' - ) - binary_lower = model.add_constraints(variable >= variable_state * big_m_lower, name=f'{variable_state.name}|lb') + big_m_upper = scaling_max * rel_upper + big_m_lower = np.maximum(CONFIG.modeling.EPSILON, scaling_min * rel_lower) + + binary_upper = model.add_constraints(variable_state * big_m_upper >= variable, name=f'{name}|ub1') + binary_lower = model.add_constraints(variable_state * big_m_lower <= variable, name=f'{name}|lb1') return [scaling_lower, scaling_upper, binary_lower, binary_upper] From 5861b281e11789333ea1a0f77fc28f7cb4ad1475 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 19 Jul 2025 19:14:13 +0200 Subject: [PATCH 210/336] Fix InvestmentModel --- flixopt/features.py | 62 +++++++++++++++------------------------------ 1 file changed, 21 insertions(+), 41 deletions(-) diff --git a/flixopt/features.py b/flixopt/features.py index 9ec91fec6..5a0ce6cf1 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -67,58 +67,39 @@ def create_variables_and_constraints(self): 'size', ) - if self._state_variable is None and not self.parameters.optional: + if self.parameters.optional: + is_invested = self.add( + self._model.add_variables( + binary=True, name=f'{self.label_of_model}|is_invested', coords=self._model.get_coords(['year', 'scenario']) + ), + 'is_invested', + ) + constraints += BoundingPatterns.bounds_with_state( + self._model, + variable=size, + variable_state=is_invested, + bounds=(self.parameters.minimum_or_fixed_size, self.parameters.maximum_or_fixed_size), + ) + + if self._state_variable is None: constraints += BoundingPatterns.scaled_bounds( self._model, variable=self._defining_variable, scaling_variable=size, relative_bounds=self._relative_bounds_of_defining_variable, - name=self._defining_variable.name if self._state_variable is None else f'{self._defining_variable.name}_state', ) - elif self._state_variable is not None and not self.parameters.optional: - constraints += BoundingPatterns.bounds_with_state( + else: + constraints += BoundingPatterns.scaled_bounds_with_state( self._model, variable=self._defining_variable, variable_state=self._state_variable, - bounds=self._relative_bounds_of_defining_variable, - name=self._defining_variable.name if self._state_variable is None else f'{self._defining_variable.name}_state', - ) - - elif self.parameters.optional: - is_invested = self.add( - self._model.add_variables( - binary=True, name=f'{self.label_of_model}|is_invested', coords=self._model.get_coords(['year', 'scenario']) - ), - 'is_invested', + scaling_variable=size, + relative_bounds=self._relative_bounds_of_defining_variable, + scaling_bounds=(self.parameters.minimum_or_fixed_size, self.parameters.maximum_or_fixed_size), + name=f'{self.label_of_model}|size+on', ) - if self._state_variable is None: - constraints += BoundingPatterns.bounds_with_state( - self._model, - variable=size, - variable_state=is_invested, - bounds=(self.parameters.minimum_or_fixed_size, self.parameters.maximum_or_fixed_size), - ) - - constraints += BoundingPatterns.scaled_bounds( - self._model, - variable=self._defining_variable, - scaling_variable=size, - relative_bounds=self._relative_bounds_of_defining_variable, - ) - - else: - constraints += BoundingPatterns.scaled_bounds_with_state( - self._model, - variable=self._defining_variable, - variable_state=self._state_variable, - scaling_variable=size, - relative_bounds=self._relative_bounds_of_defining_variable, - scaling_bounds=(self.parameters.minimum_or_fixed_size, self.parameters.maximum_or_fixed_size), - name=f'{self.label_of_model}|size+on', - ) - # Register constraints for constraint in constraints: self.add(constraint) @@ -229,7 +210,6 @@ def create_variables_and_constraints(self): variable=flow_rate, bounds=(lower_bound, upper_bound), variable_state=variables['on'], - name=flow_rate.name, ) # 3. Total duration tracking using existing pattern From 7809ee4119f8e607797a45d9e7b1f917c8f58ebd Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 19 Jul 2025 22:00:29 +0200 Subject: [PATCH 211/336] Fix Models --- flixopt/elements.py | 19 ++++++++++++++++++- flixopt/features.py | 41 +++++++++++++++++------------------------ flixopt/modeling.py | 3 +++ 3 files changed, 38 insertions(+), 25 deletions(-) diff --git a/flixopt/elements.py b/flixopt/elements.py index 09fd07fe9..4fcce79a2 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -15,6 +15,7 @@ from .features import InvestmentModel, OnOffModel, PreventSimultaneousUsageModel, ModelingPatterns, ModelingPrimitives from .interface import InvestParameters, OnOffParameters from .structure import Element, ElementModel, FlowSystemModel, register_class_for_io +from .modeling import BoundingPatterns if TYPE_CHECKING: from .flow_system import FlowSystem @@ -328,6 +329,8 @@ def do_modeling(self): 'flow_rate', ) + default_cons = not (self.element.on_off_parameters is not None and isinstance(self.element.size, InvestParameters)) + # OnOff feature if self.element.on_off_parameters is not None: self.on_off: OnOffModel = self.add( @@ -339,6 +342,7 @@ def do_modeling(self): flow_rate_bounds=[self.flow_rate_bounds_on], previous_flow_rates=[self.element.previous_flow_rate], label_of_model=self.label_of_element, + apply_bounds_to_flow_rates=default_cons, ), 'on_off', ) @@ -357,12 +361,25 @@ def do_modeling(self): self.flow_rate_lower_bound_relative, self.flow_rate_upper_bound_relative, ), - state_variable=self.on_off.on if self.on_off is not None else None, + apply_bounds_to_flow_rates=default_cons, ), 'investment', ) self._investment.do_modeling() + if not default_cons: + constraints = BoundingPatterns.scaled_bounds_with_state( + model=self._model, + variable=self.flow_rate, + scaling_variable=self._investment.size, + relative_bounds=(self.flow_rate_lower_bound_relative, self.flow_rate_upper_bound_relative), + scaling_bounds=(self.element.size.minimum_or_fixed_size, self.element.size.maximum_or_fixed_size), + variable_state=self.on_off.on, + ) + + for constraint in constraints: + self.add(constraint) + # Total flow hours tracking (could use factory pattern) variables, constraints = ModelingPrimitives.expression_tracking_variable( model=self._model, diff --git a/flixopt/features.py b/flixopt/features.py index 5a0ce6cf1..916defa4f 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -29,7 +29,7 @@ def __init__( defining_variable: linopy.Variable, relative_bounds_of_defining_variable: Tuple[TemporalData, TemporalData], label_of_model: Optional[str] = None, - state_variable: Optional[linopy.Variable] = None, + apply_bounds_to_defining_variable: bool = True, ): """ This feature model is used to model the investment of a variable. @@ -42,13 +42,13 @@ def __init__( defining_variable: The variable to be invested relative_bounds_of_defining_variable: The bounds of the variable, with respect to the minimum/maximum investment sizes label_of_model: The label of the model. This is needed to construct the full label of the model. - state_variable: The variable tracking the state of the variable + """ super().__init__(model, label_of_element=label_of_element, parameters=parameters, label_of_model=label_of_model) self._defining_variable = defining_variable self._relative_bounds_of_defining_variable = relative_bounds_of_defining_variable - self._state_variable = state_variable + self._apply_bounds_to_defining_variable = apply_bounds_to_defining_variable # Only keep non-variable attributes self.scenario_of_investment: Optional[linopy.Variable] = None @@ -81,23 +81,12 @@ def create_variables_and_constraints(self): bounds=(self.parameters.minimum_or_fixed_size, self.parameters.maximum_or_fixed_size), ) - if self._state_variable is None: + if self._apply_bounds_to_defining_variable: constraints += BoundingPatterns.scaled_bounds( self._model, variable=self._defining_variable, - scaling_variable=size, - relative_bounds=self._relative_bounds_of_defining_variable, - ) - - else: - constraints += BoundingPatterns.scaled_bounds_with_state( - self._model, - variable=self._defining_variable, - variable_state=self._state_variable, - scaling_variable=size, + scaling_variable=self.size, relative_bounds=self._relative_bounds_of_defining_variable, - scaling_bounds=(self.parameters.minimum_or_fixed_size, self.parameters.maximum_or_fixed_size), - name=f'{self.label_of_model}|size+on', ) # Register constraints @@ -174,6 +163,7 @@ def __init__( flow_rate_bounds: List[Tuple[TemporalData, TemporalData]], previous_flow_rates: List[Optional[TemporalData]], label_of_model: Optional[str] = None, + apply_bounds_to_flow_rates: bool = True, ): """ This feature model is used to model the on/off state of flow_rate(s). It does not matter of the flow_rates are @@ -192,6 +182,7 @@ def __init__( self._flow_rates = flow_rates self._flow_rate_bounds = flow_rate_bounds self._previous_flow_rates = previous_flow_rates + self._apply_bounds_to_flow_rates = apply_bounds_to_flow_rates def create_variables_and_constraints(self): variables = {} @@ -203,14 +194,16 @@ def create_variables_and_constraints(self): constraints += list(state_constraints.values()) # 2. Control variables - use big_m_binary_bounds pattern for consistency - for i, (flow_rate, (lower_bound, upper_bound)) in enumerate(zip(self._flow_rates, self._flow_rate_bounds)): - # TODO: Add suffix options - constraints += BoundingPatterns.bounds_with_state( - model=self._model, - variable=flow_rate, - bounds=(lower_bound, upper_bound), - variable_state=variables['on'], - ) + if self._apply_bounds_to_flow_rates: + for i, (flow_rate, flow_rate_bounds) in enumerate( + zip(self._flow_rates, self._flow_rate_bounds, strict=True) + ): + constraints += BoundingPatterns.bounds_with_state( + model=self._model, + variable=flow_rate, + bounds=flow_rate_bounds, + variable_state=variables['on'], + ) # 3. Total duration tracking using existing pattern duration_expr = (variables['on'] * self._model.hours_per_step).sum('time') diff --git a/flixopt/modeling.py b/flixopt/modeling.py index b8e00a723..98fc65756 100644 --- a/flixopt/modeling.py +++ b/flixopt/modeling.py @@ -550,6 +550,9 @@ def scaled_bounds( rel_lower, rel_upper = relative_bounds name = name or f'{variable.name}' + if np.abs(rel_lower - rel_upper).all() < 10e-10: + return [model.add_constraints(variable == scaling_variable * rel_lower, name=f'{name}|fixed')] + upper_constraint = model.add_constraints(variable <= scaling_variable * rel_upper, name=f'{name}|ub') lower_constraint = model.add_constraints(variable >= scaling_variable * rel_lower, name=f'{name}|lb') From 2bbdb4483f2db250d582a158ad5fb25185b70336 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 19 Jul 2025 22:03:29 +0200 Subject: [PATCH 212/336] Update constraint names in test --- tests/test_flow.py | 282 +++++++++++++++++++++++---------------------- 1 file changed, 145 insertions(+), 137 deletions(-) diff --git a/tests/test_flow.py b/tests/test_flow.py index 9038af1c7..5b99a79f2 100644 --- a/tests/test_flow.py +++ b/tests/test_flow.py @@ -143,8 +143,8 @@ def test_flow_invest(self, basic_flow_system_linopy): assert set(flow.model.constraints) == set( [ 'Sink(Wärme)|total_flow_hours', - 'Sink(Wärme)|flow_rate|upper_bound', - 'Sink(Wärme)|flow_rate|lower_bound', + 'Sink(Wärme)|flow_rate|ub', + 'Sink(Wärme)|flow_rate|lb', ] ) @@ -161,13 +161,13 @@ def test_flow_invest(self, basic_flow_system_linopy): ), ) assert_conequal( - model.constraints['Sink(Wärme)|flow_rate|lower_bound'], + model.constraints['Sink(Wärme)|flow_rate|lb'], flow.model.variables['Sink(Wärme)|flow_rate'] >= flow.model.variables['Sink(Wärme)|size'] * xr.DataArray(np.linspace(0.1, 0.5, timesteps.size), coords=(timesteps,)), ) assert_conequal( - model.constraints['Sink(Wärme)|flow_rate|upper_bound'], + model.constraints['Sink(Wärme)|flow_rate|ub'], flow.model.variables['Sink(Wärme)|flow_rate'] <= flow.model.variables['Sink(Wärme)|size'] * xr.DataArray(np.linspace(0.5, 1, timesteps.size), coords=(timesteps,)), @@ -194,10 +194,10 @@ def test_flow_invest_optional(self, basic_flow_system_linopy): assert set(flow.model.constraints) == set( [ 'Sink(Wärme)|total_flow_hours', - 'Sink(Wärme)|size|lower_bound', - 'Sink(Wärme)|size|upper_bound', - 'Sink(Wärme)|flow_rate|lower_bound', - 'Sink(Wärme)|flow_rate|upper_bound', + 'Sink(Wärme)|size|lb', + 'Sink(Wärme)|size|ub', + 'Sink(Wärme)|flow_rate|lb', + 'Sink(Wärme)|flow_rate|ub', ] ) @@ -215,13 +215,13 @@ def test_flow_invest_optional(self, basic_flow_system_linopy): ), ) assert_conequal( - model.constraints['Sink(Wärme)|flow_rate|lower_bound'], + model.constraints['Sink(Wärme)|flow_rate|lb'], flow.model.variables['Sink(Wärme)|flow_rate'] >= flow.model.variables['Sink(Wärme)|size'] * xr.DataArray(np.linspace(0.1, 0.5, timesteps.size), coords=(timesteps,)), ) assert_conequal( - model.constraints['Sink(Wärme)|flow_rate|upper_bound'], + model.constraints['Sink(Wärme)|flow_rate|ub'], flow.model.variables['Sink(Wärme)|flow_rate'] <= flow.model.variables['Sink(Wärme)|size'] * xr.DataArray(np.linspace(0.5, 1, timesteps.size), coords=(timesteps,)), @@ -229,11 +229,11 @@ def test_flow_invest_optional(self, basic_flow_system_linopy): # Is invested assert_conequal( - model.constraints['Sink(Wärme)|size|upper_bound'], + model.constraints['Sink(Wärme)|size|ub'], flow.model.variables['Sink(Wärme)|size'] <= flow.model.variables['Sink(Wärme)|is_invested'] * 100, ) assert_conequal( - model.constraints['Sink(Wärme)|size|lower_bound'], + model.constraints['Sink(Wärme)|size|lb'], flow.model.variables['Sink(Wärme)|size'] >= flow.model.variables['Sink(Wärme)|is_invested'] * 20, ) @@ -258,10 +258,10 @@ def test_flow_invest_optional_wo_min_size(self, basic_flow_system_linopy): assert set(flow.model.constraints) == set( [ 'Sink(Wärme)|total_flow_hours', - 'Sink(Wärme)|size|upper_bound', - 'Sink(Wärme)|size|lower_bound', - 'Sink(Wärme)|flow_rate|lower_bound', - 'Sink(Wärme)|flow_rate|upper_bound', + 'Sink(Wärme)|size|ub', + 'Sink(Wärme)|size|lb', + 'Sink(Wärme)|flow_rate|lb', + 'Sink(Wärme)|flow_rate|ub', ] ) @@ -279,13 +279,13 @@ def test_flow_invest_optional_wo_min_size(self, basic_flow_system_linopy): ), ) assert_conequal( - model.constraints['Sink(Wärme)|flow_rate|lower_bound'], + model.constraints['Sink(Wärme)|flow_rate|lb'], flow.model.variables['Sink(Wärme)|flow_rate'] >= flow.model.variables['Sink(Wärme)|size'] * xr.DataArray(np.linspace(0.1, 0.5, timesteps.size), coords=(timesteps,)), ) assert_conequal( - model.constraints['Sink(Wärme)|flow_rate|upper_bound'], + model.constraints['Sink(Wärme)|flow_rate|ub'], flow.model.variables['Sink(Wärme)|flow_rate'] <= flow.model.variables['Sink(Wärme)|size'] * xr.DataArray(np.linspace(0.5, 1, timesteps.size), coords=(timesteps,)), @@ -293,11 +293,11 @@ def test_flow_invest_optional_wo_min_size(self, basic_flow_system_linopy): # Is invested assert_conequal( - model.constraints['Sink(Wärme)|size|upper_bound'], + model.constraints['Sink(Wärme)|size|ub'], flow.model.variables['Sink(Wärme)|size'] <= flow.model.variables['Sink(Wärme)|is_invested'] * 100, ) assert_conequal( - model.constraints['Sink(Wärme)|size|lower_bound'], + model.constraints['Sink(Wärme)|size|lb'], flow.model.variables['Sink(Wärme)|size'] >= flow.model.variables['Sink(Wärme)|is_invested'] * 1e-5, ) @@ -322,8 +322,8 @@ def test_flow_invest_wo_min_size_non_optional(self, basic_flow_system_linopy): assert set(flow.model.constraints) == set( [ 'Sink(Wärme)|total_flow_hours', - 'Sink(Wärme)|flow_rate|lower_bound', - 'Sink(Wärme)|flow_rate|upper_bound', + 'Sink(Wärme)|flow_rate|lb', + 'Sink(Wärme)|flow_rate|ub', ] ) @@ -339,13 +339,13 @@ def test_flow_invest_wo_min_size_non_optional(self, basic_flow_system_linopy): ), ) assert_conequal( - model.constraints['Sink(Wärme)|flow_rate|lower_bound'], + model.constraints['Sink(Wärme)|flow_rate|lb'], flow.model.variables['Sink(Wärme)|flow_rate'] >= flow.model.variables['Sink(Wärme)|size'] * xr.DataArray(np.linspace(0.1, 0.5, timesteps.size), coords=(timesteps,)), ) assert_conequal( - model.constraints['Sink(Wärme)|flow_rate|upper_bound'], + model.constraints['Sink(Wärme)|flow_rate|ub'], flow.model.variables['Sink(Wärme)|flow_rate'] <= flow.model.variables['Sink(Wärme)|size'] * xr.DataArray(np.linspace(0.5, 1, timesteps.size), coords=(timesteps,)), @@ -466,8 +466,8 @@ def test_flow_on(self, basic_flow_system_linopy): [ 'Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|on_hours_total', - 'Sink(Wärme)|on_con1', - 'Sink(Wärme)|on_con2', + 'Sink(Wärme)|flow_rate|lb', + 'Sink(Wärme)|flow_rate|ub', ] ) # flow_rate @@ -490,12 +490,12 @@ def test_flow_on(self, basic_flow_system_linopy): model.add_variables(lower=0), ) assert_conequal( - model.constraints['Sink(Wärme)|on_con1'], - flow.model.variables['Sink(Wärme)|on'] * 0.2 * 100 <= flow.model.variables['Sink(Wärme)|flow_rate'], + model.constraints['Sink(Wärme)|flow_rate|lb'], + flow.model.variables['Sink(Wärme)|flow_rate'] >= flow.model.variables['Sink(Wärme)|on'] * 0.2 * 100, ) assert_conequal( - model.constraints['Sink(Wärme)|on_con2'], - flow.model.variables['Sink(Wärme)|on'] * 0.8 * 100 >= flow.model.variables['Sink(Wärme)|flow_rate'], + model.constraints['Sink(Wärme)|flow_rate|ub'], + flow.model.variables['Sink(Wärme)|flow_rate']<= flow.model.variables['Sink(Wärme)|on'] * 0.8 * 100, ) assert_conequal( @@ -530,8 +530,8 @@ def test_effects_per_running_hour(self, basic_flow_system_linopy): } assert set(flow.model.constraints) == { 'Sink(Wärme)|total_flow_hours', - 'Sink(Wärme)|on_con1', - 'Sink(Wärme)|on_con2', + 'Sink(Wärme)|flow_rate|lb', + 'Sink(Wärme)|flow_rate|ub', 'Sink(Wärme)|on_hours_total', } @@ -568,51 +568,51 @@ def test_consecutive_on_hours(self, basic_flow_system_linopy): flow_system.add_elements( fx.Sink('Sink', sink=flow)) model = create_linopy_model(flow_system) - assert {'Sink(Wärme)|ConsecutiveOn|hours', 'Sink(Wärme)|on'}.issubset(set(flow.model.variables)) + assert {'Sink(Wärme)|consecutive_on_hours', 'Sink(Wärme)|on'}.issubset(set(flow.model.variables)) - assert {'Sink(Wärme)|ConsecutiveOn|con1', - 'Sink(Wärme)|ConsecutiveOn|con2a', - 'Sink(Wärme)|ConsecutiveOn|con2b', - 'Sink(Wärme)|ConsecutiveOn|initial', - 'Sink(Wärme)|ConsecutiveOn|minimum', + assert {'Sink(Wärme)|consecutive_on_hours|ub', + 'Sink(Wärme)|consecutive_on_hours|forward', + 'Sink(Wärme)|consecutive_on_hours|backward', + 'Sink(Wärme)|consecutive_on_hours|initial', + 'Sink(Wärme)|consecutive_on_hours|lb', }.issubset(set(flow.model.constraints)) assert_var_equal( - model.variables['Sink(Wärme)|ConsecutiveOn|hours'], + model.variables['Sink(Wärme)|consecutive_on_hours'], model.add_variables(lower=0, upper=8, coords=(timesteps,)) ) mega = model.hours_per_step.sum('time') assert_conequal( - model.constraints['Sink(Wärme)|ConsecutiveOn|con1'], - model.variables['Sink(Wärme)|ConsecutiveOn|hours'] <= model.variables['Sink(Wärme)|on'] * mega + model.constraints['Sink(Wärme)|consecutive_on_hours|ub'], + model.variables['Sink(Wärme)|consecutive_on_hours'] <= model.variables['Sink(Wärme)|on'] * mega ) assert_conequal( - model.constraints['Sink(Wärme)|ConsecutiveOn|con2a'], - model.variables['Sink(Wärme)|ConsecutiveOn|hours'].isel(time=slice(1, None)) - <= model.variables['Sink(Wärme)|ConsecutiveOn|hours'].isel(time=slice(None, -1)) + model.hours_per_step.isel(time=slice(None, -1)) + model.constraints['Sink(Wärme)|consecutive_on_hours|forward'], + model.variables['Sink(Wärme)|consecutive_on_hours'].isel(time=slice(1, None)) + <= model.variables['Sink(Wärme)|consecutive_on_hours'].isel(time=slice(None, -1)) + model.hours_per_step.isel(time=slice(None, -1)) ) # eq: duration(t) >= duration(t - 1) + dt(t) + (On(t) - 1) * BIG assert_conequal( - model.constraints['Sink(Wärme)|ConsecutiveOn|con2b'], - model.variables['Sink(Wärme)|ConsecutiveOn|hours'].isel(time=slice(1, None)) - >= model.variables['Sink(Wärme)|ConsecutiveOn|hours'].isel(time=slice(None, -1)) + model.constraints['Sink(Wärme)|consecutive_on_hours|backward'], + model.variables['Sink(Wärme)|consecutive_on_hours'].isel(time=slice(1, None)) + >= model.variables['Sink(Wärme)|consecutive_on_hours'].isel(time=slice(None, -1)) + model.hours_per_step.isel(time=slice(None, -1)) + (model.variables['Sink(Wärme)|on'].isel(time=slice(1, None)) - 1) * mega ) assert_conequal( - model.constraints['Sink(Wärme)|ConsecutiveOn|initial'], - model.variables['Sink(Wärme)|ConsecutiveOn|hours'].isel(time=0) + model.constraints['Sink(Wärme)|consecutive_on_hours|initial'], + model.variables['Sink(Wärme)|consecutive_on_hours'].isel(time=0) == model.variables['Sink(Wärme)|on'].isel(time=0) * model.hours_per_step.isel(time=0), ) assert_conequal( - model.constraints['Sink(Wärme)|ConsecutiveOn|minimum'], - model.variables['Sink(Wärme)|ConsecutiveOn|hours'] + model.constraints['Sink(Wärme)|consecutive_on_hours|lb'], + model.variables['Sink(Wärme)|consecutive_on_hours'] >= (model.variables['Sink(Wärme)|on'].isel(time=slice(None, -1)) - model.variables['Sink(Wärme)|on'].isel(time=slice(1, None))) * 2 ) @@ -635,51 +635,51 @@ def test_consecutive_on_hours_previous(self, basic_flow_system_linopy): flow_system.add_elements( fx.Sink('Sink', sink=flow)) model = create_linopy_model(flow_system) - assert {'Sink(Wärme)|ConsecutiveOn|hours', 'Sink(Wärme)|on'}.issubset(set(flow.model.variables)) + assert {'Sink(Wärme)|consecutive_on_hours', 'Sink(Wärme)|on'}.issubset(set(flow.model.variables)) - assert {'Sink(Wärme)|ConsecutiveOn|con1', - 'Sink(Wärme)|ConsecutiveOn|con2a', - 'Sink(Wärme)|ConsecutiveOn|con2b', - 'Sink(Wärme)|ConsecutiveOn|initial', - 'Sink(Wärme)|ConsecutiveOn|minimum', + assert {'Sink(Wärme)|consecutive_on_hours|lb', + 'Sink(Wärme)|consecutive_on_hours|forward', + 'Sink(Wärme)|consecutive_on_hours|backward', + 'Sink(Wärme)|consecutive_on_hours|initial', + 'Sink(Wärme)|consecutive_on_hours|lb', }.issubset(set(flow.model.constraints)) assert_var_equal( - model.variables['Sink(Wärme)|ConsecutiveOn|hours'], + model.variables['Sink(Wärme)|consecutive_on_hours'], model.add_variables(lower=0, upper=8, coords=(timesteps,)) ) mega = model.hours_per_step.sum('time') + model.hours_per_step.isel(time=0) * 3 assert_conequal( - model.constraints['Sink(Wärme)|ConsecutiveOn|con1'], - model.variables['Sink(Wärme)|ConsecutiveOn|hours'] <= model.variables['Sink(Wärme)|on'] * mega + model.constraints['Sink(Wärme)|consecutive_on_hours|ub'], + model.variables['Sink(Wärme)|consecutive_on_hours'] <= model.variables['Sink(Wärme)|on'] * mega ) assert_conequal( - model.constraints['Sink(Wärme)|ConsecutiveOn|con2a'], - model.variables['Sink(Wärme)|ConsecutiveOn|hours'].isel(time=slice(1, None)) - <= model.variables['Sink(Wärme)|ConsecutiveOn|hours'].isel(time=slice(None, -1)) + model.hours_per_step.isel(time=slice(None, -1)) + model.constraints['Sink(Wärme)|consecutive_on_hours|forward'], + model.variables['Sink(Wärme)|consecutive_on_hours'].isel(time=slice(1, None)) + <= model.variables['Sink(Wärme)|consecutive_on_hours'].isel(time=slice(None, -1)) + model.hours_per_step.isel(time=slice(None, -1)) ) # eq: duration(t) >= duration(t - 1) + dt(t) + (On(t) - 1) * BIG assert_conequal( - model.constraints['Sink(Wärme)|ConsecutiveOn|con2b'], - model.variables['Sink(Wärme)|ConsecutiveOn|hours'].isel(time=slice(1, None)) - >= model.variables['Sink(Wärme)|ConsecutiveOn|hours'].isel(time=slice(None, -1)) + model.constraints['Sink(Wärme)|consecutive_on_hours|backward'], + model.variables['Sink(Wärme)|consecutive_on_hours'].isel(time=slice(1, None)) + >= model.variables['Sink(Wärme)|consecutive_on_hours'].isel(time=slice(None, -1)) + model.hours_per_step.isel(time=slice(None, -1)) + (model.variables['Sink(Wärme)|on'].isel(time=slice(1, None)) - 1) * mega ) assert_conequal( - model.constraints['Sink(Wärme)|ConsecutiveOn|initial'], - model.variables['Sink(Wärme)|ConsecutiveOn|hours'].isel(time=0) + model.constraints['Sink(Wärme)|consecutive_on_hours|initial'], + model.variables['Sink(Wärme)|consecutive_on_hours'].isel(time=0) == model.variables['Sink(Wärme)|on'].isel(time=0) * (model.hours_per_step.isel(time=0) * (1 + 3)), ) assert_conequal( - model.constraints['Sink(Wärme)|ConsecutiveOn|minimum'], - model.variables['Sink(Wärme)|ConsecutiveOn|hours'] + model.constraints['Sink(Wärme)|consecutive_on_hours|lb'], + model.variables['Sink(Wärme)|consecutive_on_hours'] >= (model.variables['Sink(Wärme)|on'].isel(time=slice(None, -1)) - model.variables['Sink(Wärme)|on'].isel(time=slice(1, None))) * 2 ) @@ -701,52 +701,52 @@ def test_consecutive_off_hours(self, basic_flow_system_linopy): flow_system.add_elements( fx.Sink('Sink', sink=flow)) model = create_linopy_model(flow_system) - assert {'Sink(Wärme)|ConsecutiveOff|hours', 'Sink(Wärme)|off'}.issubset(set(flow.model.variables)) + assert {'Sink(Wärme)|consecutive_off_hours', 'Sink(Wärme)|off'}.issubset(set(flow.model.variables)) assert { - 'Sink(Wärme)|ConsecutiveOff|con1', - 'Sink(Wärme)|ConsecutiveOff|con2a', - 'Sink(Wärme)|ConsecutiveOff|con2b', - 'Sink(Wärme)|ConsecutiveOff|initial', - 'Sink(Wärme)|ConsecutiveOff|minimum' + 'Sink(Wärme)|consecutive_off_hours|ub', + 'Sink(Wärme)|consecutive_off_hours|forward', + 'Sink(Wärme)|consecutive_off_hours|backward', + 'Sink(Wärme)|consecutive_off_hours|initial', + 'Sink(Wärme)|consecutive_off_hours|lb' }.issubset(set(flow.model.constraints)) assert_var_equal( - model.variables['Sink(Wärme)|ConsecutiveOff|hours'], + model.variables['Sink(Wärme)|consecutive_off_hours'], model.add_variables(lower=0, upper=12, coords=(timesteps,)) ) mega = model.hours_per_step.sum('time') + model.hours_per_step.isel(time=0) * 1 # previously off for 1h assert_conequal( - model.constraints['Sink(Wärme)|ConsecutiveOff|con1'], - model.variables['Sink(Wärme)|ConsecutiveOff|hours'] <= model.variables['Sink(Wärme)|off'] * mega + model.constraints['Sink(Wärme)|consecutive_off_hours|ub'], + model.variables['Sink(Wärme)|consecutive_off_hours'] <= model.variables['Sink(Wärme)|off'] * mega ) assert_conequal( - model.constraints['Sink(Wärme)|ConsecutiveOff|con2a'], - model.variables['Sink(Wärme)|ConsecutiveOff|hours'].isel(time=slice(1, None)) - <= model.variables['Sink(Wärme)|ConsecutiveOff|hours'].isel(time=slice(None, -1)) + model.hours_per_step.isel(time=slice(None, -1)) + model.constraints['Sink(Wärme)|consecutive_off_hours|forward'], + model.variables['Sink(Wärme)|consecutive_off_hours'].isel(time=slice(1, None)) + <= model.variables['Sink(Wärme)|consecutive_off_hours'].isel(time=slice(None, -1)) + model.hours_per_step.isel(time=slice(None, -1)) ) # eq: duration(t) >= duration(t - 1) + dt(t) + (On(t) - 1) * BIG assert_conequal( - model.constraints['Sink(Wärme)|ConsecutiveOff|con2b'], - model.variables['Sink(Wärme)|ConsecutiveOff|hours'].isel(time=slice(1, None)) - >= model.variables['Sink(Wärme)|ConsecutiveOff|hours'].isel(time=slice(None, -1)) + model.constraints['Sink(Wärme)|consecutive_off_hours|backward'], + model.variables['Sink(Wärme)|consecutive_off_hours'].isel(time=slice(1, None)) + >= model.variables['Sink(Wärme)|consecutive_off_hours'].isel(time=slice(None, -1)) + model.hours_per_step.isel(time=slice(None, -1)) + (model.variables['Sink(Wärme)|off'].isel(time=slice(1, None)) - 1) * mega ) assert_conequal( - model.constraints['Sink(Wärme)|ConsecutiveOff|initial'], - model.variables['Sink(Wärme)|ConsecutiveOff|hours'].isel(time=0) + model.constraints['Sink(Wärme)|consecutive_off_hours|initial'], + model.variables['Sink(Wärme)|consecutive_off_hours'].isel(time=0) == model.variables['Sink(Wärme)|off'].isel(time=0) * (model.hours_per_step.isel(time=0) * (1 + 1)), ) assert_conequal( - model.constraints['Sink(Wärme)|ConsecutiveOff|minimum'], - model.variables['Sink(Wärme)|ConsecutiveOff|hours'] + model.constraints['Sink(Wärme)|consecutive_off_hours|lb'], + model.variables['Sink(Wärme)|consecutive_off_hours'] >= (model.variables['Sink(Wärme)|off'].isel(time=slice(None, -1)) - model.variables['Sink(Wärme)|off'].isel(time=slice(1, None))) * 4 ) @@ -769,52 +769,52 @@ def test_consecutive_off_hours_previous(self, basic_flow_system_linopy): flow_system.add_elements( fx.Sink('Sink', sink=flow)) model = create_linopy_model(flow_system) - assert {'Sink(Wärme)|ConsecutiveOff|hours', 'Sink(Wärme)|off'}.issubset(set(flow.model.variables)) + assert {'Sink(Wärme)|consecutive_off_hours', 'Sink(Wärme)|off'}.issubset(set(flow.model.variables)) assert { - 'Sink(Wärme)|ConsecutiveOff|con1', - 'Sink(Wärme)|ConsecutiveOff|con2a', - 'Sink(Wärme)|ConsecutiveOff|con2b', - 'Sink(Wärme)|ConsecutiveOff|initial', - 'Sink(Wärme)|ConsecutiveOff|minimum' + 'Sink(Wärme)|consecutive_off_hours|ub', + 'Sink(Wärme)|consecutive_off_hours|forward', + 'Sink(Wärme)|consecutive_off_hours|backward', + 'Sink(Wärme)|consecutive_off_hours|initial', + 'Sink(Wärme)|consecutive_off_hours|lb' }.issubset(set(flow.model.constraints)) assert_var_equal( - model.variables['Sink(Wärme)|ConsecutiveOff|hours'], + model.variables['Sink(Wärme)|consecutive_off_hours'], model.add_variables(lower=0, upper=12, coords=(timesteps,)) ) mega = model.hours_per_step.sum('time') + model.hours_per_step.isel(time=0) * 2 assert_conequal( - model.constraints['Sink(Wärme)|ConsecutiveOff|con1'], - model.variables['Sink(Wärme)|ConsecutiveOff|hours'] <= model.variables['Sink(Wärme)|off'] * mega + model.constraints['Sink(Wärme)|consecutive_off_hours|ub'], + model.variables['Sink(Wärme)|consecutive_off_hours'] <= model.variables['Sink(Wärme)|off'] * mega ) assert_conequal( - model.constraints['Sink(Wärme)|ConsecutiveOff|con2a'], - model.variables['Sink(Wärme)|ConsecutiveOff|hours'].isel(time=slice(1, None)) - <= model.variables['Sink(Wärme)|ConsecutiveOff|hours'].isel(time=slice(None, -1)) + model.hours_per_step.isel(time=slice(None, -1)) + model.constraints['Sink(Wärme)|consecutive_off_hours|forward'], + model.variables['Sink(Wärme)|consecutive_off_hours'].isel(time=slice(1, None)) + <= model.variables['Sink(Wärme)|consecutive_off_hours'].isel(time=slice(None, -1)) + model.hours_per_step.isel(time=slice(None, -1)) ) # eq: duration(t) >= duration(t - 1) + dt(t) + (On(t) - 1) * BIG assert_conequal( - model.constraints['Sink(Wärme)|ConsecutiveOff|con2b'], - model.variables['Sink(Wärme)|ConsecutiveOff|hours'].isel(time=slice(1, None)) - >= model.variables['Sink(Wärme)|ConsecutiveOff|hours'].isel(time=slice(None, -1)) + model.constraints['Sink(Wärme)|consecutive_off_hours|backward'], + model.variables['Sink(Wärme)|consecutive_off_hours'].isel(time=slice(1, None)) + >= model.variables['Sink(Wärme)|consecutive_off_hours'].isel(time=slice(None, -1)) + model.hours_per_step.isel(time=slice(None, -1)) + (model.variables['Sink(Wärme)|off'].isel(time=slice(1, None)) - 1) * mega ) assert_conequal( - model.constraints['Sink(Wärme)|ConsecutiveOff|initial'], - model.variables['Sink(Wärme)|ConsecutiveOff|hours'].isel(time=0) + model.constraints['Sink(Wärme)|consecutive_off_hours|initial'], + model.variables['Sink(Wärme)|consecutive_off_hours'].isel(time=0) == model.variables['Sink(Wärme)|off'].isel(time=0) * (model.hours_per_step.isel(time=0) * (1+2)), ) assert_conequal( - model.constraints['Sink(Wärme)|ConsecutiveOff|minimum'], - model.variables['Sink(Wärme)|ConsecutiveOff|hours'] + model.constraints['Sink(Wärme)|consecutive_off_hours|lb'], + model.variables['Sink(Wärme)|consecutive_off_hours'] >= (model.variables['Sink(Wärme)|off'].isel(time=slice(None, -1)) - model.variables['Sink(Wärme)|off'].isel(time=slice(1, None))) * 4 ) @@ -836,26 +836,26 @@ def test_switch_on_constraints(self, basic_flow_system_linopy): model = create_linopy_model(flow_system) # Check that variables exist - assert {'Sink(Wärme)|switch_on', 'Sink(Wärme)|switch_off', 'Sink(Wärme)|switch_on_nr'}.issubset( + assert {'Sink(Wärme)|switch|on', 'Sink(Wärme)|switch|off', 'Sink(Wärme)|switch|count'}.issubset( set(flow.model.variables) ) # Check that constraints exist assert { - 'Sink(Wärme)|switch_con', - 'Sink(Wärme)|initial_switch_con', - 'Sink(Wärme)|switch_on_or_off', - 'Sink(Wärme)|switch_on_nr', + 'Sink(Wärme)|switch', + 'Sink(Wärme)|switch|initial', + 'Sink(Wärme)|switch|mutex', + 'Sink(Wärme)|switch|count', }.issubset(set(flow.model.constraints)) # Check switch_on_nr variable bounds - assert_var_equal(flow.model.variables['Sink(Wärme)|switch_on_nr'], model.add_variables(lower=0, upper=5)) + assert_var_equal(flow.model.variables['Sink(Wärme)|switch|count'], model.add_variables(lower=0, upper=5)) # Verify switch_on_nr constraint (limits number of startups) assert_conequal( - model.constraints['Sink(Wärme)|switch_on_nr'], - flow.model.variables['Sink(Wärme)|switch_on_nr'] - == flow.model.variables['Sink(Wärme)|switch_on'].sum('time'), + model.constraints['Sink(Wärme)|switch|count'], + flow.model.variables['Sink(Wärme)|switch|count'] + == flow.model.variables['Sink(Wärme)|switch|on'].sum('time'), ) # Check that startup cost effect constraint exists @@ -864,7 +864,7 @@ def test_switch_on_constraints(self, basic_flow_system_linopy): # Verify the startup cost effect constraint assert_conequal( model.constraints['Sink(Wärme)->Costs(operation)'], - model.variables['Sink(Wärme)->Costs(operation)'] == flow.model.variables['Sink(Wärme)|switch_on'] * 100, + model.variables['Sink(Wärme)->Costs(operation)'] == flow.model.variables['Sink(Wärme)|switch|on'] * 100, ) def test_on_hours_limits(self, basic_flow_system_linopy): @@ -933,12 +933,12 @@ def test_flow_on_invest_optional(self, basic_flow_system_linopy): [ 'Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|on_hours_total', - 'Sink(Wärme)|on_con1', - 'Sink(Wärme)|on_con2', - 'Sink(Wärme)|size|lower_bound', - 'Sink(Wärme)|size|upper_bound', - 'Sink(Wärme)|flow_rate|lower_bound', - 'Sink(Wärme)|flow_rate|upper_bound', + 'Sink(Wärme)|flow_rate|lb1', + 'Sink(Wärme)|flow_rate|ub1', + 'Sink(Wärme)|size|lb', + 'Sink(Wärme)|size|ub', + 'Sink(Wärme)|flow_rate|lb2', + 'Sink(Wärme)|flow_rate|ub2', ] ) @@ -962,11 +962,19 @@ def test_flow_on_invest_optional(self, basic_flow_system_linopy): model.add_variables(lower=0), ) assert_conequal( - model.constraints['Sink(Wärme)|on_con1'], + model.constraints['Sink(Wärme)|size|lb'], + flow.model.variables['Sink(Wärme)|size'] >= flow.model.variables['Sink(Wärme)|is_invested'] * 20, + ) + assert_conequal( + model.constraints['Sink(Wärme)|size|ub'], + flow.model.variables['Sink(Wärme)|size']<= flow.model.variables['Sink(Wärme)|is_invested'] * 200, + ) + assert_conequal( + model.constraints['Sink(Wärme)|flow_rate|lb1'], flow.model.variables['Sink(Wärme)|on'] * 0.2 * 20 <= flow.model.variables['Sink(Wärme)|flow_rate'], ) assert_conequal( - model.constraints['Sink(Wärme)|on_con2'], + model.constraints['Sink(Wärme)|flow_rate|ub1'], flow.model.variables['Sink(Wärme)|on'] * 0.8 * 200 >= flow.model.variables['Sink(Wärme)|flow_rate'], ) assert_conequal( @@ -980,12 +988,12 @@ def test_flow_on_invest_optional(self, basic_flow_system_linopy): mega = 0.2 * 200 # Relative minimum * maximum size assert_conequal( - model.constraints['Sink(Wärme)|flow_rate|lower_bound'], + model.constraints['Sink(Wärme)|flow_rate|lb2'], flow.model.variables['Sink(Wärme)|flow_rate'] >= flow.model.variables['Sink(Wärme)|on'] * mega + flow.model.variables['Sink(Wärme)|size'] * 0.2 - mega, ) assert_conequal( - model.constraints['Sink(Wärme)|flow_rate|upper_bound'], + model.constraints['Sink(Wärme)|flow_rate|ub2'], flow.model.variables['Sink(Wärme)|flow_rate'] <= flow.model.variables['Sink(Wärme)|size'] * 0.8, ) @@ -1017,10 +1025,10 @@ def test_flow_on_invest_non_optional(self, basic_flow_system_linopy): [ 'Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|on_hours_total', - 'Sink(Wärme)|on_con1', - 'Sink(Wärme)|on_con2', - 'Sink(Wärme)|flow_rate|lower_bound', - 'Sink(Wärme)|flow_rate|upper_bound', + 'Sink(Wärme)|flow_rate|lb1', + 'Sink(Wärme)|flow_rate|ub1', + 'Sink(Wärme)|flow_rate|lb2', + 'Sink(Wärme)|flow_rate|ub2', ] ) @@ -1044,11 +1052,11 @@ def test_flow_on_invest_non_optional(self, basic_flow_system_linopy): model.add_variables(lower=0), ) assert_conequal( - model.constraints['Sink(Wärme)|on_con1'], + model.constraints['Sink(Wärme)|flow_rate|lb1'], flow.model.variables['Sink(Wärme)|on'] * 0.2 * 20 <= flow.model.variables['Sink(Wärme)|flow_rate'], ) assert_conequal( - model.constraints['Sink(Wärme)|on_con2'], + model.constraints['Sink(Wärme)|flow_rate|ub1'], flow.model.variables['Sink(Wärme)|on'] * 0.8 * 200 >= flow.model.variables['Sink(Wärme)|flow_rate'], ) assert_conequal( @@ -1062,12 +1070,12 @@ def test_flow_on_invest_non_optional(self, basic_flow_system_linopy): mega = 0.2 * 200 # Relative minimum * maximum size assert_conequal( - model.constraints['Sink(Wärme)|flow_rate|lower_bound'], + model.constraints['Sink(Wärme)|flow_rate|lb2'], flow.model.variables['Sink(Wärme)|flow_rate'] >= flow.model.variables['Sink(Wärme)|on'] * mega + flow.model.variables['Sink(Wärme)|size'] * 0.2 - mega, ) assert_conequal( - model.constraints['Sink(Wärme)|flow_rate|upper_bound'], + model.constraints['Sink(Wärme)|flow_rate|ub2'], flow.model.variables['Sink(Wärme)|flow_rate'] <= flow.model.variables['Sink(Wärme)|size'] * 0.8, ) @@ -1122,7 +1130,7 @@ def test_fixed_profile_with_investment(self, basic_flow_system_linopy): # The constraint should link flow_rate to size * profile assert_conequal( - model.constraints['Sink(Wärme)|fix_Sink(Wärme)|flow_rate'], + model.constraints['Sink(Wärme)|flow_rate|fixed'], flow.model.variables['Sink(Wärme)|flow_rate'] == flow.model.variables['Sink(Wärme)|size'] * xr.DataArray(profile, coords=(timesteps,)), ) From 2a01abe31cf6f0e2c1eaff9a40620dcb94099c30 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 19 Jul 2025 22:43:13 +0200 Subject: [PATCH 213/336] Fix OnOffModel for multiple Flows --- flixopt/elements.py | 16 ++++---- flixopt/features.py | 97 ++++++++++++++++++++++++++++++--------------- 2 files changed, 75 insertions(+), 38 deletions(-) diff --git a/flixopt/elements.py b/flixopt/elements.py index 4fcce79a2..627a4afd6 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -361,7 +361,7 @@ def do_modeling(self): self.flow_rate_lower_bound_relative, self.flow_rate_upper_bound_relative, ), - apply_bounds_to_flow_rates=default_cons, + apply_bounds_to_defining_variable=default_cons, ), 'investment', ) @@ -589,13 +589,15 @@ def do_modeling(self): if self.element.on_off_parameters: self.on_off = self.add( OnOffModel( - self._model, - on_off_parameters=self.element.on_off_parameters, + model=self._model, label_of_element=self.label_of_element, - defining_variables=[flow.model.flow_rate for flow in all_flows], - defining_bounds=[flow.model.flow_rate_bounds_on for flow in all_flows], - previous_values=[flow.previous_flow_rate for flow in all_flows], - ) + parameters=self.element.on_off_parameters, + flow_rates=[flow.model.flow_rate for flow in all_flows], + flow_rate_bounds=[flow.model.flow_rate_bounds_on for flow in all_flows], + previous_flow_rates=[flow.previous_flow_rate for flow in all_flows], + label_of_model=self.label_of_element, + apply_bounds_to_flow_rates=True, + ), ) self.on_off.do_modeling() diff --git a/flixopt/features.py b/flixopt/features.py index 916defa4f..5a1a91810 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -185,77 +185,112 @@ def __init__( self._apply_bounds_to_flow_rates = apply_bounds_to_flow_rates def create_variables_and_constraints(self): - variables = {} - constraints = [] - # 1. Main binary state using existing pattern state_vars, state_constraints = ModelingPrimitives.binary_state_pair(self._model, self.label_of_model, use_complement=self.parameters.use_off) - variables.update(state_vars) - constraints += list(state_constraints.values()) + for k, v in state_vars.items(): + self.add(v, k) + for k, v in state_constraints.items(): + self.add(v, k) # 2. Control variables - use big_m_binary_bounds pattern for consistency if self._apply_bounds_to_flow_rates: - for i, (flow_rate, flow_rate_bounds) in enumerate( - zip(self._flow_rates, self._flow_rate_bounds, strict=True) - ): - constraints += BoundingPatterns.bounds_with_state( - model=self._model, - variable=flow_rate, - bounds=flow_rate_bounds, - variable_state=variables['on'], - ) + self._add_defining_constraints() # 3. Total duration tracking using existing pattern - duration_expr = (variables['on'] * self._model.hours_per_step).sum('time') + duration_expr = (self.on * self._model.hours_per_step).sum('time') duration_vars, duration_constraints = ModelingPrimitives.expression_tracking_variable( self._model, f'{self.label_of_model}|on_hours_total', duration_expr, (self.parameters.on_hours_total_min if self.parameters.on_hours_total_min is not None else 0, self.parameters.on_hours_total_max if self.parameters.on_hours_total_max is not None else np.inf),#TODO: self._model.hours_per_step.sum('time').item() + self._get_previous_on_duration()) ) - variables['on_hours_total'] = duration_vars['tracker'] - constraints += [duration_constraints['tracking']] + self.add(duration_vars['tracker'], 'on_hours_total') + self.add(duration_constraints['tracking']) # 4. Switch tracking using existing pattern if self.parameters.use_switch_on: switch_vars, switch_constraints = ModelingPrimitives.state_transition_variables( - self._model, f'{self.label_of_model}|switch', variables['on'], + self._model, f'{self.label_of_model}|switch', self.on, previous_state=ModelingUtilities.get_most_recent_state(self._previous_flow_rates), max_count=self.parameters.switch_on_total_max, ) - variables.update({'switch|on': switch_vars['on'], 'switch|off': switch_vars['off'], 'switch|count': switch_vars['count']}) - constraints += list(switch_constraints.values()) + self.add(switch_vars['on'], 'switch|on') + self.add(switch_vars['off'], 'switch|off') + self.add(switch_vars['count'], 'switch|count') + self.add(switch_constraints['transition']) + self.add(switch_constraints['initial']) + self.add(switch_constraints['mutex']) # 5. Consecutive on duration using existing pattern if self.parameters.use_consecutive_on_hours: consecutive_on_vars, consecutive_on_constraints = ModelingPrimitives.consecutive_duration_tracking( self._model, f'{self.label_of_model}|consecutive_on_hours', #TODO: Change name - variables['on'], + self.on, minimum_duration=self.parameters.consecutive_on_hours_min, maximum_duration=self.parameters.consecutive_on_hours_max, previous_duration=ModelingUtilities.compute_previous_on_duration(self._previous_flow_rates, self._model.hours_per_step), ) - variables['consecutive_on_hours'] = consecutive_on_vars['duration'] - constraints += list(consecutive_on_constraints.values()) + self.add(consecutive_on_vars['duration'], 'consecutive_on_hours') + for constraint in consecutive_on_constraints.values(): + self.add(constraint) # 6. Consecutive off duration using existing pattern if self.parameters.use_consecutive_off_hours: consecutive_off_vars, consecutive_off_constraints = ModelingPrimitives.consecutive_duration_tracking( self._model, f'{self.label_of_model}|consecutive_off_hours', - variables['off'], + self.off, minimum_duration=self.parameters.consecutive_off_hours_min, maximum_duration=self.parameters.consecutive_off_hours_max, previous_duration=ModelingUtilities.compute_previous_off_duration(self._previous_flow_rates, self._model.hours_per_step), ) - variables['consecutive_off_hours'] = consecutive_off_vars['duration'] - constraints += list(consecutive_off_constraints.values()) + self.add(consecutive_off_vars['duration'], 'consecutive_off_hours') + for constraint in consecutive_off_constraints.values(): + self.add(constraint) - # Register all constraints and variables - for constraint in constraints: - self.add(constraint) - for variable_name, variable in variables.items(): - self.add(variable, variable_name) + def _add_defining_constraints(self): + """Add constraints that link defining variables to the on state""" + count = len(self._flow_rates) + + if count == 1: + # Case for a single defining variable + flow_rate = self._flow_rates[0] + lb, ub = self._flow_rate_bounds[0] + + # Constraint: on * lower_bound <= def_var + self.add( + self._model.add_constraints( + self.on * np.maximum(CONFIG.modeling.EPSILON, lb) <= flow_rate, name=f'{self.label_full}|on|lb' + ), + 'on|lb', + ) + + # Constraint: on * upper_bound >= def_var + self.add( + self._model.add_constraints(self.on * ub >= flow_rate, name=f'{self.label_full}|on|ub'), 'on|ub' + ) + else: + # Case for multiple defining variables + ub = sum(bound[1] for bound in self._flow_rate_bounds) / count + lb = CONFIG.modeling.EPSILON #TODO: Can this be a bigger value? (maybe the smallest bound?) + + # Constraint: on * epsilon <= sum(all_defining_variables) + self.add( + self._model.add_constraints( + self.on * lb <= sum(self._flow_rates), name=f'{self.label_full}|on|lb' + ), + 'on|lb', + ) + + # Constraint to ensure all variables are zero when off. + # Divide by count to improve numerical stability (smaller factors) + self.add( + self._model.add_constraints( + self.on * ub >= sum([def_var / count for def_var in self._flow_rates]), + name=f'{self.label_full}|on|ub', + ), + 'on|ub', + ) # Properties access variables from Model's tracking system @property From 1f1ebb702a70a1c10a11b1b975af80f16f1fc618 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 19 Jul 2025 22:43:25 +0200 Subject: [PATCH 214/336] Update constraint names in tests --- tests/test_component.py | 56 +++++++++++++++--------------- tests/test_on_hours_computation.py | 8 ++--- 2 files changed, 32 insertions(+), 32 deletions(-) diff --git a/tests/test_component.py b/tests/test_component.py index 11b5385c2..fbedbd415 100644 --- a/tests/test_component.py +++ b/tests/test_component.py @@ -78,7 +78,7 @@ def test_on_with_multiple_flows(self, basic_flow_system_linopy): flow_system.add_elements(comp) model = create_linopy_model(flow_system) - assert { + assert set(comp.model.variables) == { 'TestComponent(In1)|flow_rate', 'TestComponent(In1)|total_flow_hours', 'TestComponent(In1)|on', @@ -93,38 +93,38 @@ def test_on_with_multiple_flows(self, basic_flow_system_linopy): 'TestComponent(Out2)|on_hours_total', 'TestComponent|on', 'TestComponent|on_hours_total', - } == set(comp.model.variables) + } - assert { + assert set(comp.model.constraints) == { 'TestComponent(In1)|total_flow_hours', - 'TestComponent(In1)|on_con1', - 'TestComponent(In1)|on_con2', + 'TestComponent(In1)|on|lb', + 'TestComponent(In1)|on|ub', 'TestComponent(In1)|on_hours_total', 'TestComponent(Out1)|total_flow_hours', - 'TestComponent(Out1)|on_con1', - 'TestComponent(Out1)|on_con2', + 'TestComponent(Out1)|on|lb', + 'TestComponent(Out1)|on|ub', 'TestComponent(Out1)|on_hours_total', 'TestComponent(Out2)|total_flow_hours', - 'TestComponent(Out2)|on_con1', - 'TestComponent(Out2)|on_con2', + 'TestComponent(Out2)|on|lb', + 'TestComponent(Out2)|on|ub', 'TestComponent(Out2)|on_hours_total', - 'TestComponent|on_con1', - 'TestComponent|on_con2', + 'TestComponent|on|lb', + 'TestComponent|on|ub', 'TestComponent|on_hours_total', - } == set(comp.model.constraints) + } assert_var_equal(model['TestComponent(Out2)|flow_rate'], model.add_variables(lower=0, upper=300 * ub_out2, coords=(timesteps,))) assert_var_equal(model['TestComponent|on'], model.add_variables(binary=True, coords = (timesteps,))) assert_var_equal(model['TestComponent(Out2)|on'], model.add_variables(binary=True, coords=(timesteps,))) - assert_conequal(model.constraints['TestComponent(Out2)|on_con1'], model.variables['TestComponent(Out2)|on'] * 0.3 * 300 <= model.variables['TestComponent(Out2)|flow_rate']) - assert_conequal(model.constraints['TestComponent(Out2)|on_con2'], model.variables['TestComponent(Out2)|on'] * 300 * ub_out2 >= model.variables['TestComponent(Out2)|flow_rate']) + assert_conequal(model.constraints['TestComponent(Out2)|on|lb'], model.variables['TestComponent(Out2)|on'] * 0.3 * 300 <= model.variables['TestComponent(Out2)|flow_rate']) + assert_conequal(model.constraints['TestComponent(Out2)|on|ub'], model.variables['TestComponent(Out2)|on'] * 300 * ub_out2 >= model.variables['TestComponent(Out2)|flow_rate']) - assert_conequal(model.constraints['TestComponent|on_con1'], + assert_conequal(model.constraints['TestComponent|on|lb'], model.variables['TestComponent|on'] * 1e-5 <= model.variables['TestComponent(In1)|flow_rate'] + model.variables['TestComponent(Out1)|flow_rate'] + model.variables['TestComponent(Out2)|flow_rate']) # TODO: Might there be a better way to no use 1e-5? - assert_conequal(model.constraints['TestComponent|on_con2'], + assert_conequal(model.constraints['TestComponent|on|ub'], model.variables['TestComponent|on'] * (100 + 200 + 300 * ub_out2)/3 >= (model.variables['TestComponent(In1)|flow_rate'] + model.variables['TestComponent(Out1)|flow_rate'] @@ -145,24 +145,24 @@ def test_on_with_single_flow(self, basic_flow_system_linopy): flow_system.add_elements(comp) model = create_linopy_model(flow_system) - assert { + assert set(comp.model.variables) == { 'TestComponent(In1)|flow_rate', 'TestComponent(In1)|total_flow_hours', 'TestComponent(In1)|on', 'TestComponent(In1)|on_hours_total', 'TestComponent|on', 'TestComponent|on_hours_total', - } == set(comp.model.variables) + } - assert { + assert set(comp.model.constraints) == { 'TestComponent(In1)|total_flow_hours', - 'TestComponent(In1)|on_con1', - 'TestComponent(In1)|on_con2', + 'TestComponent(In1)|on|lb', + 'TestComponent(In1)|on|ub', 'TestComponent(In1)|on_hours_total', - 'TestComponent|on_con1', - 'TestComponent|on_con2', + 'TestComponent|on|lb', + 'TestComponent|on|ub', 'TestComponent|on_hours_total', - } == set(comp.model.constraints) + } assert_var_equal( model['TestComponent(In1)|flow_rate'], model.add_variables(lower=0, upper=100, coords=(timesteps,)) @@ -171,20 +171,20 @@ def test_on_with_single_flow(self, basic_flow_system_linopy): assert_var_equal(model['TestComponent(In1)|on'], model.add_variables(binary=True, coords=(timesteps,))) assert_conequal( - model.constraints['TestComponent(In1)|on_con1'], + model.constraints['TestComponent(In1)|on|lb'], model.variables['TestComponent(In1)|on'] * 0.1 * 100 <= model.variables['TestComponent(In1)|flow_rate'], ) assert_conequal( - model.constraints['TestComponent(In1)|on_con2'], + model.constraints['TestComponent(In1)|on|ub'], model.variables['TestComponent(In1)|on'] * 100 >= model.variables['TestComponent(In1)|flow_rate'], ) assert_conequal( - model.constraints['TestComponent|on_con1'], + model.constraints['TestComponent|on|lb'], model.variables['TestComponent|on'] * 0.1 * 100 <= model.variables['TestComponent(In1)|flow_rate'], ) assert_conequal( - model.constraints['TestComponent|on_con2'], + model.constraints['TestComponent|on|ub'], model.variables['TestComponent|on'] * 100 >= model.variables['TestComponent(In1)|flow_rate'], ) diff --git a/tests/test_on_hours_computation.py b/tests/test_on_hours_computation.py index a873bbd12..c8fa113aa 100644 --- a/tests/test_on_hours_computation.py +++ b/tests/test_on_hours_computation.py @@ -1,7 +1,7 @@ import numpy as np import pytest -from flixopt.features import ConsecutiveStateModel, StateModel +from flixopt.modeling import ModelingUtilities class TestComputeConsecutiveDuration: @@ -31,7 +31,7 @@ class TestComputeConsecutiveDuration: ]) def test_compute_duration(self, binary_values, hours_per_timestep, expected): """Test compute_consecutive_duration with various inputs.""" - result = ConsecutiveStateModel.compute_consecutive_hours_in_state(binary_values, hours_per_timestep) + result = ModelingUtilities.compute_consecutive_hours_in_state(binary_values, hours_per_timestep) assert np.isclose(result, expected) @pytest.mark.parametrize("binary_values, hours_per_timestep", [ @@ -41,7 +41,7 @@ def test_compute_duration(self, binary_values, hours_per_timestep, expected): def test_compute_duration_raises_error(self, binary_values, hours_per_timestep): """Test error conditions.""" with pytest.raises(TypeError): - ConsecutiveStateModel.compute_consecutive_hours_in_state(binary_values, hours_per_timestep) + ModelingUtilities.compute_consecutive_hours_in_state(binary_values, hours_per_timestep) class TestComputePreviousOnStates: @@ -76,7 +76,7 @@ class TestComputePreviousOnStates: ) def test_compute_previous_on_states(self, previous_values, expected): """Test compute_previous_on_states with various inputs.""" - result = StateModel.compute_previous_states(previous_values) + result = ModelingUtilities.compute_previous_states(previous_values) np.testing.assert_array_equal(result, expected) @pytest.mark.parametrize("previous_values, epsilon, expected", [ From c7b351fbd688ef742c1ca430c7b05a47941b872d Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 20 Jul 2025 00:01:59 +0200 Subject: [PATCH 215/336] Simplify --- flixopt/elements.py | 12 ++-- flixopt/features.py | 130 +++++++++++++++---------------------------- flixopt/modeling.py | 80 ++++++++++++++------------ flixopt/structure.py | 6 ++ 4 files changed, 103 insertions(+), 125 deletions(-) diff --git a/flixopt/elements.py b/flixopt/elements.py index 627a4afd6..15d17ef92 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -338,9 +338,9 @@ def do_modeling(self): model=self._model, label_of_element=self.label_of_element, parameters=self.element.on_off_parameters, - flow_rates=[self.flow_rate], - flow_rate_bounds=[self.flow_rate_bounds_on], - previous_flow_rates=[self.element.previous_flow_rate], + flow_rate=self.flow_rate, + flow_rate_bounds=self.flow_rate_bounds_on, + previous_flow_rate=self.element.previous_flow_rate, label_of_model=self.label_of_element, apply_bounds_to_flow_rates=default_cons, ), @@ -381,7 +381,7 @@ def do_modeling(self): self.add(constraint) # Total flow hours tracking (could use factory pattern) - variables, constraints = ModelingPrimitives.expression_tracking_variable( + variable, constraint = ModelingPrimitives.expression_tracking_variable( model=self._model, name=f'{self.label_full}|total_flow_hours', tracked_expression=(self.flow_rate * self._model.hours_per_step).sum('time'), @@ -392,8 +392,8 @@ def do_modeling(self): coords=['year', 'scenario'], ) - self.add(variables['tracker'], 'total_flow_hours') - self.add(constraints['tracking'], 'total_flow_hours_tracking') + self.add(variable, 'total_flow_hours') + self.add(constraint, 'total_flow_hours_tracking') # Load factor constraints self._create_bounds_for_load_factor() diff --git a/flixopt/features.py b/flixopt/features.py index 5a1a91810..dab25b49b 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -159,9 +159,9 @@ def __init__( model: FlowSystemModel, label_of_element: str, parameters: OnOffParameters, - flow_rates: List[linopy.Variable], - flow_rate_bounds: List[Tuple[TemporalData, TemporalData]], - previous_flow_rates: List[Optional[TemporalData]], + flow_rate: linopy.Variable, + flow_rate_bounds: Tuple[TemporalData, TemporalData], + previous_flow_rate: Optional[TemporalData], label_of_model: Optional[str] = None, apply_bounds_to_flow_rates: bool = True, ): @@ -173,53 +173,59 @@ def __init__( model: The optimization model instance label_of_element: The label of the parent (Element). Used to construct the full label of the model. parameters: The parameters of the feature model. - flow_rates: The flow_rates to be modeled + flow_rate: The flow_rates to be modeled flow_rate_bounds: The bounds of the flow_rates, with respect to the minimum/maximum investment sizes - previous_flow_rates: The previous flow_rates + previous_flow_rate: The previous flow_rates label_of_model: The label of the model. This is needed to construct the full label of the model. """ super().__init__(model, label_of_element, parameters=parameters, label_of_model=label_of_model) - self._flow_rates = flow_rates + self._flow_rate = flow_rate self._flow_rate_bounds = flow_rate_bounds - self._previous_flow_rates = previous_flow_rates + self._previous_flow_rate = previous_flow_rate self._apply_bounds_to_flow_rates = apply_bounds_to_flow_rates def create_variables_and_constraints(self): # 1. Main binary state using existing pattern - state_vars, state_constraints = ModelingPrimitives.binary_state_pair(self._model, self.label_of_model, use_complement=self.parameters.use_off) - for k, v in state_vars.items(): - self.add(v, k) - for k, v in state_constraints.items(): - self.add(v, k) + on = self.add(self._model.add_variables(binary=True, name=f'{self.label_of_model}|on', coords=self._model.get_coords()), 'on') + if self.parameters.use_off: + off = self.add(self._model.add_variables(binary=True, name=f'{self.label_of_model}|off', coords=self._model.get_coords()), 'off') + self.add(self._model.add_constraints(on + off == 1, name=f'{self.label_of_model}|complementary'), 'complementary') - # 2. Control variables - use big_m_binary_bounds pattern for consistency + # 2. Control variables if self._apply_bounds_to_flow_rates: - self._add_defining_constraints() + self.add_batch(*BoundingPatterns.bounds_with_state( + self._model, + variable=self._flow_rate, + bounds=self._flow_rate_bounds, + variable_state=self.on, + )) # 3. Total duration tracking using existing pattern duration_expr = (self.on * self._model.hours_per_step).sum('time') - duration_vars, duration_constraints = ModelingPrimitives.expression_tracking_variable( + var, con = ModelingPrimitives.expression_tracking_variable( self._model, f'{self.label_of_model}|on_hours_total', duration_expr, (self.parameters.on_hours_total_min if self.parameters.on_hours_total_min is not None else 0, self.parameters.on_hours_total_max if self.parameters.on_hours_total_max is not None else np.inf),#TODO: self._model.hours_per_step.sum('time').item() + self._get_previous_on_duration()) ) - self.add(duration_vars['tracker'], 'on_hours_total') - self.add(duration_constraints['tracking']) + self.add(var, 'on_hours_total') + self.add(con) # 4. Switch tracking using existing pattern if self.parameters.use_switch_on: switch_vars, switch_constraints = ModelingPrimitives.state_transition_variables( self._model, f'{self.label_of_model}|switch', self.on, - previous_state=ModelingUtilities.get_most_recent_state(self._previous_flow_rates), - max_count=self.parameters.switch_on_total_max, + previous_state=ModelingUtilities.get_most_recent_state(self._previous_flow_rate), ) self.add(switch_vars['on'], 'switch|on') self.add(switch_vars['off'], 'switch|off') - self.add(switch_vars['count'], 'switch|count') self.add(switch_constraints['transition']) self.add(switch_constraints['initial']) self.add(switch_constraints['mutex']) + if self.parameters.switch_on_total_max is not None: + count = self.add(self._model.add_variables(lower=0, upper=self.parameters.switch_on_total_max, coords=self._model.get_coords(('year', 'scenario')), name=f'{self.label_of_model}|switch|count'), 'switch|count') + self.add(self._model.add_constraints(count == self.switch_on.sum('time'), name=f'{self.label_of_model}|switch|count'), 'switch|count') + # 5. Consecutive on duration using existing pattern if self.parameters.use_consecutive_on_hours: consecutive_on_vars, consecutive_on_constraints = ModelingPrimitives.consecutive_duration_tracking( @@ -228,7 +234,7 @@ def create_variables_and_constraints(self): self.on, minimum_duration=self.parameters.consecutive_on_hours_min, maximum_duration=self.parameters.consecutive_on_hours_max, - previous_duration=ModelingUtilities.compute_previous_on_duration(self._previous_flow_rates, self._model.hours_per_step), + previous_duration=ModelingUtilities.compute_previous_on_duration([self._previous_flow_rate], self._model.hours_per_step), ) self.add(consecutive_on_vars['duration'], 'consecutive_on_hours') for constraint in consecutive_on_constraints.values(): @@ -242,54 +248,31 @@ def create_variables_and_constraints(self): self.off, minimum_duration=self.parameters.consecutive_off_hours_min, maximum_duration=self.parameters.consecutive_off_hours_max, - previous_duration=ModelingUtilities.compute_previous_off_duration(self._previous_flow_rates, self._model.hours_per_step), + previous_duration=ModelingUtilities.compute_previous_off_duration([self._previous_flow_rate], self._model.hours_per_step), ) self.add(consecutive_off_vars['duration'], 'consecutive_off_hours') for constraint in consecutive_off_constraints.values(): self.add(constraint) - def _add_defining_constraints(self): - """Add constraints that link defining variables to the on state""" - count = len(self._flow_rates) - - if count == 1: - # Case for a single defining variable - flow_rate = self._flow_rates[0] - lb, ub = self._flow_rate_bounds[0] - - # Constraint: on * lower_bound <= def_var - self.add( - self._model.add_constraints( - self.on * np.maximum(CONFIG.modeling.EPSILON, lb) <= flow_rate, name=f'{self.label_full}|on|lb' - ), - 'on|lb', - ) - - # Constraint: on * upper_bound >= def_var - self.add( - self._model.add_constraints(self.on * ub >= flow_rate, name=f'{self.label_full}|on|ub'), 'on|ub' - ) - else: - # Case for multiple defining variables - ub = sum(bound[1] for bound in self._flow_rate_bounds) / count - lb = CONFIG.modeling.EPSILON #TODO: Can this be a bigger value? (maybe the smallest bound?) - - # Constraint: on * epsilon <= sum(all_defining_variables) - self.add( - self._model.add_constraints( - self.on * lb <= sum(self._flow_rates), name=f'{self.label_full}|on|lb' - ), - 'on|lb', + def add_effects(self): + """Add operational effects""" + if self.parameters.effects_per_running_hour: + self._model.effects.add_share_to_effects( + name=self.label_of_element, + expressions={ + effect: self.on * factor * self._model.hours_per_step + for effect, factor in self.parameters.effects_per_running_hour.items() + }, + target='operation', ) - # Constraint to ensure all variables are zero when off. - # Divide by count to improve numerical stability (smaller factors) - self.add( - self._model.add_constraints( - self.on * ub >= sum([def_var / count for def_var in self._flow_rates]), - name=f'{self.label_full}|on|ub', - ), - 'on|ub', + if self.parameters.effects_per_switch_on: + self._model.effects.add_share_to_effects( + name=self.label_of_element, + expressions={ + effect: self.switch_on * factor for effect, factor in self.parameters.effects_per_switch_on.items() + }, + target='operation', ) # Properties access variables from Model's tracking system @@ -334,30 +317,9 @@ def consecutive_off_hours(self) -> Optional[linopy.Variable]: """Consecutive off hours variable""" return self.get_variable_by_short_name('consecutive_off_hours') - def add_effects(self): - """Add operational effects""" - if self.parameters.effects_per_running_hour: - self._model.effects.add_share_to_effects( - name=self.label_of_element, - expressions={ - effect: self.on * factor * self._model.hours_per_step - for effect, factor in self.parameters.effects_per_running_hour.items() - }, - target='operation', - ) - - if self.parameters.effects_per_switch_on: - self._model.effects.add_share_to_effects( - name=self.label_of_element, - expressions={ - effect: self.switch_on * factor for effect, factor in self.parameters.effects_per_switch_on.items() - }, - target='operation', - ) - def _get_previous_on_duration(self): hours_per_step = self._model.hours_per_step.isel(time=0).values.flatten()[0] - return ModelingUtilities.compute_previous_on_duration(self._previous_flow_rates, hours_per_step) + return ModelingUtilities.compute_previous_on_duration([self._previous_flow_rate], hours_per_step) def _get_previous_off_duration(self): hours_per_step = self._model.hours_per_step.isel(time=0).values.flatten()[0] diff --git a/flixopt/modeling.py b/flixopt/modeling.py index 98fc65756..8c539caa2 100644 --- a/flixopt/modeling.py +++ b/flixopt/modeling.py @@ -151,31 +151,23 @@ class ModelingPrimitives: @staticmethod def binary_state_pair( model: FlowSystemModel, name: str, coords: List[str] = None, use_complement: bool = True - ) -> Tuple[Dict[str, linopy.Variable], Dict[str, linopy.Constraint]]: + ) -> Tuple[Tuple[linopy.Variable, linopy.Variable], linopy.Constraint]: """ Creates complementary binary variables with completeness constraint. Mathematical formulation: on[t] + off[t] = 1 ∀t on[t], off[t] ∈ {0, 1} - - Returns: - variables: {'on': binary_var, 'off': binary_var} - constraints: {'complementary': constraint} """ coords = coords or ['time'] on = model.add_variables(binary=True, name=f'{name}|on', coords=model.get_coords(coords)) - if use_complement: - off = model.add_variables(binary=True, name=f'{name}|off', coords=model.get_coords(coords)) + off = model.add_variables(binary=True, name=f'{name}|off', coords=model.get_coords(coords)) - # Constraint: on + off = 1 - complementary = model.add_constraints(on + off == 1, name=f'{name}|complementary') + # Constraint: on + off = 1 + complementary = model.add_constraints(on + off == 1, name=f'{name}|complementary') - variables = {'on': on, 'off': off} - constraints = {'complementary': complementary} - return variables, constraints - return {'on': on}, {} + return (on, off), complementary @staticmethod def proportionally_bounded_variable( @@ -220,7 +212,7 @@ def expression_tracking_variable( tracked_expression, bounds: Tuple[TemporalData, TemporalData] = None, coords: List[str] = None, - ) -> Tuple[Dict[str, linopy.Variable], Dict[str, linopy.Constraint]]: + ) -> Tuple[linopy.Variable, linopy.Constraint]: """ Creates variable that equals a given expression. @@ -247,15 +239,14 @@ def expression_tracking_variable( # Constraint: tracker = expression tracking = model.add_constraints(tracker == tracked_expression, name=f'{name}') - variables = {'tracker': tracker} - constraints = {'tracking': tracking} - - return variables, constraints + return tracker, tracking @staticmethod def state_transition_variables( - model: FlowSystemModel, name: str, state_variable, previous_state=0, - max_count: Optional[int] = None, + model: FlowSystemModel, + name: str, + state_variable: linopy.Variable, + previous_state=0, ) -> Tuple[Dict[str, linopy.Variable], Dict[str, linopy.Constraint]]: """ Creates switch-on/off variables with state transition logic. @@ -289,19 +280,41 @@ def state_transition_variables( # At most one switch per timestep mutex = model.add_constraints(switch_on + switch_off <= 1, name=f'{name}|mutex') + return {'on': switch_on, 'off': switch_off}, {'transition': transition, 'initial': initial, 'mutex': mutex} + + @staticmethod + def sum_up_variable( + model: FlowSystemModel, + variable_to_count: linopy.Variable, + name: str, + bounds: Tuple[NonTemporalData, NonTemporalData] = None, + factor: TemporalData = 1, + ) -> Tuple[linopy.Variable, linopy.Constraint]: + """ + SUms up a variable over time, applying a factor to the variable. + + Args: + model: The optimization model instance + variable_to_count: The variable to be summed up + name: The name of the constraint + bounds: The bounds of the constraint + factor: The factor to be applied to the variable + """ + if bounds is None: + bounds = (0, np.inf) + else: + bounds = (bounds[0] if bounds[0] is not None else 0, bounds[1] if bounds[1] is not None else np.inf) + count = model.add_variables( - lower=0, - upper=max_count if max_count is not None else np.inf, + lower=bounds[0], + upper=bounds[1], coords=model.get_coords(['year', 'scenario']), - name=f'{name}|count', + name=name, ) - count_constraint = model.add_constraints(count == switch_on.sum('time'), name=f'{name}|count') + count_constraint = model.add_constraints(count == (variable_to_count * factor).sum('time'), name=name) - variables = {'on': switch_on, 'off': switch_off, 'count': count} - constraints = {'transition': transition, 'initial': initial, 'mutex': mutex, 'count': count_constraint} - - return variables, constraints + return count, count_constraint @staticmethod def consecutive_duration_tracking( @@ -397,8 +410,8 @@ def consecutive_duration_tracking( @staticmethod def mutual_exclusivity_constraint( - model: FlowSystemModel, name: str, binary_variables: List[linopy.Variable], tolerance: float = 1.1 - ) -> Tuple[Dict, Dict[str, linopy.Constraint]]: + model: FlowSystemModel, name: str, binary_variables: List[linopy.Variable], tolerance: float = 1 + ) -> linopy.Constraint: """ Creates mutual exclusivity constraint for binary variables. @@ -410,7 +423,7 @@ def mutual_exclusivity_constraint( Args: binary_variables: List of binary variables that should be mutually exclusive - tolerance: Upper bound (typically 1.1 for numerical stability) + tolerance: Upper bound Returns: variables: {} (no new variables created) @@ -433,10 +446,7 @@ def mutual_exclusivity_constraint( sum(binary_variables) <= tolerance, name=f'{name}|mutual_exclusivity' ) - variables = {} # No new variables created - constraints = {'mutual_exclusivity': mutual_exclusivity} - - return variables, constraints + return mutual_exclusivity class BoundingPatterns: diff --git a/flixopt/structure.py b/flixopt/structure.py index ec594ca6e..0997a8093 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -750,6 +750,12 @@ def add( ) return item + def add_batch(self, *con_or_var: Union[linopy.Constraint, linopy.Variable]) -> None: + """Add constraints to the model""" + con_or_var = list(con_or_var) + for c_o_v in con_or_var: + self.add(c_o_v) + def filter_variables( self, filter_by: Optional[Literal['binary', 'continuous', 'integer']] = None, From 5d9b591498be73e011e8ef24e61397f97821261d Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 20 Jul 2025 00:18:42 +0200 Subject: [PATCH 216/336] Improve handling of vars/cons and models --- flixopt/structure.py | 84 ++++++++++++++++++++++++++++++++++++++------ 1 file changed, 73 insertions(+), 11 deletions(-) diff --git a/flixopt/structure.py b/flixopt/structure.py index 0997a8093..49ef38db8 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -721,41 +721,103 @@ def __init__( self._sub_models_short: Dict[str, str] = {} logger.debug(f'Created {self.__class__.__name__} "{self.label_full}"') + def __getitem__(self, key: str) -> linopy.Variable: + if key in self._variables: + return self.variables_direct[key] + if key in self._variables_short: + return self.variables_direct[self._variables_short[key]] + raise KeyError(f'Variable "{key}" not found in model "{self.label_full}"') + def do_modeling(self): raise NotImplementedError('Every Model needs a do_modeling() method') def add( + self, + item: Union[ + linopy.Variable, linopy.Constraint, 'Model', Dict[str, Union[linopy.Variable, linopy.Constraint, 'Model']] + ], + short_name: Optional[str] = None, + ) -> Union[ + linopy.Variable, linopy.Constraint, 'Model', Dict[str, Union[linopy.Variable, linopy.Constraint, 'Model']] + ]: + """ + Add a variable, constraint, sub-model, or batch of items to the model + + Args: + item: The variable, constraint, sub-model, or dictionary of items to add + short_name: The short name for single items. Ignored for dictionary inputs. + + Returns: + The added item(s) - same type as input + + Examples: + # Single item + self.add(my_variable, 'var_name') + + # Batch of items + self.add({ + 'on': on_variable, + 'off': off_variable, + 'duration': duration_constraint + }) + """ + # Handle dictionary input (batch mode) + if isinstance(item, dict): + return self._add_batch(item) + + # Handle single item + return self._add_single(item, short_name) + + def _add_batch( + self, items: Dict[str, Union[linopy.Variable, linopy.Constraint, 'Model']] + ) -> Dict[str, Union[linopy.Variable, linopy.Constraint, 'Model']]: + """ + Add a batch of items using their dictionary keys as short names + + Args: + items: Dictionary with short_name -> item mapping + + Returns: + The same dictionary for chaining + """ + for short_name, item in items.items(): + self._add_single(item, short_name) + return items + + def _add_single( self, item: Union[linopy.Variable, linopy.Constraint, 'Model'], short_name: Optional[str] = None ) -> Union[linopy.Variable, linopy.Constraint, 'Model']: """ - Add a variable, constraint or sub-model to the model + Add a single variable, constraint or sub-model to the model Args: item: The variable, constraint or sub-model to add to the model short_name: The short name of the variable, constraint or sub-model. If not provided, the full name is used. + + Returns: + The added item for chaining """ - # TODO: Check uniquenes of short names + if short_name is not None and (short_name in self._variables_short or short_name in self._constraints_short or short_name in self._sub_models_short): + raise ValueError(f'Short name "{short_name}" already assigned to model') + # TODO: Check uniqueness of short names if isinstance(item, linopy.Variable): self._variables_direct.append(item.name) - self._variables_short[short_name] = item.name + if short_name is not None: + self._variables_short[short_name] = item.name elif isinstance(item, linopy.Constraint): self._constraints_direct.append(item.name) - self._constraints_short[short_name] = item.name + if short_name is not None: + self._constraints_short[short_name] = item.name elif isinstance(item, Model): self.sub_models.append(item) - self._sub_models_short[item.label_full] = short_name or item.label_full + if short_name is not None: + self._sub_models_short[short_name] = item.label_full else: raise ValueError( f'Item must be a linopy.Variable, linopy.Constraint or flixopt.structure.Model, got {type(item)}' ) return item - def add_batch(self, *con_or_var: Union[linopy.Constraint, linopy.Variable]) -> None: - """Add constraints to the model""" - con_or_var = list(con_or_var) - for c_o_v in con_or_var: - self.add(c_o_v) - def filter_variables( self, filter_by: Optional[Literal['binary', 'continuous', 'integer']] = None, From 5c56b63da274295c0433c14d21a9c8f9d568b698 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 20 Jul 2025 10:33:46 +0200 Subject: [PATCH 217/336] Revising the basic structure of a class Model --- flixopt/structure.py | 222 +++++++++++++++---------------------------- 1 file changed, 77 insertions(+), 145 deletions(-) diff --git a/flixopt/structure.py b/flixopt/structure.py index 49ef38db8..59cb62251 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -712,111 +712,59 @@ def __init__( self.label_of_element = label_of_element self.label_of_model = label_of_model if label_of_model is not None else self.label_of_element - self._variables_direct: List[str] = [] - self._constraints_direct: List[str] = [] - self.sub_models: List[Model] = [] + self._variables: Dict[str, linopy.Variable] = {} # Mapping from short name to variable + self._constraints: Dict[str, linopy.Constraint] = {} # Mapping from short name to constraint + self.sub_models: Dict[str, 'Model'] = {} - self._variables_short: Dict[str, str] = {} - self._constraints_short: Dict[str, str] = {} - self._sub_models_short: Dict[str, str] = {} logger.debug(f'Created {self.__class__.__name__} "{self.label_full}"') - def __getitem__(self, key: str) -> linopy.Variable: - if key in self._variables: - return self.variables_direct[key] - if key in self._variables_short: - return self.variables_direct[self._variables_short[key]] - raise KeyError(f'Variable "{key}" not found in model "{self.label_full}"') - - def do_modeling(self): - raise NotImplementedError('Every Model needs a do_modeling() method') - - def add( - self, - item: Union[ - linopy.Variable, linopy.Constraint, 'Model', Dict[str, Union[linopy.Variable, linopy.Constraint, 'Model']] - ], - short_name: Optional[str] = None, - ) -> Union[ - linopy.Variable, linopy.Constraint, 'Model', Dict[str, Union[linopy.Variable, linopy.Constraint, 'Model']] - ]: - """ - Add a variable, constraint, sub-model, or batch of items to the model - - Args: - item: The variable, constraint, sub-model, or dictionary of items to add - short_name: The short name for single items. Ignored for dictionary inputs. - - Returns: - The added item(s) - same type as input - - Examples: - # Single item - self.add(my_variable, 'var_name') - - # Batch of items - self.add({ - 'on': on_variable, - 'off': off_variable, - 'duration': duration_constraint - }) - """ - # Handle dictionary input (batch mode) - if isinstance(item, dict): - return self._add_batch(item) - - # Handle single item - return self._add_single(item, short_name) - - def _add_batch( - self, items: Dict[str, Union[linopy.Variable, linopy.Constraint, 'Model']] - ) -> Dict[str, Union[linopy.Variable, linopy.Constraint, 'Model']]: - """ - Add a batch of items using their dictionary keys as short names - - Args: - items: Dictionary with short_name -> item mapping - - Returns: - The same dictionary for chaining - """ - for short_name, item in items.items(): - self._add_single(item, short_name) - return items - - def _add_single( - self, item: Union[linopy.Variable, linopy.Constraint, 'Model'], short_name: Optional[str] = None - ) -> Union[linopy.Variable, linopy.Constraint, 'Model']: - """ - Add a single variable, constraint or sub-model to the model + def add_variable(self, short_name: str, **kwargs) -> linopy.Variable: + """Create and add a variable in one step""" + if 'name' not in kwargs: + kwargs['name'] = f'{self.label_of_model}|{short_name}' + + variable = self._model.add_variables(**kwargs) + self.register_variable(variable, short_name) + return variable + + def add_constraint(self, short_name: str, expression, **kwargs) -> linopy.Constraint: + """Create and add a constraint in one step""" + if 'name' not in kwargs: + kwargs['name'] = f'{self.label_of_model}|{short_name}' + + constraint = self._model.add_constraints(expression, **kwargs) + self.register_constraint(constraint, short_name) + return constraint + + def register_variable(self, variable: linopy.Variable, short_name: str = None) -> None: + """Register a variable with the model""" + if short_name is None: + short_name = self._extract_short_name(variable) + if short_name in self._variables: + raise ValueError(f'Short name "{short_name}" already assigned to model') + self._variables[short_name] = variable - Args: - item: The variable, constraint or sub-model to add to the model - short_name: The short name of the variable, constraint or sub-model. If not provided, the full name is used. + def register_constraint(self, constraint: linopy.Constraint, short_name: str = None) -> None: + """Register a constraint with the model""" + if short_name is None: + short_name = self._extract_short_name(constraint) + if short_name in self._constraints: + raise ValueError(f'Short name "{short_name}" already assigned to model') + self._constraints[short_name] = constraint - Returns: - The added item for chaining - """ - if short_name is not None and (short_name in self._variables_short or short_name in self._constraints_short or short_name in self._sub_models_short): + def register_sub_model(self, sub_model: 'Model', short_name: str) -> None: + """Register a sub-model with the model""" + if short_name is None: + short_name = sub_model.__class__.__name__ + if short_name in self.sub_models: raise ValueError(f'Short name "{short_name}" already assigned to model') - # TODO: Check uniqueness of short names - if isinstance(item, linopy.Variable): - self._variables_direct.append(item.name) - if short_name is not None: - self._variables_short[short_name] = item.name - elif isinstance(item, linopy.Constraint): - self._constraints_direct.append(item.name) - if short_name is not None: - self._constraints_short[short_name] = item.name - elif isinstance(item, Model): - self.sub_models.append(item) - if short_name is not None: - self._sub_models_short[short_name] = item.label_full - else: - raise ValueError( - f'Item must be a linopy.Variable, linopy.Constraint or flixopt.structure.Model, got {type(item)}' - ) - return item + self.sub_models[short_name] = sub_model + + def __getitem__(self, key: str) -> linopy.Variable: + """Get a variable by its short name""" + if key in self._variables: + return self._variables[key] + raise KeyError(f'Variable "{key}" not found in model "{self.label_full}"') def filter_variables( self, @@ -841,67 +789,51 @@ def filter_variables( return all_variables[[name for name in all_variables if 'time' in all_variables[name].dims]] raise ValueError(f'Invalid length "{length}", must be one of "scalar", "time" or None') - @property - def label(self) -> str: - return self.label_of_model - @property def label_full(self) -> str: return self.label_of_model @property - def variables_direct(self) -> linopy.Variables: - return self._model.variables[self._variables_direct] + def variables(self) -> linopy.Variables: + return self._model.variables[[var.name for var in self._variables.values()]] @property - def constraints_direct(self) -> linopy.Constraints: - return self._model.constraints[self._constraints_direct] + def constraints(self) -> linopy.Constraints: + return self._model.constraints[[con.name for con in self._constraints.values()]] @property - def _variables(self) -> List[str]: - all_variables = self._variables_direct.copy() - for sub_model in self.sub_models: - for variable in sub_model._variables: - if variable in all_variables: - raise KeyError( - f"Duplicate key found: '{variable}' in both {self.label_full} and {sub_model.label_full}!" - ) - all_variables.append(variable) - return all_variables + def all_sub_models(self) -> List['Model']: + return [model for sub_model in self.sub_models.values() for model in [sub_model] + sub_model.all_sub_models] @property - def _constraints(self) -> List[str]: - all_constraints = self._constraints_direct.copy() - for sub_model in self.sub_models: - for constraint in sub_model._constraints: - if constraint in all_constraints: - raise KeyError(f"Duplicate key found: '{constraint}' in both main model and submodel!") - all_constraints.append(constraint) - return all_constraints + def all_constraints(self) -> linopy.Constraints: + names = [constraint_name for constraint_name in self.constraints] + [ + constraint.name + for sub_model in self.all_sub_models + for constraint in sub_model.constraints.values() + ] - @property - def variables(self) -> linopy.Variables: - return self._model.variables[self._variables] + return self._model.constraints[names] @property - def constraints(self) -> linopy.Constraints: - return self._model.constraints[self._constraints] + def all_variables(self) -> linopy.Variables: + names = [variable_name for variable_name in self.variables] + [ + variable.name + for sub_model in self.all_sub_models + for variable in sub_model.constraints.values() + ] - @property - def all_sub_models(self) -> List['Model']: - return [model for sub_model in self.sub_models for model in [sub_model] + sub_model.all_sub_models] - - def get_variable_by_short_name(self, short_name: str, default_return = None) -> Optional[linopy.Variable]: - """Get variable by short name""" - if short_name not in self._variables_short: - return default_return - return self._model.variables[self._variables_short.get(short_name)] - - def get_constraint_by_short_name(self, short_name: str, default_return = None) -> Optional[linopy.Constraint]: - """Get variable by short name""" - if short_name not in self._constraints_short: - return default_return - return self._model.constraints[self._constraints_short.get(short_name)] + return self._model.variables[names] + + @staticmethod + def _extract_short_name(item: Union[linopy.Variable, linopy.Constraint]) -> str: + """Extract short name from variable's full name""" + # Assumes format like "model_prefix|short_name" + name = str(item.name) + if '|' in name: + return name.split('|')[-1] # Take last part after | + else: + return name # Use full name if no | separator class BaseFeatureModel(Model): From 9d242b6aee8ad2f2270f9738234722914e703211 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 20 Jul 2025 12:08:12 +0200 Subject: [PATCH 218/336] Revising the basic structure of a class Model --- flixopt/calculation.py | 4 +- flixopt/components.py | 164 ++++++++++------------ flixopt/effects.py | 35 ++--- flixopt/elements.py | 122 ++++++++--------- flixopt/features.py | 301 ++++++++++++++++------------------------- flixopt/modeling.py | 2 +- flixopt/structure.py | 84 ++++++++---- 7 files changed, 325 insertions(+), 387 deletions(-) diff --git a/flixopt/calculation.py b/flixopt/calculation.py index 6bf86bb20..438fbeea5 100644 --- a/flixopt/calculation.py +++ b/flixopt/calculation.py @@ -116,13 +116,13 @@ def main_results(self) -> Dict[str, Union[Scalar, Dict]]: 'Invested': { model.label_of_element: model.size.solution for component in self.flow_system.components.values() - for model in component.model.all_sub_models + for model in component.model.sub_models if isinstance(model, InvestmentModel) and model.size.solution.max() >= CONFIG.modeling.EPSILON }, 'Not invested': { model.label_of_element: model.size.solution for component in self.flow_system.components.values() - for model in component.model.all_sub_models + for model in component.model.sub_models if isinstance(model, InvestmentModel) and model.size.solution.max() < CONFIG.modeling.EPSILON }, }, diff --git a/flixopt/components.py b/flixopt/components.py index 685928714..6631cb214 100644 --- a/flixopt/components.py +++ b/flixopt/components.py @@ -418,23 +418,17 @@ def do_modeling(self): # equate size of both directions if self.element.balanced: # eq: in1.size = in2.size - self.add( - self._model.add_constraints( - self.element.in1.model._investment.size == self.element.in2.model._investment.size, - name=f'{self.label_full}|same_size', - ), - 'same_size', + self.add_constraints( + self.element.in1.model._investment.size == self.element.in2.model._investment.size, + short_name='same_size', ) def create_transmission_equation(self, name: str, in_flow: Flow, out_flow: Flow) -> linopy.Constraint: """Creates an Equation for the Transmission efficiency and adds it to the model""" # eq: out(t) + on(t)*loss_abs(t) = in(t)*(1 - loss_rel(t)) - con_transmission = self.add( - self._model.add_constraints( - out_flow.model.flow_rate == -in_flow.model.flow_rate * (self.element.relative_losses - 1), - name=f'{self.label_full}|{name}', - ), - name, + con_transmission = self.add_constraints( + out_flow.model.flow_rate == -in_flow.model.flow_rate * (self.element.relative_losses - 1), + short_name=name, ) if self.element.absolute_losses is not None: @@ -464,12 +458,10 @@ def do_modeling(self): used_inputs: Set = all_input_flows & used_flows used_outputs: Set = all_output_flows & used_flows - self.add( - self._model.add_constraints( - sum([flow.model.flow_rate * conv_factors[flow.label] for flow in used_inputs]) - == sum([flow.model.flow_rate * conv_factors[flow.label] for flow in used_outputs]), - name=f'{self.label_full}|conversion_{i}', - ) + self.add_constraints( + sum([flow.model.flow_rate * conv_factors[flow.label] for flow in used_inputs]) + == sum([flow.model.flow_rate * conv_factors[flow.label] for flow in used_outputs]), + short_name=f'conversion_{i}', ) else: @@ -479,14 +471,15 @@ def do_modeling(self): for flow, piecewise in self.element.piecewise_conversion.items() } - self.piecewise_conversion = self.add( + self.piecewise_conversion = self.register_sub_model( PiecewiseModel( model=self._model, label_of_element=self.label_of_element, piecewise_variables=piecewise_conversion, zero_point=self.on_off.on if self.on_off is not None else False, as_time_series=True, - ) + ), + short_name='PiecewiseConversion', ) self.piecewise_conversion.do_modeling() @@ -497,36 +490,26 @@ class StorageModel(ComponentModel): def __init__(self, model: FlowSystemModel, element: Storage): super().__init__(model, element) self.element: Storage = element - self.charge_state: Optional[linopy.Variable] = None - self.netto_discharge: Optional[linopy.Variable] = None - self._investment: Optional[InvestmentModel] = None def do_modeling(self): super().do_modeling() lb, ub = self.absolute_charge_state_bounds - self.charge_state = self.add( - self._model.add_variables( - lower=lb, - upper=ub, - coords=self._model.get_coords(extra_timestep=True), - name=f'{self.label_full}|charge_state', - ), - 'charge_state', - ) - self.netto_discharge = self.add( - self._model.add_variables(coords=self._model.get_coords(), name=f'{self.label_full}|netto_discharge'), - 'netto_discharge', + self.add_variables( + lower=lb, + upper=ub, + coords=self._model.get_coords(extra_timestep=True), + short_name='charge_state', ) + + self.add_variables(coords=self._model.get_coords(), short_name='netto_discharge') + # netto_discharge: # eq: nettoFlow(t) - discharging(t) + charging(t) = 0 - self.add( - self._model.add_constraints( - self.netto_discharge - == self.element.discharging.model.flow_rate - self.element.charging.model.flow_rate, - name=f'{self.label_full}|netto_discharge', - ), - 'netto_discharge', + self.add_constraints( + self.netto_discharge + == self.element.discharging.model.flow_rate - self.element.charging.model.flow_rate, + short_name='netto_discharge', ) charge_state = self.charge_state @@ -537,76 +520,57 @@ def do_modeling(self): eff_charge = self.element.eta_charge eff_discharge = self.element.eta_discharge - self.add( - self._model.add_constraints( - charge_state.isel(time=slice(1, None)) - == charge_state.isel(time=slice(None, -1)) * ((1 - rel_loss) ** hours_per_step) - + charge_rate * eff_charge * hours_per_step - - discharge_rate * eff_discharge * hours_per_step, - name=f'{self.label_full}|charge_state', - ), - 'charge_state', + self.add_constraints( + charge_state.isel(time=slice(1, None)) + == charge_state.isel(time=slice(None, -1)) * ((1 - rel_loss) ** hours_per_step) + + charge_rate * eff_charge * hours_per_step + - discharge_rate * eff_discharge * hours_per_step, + short_name='charge_state', ) if isinstance(self.element.capacity_in_flow_hours, InvestParameters): - self._investment = InvestmentModel( - model=self._model, - label_of_element=self.label_of_element, - parameters=self.element.capacity_in_flow_hours, - defining_variable=self.charge_state, - relative_bounds_of_defining_variable=self.relative_charge_state_bounds, + self.register_sub_model( + InvestmentModel( + model=self._model, + label_of_element=self.label_of_element, + parameters=self.element.capacity_in_flow_hours, + defining_variable=self.charge_state, + relative_bounds_of_defining_variable=self.relative_charge_state_bounds, + ), + short_name='investment', ) - self.sub_models.append(self._investment) self._investment.do_modeling() # Initial charge state self._initial_and_final_charge_state() if self.element.balanced: - self.add( - self._model.add_constraints( - self.element.charging.model._investment.size * 1 == self.element.discharging.model._investment.size * 1, - name=f'{self.label_full}|balanced_sizes', - ), - 'balanced_sizes' + self.add_constraints( + self.element.charging.model._investment.size * 1 == self.element.discharging.model._investment.size * 1, + short_name='balanced_sizes', ) def _initial_and_final_charge_state(self): if self.element.initial_charge_state is not None: - name_short = 'initial_charge_state' - name = f'{self.label_full}|{name_short}' - if isinstance(self.element.initial_charge_state, str): - self.add( - self._model.add_constraints( - self.charge_state.isel(time=0) == self.charge_state.isel(time=-1), name=name - ), - name_short, + self.add_constraints( + self.charge_state.isel(time=0) == self.charge_state.isel(time=-1), short_name='initial_charge_state' ) else: - self.add( - self._model.add_constraints( - self.charge_state.isel(time=0) == self.element.initial_charge_state, name=name - ), - name_short, + self.add_constraints( + self.charge_state.isel(time=0) == self.element.initial_charge_state, short_name='initial_charge_state' ) if self.element.maximal_final_charge_state is not None: - self.add( - self._model.add_constraints( - self.charge_state.isel(time=-1) <= self.element.maximal_final_charge_state, - name=f'{self.label_full}|final_charge_max', - ), - 'final_charge_max', + self.add_constraints( + self.charge_state.isel(time=-1) <= self.element.maximal_final_charge_state, + short_name='final_charge_max', ) if self.element.minimal_final_charge_state is not None: - self.add( - self._model.add_constraints( - self.charge_state.isel(time=-1) >= self.element.minimal_final_charge_state, - name=f'{self.label_full}|final_charge_min', - ), - 'final_charge_min', + self.add_constraints( + self.charge_state.isel(time=-1) >= self.element.minimal_final_charge_state, + short_name='final_charge_min', ) @property @@ -652,6 +616,28 @@ def relative_charge_state_bounds(self) -> Tuple[xr.DataArray, xr.DataArray]: return min_bounds, max_bounds + @property + def _investment(self) -> Optional[InvestmentModel]: + """Deprecated alias for investment""" + return self.investment + + @property + def investment(self) -> Optional[InvestmentModel]: + """OnOff feature""" + if 'investment' not in self.sub_models_direct: + return None + return self.sub_models_direct['investment'] + + @property + def charge_state(self) -> linopy.Variable: + """Charge state variable""" + return self['charge_state'] + + @property + def netto_discharge(self) -> linopy.Variable: + """Netto discharge variable""" + return self['netto_discharge'] + @register_class_for_io class SourceAndSink(Component): diff --git a/flixopt/effects.py b/flixopt/effects.py index 0e4236076..13ee524e5 100644 --- a/flixopt/effects.py +++ b/flixopt/effects.py @@ -142,7 +142,7 @@ def __init__(self, model: FlowSystemModel, element: Effect): super().__init__(model, element) self.element: Effect = element self.total: Optional[linopy.Variable] = None - self.invest: ShareAllocationModel = self.add( + self.invest: ShareAllocationModel = self.register_sub_model( ShareAllocationModel( model=self._model, dims=('year', 'scenario'), @@ -150,10 +150,11 @@ def __init__(self, model: FlowSystemModel, element: Effect): label_of_model=f'{self.label_of_model}(invest)', total_max=self.element.maximum_invest, total_min=self.element.minimum_invest, - ) + ), + short_name='invest', ) - self.operation: ShareAllocationModel = self.add( + self.operation: ShareAllocationModel = self.register_sub_model( ShareAllocationModel( model=self._model, dims=('time', 'year', 'scenario'), @@ -167,29 +168,22 @@ def __init__(self, model: FlowSystemModel, element: Effect): max_per_hour=self.element.maximum_operation_per_hour if self.element.maximum_operation_per_hour is not None else None, - ) + ), + short_name='operation', ) def do_modeling(self): for model in self.sub_models: model.do_modeling() - self.total = self.add( - self._model.add_variables( - lower=self.element.minimum_total if self.element.minimum_total is not None else -np.inf, - upper=self.element.maximum_total if self.element.maximum_total is not None else np.inf, - coords=self._model.get_coords(['year', 'scenario']), - name=f'{self.label_full}|total', - ), - 'total', + self.total = self.add_variables( + lower=self.element.minimum_total if self.element.minimum_total is not None else -np.inf, + upper=self.element.maximum_total if self.element.maximum_total is not None else np.inf, + coords=self._model.get_coords(['year', 'scenario']), + short_name='total', ) - self.add( - self._model.add_constraints( - self.total == self.operation.total + self.invest.total, name=f'{self.label_full}|total' - ), - 'total', - ) + self.add_constraints(self.total == self.operation.total + self.invest.total, short_name='total') TemporalEffectsUser = Union[TemporalDataUser, Dict[str, TemporalDataUser]] # User-specified Shares to Effects @@ -421,8 +415,9 @@ def add_share_to_penalty(self, name: str, expression: linopy.LinearExpression) - def do_modeling(self): for effect in self.effects: effect.create_model(self._model) - self.penalty = self.add( - ShareAllocationModel(self._model, dims=(), label_of_element='Penalty') + self.penalty = self.register_sub_model( + ShareAllocationModel(self._model, dims=(), label_of_element='Penalty'), + short_name='penalty', ) for model in [effect.model for effect in self.effects] + [self.penalty]: model.do_modeling() diff --git a/flixopt/elements.py b/flixopt/elements.py index 15d17ef92..dec7425a7 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -313,27 +313,20 @@ def __init__(self, model: FlowSystemModel, element: Flow): super().__init__(model, element) self.element: Flow = element - # Feature models (set by do_modeling) - self.on_off: Optional[OnOffModel] = None - self._investment: Optional[InvestmentModel] = None - def do_modeling(self): # Main flow rate variable - self.add( - self._model.add_variables( - lower=self.flow_rate_lower_bound, - upper=self.flow_rate_upper_bound, - coords=self._model.get_coords(), - name=f'{self.label_full}|flow_rate', - ), - 'flow_rate', + self.add_variables( + lower=self.flow_rate_lower_bound, + upper=self.flow_rate_upper_bound, + coords=self._model.get_coords(), + short_name='flow_rate', ) default_cons = not (self.element.on_off_parameters is not None and isinstance(self.element.size, InvestParameters)) # OnOff feature if self.element.on_off_parameters is not None: - self.on_off: OnOffModel = self.add( + self.register_sub_model( OnOffModel( model=self._model, label_of_element=self.label_of_element, @@ -344,13 +337,12 @@ def do_modeling(self): label_of_model=self.label_of_element, apply_bounds_to_flow_rates=default_cons, ), - 'on_off', - ) - self.on_off.do_modeling() + short_name='on_off', + ).do_modeling() # Investment feature if isinstance(self.element.size, InvestParameters): - self._investment: InvestmentModel = self.add( + self.register_sub_model( InvestmentModel( model=self._model, label_of_element=self.label_of_element, @@ -364,12 +356,11 @@ def do_modeling(self): apply_bounds_to_defining_variable=default_cons, ), 'investment', - ) - self._investment.do_modeling() + ).do_modeling() if not default_cons: - constraints = BoundingPatterns.scaled_bounds_with_state( - model=self._model, + BoundingPatterns.scaled_bounds_with_state( + model=self, variable=self.flow_rate, scaling_variable=self._investment.size, relative_bounds=(self.flow_rate_lower_bound_relative, self.flow_rate_upper_bound_relative), @@ -377,12 +368,9 @@ def do_modeling(self): variable_state=self.on_off.on, ) - for constraint in constraints: - self.add(constraint) - - # Total flow hours tracking (could use factory pattern) - variable, constraint = ModelingPrimitives.expression_tracking_variable( - model=self._model, + # Total flow hours tracking + ModelingPrimitives.expression_tracking_variable( + model=self, name=f'{self.label_full}|total_flow_hours', tracked_expression=(self.flow_rate * self._model.hours_per_step).sum('time'), bounds=( @@ -392,9 +380,6 @@ def do_modeling(self): coords=['year', 'scenario'], ) - self.add(variable, 'total_flow_hours') - self.add(constraint, 'total_flow_hours_tracking') - # Load factor constraints self._create_bounds_for_load_factor() @@ -403,14 +388,14 @@ def do_modeling(self): # Properties for clean access to variables @property - def flow_rate(self) -> Optional[linopy.Variable]: + def flow_rate(self) -> linopy.Variable: """Main flow rate variable""" - return self.get_variable_by_short_name('flow_rate') + return self['flow_rate'] @property - def total_flow_hours(self) -> Optional[linopy.Variable]: + def total_flow_hours(self) -> linopy.Variable: """Total flow hours variable""" - return self.get_variable_by_short_name('total_flow_hours') + return self['total_flow_hours'] def results_structure(self): return { @@ -440,23 +425,17 @@ def _create_bounds_for_load_factor(self): # Maximum load factor constraint if self.element.load_factor_max is not None: flow_hours_per_size_max = self._model.hours_per_step.sum('time') * self.element.load_factor_max - self.add( - self._model.add_constraints( - self.total_flow_hours <= size * flow_hours_per_size_max, - name=f'{self.label_full}|load_factor_max', - ), - 'load_factor_max', + self.add_constraints( + self.total_flow_hours <= size * flow_hours_per_size_max, + short_name='load_factor_max', ) # Minimum load factor constraint if self.element.load_factor_min is not None: flow_hours_per_size_min = self._model.hours_per_step.sum('time') * self.element.load_factor_min - self.add( - self._model.add_constraints( - self.total_flow_hours >= size * flow_hours_per_size_min, - name=f'{self.label_full}|load_factor_min', - ), - 'load_factor_min', + self.add_constraints( + self.total_flow_hours >= size * flow_hours_per_size_min, + short_name='load_factor_min', ) @property @@ -512,6 +491,25 @@ def flow_rate_upper_bound(self) -> TemporalData: return self.flow_rate_upper_bound_relative * self.element.size.maximum_or_fixed_size return self.flow_rate_upper_bound_relative * self.element.size + @property + def on_off(self) -> Optional[OnOffModel]: + """OnOff feature""" + if 'on_off' not in self.sub_models_direct: + return None + return self.sub_models_direct['on_off'] + + @property + def _investment(self) -> Optional[InvestmentModel]: + """Deprecated alias for investment""" + return self.investment + + @property + def investment(self) -> Optional[InvestmentModel]: + """OnOff feature""" + if 'investment' not in self.sub_models_direct: + return None + return self.sub_models_direct['investment'] + class BusModel(ElementModel): def __init__(self, model: FlowSystemModel, element: Bus): @@ -523,28 +521,19 @@ def __init__(self, model: FlowSystemModel, element: Bus): def do_modeling(self) -> None: # inputs == outputs for flow in self.element.inputs + self.element.outputs: - self.add(flow.model.flow_rate, flow.label_full) + self.register_variable(flow.model.flow_rate, flow.label_full) inputs = sum([flow.model.flow_rate for flow in self.element.inputs]) outputs = sum([flow.model.flow_rate for flow in self.element.outputs]) - eq_bus_balance = self.add(self._model.add_constraints(inputs == outputs, name=f'{self.label_full}|balance')) + eq_bus_balance = self.add_constraints(inputs == outputs, short_name='balance') # Fehlerplus/-minus: if self.element.with_excess: - excess_penalty = np.multiply( - self._model.hours_per_step, self.element.excess_penalty_per_flow_hour - ) - self.excess_input = self.add( - self._model.add_variables( - lower=0, coords=self._model.get_coords(), name=f'{self.label_full}|excess_input' - ), - 'excess_input', - ) - self.excess_output = self.add( - self._model.add_variables( - lower=0, coords=self._model.get_coords(), name=f'{self.label_full}|excess_output' - ), - 'excess_output', - ) + excess_penalty = np.multiply(self._model.hours_per_step, self.element.excess_penalty_per_flow_hour) + + self.excess_input = self.add_variables(lower=0, coords=self._model.get_coords(), short_name='excess_input') + + self.excess_output = self.add_variables(lower=0, coords=self._model.get_coords(), short_name='excess_output') + eq_bus_balance.lhs -= -self.excess_input + self.excess_output self._model.effects.add_share_to_penalty(self.label_of_element, (self.excess_input * excess_penalty).sum()) @@ -581,13 +570,13 @@ def do_modeling(self): flow.on_off_parameters = OnOffParameters() for flow in all_flows: - self.add(flow.create_model(self._model), flow.label) + self.register_sub_model(flow.create_model(self._model), short_name=flow.label) for sub_model in self.sub_models: sub_model.do_modeling() if self.element.on_off_parameters: - self.on_off = self.add( + self.on_off = self.register_sub_model( OnOffModel( model=self._model, label_of_element=self.label_of_element, @@ -598,6 +587,7 @@ def do_modeling(self): label_of_model=self.label_of_element, apply_bounds_to_flow_rates=True, ), + short_name='on_off', ) self.on_off.do_modeling() @@ -605,7 +595,7 @@ def do_modeling(self): if self.element.prevent_simultaneous_flows: # Simultanious Useage --> Only One FLow is On at a time, but needs a Binary for every flow on_variables = [flow.model.on_off.on for flow in self.element.prevent_simultaneous_flows] - simultaneous_use = self.add(PreventSimultaneousUsageModel(self._model, on_variables, self.label_full)) + simultaneous_use = self.register_sub_model(PreventSimultaneousUsageModel(self._model, on_variables, self.label_full), short_name='prevent_simultaneous_use') simultaneous_use.do_modeling() def results_structure(self): diff --git a/flixopt/features.py b/flixopt/features.py index dab25b49b..49c80d1c8 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -51,50 +51,40 @@ def __init__( self._apply_bounds_to_defining_variable = apply_bounds_to_defining_variable # Only keep non-variable attributes - self.scenario_of_investment: Optional[linopy.Variable] = None self.piecewise_effects: Optional[PiecewiseEffectsModel] = None + def create_variables_and_constraints(self): - constraints = [] size_min, size_max = (self.parameters.minimum_or_fixed_size, self.parameters.maximum_or_fixed_size) - size = self.add( - self._model.add_variables( - lower=0 if self.parameters.optional else size_min, - upper=size_max, - name=f'{self.label_of_model}|size', - coords=self._model.get_coords(['year', 'scenario']), - ), - 'size', + self.add_variables( + lower=0 if self.parameters.optional else size_min, + upper=size_max, + name=f'{self.label_of_model}|size', + coords=self._model.get_coords(['year', 'scenario']), ) if self.parameters.optional: - is_invested = self.add( - self._model.add_variables( - binary=True, name=f'{self.label_of_model}|is_invested', coords=self._model.get_coords(['year', 'scenario']) - ), - 'is_invested', + self.add_variables( + binary=True, name=f'{self.label_of_model}|is_invested', coords=self._model.get_coords(['year', 'scenario']) ) - constraints += BoundingPatterns.bounds_with_state( - self._model, - variable=size, - variable_state=is_invested, + + BoundingPatterns.bounds_with_state( + self, + variable=self.size, + variable_state=self.is_invested, bounds=(self.parameters.minimum_or_fixed_size, self.parameters.maximum_or_fixed_size), ) if self._apply_bounds_to_defining_variable: - constraints += BoundingPatterns.scaled_bounds( - self._model, + BoundingPatterns.scaled_bounds( + self, variable=self._defining_variable, scaling_variable=self.size, relative_bounds=self._relative_bounds_of_defining_variable, ) - # Register constraints - for constraint in constraints: - self.add(constraint) - if self.parameters.piecewise_effects: - self.piecewise_effects = self.add( + self.piecewise_effects = self.register_sub_model( PiecewiseEffectsModel( model=self._model, label_of_element=self.label_of_element, @@ -102,21 +92,10 @@ def create_variables_and_constraints(self): piecewise_shares=self.parameters.piecewise_effects.piecewise_shares, zero_point=self.is_invested, ), - 'segments', + short_name='segments', ) self.piecewise_effects.do_modeling() - # Properties access variables from Model's tracking system - @property - def size(self) -> Optional[linopy.Variable]: - """Investment size variable""" - return self.get_variable_by_short_name('size') - - @property - def is_invested(self) -> Optional[linopy.Variable]: - """Binary investment decision variable""" - return self.get_variable_by_short_name('is_invested') - def add_effects(self): """Add investment effects""" if self.parameters.fix_effects: @@ -146,9 +125,17 @@ def add_effects(self): target='invest', ) - def _create_bounds_for_scenarios(self): - """Keep existing scenario logic""" - pass + @property + def size(self) -> linopy.Variable: + """Investment size variable""" + return self._variables['size'] + + @property + def is_invested(self) -> Optional[linopy.Variable]: + """Binary investment decision variable""" + if 'is_invested' not in self._variables: + return None + return self._variables['is_invested'] class OnOffModel(BaseFeatureModel): @@ -186,73 +173,60 @@ def __init__( def create_variables_and_constraints(self): # 1. Main binary state using existing pattern - on = self.add(self._model.add_variables(binary=True, name=f'{self.label_of_model}|on', coords=self._model.get_coords()), 'on') + on = self.add_variables(binary=True, short_name='on', coords=self._model.get_coords()) if self.parameters.use_off: - off = self.add(self._model.add_variables(binary=True, name=f'{self.label_of_model}|off', coords=self._model.get_coords()), 'off') - self.add(self._model.add_constraints(on + off == 1, name=f'{self.label_of_model}|complementary'), 'complementary') + off = self.add_variables(binary=True, short_name='off', coords=self._model.get_coords()) + self.add_constraints(on + off == 1, short_name='complementary') # 2. Control variables if self._apply_bounds_to_flow_rates: - self.add_batch(*BoundingPatterns.bounds_with_state( - self._model, + BoundingPatterns.bounds_with_state( + self, variable=self._flow_rate, bounds=self._flow_rate_bounds, variable_state=self.on, - )) + ) # 3. Total duration tracking using existing pattern duration_expr = (self.on * self._model.hours_per_step).sum('time') - var, con = ModelingPrimitives.expression_tracking_variable( - self._model, f'{self.label_of_model}|on_hours_total', duration_expr, + ModelingPrimitives.expression_tracking_variable( + self, f'{self.label_of_model}|on_hours_total', duration_expr, (self.parameters.on_hours_total_min if self.parameters.on_hours_total_min is not None else 0, self.parameters.on_hours_total_max if self.parameters.on_hours_total_max is not None else np.inf),#TODO: self._model.hours_per_step.sum('time').item() + self._get_previous_on_duration()) ) - self.add(var, 'on_hours_total') - self.add(con) # 4. Switch tracking using existing pattern if self.parameters.use_switch_on: - switch_vars, switch_constraints = ModelingPrimitives.state_transition_variables( - self._model, f'{self.label_of_model}|switch', self.on, + ModelingPrimitives.state_transition_variables( + self, f'{self.label_of_model}|switch', self.on, previous_state=ModelingUtilities.get_most_recent_state(self._previous_flow_rate), ) - self.add(switch_vars['on'], 'switch|on') - self.add(switch_vars['off'], 'switch|off') - self.add(switch_constraints['transition']) - self.add(switch_constraints['initial']) - self.add(switch_constraints['mutex']) if self.parameters.switch_on_total_max is not None: - count = self.add(self._model.add_variables(lower=0, upper=self.parameters.switch_on_total_max, coords=self._model.get_coords(('year', 'scenario')), name=f'{self.label_of_model}|switch|count'), 'switch|count') - self.add(self._model.add_constraints(count == self.switch_on.sum('time'), name=f'{self.label_of_model}|switch|count'), 'switch|count') + count = self.add_variables(lower=0, upper=self.parameters.switch_on_total_max, coords=self._model.get_coords(('year', 'scenario')), short_name='switch|count') + self.add_constraints(count == self.switch_on.sum('time'), short_name='switch|count') # 5. Consecutive on duration using existing pattern if self.parameters.use_consecutive_on_hours: - consecutive_on_vars, consecutive_on_constraints = ModelingPrimitives.consecutive_duration_tracking( - self._model, + ModelingPrimitives.consecutive_duration_tracking( + self, f'{self.label_of_model}|consecutive_on_hours', #TODO: Change name self.on, minimum_duration=self.parameters.consecutive_on_hours_min, maximum_duration=self.parameters.consecutive_on_hours_max, previous_duration=ModelingUtilities.compute_previous_on_duration([self._previous_flow_rate], self._model.hours_per_step), ) - self.add(consecutive_on_vars['duration'], 'consecutive_on_hours') - for constraint in consecutive_on_constraints.values(): - self.add(constraint) # 6. Consecutive off duration using existing pattern if self.parameters.use_consecutive_off_hours: - consecutive_off_vars, consecutive_off_constraints = ModelingPrimitives.consecutive_duration_tracking( - self._model, + ModelingPrimitives.consecutive_duration_tracking( + self, f'{self.label_of_model}|consecutive_off_hours', self.off, minimum_duration=self.parameters.consecutive_off_hours_min, maximum_duration=self.parameters.consecutive_off_hours_max, previous_duration=ModelingUtilities.compute_previous_off_duration([self._previous_flow_rate], self._model.hours_per_step), ) - self.add(consecutive_off_vars['duration'], 'consecutive_off_hours') - for constraint in consecutive_off_constraints.values(): - self.add(constraint) def add_effects(self): """Add operational effects""" @@ -279,43 +253,42 @@ def add_effects(self): @property def on(self) -> Optional[linopy.Variable]: """Binary on state variable""" - return self.get_variable_by_short_name('on') - - @property - def off(self) -> Optional[linopy.Variable]: - """Binary off state variable""" - return self.get_variable_by_short_name('off') + return self['on'] @property def total_on_hours(self) -> Optional[linopy.Variable]: """Total on hours variable""" - return self.get_variable_by_short_name('total_on_hours') + return self['total_on_hours'] + + @property + def off(self) -> Optional[linopy.Variable]: + """Binary off state variable""" + return self.get('off') @property def switch_on(self) -> Optional[linopy.Variable]: """Switch on variable""" - return self.get_variable_by_short_name('switch|on') + return self.get('switch|on') @property def switch_off(self) -> Optional[linopy.Variable]: """Switch off variable""" - return self.get_variable_by_short_name('switch|off') + return self.get('switch|off') @property def switch_on_nr(self) -> Optional[linopy.Variable]: """Number of switch-ons variable""" - # This could be added to factory if needed - return self.get_variable_by_short_name('switch|count') + return self.get('switch|count') @property def consecutive_on_hours(self) -> Optional[linopy.Variable]: """Consecutive on hours variable""" - return self.get_variable_by_short_name('consecutive_on_hours') + return self.get('consecutive_on_hours') @property def consecutive_off_hours(self) -> Optional[linopy.Variable]: """Consecutive off hours variable""" - return self.get_variable_by_short_name('consecutive_off_hours') + return self.get('consecutive_off_hours') def _get_previous_on_duration(self): hours_per_step = self._model.hours_per_step.isel(time=0).values.flatten()[0] @@ -347,42 +320,27 @@ def __init__( def do_modeling(self): dims =('time', 'year','scenario') if self._as_time_series else ('year','scenario') - self.inside_piece = self.add( - self._model.add_variables( - binary=True, - name=f'{self.label_full}|inside_piece', - coords=self._model.get_coords(dims=dims), - ), - 'inside_piece', + self.inside_piece = self.add_variables( + binary=True, + short_name='inside_piece', + coords=self._model.get_coords(dims=dims), ) - - self.lambda0 = self.add( - self._model.add_variables( - lower=0, - upper=1, - name=f'{self.label_full}|lambda0', - coords=self._model.get_coords(dims=dims), - ), - 'lambda0', + self.lambda0 = self.add_variables( + lower=0, + upper=1, + name='lambda0', + coords=self._model.get_coords(dims=dims), ) - self.lambda1 = self.add( - self._model.add_variables( - lower=0, - upper=1, - name=f'{self.label_full}|lambda1', - coords=self._model.get_coords(dims=dims), - ), - 'lambda1', + self.lambda1 = self.add_variables( + lower=0, + upper=1, + short_name='lambda1', + coords=self._model.get_coords(dims=dims), ) # eq: lambda0(t) + lambda1(t) = inside_piece(t) - self.add( - self._model.add_constraints( - self.inside_piece == self.lambda0 + self.lambda1, name=f'{self.label_full}|inside_piece' - ), - 'inside_piece', - ) + self.add_constraints(self.inside_piece == self.lambda0 + self.lambda1, short_name='inside_piece') class PiecewiseModel(Model): @@ -418,34 +376,33 @@ def __init__( def do_modeling(self): for i in range(len(list(self._piecewise_variables.values())[0])): - new_piece = self.add( + new_piece = self.register_sub_model( PieceModel( model=self._model, label_of_element=self.label_of_element, label_of_model=f'{self.label_of_element}|Piece_{i}', as_time_series=self._as_time_series, - ) + ), + short_name=f'Piece_{i}', ) self.pieces.append(new_piece) new_piece.do_modeling() for var_name in self._piecewise_variables: variable = self._model.variables[var_name] - self.add( - self._model.add_constraints( - variable - == sum( - [ - piece_model.lambda0 * piece_bounds.start + piece_model.lambda1 * piece_bounds.end - for piece_model, piece_bounds in zip( - self.pieces, self._piecewise_variables[var_name], strict=False - ) - ] - ), - name=f'{self.label_full}|{var_name}|lambda', + self.add_constraints( + variable + == sum( + [ + piece_model.lambda0 * piece_bounds.start + piece_model.lambda1 * piece_bounds.end + for piece_model, piece_bounds in zip( + self.pieces, self._piecewise_variables[var_name], strict=False + ) + ] ), - f'{var_name}|lambda', - ) + name=f'{self.label_full}|{var_name}|lambda', + short_name=f'{var_name}|lambda', + ) # a) eq: Segment1.onSeg(t) + Segment2.onSeg(t) + ... = 1 Aufenthalt nur in Segmenten erlaubt # b) eq: -On(t) + Segment1.onSeg(t) + Segment2.onSeg(t) + ... = 0 zusätzlich kann alles auch Null sein @@ -453,22 +410,17 @@ def do_modeling(self): self.zero_point = self._zero_point rhs = self.zero_point elif self._zero_point is True: - self.zero_point = self.add( - self._model.add_variables( - coords=self._model.get_coords(), binary=True, name=f'{self.label_full}|zero_point' - ), - 'zero_point', + self.zero_point = self.add_variables( + coords=self._model.get_coords(), binary=True, short_name='zero_point' ) rhs = self.zero_point else: rhs = 1 - self.add( - self._model.add_constraints( - sum([piece.inside_piece for piece in self.pieces]) <= rhs, - name=f'{self.label_full}|{variable.name}|single_segment', - ), - f'{var_name}|single_segment', + self.add_constraints( + sum([piece.inside_piece for piece in self.pieces]) <= rhs, + name=f'{self.label_full}|{variable.name}|single_segment', + short_name=f'{var_name}|single_segment', ) @@ -495,12 +447,7 @@ def __init__( def do_modeling(self): self.shares = { - effect: self.add( - self._model.add_variables( - coords=self._model.get_coords(['year', 'scenario']), name=f'{self.label_full}|{effect}' - ), - f'{effect}', - ) + effect: self.add_variables(coords=self._model.get_coords(['year', 'scenario']), short_name=effect) for effect in self._piecewise_shares } @@ -512,7 +459,7 @@ def do_modeling(self): }, } - self.piecewise_model = self.add( + self.piecewise_model = self.register_sub_model( PiecewiseModel( model=self._model, label_of_element=self.label_of_element, @@ -520,7 +467,8 @@ def do_modeling(self): zero_point=self._zero_point, as_time_series=False, label_of_model=f'{self.label_of_element}|PiecewiseEffects', - ) + ), + short_name='PiecewiseEffects', ) self.piecewise_model.do_modeling() @@ -566,35 +514,24 @@ def __init__( self._min_per_hour = min_per_hour if min_per_hour is not None else -np.inf def do_modeling(self): - self.total = self.add( - self._model.add_variables( - lower=self._total_min, - upper=self._total_max, - coords=self._model.get_coords([dim for dim in self._dims if dim != 'time']), - name=f'{self.label_full}|total', - ), - 'total', + self.total = self.add_variables( + lower=self._total_min, + upper=self._total_max, + coords=self._model.get_coords([dim for dim in self._dims if dim != 'time']), + short_name='total' ) # eq: sum = sum(share_i) # skalar - self._eq_total = self.add( - self._model.add_constraints(self.total == 0, name=f'{self.label_full}|total'), 'total' - ) + self._eq_total = self.add_constraints(self.total == 0, short_name='total') if 'time' in self._dims: - self.total_per_timestep = self.add( - self._model.add_variables( - lower=-np.inf if (self._min_per_hour is None) else self._min_per_hour * self._model.hours_per_step, - upper=np.inf if (self._max_per_hour is None) else self._max_per_hour * self._model.hours_per_step, - coords=self._model.get_coords(self._dims), - name=f'{self.label_full}|total_per_timestep', - ), - 'total_per_timestep', + self.total_per_timestep = self.add_variables( + lower=-np.inf if (self._min_per_hour is None) else self._min_per_hour * self._model.hours_per_step, + upper=np.inf if (self._max_per_hour is None) else self._max_per_hour * self._model.hours_per_step, + coords=self._model.get_coords(self._dims), + short_name='total_per_timestep', ) - self._eq_total_per_timestep = self.add( - self._model.add_constraints(self.total_per_timestep == 0, name=f'{self.label_full}|total_per_timestep'), - 'total_per_timestep', - ) + self._eq_total_per_timestep = self.add_constraints(self.total_per_timestep == 0, short_name='total_per_timestep') # Add it to the total self._eq_total.lhs -= self.total_per_timestep.sum(dim='time') @@ -629,16 +566,15 @@ def add_share( if name in self.shares: self.share_constraints[name].lhs -= expression else: - self.shares[name] = self.add( - self._model.add_variables( - coords=self._model.get_coords(dims), - name=f'{name}->{self.label_full}', - ), - name, + self.shares[name] = self.add_variables( + coords=self._model.get_coords(dims), + short_name=f'{name}->{self.label_full}', ) - self.share_constraints[name] = self.add( - self._model.add_constraints(self.shares[name] == expression, name=f'{name}->{self.label_full}'), name + + self.share_constraints[name] = self.add_constraints( + self.shares[name] == expression, short_name=f'{name}->{self.label_full}' ) + if 'time' not in dims: self._eq_total.lhs -= self.shares[name] else: @@ -680,9 +616,4 @@ def __init__( def do_modeling(self): # eq: sum(flow_i.on(t)) <= 1.1 (1 wird etwas größer gewählt wg. Binärvariablengenauigkeit) - self.add( - self._model.add_constraints( - sum(self._simultanious_use_variables) <= 1.1, name=f'{self.label_full}|prevent_simultaneous_use' - ), - 'prevent_simultaneous_use', - ) + self.add_constraints(sum(self._simultanious_use_variables) <= 1.1, short_name='prevent_simultaneous_use') diff --git a/flixopt/modeling.py b/flixopt/modeling.py index 8c539caa2..41f5b4224 100644 --- a/flixopt/modeling.py +++ b/flixopt/modeling.py @@ -324,7 +324,7 @@ def consecutive_duration_tracking( minimum_duration: Optional[TemporalData] = None, maximum_duration: Optional[TemporalData] = None, previous_duration: TemporalData = 0, - ) -> Tuple[Dict[str, linopy.Variable], Dict[str, linopy.Constraint]]: + ) -> Tuple[linopy.Variable, Tuple[linopy.Constraint, linopy.Constraint, linopy.Constraint]]: """ Creates consecutive duration tracking for a binary state variable. diff --git a/flixopt/structure.py b/flixopt/structure.py index 59cb62251..b6c4572d1 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -714,51 +714,58 @@ def __init__( self._variables: Dict[str, linopy.Variable] = {} # Mapping from short name to variable self._constraints: Dict[str, linopy.Constraint] = {} # Mapping from short name to constraint - self.sub_models: Dict[str, 'Model'] = {} + self._sub_models: Dict[str, 'Model'] = {} logger.debug(f'Created {self.__class__.__name__} "{self.label_full}"') - def add_variable(self, short_name: str, **kwargs) -> linopy.Variable: - """Create and add a variable in one step""" + def add_variables(self, short_name: str = None, **kwargs) -> linopy.Variable: + """Create and register a variable in one step""" if 'name' not in kwargs: + if short_name is None: + raise ValueError('Short name must be provided when no name is given') kwargs['name'] = f'{self.label_of_model}|{short_name}' variable = self._model.add_variables(**kwargs) self.register_variable(variable, short_name) return variable - def add_constraint(self, short_name: str, expression, **kwargs) -> linopy.Constraint: - """Create and add a constraint in one step""" + def add_constraints(self, expression, short_name: str = None, **kwargs) -> linopy.Constraint: + """Create and register a constraint in one step""" if 'name' not in kwargs: + if short_name is None: + raise ValueError('Short name must be provided when no name is given') kwargs['name'] = f'{self.label_of_model}|{short_name}' constraint = self._model.add_constraints(expression, **kwargs) self.register_constraint(constraint, short_name) return constraint - def register_variable(self, variable: linopy.Variable, short_name: str = None) -> None: + def register_variable(self, variable: linopy.Variable, short_name: str = None) -> linopy.Variable: """Register a variable with the model""" if short_name is None: short_name = self._extract_short_name(variable) if short_name in self._variables: raise ValueError(f'Short name "{short_name}" already assigned to model') self._variables[short_name] = variable + return variable - def register_constraint(self, constraint: linopy.Constraint, short_name: str = None) -> None: + def register_constraint(self, constraint: linopy.Constraint, short_name: str = None) -> linopy.Constraint: """Register a constraint with the model""" if short_name is None: short_name = self._extract_short_name(constraint) if short_name in self._constraints: raise ValueError(f'Short name "{short_name}" already assigned to model') self._constraints[short_name] = constraint + return constraint - def register_sub_model(self, sub_model: 'Model', short_name: str) -> None: + def register_sub_model(self, sub_model: 'Model', short_name: str) -> 'Model': """Register a sub-model with the model""" if short_name is None: short_name = sub_model.__class__.__name__ - if short_name in self.sub_models: + if short_name in self._sub_models: raise ValueError(f'Short name "{short_name}" already assigned to model') - self.sub_models[short_name] = sub_model + self._sub_models[short_name] = sub_model + return sub_model def __getitem__(self, key: str) -> linopy.Variable: """Get a variable by its short name""" @@ -766,6 +773,24 @@ def __getitem__(self, key: str) -> linopy.Variable: return self._variables[key] raise KeyError(f'Variable "{key}" not found in model "{self.label_full}"') + def __contains__(self, name: str) -> bool: + """Check if a variable exists in the model""" + return name in self._variables or name in self.variables + + def get(self, name: str, default=None): + """Get variable by short name, returning default if not found""" + try: + return self[name] + except KeyError: + return default + + def get_coords( + self, + dims: Optional[Collection[str]] = None, + extra_timestep: bool = False, + ) -> Optional[xr.Coordinates]: + return self._model.get_coords(dims=dims, extra_timestep=extra_timestep) + def filter_variables( self, filter_by: Optional[Literal['binary', 'continuous', 'integer']] = None, @@ -794,33 +819,44 @@ def label_full(self) -> str: return self.label_of_model @property - def variables(self) -> linopy.Variables: + def variables_direct(self) -> linopy.Variables: + """Variables of the model, excluding those of sub-models""" return self._model.variables[[var.name for var in self._variables.values()]] @property - def constraints(self) -> linopy.Constraints: + def constraints_direct(self) -> linopy.Constraints: + """Costraints of the model, excluding those of sub-models""" return self._model.constraints[[con.name for con in self._constraints.values()]] @property - def all_sub_models(self) -> List['Model']: - return [model for sub_model in self.sub_models.values() for model in [sub_model] + sub_model.all_sub_models] + def sub_models_direct(self) -> Dict[str, 'Model']: + """All sub-models of the model, excluding those of sub-models""" + return self._sub_models @property - def all_constraints(self) -> linopy.Constraints: - names = [constraint_name for constraint_name in self.constraints] + [ - constraint.name - for sub_model in self.all_sub_models - for constraint in sub_model.constraints.values() + def sub_models(self) -> List['Model']: + """All sub-models of the model""" + direct = list(self.sub_models_direct.values()) + return direct + [model for sub_model in direct for model in sub_model.sub_models] + + @property + def constraints(self) -> linopy.Constraints: + """All constraints of the model, including those of sub-models""" + names = list(self.constraints_direct) + [ + constraint_name + for sub_model in self.sub_models + for constraint_name in sub_model.constraints_direct ] return self._model.constraints[names] @property - def all_variables(self) -> linopy.Variables: - names = [variable_name for variable_name in self.variables] + [ - variable.name - for sub_model in self.all_sub_models - for variable in sub_model.constraints.values() + def variables(self) -> linopy.Variables: + """All variables of the model, including those of sub-models""" + names = list(self.variables_direct) + [ + variable_name + for sub_model in self.sub_models + for variable_name in sub_model.variables_direct ] return self._model.variables[names] From 099784384730e140f9b4f8da56125660417ae71f Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 21 Jul 2025 11:05:02 +0200 Subject: [PATCH 219/336] Simplify and focus more on own Model class --- flixopt/elements.py | 3 +- flixopt/features.py | 42 +++++--- flixopt/modeling.py | 252 ++++--------------------------------------- flixopt/structure.py | 44 ++++++-- 4 files changed, 84 insertions(+), 257 deletions(-) diff --git a/flixopt/elements.py b/flixopt/elements.py index dec7425a7..9329a88dd 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -12,7 +12,7 @@ from .config import CONFIG from .core import PlausibilityError, Scalar, TemporalData, TemporalDataUser from .effects import TemporalEffectsUser -from .features import InvestmentModel, OnOffModel, PreventSimultaneousUsageModel, ModelingPatterns, ModelingPrimitives +from .features import InvestmentModel, OnOffModel, PreventSimultaneousUsageModel, ModelingPrimitives from .interface import InvestParameters, OnOffParameters from .structure import Element, ElementModel, FlowSystemModel, register_class_for_io from .modeling import BoundingPatterns @@ -378,6 +378,7 @@ def do_modeling(self): self.element.flow_hours_total_max if self.element.flow_hours_total_max is not None else None, ), coords=['year', 'scenario'], + short_name='total_flow_hours', ) # Load factor constraints diff --git a/flixopt/features.py b/flixopt/features.py index 49c80d1c8..6708a3221 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -13,7 +13,7 @@ from .core import NonTemporalData, Scalar, TemporalData, FlowSystemDimensions from .interface import InvestParameters, OnOffParameters, Piecewise, PiecewiseEffects from .structure import Model, FlowSystemModel, BaseFeatureModel -from .modeling import ModelingPatterns, ModelingUtilities, ModelingPrimitives, BoundingPatterns +from .modeling import ModelingUtilities, ModelingPrimitives, BoundingPatterns logger = logging.getLogger('flixopt') @@ -57,15 +57,15 @@ def __init__( def create_variables_and_constraints(self): size_min, size_max = (self.parameters.minimum_or_fixed_size, self.parameters.maximum_or_fixed_size) self.add_variables( + short_name='size', lower=0 if self.parameters.optional else size_min, upper=size_max, - name=f'{self.label_of_model}|size', coords=self._model.get_coords(['year', 'scenario']), ) if self.parameters.optional: self.add_variables( - binary=True, name=f'{self.label_of_model}|is_invested', coords=self._model.get_coords(['year', 'scenario']) + binary=True, coords=self._model.get_coords(['year', 'scenario']), short_name='is_invested', ) BoundingPatterns.bounds_with_state( @@ -190,15 +190,24 @@ def create_variables_and_constraints(self): # 3. Total duration tracking using existing pattern duration_expr = (self.on * self._model.hours_per_step).sum('time') ModelingPrimitives.expression_tracking_variable( - self, f'{self.label_of_model}|on_hours_total', duration_expr, - (self.parameters.on_hours_total_min if self.parameters.on_hours_total_min is not None else 0, - self.parameters.on_hours_total_max if self.parameters.on_hours_total_max is not None else np.inf),#TODO: self._model.hours_per_step.sum('time').item() + self._get_previous_on_duration()) + self, duration_expr, short_name='on_hours_total', + bounds=( + self.parameters.on_hours_total_min if self.parameters.on_hours_total_min is not None else 0, + self.parameters.on_hours_total_max if self.parameters.on_hours_total_max is not None else np.inf, + ), #TODO: self._model.hours_per_step.sum('time').item() + self._get_previous_on_duration()) ) # 4. Switch tracking using existing pattern if self.parameters.use_switch_on: + self.add_variables(binary=True, short_name='switch|on', coords=self.get_coords()) + self.add_variables(binary=True, short_name='switch|off', coords=self.get_coords()) + ModelingPrimitives.state_transition_variables( - self, f'{self.label_of_model}|switch', self.on, + self, + state_variable=self.on, + switch_on=self.switch_on, + switch_off=self.switch_off, + name=f'{self.label_of_model}|switch', previous_state=ModelingUtilities.get_most_recent_state(self._previous_flow_rate), ) @@ -210,8 +219,8 @@ def create_variables_and_constraints(self): if self.parameters.use_consecutive_on_hours: ModelingPrimitives.consecutive_duration_tracking( self, - f'{self.label_of_model}|consecutive_on_hours', #TODO: Change name - self.on, + state_variable=self.on, + short_name='consecutive_on_hours', minimum_duration=self.parameters.consecutive_on_hours_min, maximum_duration=self.parameters.consecutive_on_hours_max, previous_duration=ModelingUtilities.compute_previous_on_duration([self._previous_flow_rate], self._model.hours_per_step), @@ -221,11 +230,13 @@ def create_variables_and_constraints(self): if self.parameters.use_consecutive_off_hours: ModelingPrimitives.consecutive_duration_tracking( self, - f'{self.label_of_model}|consecutive_off_hours', - self.off, + state_variable=self.off, + short_name='consecutive_off_hours', minimum_duration=self.parameters.consecutive_off_hours_min, maximum_duration=self.parameters.consecutive_off_hours_max, - previous_duration=ModelingUtilities.compute_previous_off_duration([self._previous_flow_rate], self._model.hours_per_step), + previous_duration=ModelingUtilities.compute_previous_off_duration( + [self._previous_flow_rate], self._model.hours_per_step + ), ) def add_effects(self): @@ -328,7 +339,7 @@ def do_modeling(self): self.lambda0 = self.add_variables( lower=0, upper=1, - name='lambda0', + short_name='lambda0', coords=self._model.get_coords(dims=dims), ) @@ -568,11 +579,12 @@ def add_share( else: self.shares[name] = self.add_variables( coords=self._model.get_coords(dims), - short_name=f'{name}->{self.label_full}', + name=f'{name}->{self.label_full}', + short_name=name, ) self.share_constraints[name] = self.add_constraints( - self.shares[name] == expression, short_name=f'{name}->{self.label_full}' + self.shares[name] == expression, name=f'{name}->{self.label_full}' ) if 'time' not in dims: diff --git a/flixopt/modeling.py b/flixopt/modeling.py index 41f5b4224..17f47c939 100644 --- a/flixopt/modeling.py +++ b/flixopt/modeling.py @@ -148,68 +148,12 @@ def get_most_recent_state(previous_values: List[TemporalData]) -> int: class ModelingPrimitives: """Mathematical modeling primitives returning (variables, constraints) tuples""" - @staticmethod - def binary_state_pair( - model: FlowSystemModel, name: str, coords: List[str] = None, use_complement: bool = True - ) -> Tuple[Tuple[linopy.Variable, linopy.Variable], linopy.Constraint]: - """ - Creates complementary binary variables with completeness constraint. - - Mathematical formulation: - on[t] + off[t] = 1 ∀t - on[t], off[t] ∈ {0, 1} - """ - coords = coords or ['time'] - - on = model.add_variables(binary=True, name=f'{name}|on', coords=model.get_coords(coords)) - off = model.add_variables(binary=True, name=f'{name}|off', coords=model.get_coords(coords)) - - # Constraint: on + off = 1 - complementary = model.add_constraints(on + off == 1, name=f'{name}|complementary') - - return (on, off), complementary - - @staticmethod - def proportionally_bounded_variable( - model: FlowSystemModel, - name: str, - controlling_variable, - bounds: Tuple[TemporalData, TemporalData], - coords: List[str] = None, - ) -> Tuple[Dict[str, linopy.Variable], Dict[str, linopy.Constraint]]: - """ - Creates variable with bounds proportional to another variable. - - Mathematical formulation: - lower_factor[t] * controller[t] ≤ variable[t] ≤ upper_factor[t] * controller[t] ∀t - - Returns: - variables: {'variable': bounded_var} - constraints: {'lb': constraint, 'ub': constraint} - """ - coords = coords or ['time'] - variable = model.add_variables(name=f'{name}|bounded', coords=model.get_coords(coords)) - - lower_factor, upper_factor = bounds - - # Constraints: lower_factor * controller ≤ var ≤ upper_factor * controller - lower_bound = model.add_constraints( - variable >= controlling_variable * lower_factor, name=f'{name}|proportional_lb' - ) - upper_bound = model.add_constraints( - variable <= controlling_variable * upper_factor, name=f'{name}|proportional_ub' - ) - - variables = {'variable': variable} - constraints = {'lb': lower_bound, 'ub': upper_bound} - - return variables, constraints - @staticmethod def expression_tracking_variable( - model: FlowSystemModel, - name: str, + model: Model, tracked_expression, + name: str = None, + short_name: str = None, bounds: Tuple[TemporalData, TemporalData] = None, coords: List[str] = None, ) -> Tuple[linopy.Variable, linopy.Constraint]: @@ -227,27 +171,30 @@ def expression_tracking_variable( coords = coords or ['year', 'scenario'] if not bounds: - tracker = model.add_variables(name=f'{name}', coords=model.get_coords(coords)) + tracker = model.add_variables(name=name, coords=model.get_coords(coords), short_name=short_name) else: tracker = model.add_variables( lower=bounds[0] if bounds[0] is not None else -np.inf, upper=bounds[1] if bounds[1] is not None else np.inf, - name=f'{name}', + name=name, coords=model.get_coords(coords), + short_name=short_name, ) # Constraint: tracker = expression - tracking = model.add_constraints(tracker == tracked_expression, name=f'{name}') + tracking = model.add_constraints(tracker == tracked_expression, name=name, short_name=short_name) return tracker, tracking @staticmethod def state_transition_variables( - model: FlowSystemModel, - name: str, + model: Union[FlowSystemModel, Model], state_variable: linopy.Variable, + switch_on: linopy.Variable, + switch_off: linopy.Variable, + name: str, previous_state=0, - ) -> Tuple[Dict[str, linopy.Variable], Dict[str, linopy.Constraint]]: + ) -> Tuple[linopy.Constraint, linopy.Constraint, linopy.Constraint]: """ Creates switch-on/off variables with state transition logic. @@ -261,14 +208,11 @@ def state_transition_variables( variables: {'switch_on': binary_var, 'switch_off': binary_var} constraints: {'transition': constraint, 'initial': constraint, 'mutex': constraint} """ - switch_on = model.add_variables(binary=True, name=f'{name}|on', coords=model.get_coords(['time'])) - switch_off = model.add_variables(binary=True, name=f'{name}|off', coords=model.get_coords(['time'])) - # State transition constraints for t > 0 transition = model.add_constraints( switch_on.isel(time=slice(1, None)) - switch_off.isel(time=slice(1, None)) == state_variable.isel(time=slice(1, None)) - state_variable.isel(time=slice(None, -1)), - name=name, + name=f'{name}|transition', ) # Initial state transition for t = 0 @@ -280,13 +224,14 @@ def state_transition_variables( # At most one switch per timestep mutex = model.add_constraints(switch_on + switch_off <= 1, name=f'{name}|mutex') - return {'on': switch_on, 'off': switch_off}, {'transition': transition, 'initial': initial, 'mutex': mutex} + return transition, initial, mutex @staticmethod def sum_up_variable( model: FlowSystemModel, variable_to_count: linopy.Variable, - name: str, + name: str = None, + short_name: str = None, bounds: Tuple[NonTemporalData, NonTemporalData] = None, factor: TemporalData = 1, ) -> Tuple[linopy.Variable, linopy.Constraint]: @@ -319,8 +264,9 @@ def sum_up_variable( @staticmethod def consecutive_duration_tracking( model: FlowSystemModel, - name: str, state_variable: linopy.Variable, + name: str = None, + short_name: str = None, minimum_duration: Optional[TemporalData] = None, maximum_duration: Optional[TemporalData] = None, previous_duration: TemporalData = 0, @@ -357,6 +303,7 @@ def consecutive_duration_tracking( upper=maximum_duration if maximum_duration is not None else mega, coords=model.get_coords(['time']), name=name, + short_name=short_name, ) constraints = {} @@ -708,164 +655,3 @@ def auto_bounds( return BoundingPatterns.basic_bounds(model, variable, bounds) raise ValueError('Invalid combination of arguments') - - -class ModelingPatterns: - """High-level patterns that compose primitives and return (variables, constraints) tuples""" - - @staticmethod - def investment_sizing_pattern( - model: FlowSystemModel, - name: str, - size_bounds: Tuple[TemporalData, TemporalData], - controlled_variable: linopy.Variable, - control_factors: Tuple[TemporalData, TemporalData], - state_variable: List[linopy.Variable] = None, - optional: bool = False, - ) -> Tuple[Dict[str, linopy.Variable], Dict[str, linopy.Constraint]]: - """ - Complete investment sizing pattern with optional binary decision. - - Args: - model: The model to add the variables to. - name: The name of the investment variable. - size_bounds: The minimum and maximum investment size. - controlled_variables: The variables that are controlled by the investment decision. - control_factors: The control factors for the controlled variables. - state_variables: State variable defining the state of the controlled variables. - optional: Whether the investment decision is optional. - - Returns: - variables: {'size': size_var, 'is_invested': binary_var (if optional)} - constraints: {'ub': constraint, 'lb': constraint, ...} - """ - variables = {} - constraints = {} - - # Investment size variable - size_min, size_max = size_bounds - variables['size'] = model.add_variables( - lower=0 if optional else size_min, - upper=size_max, - name=f'{name}|size', - coords=model.get_coords(['year', 'scenario']), - ) - - # Optional binary investment decision - if optional: - variables['is_invested'] = model.add_variables( - binary=True, name=f'{name}|is_invested', coords=model.get_coords(['year', 'scenario']) - ) - - _, new_cons = BoundingPatterns.auto_bounds( - model=model, - variable=controlled_variable, - bounds=control_factors, - upper_bound_name=f'{controlled_variable.name}|ub', - lower_bound_name=f'{controlled_variable.name}|lb', - scaling_variable=variables['size'], - binary_control=variables['is_invested'] if optional else None, - scaling_bounds=(size_min, size_max), - constraint_name_prefix=name, - ) - - constraints.update(new_cons) - - return variables, constraints - - @staticmethod - def operational_binary_control_pattern( - model: FlowSystemModel, - name: str, - controlled_variables: List[linopy.Variable], - variable_bounds: List[Tuple[TemporalData, TemporalData]], - use_complement: bool = False, - track_total_duration: bool = False, - track_switches: bool = False, - previous_state=0, - duration_bounds: Tuple[TemporalData, TemporalData] = None, - track_consecutive_on: bool = False, - consecutive_on_bounds: Tuple[Optional[TemporalData], Optional[TemporalData]] = (None, None), - previous_on_duration: TemporalData = 0, - track_consecutive_off: bool = False, - consecutive_off_bounds: Tuple[Optional[TemporalData], Optional[TemporalData]] = (None, None), - previous_off_duration: TemporalData = 0, - ) -> Tuple[Dict[str, linopy.Variable], Dict[str, linopy.Constraint]]: - """ - Enhanced operational binary control using composable patterns. - """ - variables = {} - constraints = {} - - # 1. Main binary state using existing pattern - if use_complement: - state_vars, state_constraints = ModelingPrimitives.binary_state_pair(model, name) - variables.update(state_vars) - constraints.update(state_constraints) - else: - variables['on'] = model.add_variables(binary=True, name=f'{name}|on', coords=model.get_coords(['time'])) - - # 2. Control variables - use big_m_binary_bounds pattern for consistency - for i, (var, (lower_bound, upper_bound)) in enumerate(zip(controlled_variables, variable_bounds)): - # Use the big_m pattern but without binary control (None) - _, control_constraints = BoundingPatterns.big_m_binary_bounds( - model=model, - variable=var, - binary_control=variables['on'], # The on state controls the variables - size_variable=1, # No size scaling, just on/off - relative_bounds=(lower_bound, upper_bound), - upper_bound_name=f'{name}|control_{i}_upper', - lower_bound_name=f'{name}|control_{i}_lower', - ) - constraints[f'control_{i}_upper'] = control_constraints['ub'] - constraints[f'control_{i}_lower'] = control_constraints['lb'] - - # 3. Total duration tracking using existing pattern - if track_total_duration: - duration_expr = (variables['on'] * model.hours_per_step).sum('time') - duration_vars, duration_constraints = ModelingPrimitives.expression_tracking_variable( - model, f'{name}|on_hours_total', duration_expr, duration_bounds - ) - variables['total_duration'] = duration_vars['tracker'] - constraints['duration_tracking'] = duration_constraints['tracking'] - - # 4. Switch tracking using existing pattern - if track_switches: - switch_vars, switch_constraints = ModelingPrimitives.state_transition_variables( - model, f'{name}|switches', variables['on'], previous_state - ) - variables.update(switch_vars) - for switch_name, switch_constraint in switch_constraints.items(): - constraints[f'switch_{switch_name}'] = switch_constraint - - # 5. Consecutive on duration using existing pattern - if track_consecutive_on: - min_on, max_on = consecutive_on_bounds - consecutive_on_vars, consecutive_on_constraints = ModelingPrimitives.consecutive_duration_tracking( - model, - f'{name}|consecutive_on', - variables['on'], - minimum_duration=min_on, - maximum_duration=max_on, - previous_duration=previous_on_duration, - ) - variables['consecutive_on_duration'] = consecutive_on_vars['duration'] - for cons_name, cons_constraint in consecutive_on_constraints.items(): - constraints[f'consecutive_on_{cons_name}'] = cons_constraint - - # 6. Consecutive off duration using existing pattern - if track_consecutive_off and 'off' in variables: - min_off, max_off = consecutive_off_bounds - consecutive_off_vars, consecutive_off_constraints = ModelingPrimitives.consecutive_duration_tracking( - model, - f'{name}|consecutive_off', - variables['off'], - minimum_duration=min_off, - maximum_duration=max_off, - previous_duration=previous_off_duration, - ) - variables['consecutive_off_duration'] = consecutive_off_vars['duration'] - for cons_name, cons_constraint in consecutive_off_constraints.items(): - constraints[f'consecutive_off_{cons_name}'] = cons_constraint - - return variables, constraints diff --git a/flixopt/structure.py b/flixopt/structure.py index b6c4572d1..953636f9b 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -720,7 +720,7 @@ def __init__( def add_variables(self, short_name: str = None, **kwargs) -> linopy.Variable: """Create and register a variable in one step""" - if 'name' not in kwargs: + if kwargs.get('name') is None: if short_name is None: raise ValueError('Short name must be provided when no name is given') kwargs['name'] = f'{self.label_of_model}|{short_name}' @@ -731,7 +731,7 @@ def add_variables(self, short_name: str = None, **kwargs) -> linopy.Variable: def add_constraints(self, expression, short_name: str = None, **kwargs) -> linopy.Constraint: """Create and register a constraint in one step""" - if 'name' not in kwargs: + if kwargs.get('name') is None: if short_name is None: raise ValueError('Short name must be provided when no name is given') kwargs['name'] = f'{self.label_of_model}|{short_name}' @@ -743,18 +743,20 @@ def add_constraints(self, expression, short_name: str = None, **kwargs) -> linop def register_variable(self, variable: linopy.Variable, short_name: str = None) -> linopy.Variable: """Register a variable with the model""" if short_name is None: - short_name = self._extract_short_name(variable) - if short_name in self._variables: - raise ValueError(f'Short name "{short_name}" already assigned to model') + short_name = variable.name + elif short_name in self._variables: + raise ValueError(f'Short name "{short_name}" already assigned to model variables') + self._variables[short_name] = variable return variable def register_constraint(self, constraint: linopy.Constraint, short_name: str = None) -> linopy.Constraint: """Register a constraint with the model""" if short_name is None: - short_name = self._extract_short_name(constraint) - if short_name in self._constraints: - raise ValueError(f'Short name "{short_name}" already assigned to model') + short_name = constraint.name + elif short_name in self._constraints: + raise ValueError(f'Short name "{short_name}" already assigned to model constraint') + self._constraints[short_name] = constraint return constraint @@ -871,6 +873,28 @@ def _extract_short_name(item: Union[linopy.Variable, linopy.Constraint]) -> str: else: return name # Use full name if no | separator + def __repr__(self) -> str: + """ + Return a string representation of the linopy model. + """ + var_string = self.variables.__repr__().split("\n", 2)[2] + con_string = self.constraints.__repr__().split("\n", 2)[2] + model_string = f"Linopy {self._model.type} submodel: {self.label_of_model}" + + if len(self.sub_models) == 0: + sub_models_string = ' \n' + else: + sub_models_string = '' + for sub_model in self.sub_models: + sub_models_string += f'\n * {sub_model.label_of_model}' + + return ( + f"{model_string}\n{'=' * len(model_string)}\n\n" + f"Variables:\n----------\n{var_string}\n" + f"Constraints:\n------------\n{con_string}\n" + f"Submodels:\n----------\n{sub_models_string}" + ) + class BaseFeatureModel(Model): """Minimal base class for feature models that use factory patterns""" @@ -900,6 +924,10 @@ def add_effects(self): """Override in subclasses to add effects""" pass # Default: no effects + @property + def hours_per_step(self): + return self._model.hours_per_step + class ElementModel(Model): """Stores the mathematical Variables and Constraints for Elements""" From 1d6ef9745b11c29fa5d7c2c35ac27cb8aab7e5a1 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 21 Jul 2025 11:05:39 +0200 Subject: [PATCH 220/336] Update tests --- tests/test_component.py | 24 ++++++++++++------------ tests/test_flow.py | 6 +++--- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/tests/test_component.py b/tests/test_component.py index fbedbd415..f65c93414 100644 --- a/tests/test_component.py +++ b/tests/test_component.py @@ -97,16 +97,16 @@ def test_on_with_multiple_flows(self, basic_flow_system_linopy): assert set(comp.model.constraints) == { 'TestComponent(In1)|total_flow_hours', - 'TestComponent(In1)|on|lb', - 'TestComponent(In1)|on|ub', + 'TestComponent(In1)|flow_rate|lb', + 'TestComponent(In1)|flow_rate|ub', 'TestComponent(In1)|on_hours_total', 'TestComponent(Out1)|total_flow_hours', - 'TestComponent(Out1)|on|lb', - 'TestComponent(Out1)|on|ub', + 'TestComponent(Out1)|flow_rate|lb', + 'TestComponent(Out1)|flow_rate|ub', 'TestComponent(Out1)|on_hours_total', 'TestComponent(Out2)|total_flow_hours', - 'TestComponent(Out2)|on|lb', - 'TestComponent(Out2)|on|ub', + 'TestComponent(Out2)|flow_rate|lb', + 'TestComponent(Out2)|flow_rate|ub', 'TestComponent(Out2)|on_hours_total', 'TestComponent|on|lb', 'TestComponent|on|ub', @@ -118,8 +118,8 @@ def test_on_with_multiple_flows(self, basic_flow_system_linopy): assert_var_equal(model['TestComponent|on'], model.add_variables(binary=True, coords = (timesteps,))) assert_var_equal(model['TestComponent(Out2)|on'], model.add_variables(binary=True, coords=(timesteps,))) - assert_conequal(model.constraints['TestComponent(Out2)|on|lb'], model.variables['TestComponent(Out2)|on'] * 0.3 * 300 <= model.variables['TestComponent(Out2)|flow_rate']) - assert_conequal(model.constraints['TestComponent(Out2)|on|ub'], model.variables['TestComponent(Out2)|on'] * 300 * ub_out2 >= model.variables['TestComponent(Out2)|flow_rate']) + assert_conequal(model.constraints['TestComponent(Out2)|flow_rate|lb'], model.variables['TestComponent(Out2)|flow_rate'] >= model.variables['TestComponent(Out2)|on'] * 0.3 * 300) + assert_conequal(model.constraints['TestComponent(Out2)|flow_rate|ub'], model.variables['TestComponent(Out2)|flow_rate'] <= model.variables['TestComponent(Out2)|on'] * 300 * ub_out2) assert_conequal(model.constraints['TestComponent|on|lb'], model.variables['TestComponent|on'] * 1e-5 <= model.variables['TestComponent(In1)|flow_rate'] + model.variables['TestComponent(Out1)|flow_rate'] + model.variables['TestComponent(Out2)|flow_rate']) @@ -156,8 +156,8 @@ def test_on_with_single_flow(self, basic_flow_system_linopy): assert set(comp.model.constraints) == { 'TestComponent(In1)|total_flow_hours', - 'TestComponent(In1)|on|lb', - 'TestComponent(In1)|on|ub', + 'TestComponent(In1)|flow_rate|lb', + 'TestComponent(In1)|flow_rate|ub', 'TestComponent(In1)|on_hours_total', 'TestComponent|on|lb', 'TestComponent|on|ub', @@ -171,11 +171,11 @@ def test_on_with_single_flow(self, basic_flow_system_linopy): assert_var_equal(model['TestComponent(In1)|on'], model.add_variables(binary=True, coords=(timesteps,))) assert_conequal( - model.constraints['TestComponent(In1)|on|lb'], + model.constraints['TestComponent(In1)|flow_rate|lb'], model.variables['TestComponent(In1)|on'] * 0.1 * 100 <= model.variables['TestComponent(In1)|flow_rate'], ) assert_conequal( - model.constraints['TestComponent(In1)|on|ub'], + model.constraints['TestComponent(In1)|flow_rate|ub'], model.variables['TestComponent(In1)|on'] * 100 >= model.variables['TestComponent(In1)|flow_rate'], ) diff --git a/tests/test_flow.py b/tests/test_flow.py index 5b99a79f2..50154859d 100644 --- a/tests/test_flow.py +++ b/tests/test_flow.py @@ -491,11 +491,11 @@ def test_flow_on(self, basic_flow_system_linopy): ) assert_conequal( model.constraints['Sink(Wärme)|flow_rate|lb'], - flow.model.variables['Sink(Wärme)|flow_rate'] >= flow.model.variables['Sink(Wärme)|on'] * 0.2 * 100, + flow.model.variables['Sink(Wärme)|flow_rate'] >= flow.model.variables['Sink(Wärme)|on'] * 0.2 * 100, ) assert_conequal( model.constraints['Sink(Wärme)|flow_rate|ub'], - flow.model.variables['Sink(Wärme)|flow_rate']<= flow.model.variables['Sink(Wärme)|on'] * 0.8 * 100, + flow.model.variables['Sink(Wärme)|flow_rate'] <= flow.model.variables['Sink(Wärme)|on'] * 0.8 * 100, ) assert_conequal( @@ -842,7 +842,7 @@ def test_switch_on_constraints(self, basic_flow_system_linopy): # Check that constraints exist assert { - 'Sink(Wärme)|switch', + 'Sink(Wärme)|switch|transition', 'Sink(Wärme)|switch|initial', 'Sink(Wärme)|switch|mutex', 'Sink(Wärme)|switch|count', From 972cb901920b4c8dfc39bc3a497439f7c75bfe1a Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 21 Jul 2025 14:22:22 +0200 Subject: [PATCH 221/336] Improve state computation in ModelingUtilities --- flixopt/elements.py | 207 +++++++++++++++++++++++-------------------- flixopt/features.py | 80 ++++++----------- flixopt/modeling.py | 167 +++++++++++++++++++--------------- flixopt/structure.py | 8 +- 4 files changed, 239 insertions(+), 223 deletions(-) diff --git a/flixopt/elements.py b/flixopt/elements.py index 9329a88dd..796d82864 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -8,6 +8,7 @@ import linopy import numpy as np +import xarray as xr from .config import CONFIG from .core import PlausibilityError, Scalar, TemporalData, TemporalDataUser @@ -15,7 +16,7 @@ from .features import InvestmentModel, OnOffModel, PreventSimultaneousUsageModel, ModelingPrimitives from .interface import InvestParameters, OnOffParameters from .structure import Element, ElementModel, FlowSystemModel, register_class_for_io -from .modeling import BoundingPatterns +from .modeling import BoundingPatterns, ModelingUtilities if TYPE_CHECKING: from .flow_system import FlowSystem @@ -316,57 +317,13 @@ def __init__(self, model: FlowSystemModel, element: Flow): def do_modeling(self): # Main flow rate variable self.add_variables( - lower=self.flow_rate_lower_bound, - upper=self.flow_rate_upper_bound, + lower=self.absolute_flow_rate_bounds[0], + upper=self.absolute_flow_rate_bounds[1], coords=self._model.get_coords(), short_name='flow_rate', ) - default_cons = not (self.element.on_off_parameters is not None and isinstance(self.element.size, InvestParameters)) - - # OnOff feature - if self.element.on_off_parameters is not None: - self.register_sub_model( - OnOffModel( - model=self._model, - label_of_element=self.label_of_element, - parameters=self.element.on_off_parameters, - flow_rate=self.flow_rate, - flow_rate_bounds=self.flow_rate_bounds_on, - previous_flow_rate=self.element.previous_flow_rate, - label_of_model=self.label_of_element, - apply_bounds_to_flow_rates=default_cons, - ), - short_name='on_off', - ).do_modeling() - - # Investment feature - if isinstance(self.element.size, InvestParameters): - self.register_sub_model( - InvestmentModel( - model=self._model, - label_of_element=self.label_of_element, - label_of_model=self.label_of_element, - parameters=self.element.size, - defining_variable=self.flow_rate, - relative_bounds_of_defining_variable=( - self.flow_rate_lower_bound_relative, - self.flow_rate_upper_bound_relative, - ), - apply_bounds_to_defining_variable=default_cons, - ), - 'investment', - ).do_modeling() - - if not default_cons: - BoundingPatterns.scaled_bounds_with_state( - model=self, - variable=self.flow_rate, - scaling_variable=self._investment.size, - relative_bounds=(self.flow_rate_lower_bound_relative, self.flow_rate_upper_bound_relative), - scaling_bounds=(self.element.size.minimum_or_fixed_size, self.element.size.maximum_or_fixed_size), - variable_state=self.on_off.on, - ) + self._constraint_flow_rate() # Total flow hours tracking ModelingPrimitives.expression_tracking_variable( @@ -387,6 +344,81 @@ def do_modeling(self): # Effects self._create_shares() + def _create_on_off_model(self): + on = self.add_variables(binary=True, short_name='on', coords=self._model.get_coords()) + self.register_sub_model( + OnOffModel( + model=self._model, + label_of_element=self.label_of_element, + parameters=self.element.on_off_parameters, + on_variable=on, + previous_states=self.previous_states, + label_of_model=self.label_of_element, + ), + short_name='on_off', + ).do_modeling() + + def _create_investment_model(self): + self.register_sub_model( + InvestmentModel( + model=self._model, + label_of_element=self.label_of_element, + parameters=self.element.size, + label_of_model=self.label_of_element, + ), + 'investment', + ).do_modeling() + + def _constraint_flow_rate(self): + if not self.with_investment and not self.with_on_off: + # Most basic case. Already covered by direct variable bounds + pass + + elif self.with_on_off and not self.with_investment: + # OnOff, but no Investment + self._create_on_off_model() + bounds = self.relative_flow_rate_bounds + BoundingPatterns.bounds_with_state( + self, + variable=self.flow_rate, + bounds=(bounds[0] * self.element.size, bounds[1] * self.element.size), + variable_state=self.on_off.on, + ) + + elif self.with_investment and not self.with_on_off: + # Investment, but no OnOff + self._create_investment_model() + BoundingPatterns.scaled_bounds( + self, + variable=self.flow_rate, + scaling_variable=self.investment.size, + relative_bounds=self.relative_flow_rate_bounds, + ) + + elif self.with_investment and self.with_on_off: + # Investment and OnOff + self._create_investment_model() + self._create_on_off_model() + + BoundingPatterns.scaled_bounds_with_state( + model=self, + variable=self.flow_rate, + scaling_variable=self._investment.size, + relative_bounds=self.relative_flow_rate_bounds, + scaling_bounds=(self.element.size.minimum_or_fixed_size, self.element.size.maximum_or_fixed_size), + variable_state=self.on_off.on, + ) + else: + raise Exception('Not valid') + + @property + def with_on_off(self) -> bool: + return self.element.on_off_parameters is not None + + @property + def with_investment(self) -> bool: + return isinstance(self.element.size, InvestParameters) + # Properties for clean access to variables @property def flow_rate(self) -> linopy.Variable: @@ -421,7 +453,7 @@ def _create_shares(self): def _create_bounds_for_load_factor(self): """Create load factor constraints using current approach""" # Get the size (either from element or investment) - size = self.element.size if self._investment is None else self._investment.size + size = self.investment.size if self.with_investment else self.element.size # Maximum load factor constraint if self.element.load_factor_max is not None: @@ -440,57 +472,34 @@ def _create_bounds_for_load_factor(self): ) @property - def flow_rate_bounds_on(self) -> Tuple[TemporalData, TemporalData]: - """Returns absolute flow rate bounds for OnOffModel""" - relative_minimum, relative_maximum = self.flow_rate_lower_bound_relative, self.flow_rate_upper_bound_relative - size = self.element.size - if not isinstance(size, InvestParameters): - return relative_minimum * size, relative_maximum * size - - if size.fixed_size is not None: - return relative_minimum * size.fixed_size, relative_maximum * size.fixed_size - - return relative_minimum * size.minimum_or_fixed_size, relative_maximum * size.maximum_or_fixed_size - - @property - def flow_rate_lower_bound_relative(self) -> TemporalData: - """Returns the lower bound of the flow_rate relative to its size""" - fixed_profile = self.element.fixed_relative_profile - if fixed_profile is None: - return self.element.relative_minimum - return fixed_profile + def relative_flow_rate_bounds(self) -> Tuple[TemporalData, TemporalData]: + if self.element.fixed_relative_profile is not None: + return self.element.fixed_relative_profile, self.element.fixed_relative_profile + return self.element.relative_minimum, self.element.relative_maximum @property - def flow_rate_upper_bound_relative(self) -> TemporalData: - """Returns the upper bound of the flow_rate relative to its size""" - fixed_profile = self.element.fixed_relative_profile - if fixed_profile is None: - return self.element.relative_maximum - return fixed_profile - - @property - def flow_rate_lower_bound(self) -> TemporalData: + def absolute_flow_rate_bounds(self) -> Tuple[TemporalData, TemporalData]: """ - Returns the minimum bound the flow_rate can reach. - Further constraining might be done in OnOffModel and InvestmentModel + Returns the absolute bounds the flow_rate can reach. + Further constraining might be needed """ - if self.element.on_off_parameters is not None: - return 0 - if isinstance(self.element.size, InvestParameters): - if self.element.size.optional: - return 0 - return self.flow_rate_lower_bound_relative * self.element.size.minimum_or_fixed_size - return self.flow_rate_lower_bound_relative * self.element.size + lb_relative, ub_relative = self.relative_flow_rate_bounds + + lb = 0 + if not self.with_on_off: + if not self.with_investment: + # Basic case without investment and without OnOff + lb = lb_relative * self.element.size + elif not self.element.size.optional: + # With non-optional Investment + lb = lb_relative * self.element.size.minimum_or_fixed_size + + if self.with_investment: + ub = ub_relative * self.element.size.maximum_or_fixed_size + else: + ub = ub_relative * self.element.size - @property - def flow_rate_upper_bound(self) -> TemporalData: - """ - Returns the maximum bound the flow_rate can reach. - Further constraining might be done in OnOffModel and InvestmentModel - """ - if isinstance(self.element.size, InvestParameters): - return self.flow_rate_upper_bound_relative * self.element.size.maximum_or_fixed_size - return self.flow_rate_upper_bound_relative * self.element.size + return lb, ub @property def on_off(self) -> Optional[OnOffModel]: @@ -511,6 +520,14 @@ def investment(self) -> Optional[InvestmentModel]: return None return self.sub_models_direct['investment'] + @property + def previous_states(self) -> Optional[xr.DataArray]: + """Previous states of the flow rate""" + if self.element.previous_flow_rate is None: + return None + + return ModelingUtilities.compute_previous_states(self.element.previous_flow_rate) + class BusModel(ElementModel): def __init__(self, model: FlowSystemModel, element: Bus): diff --git a/flixopt/features.py b/flixopt/features.py index 6708a3221..a9e2dc589 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -26,10 +26,7 @@ def __init__( model: FlowSystemModel, label_of_element: str, parameters: InvestParameters, - defining_variable: linopy.Variable, - relative_bounds_of_defining_variable: Tuple[TemporalData, TemporalData], label_of_model: Optional[str] = None, - apply_bounds_to_defining_variable: bool = True, ): """ This feature model is used to model the investment of a variable. @@ -46,11 +43,6 @@ def __init__( """ super().__init__(model, label_of_element=label_of_element, parameters=parameters, label_of_model=label_of_model) - self._defining_variable = defining_variable - self._relative_bounds_of_defining_variable = relative_bounds_of_defining_variable - self._apply_bounds_to_defining_variable = apply_bounds_to_defining_variable - - # Only keep non-variable attributes self.piecewise_effects: Optional[PiecewiseEffectsModel] = None @@ -75,14 +67,6 @@ def create_variables_and_constraints(self): bounds=(self.parameters.minimum_or_fixed_size, self.parameters.maximum_or_fixed_size), ) - if self._apply_bounds_to_defining_variable: - BoundingPatterns.scaled_bounds( - self, - variable=self._defining_variable, - scaling_variable=self.size, - relative_bounds=self._relative_bounds_of_defining_variable, - ) - if self.parameters.piecewise_effects: self.piecewise_effects = self.register_sub_model( PiecewiseEffectsModel( @@ -146,11 +130,9 @@ def __init__( model: FlowSystemModel, label_of_element: str, parameters: OnOffParameters, - flow_rate: linopy.Variable, - flow_rate_bounds: Tuple[TemporalData, TemporalData], - previous_flow_rate: Optional[TemporalData], + on_variable: linopy.Variable, + previous_states: Optional[TemporalData], label_of_model: Optional[str] = None, - apply_bounds_to_flow_rates: bool = True, ): """ This feature model is used to model the on/off state of flow_rate(s). It does not matter of the flow_rates are @@ -160,32 +142,18 @@ def __init__( model: The optimization model instance label_of_element: The label of the parent (Element). Used to construct the full label of the model. parameters: The parameters of the feature model. - flow_rate: The flow_rates to be modeled - flow_rate_bounds: The bounds of the flow_rates, with respect to the minimum/maximum investment sizes - previous_flow_rate: The previous flow_rates + on_variable: The variable that determines the on state + previous_states: The previous flow_rates label_of_model: The label of the model. This is needed to construct the full label of the model. """ super().__init__(model, label_of_element, parameters=parameters, label_of_model=label_of_model) - self._flow_rate = flow_rate - self._flow_rate_bounds = flow_rate_bounds - self._previous_flow_rate = previous_flow_rate - self._apply_bounds_to_flow_rates = apply_bounds_to_flow_rates + self.on = on_variable + self._previous_states = previous_states def create_variables_and_constraints(self): - # 1. Main binary state using existing pattern - on = self.add_variables(binary=True, short_name='on', coords=self._model.get_coords()) if self.parameters.use_off: off = self.add_variables(binary=True, short_name='off', coords=self._model.get_coords()) - self.add_constraints(on + off == 1, short_name='complementary') - - # 2. Control variables - if self._apply_bounds_to_flow_rates: - BoundingPatterns.bounds_with_state( - self, - variable=self._flow_rate, - bounds=self._flow_rate_bounds, - variable_state=self.on, - ) + self.add_constraints(self.on + off == 1, short_name='complementary') # 3. Total duration tracking using existing pattern duration_expr = (self.on * self._model.hours_per_step).sum('time') @@ -208,7 +176,9 @@ def create_variables_and_constraints(self): switch_on=self.switch_on, switch_off=self.switch_off, name=f'{self.label_of_model}|switch', - previous_state=ModelingUtilities.get_most_recent_state(self._previous_flow_rate), + previous_state=ModelingUtilities.get_most_recent_state( + self._previous_states.isel(time=-1) + ) if self._previous_states is not None else 0, ) if self.parameters.switch_on_total_max is not None: @@ -223,7 +193,7 @@ def create_variables_and_constraints(self): short_name='consecutive_on_hours', minimum_duration=self.parameters.consecutive_on_hours_min, maximum_duration=self.parameters.consecutive_on_hours_max, - previous_duration=ModelingUtilities.compute_previous_on_duration([self._previous_flow_rate], self._model.hours_per_step), + previous_duration=self._get_previous_on_duration(), ) # 6. Consecutive off duration using existing pattern @@ -234,10 +204,9 @@ def create_variables_and_constraints(self): short_name='consecutive_off_hours', minimum_duration=self.parameters.consecutive_off_hours_min, maximum_duration=self.parameters.consecutive_off_hours_max, - previous_duration=ModelingUtilities.compute_previous_off_duration( - [self._previous_flow_rate], self._model.hours_per_step - ), + previous_duration=self._get_previous_off_duration(), ) + #TODO: def add_effects(self): """Add operational effects""" @@ -261,10 +230,6 @@ def add_effects(self): ) # Properties access variables from Model's tracking system - @property - def on(self) -> Optional[linopy.Variable]: - """Binary on state variable""" - return self['on'] @property def total_on_hours(self) -> Optional[linopy.Variable]: @@ -302,15 +267,20 @@ def consecutive_off_hours(self) -> Optional[linopy.Variable]: return self.get('consecutive_off_hours') def _get_previous_on_duration(self): - hours_per_step = self._model.hours_per_step.isel(time=0).values.flatten()[0] - return ModelingUtilities.compute_previous_on_duration([self._previous_flow_rate], hours_per_step) + """Get previous on duration. Previously OFF by default, for one timestep""" + hours_per_step = self._model.hours_per_step.isel(time=0).min().item() + if self._previous_states is None: + return 0 + else: + return ModelingUtilities.compute_consecutive_hours_in_state(self._previous_states, hours_per_step) def _get_previous_off_duration(self): - hours_per_step = self._model.hours_per_step.isel(time=0).values.flatten()[0] - return ModelingUtilities.compute_previous_off_duration(self._previous_flow_rates, hours_per_step) - - def _get_previous_state(self): - return ModelingUtilities.get_most_recent_state(self._previous_flow_rates) + """Get previous off duration. Previously OFF by default, for one timestep""" + hours_per_step = self._model.hours_per_step.isel(time=0).min().item() + if self._previous_states is None: + return hours_per_step + else: + return ModelingUtilities.compute_consecutive_hours_in_state(self._previous_states * -1 + 1, hours_per_step) class PieceModel(Model): diff --git a/flixopt/modeling.py b/flixopt/modeling.py index 17f47c939..0c989d01e 100644 --- a/flixopt/modeling.py +++ b/flixopt/modeling.py @@ -3,6 +3,7 @@ import linopy import numpy as np +import xarray as xr from .config import CONFIG from .core import NonTemporalData, Scalar, TemporalData, FlowSystemDimensions @@ -11,138 +12,166 @@ logger = logging.getLogger('flixopt') -class ModelingUtilities: - """Utility functions for modeling calculations - used across different classes""" +class ModelingUtilitiesAbstract: + """Utility functions for modeling calculations - leveraging xarray for temporal data""" @staticmethod - def compute_consecutive_hours_in_state( - binary_values: TemporalData, hours_per_timestep: Union[int, float, np.ndarray] - ) -> Scalar: + def to_binary( + values: xr.DataArray, + epsilon: Optional[float] = None, + dims: Optional[Union[str, List[str]]] = None, + ) -> xr.DataArray: """ - Computes the final consecutive duration in state 'on' (=1) in hours, from a binary array. + Converts a DataArray to binary {0, 1} values. Args: - binary_values: An int or 1D binary array containing only `0`s and `1`s. - hours_per_timestep: The duration of each timestep in hours. - If a scalar is provided, it is used for all timesteps. - If an array is provided, it must be as long as the last consecutive duration in binary_values. + values: Input DataArray to convert to binary + epsilon: Tolerance for zero detection (uses CONFIG.modeling.EPSILON if None) + dims: Dims to keep. Other dimensions are collapsed using .any() -> If any value is 1, all are 1. Returns: - The duration of the binary variable in hours. - - Raises - ------ - TypeError - If the length of binary_values and dt_in_hours is not equal, but None is a scalar. + Binary DataArray with same shape (or collapsed if collapse_non_time=True) """ - if np.isscalar(binary_values) and np.isscalar(hours_per_timestep): - return binary_values * hours_per_timestep - elif np.isscalar(binary_values) and not np.isscalar(hours_per_timestep): - return binary_values * hours_per_timestep[-1] + if not isinstance(values, xr.DataArray): + values = xr.DataArray(values, dims=['time'], coords={'time': range(len(values))}) - if np.isclose(binary_values[-1], 0, atol=CONFIG.modeling.EPSILON): - return 0 + if epsilon is None: + epsilon = CONFIG.modeling.EPSILON - if np.isscalar(hours_per_timestep): - hours_per_timestep = np.ones(len(binary_values)) * hours_per_timestep - hours_per_timestep: np.ndarray + if values.size == 0: + return xr.DataArray(0) if values.item() < epsilon else xr.DataArray(1) - indexes_with_zero_values = np.where(np.isclose(binary_values, 0, atol=CONFIG.modeling.EPSILON))[0] - if len(indexes_with_zero_values) == 0: - nr_of_indexes_with_consecutive_ones = len(binary_values) - else: - nr_of_indexes_with_consecutive_ones = len(binary_values) - indexes_with_zero_values[-1] - 1 + # Convert to binary states + binary_states = (np.abs(values) >= epsilon) - if len(hours_per_timestep) < nr_of_indexes_with_consecutive_ones: - raise ValueError( - f'When trying to calculate the consecutive duration, the length of the last duration ' - f'({nr_of_indexes_with_consecutive_ones}) is longer than the provided hours_per_timestep ({len(hours_per_timestep)}), ' - f'as {binary_values=}' - ) + # Optionally collapse dimensions using .any() + if dims is not None: + dims = [dims] if isinstance(dims, str) else dims - return np.sum( - binary_values[-nr_of_indexes_with_consecutive_ones:] - * hours_per_timestep[-nr_of_indexes_with_consecutive_ones:] - ) + binary_states = binary_states.any(dim=[d for d in binary_states.dims if d not in dims]) + + return binary_states.astype(int) @staticmethod - def compute_previous_states(previous_values: List[TemporalData], epsilon: float = None) -> np.ndarray: + def count_consecutive_states( + binary_values: xr.DataArray, + epsilon: float = None, + ) -> float: """ - Computes the previous states {0, 1} of defining variables as a binary array from their previous values. + Counts the number of consecutive states in a binary time series. Args: - previous_values: List of previous values for variables + binary_values: Binary DataArray with 'time' dim epsilon: Tolerance for zero detection (uses CONFIG.modeling.EPSILON if None) Returns: - Binary array of previous states + The consecutive number of steps spent in the final state of the timeseries """ if epsilon is None: epsilon = CONFIG.modeling.EPSILON - if not previous_values or all(val is None for val in previous_values): - return np.array([0]) + binary_values = binary_values.any(dim=[d for d in binary_values.dims if d != 'time']) + + # Handle scalar case + if binary_values.ndim == 0: + return float(binary_values.item()) - # Convert to 2D-array and compute binary on/off states - previous_values = np.array([values for values in previous_values if values is not None]) # Filter out None - if previous_values.ndim > 1: - return np.any(~np.isclose(previous_values, 0, atol=epsilon), axis=0).astype(int) + # Check if final state is off + if np.isclose(binary_values.isel(time=-1).item(), 0, atol=epsilon): + return 0.0 + + # Find consecutive 'on' period from the end + is_zero = np.isclose(binary_values, 0, atol=epsilon) + + # Find the last zero, then sum everything after it + zero_indices = np.where(is_zero)[0] + if len(zero_indices) == 0: + # All 'on' - sum everything + start_idx = 0 + else: + # Start after last zero + start_idx = zero_indices[-1] + 1 - return (~np.isclose(previous_values, 0, atol=epsilon)).astype(int) + consecutive_values = binary_values.isel(time=slice(start_idx, None)) + + return float(consecutive_values.sum().item()) + + +class ModelingUtilities: @staticmethod - def compute_previous_on_duration(previous_values: List[TemporalData], hours_per_step: Union[int, float]) -> Scalar: + def compute_consecutive_hours_in_state( + binary_values: Union[xr.DataArray, np.ndarray, int], + hours_per_timestep: Union[int, float], + epsilon: float = None, + ) -> float: """ - Convenience method to compute previous consecutive 'on' duration. + Computes the final consecutive duration in state 'on' (=1) in hours. Args: - previous_values: List of previous values for variables - hours_per_step: Duration of each timestep in hours + binary_values: Binary DataArray with 'time' dim, or scalar/array + hours_per_timestep: Duration of each timestep in hours + epsilon: Tolerance for zero detection (uses CONFIG.modeling.EPSILON if None) Returns: - Previous consecutive on duration in hours + The duration of the final consecutive 'on' period in hours """ - if not previous_values: - return 0 + if not isinstance(hours_per_timestep, (int, float)): + raise TypeError(f'hours_per_timestep must be a scalar, got {type(hours_per_timestep)}') - previous_states = ModelingUtilities.compute_previous_states(previous_values) - return ModelingUtilities.compute_consecutive_hours_in_state(previous_states, hours_per_step) + return ModelingUtilitiesAbstract.count_consecutive_states( + binary_values=binary_values, epsilon=epsilon + ) * hours_per_timestep + + @staticmethod + def compute_previous_states(previous_values: Optional[xr.DataArray], epsilon: Optional[float] = None) -> xr.DataArray: + return ModelingUtilitiesAbstract.to_binary(values=previous_values, epsilon=epsilon, dims='time') @staticmethod - def compute_previous_off_duration(previous_values: List[TemporalData], hours_per_step: Union[int, float]) -> Scalar: + def compute_previous_on_duration( + previous_values: xr.DataArray, hours_per_step: Union[xr.DataArray, float, int] + ) -> float: + return ModelingUtilitiesAbstract.count_consecutive_states( + ModelingUtilitiesAbstract.to_binary(previous_values) + ) * hours_per_step + + @staticmethod + def compute_previous_off_duration( + previous_values: xr.DataArray, hours_per_step: Union[xr.DataArray, float, int] + ) -> float: """ - Convenience method to compute previous consecutive 'off' duration. + Compute previous consecutive 'off' duration. Args: - previous_values: List of previous values for variables + previous_values: DataArray with 'time' dimension hours_per_step: Duration of each timestep in hours Returns: Previous consecutive off duration in hours """ - if not previous_values: - return 0 + if previous_values is None or previous_values.size == 0: + return 0.0 previous_states = ModelingUtilities.compute_previous_states(previous_values) previous_off_states = 1 - previous_states return ModelingUtilities.compute_consecutive_hours_in_state(previous_off_states, hours_per_step) @staticmethod - def get_most_recent_state(previous_values: List[TemporalData]) -> int: + def get_most_recent_state(previous_values: Optional[xr.DataArray]) -> int: """ Get the most recent binary state from previous values. Args: - previous_values: List of previous values for variables + previous_values: DataArray with 'time' dimension Returns: Most recent binary state (0 or 1) """ - if not previous_values: + if previous_values is None or previous_values.size == 0: return 0 previous_states = ModelingUtilities.compute_previous_states(previous_values) - return int(previous_states[-1]) + return int(previous_states.isel(time=-1).item()) class ModelingPrimitives: diff --git a/flixopt/structure.py b/flixopt/structure.py index 953636f9b..8fdce6ad0 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -895,6 +895,10 @@ def __repr__(self) -> str: f"Submodels:\n----------\n{sub_models_string}" ) + @property + def hours_per_step(self): + return self._model.hours_per_step + class BaseFeatureModel(Model): """Minimal base class for feature models that use factory patterns""" @@ -924,10 +928,6 @@ def add_effects(self): """Override in subclasses to add effects""" pass # Default: no effects - @property - def hours_per_step(self): - return self._model.hours_per_step - class ElementModel(Model): """Stores the mathematical Variables and Constraints for Elements""" From 29bec8c97aa2b58a5f353590fa273b4aa6db2c6d Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 21 Jul 2025 16:59:38 +0200 Subject: [PATCH 222/336] Improve handling of previous flowrates --- flixopt/elements.py | 69 ++++++++++++++++++------ flixopt/modeling.py | 13 ++--- tests/test_component.py | 113 ++++++++++++++++++++++++++++++++++------ 3 files changed, 159 insertions(+), 36 deletions(-) diff --git a/flixopt/elements.py b/flixopt/elements.py index 796d82864..02d0bf115 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -16,7 +16,7 @@ from .features import InvestmentModel, OnOffModel, PreventSimultaneousUsageModel, ModelingPrimitives from .interface import InvestParameters, OnOffParameters from .structure import Element, ElementModel, FlowSystemModel, register_class_for_io -from .modeling import BoundingPatterns, ModelingUtilities +from .modeling import BoundingPatterns, ModelingUtilitiesAbstract if TYPE_CHECKING: from .flow_system import FlowSystem @@ -163,7 +163,7 @@ def __init__( flow_hours_total_min: Optional[Scalar] = None, load_factor_min: Optional[Scalar] = None, load_factor_max: Optional[Scalar] = None, - previous_flow_rate: Optional[TemporalDataUser] = None, + previous_flow_rate: Optional[Union[Scalar, List[Scalar]]] = None, meta_data: Optional[Dict] = None, ): r""" @@ -210,9 +210,7 @@ def __init__( self.flow_hours_total_min = flow_hours_total_min self.on_off_parameters = on_off_parameters - self.previous_flow_rate = ( - previous_flow_rate if not isinstance(previous_flow_rate, list) else np.array(previous_flow_rate) - ) + self.previous_flow_rate = previous_flow_rate self.component: str = 'UnknownComponent' self.is_input_in_component: Optional[bool] = None @@ -294,6 +292,11 @@ def _plausibility_checks(self) -> None: f'Consider using on_off_parameters to allow the flow to be switched on and off.' ) + if self.previous_flow_rate is not None: + if not any([isinstance(self.previous_flow_rate, np.ndarray) and self.previous_flow_rate.ndim == 1, + isinstance(self.previous_flow_rate, (int, float, list))]): + raise TypeError(f'previous_flow_rate must be None, a scalar, a list of scalars or a 1D-numpy-array. Got {type(self.previous_flow_rate)}') + @property def label_full(self) -> str: return f'{self.component}({self.label})' @@ -523,10 +526,19 @@ def investment(self) -> Optional[InvestmentModel]: @property def previous_states(self) -> Optional[xr.DataArray]: """Previous states of the flow rate""" - if self.element.previous_flow_rate is None: + #TODO: This would be nicer to handle in the Flow itself, and allow DataArrays as well. + previous_flow_rate = self.element.previous_flow_rate + if previous_flow_rate is None: return None - return ModelingUtilities.compute_previous_states(self.element.previous_flow_rate) + return ModelingUtilitiesAbstract.to_binary( + values=xr.DataArray( + [previous_flow_rate] if np.isscalar(previous_flow_rate) else previous_flow_rate, + dims='time' + ), + epsilon=CONFIG.modeling.EPSILON, + dims='time', + ) class BusModel(ElementModel): @@ -588,22 +600,27 @@ def do_modeling(self): flow.on_off_parameters = OnOffParameters() for flow in all_flows: - self.register_sub_model(flow.create_model(self._model), short_name=flow.label) - - for sub_model in self.sub_models: - sub_model.do_modeling() + flow_model = self.register_sub_model(flow.create_model(self._model), short_name=flow.label) + flow_model.do_modeling() if self.element.on_off_parameters: + on = self.add_variables(binary=True, short_name='on', coords=self._model.get_coords()) + if len(all_flows) == 1: + self.add_constraints(on == all_flows[0].model.on_off.on, short_name='on') + else: + flow_ons = [flow.model.on_off.on for flow in all_flows] + #TODO: Is the EPSILON even necessary? + self.add_constraints(on <= sum(flow_ons) + CONFIG.modeling.EPSILON, short_name='on|ub') + self.add_constraints(on >= sum(flow_ons) / (len(flow_ons) + CONFIG.modeling.EPSILON), short_name='on|lb') + self.on_off = self.register_sub_model( OnOffModel( model=self._model, label_of_element=self.label_of_element, parameters=self.element.on_off_parameters, - flow_rates=[flow.model.flow_rate for flow in all_flows], - flow_rate_bounds=[flow.model.flow_rate_bounds_on for flow in all_flows], - previous_flow_rates=[flow.previous_flow_rate for flow in all_flows], + on_variable=on, label_of_model=self.label_of_element, - apply_bounds_to_flow_rates=True, + previous_states=self.previous_states, ), short_name='on_off', ) @@ -623,3 +640,25 @@ def results_structure(self): 'outputs': [flow.model.flow_rate.name for flow in self.element.outputs], 'flows': [flow.label_full for flow in self.element.inputs + self.element.outputs], } + + @property + def previous_states(self) -> Optional[xr.DataArray]: + """Previous state of the component, derived from its flows""" + if self.element.on_off_parameters is None: + raise ValueError(f'OnOffModel not present in \n{self}\nCant access previous_states') + + previous_states = [flow.model.on_off._previous_states for flow in self.element.inputs + self.element.outputs] + previous_states = [da for da in previous_states if da is not None] + + if not previous_states: # Empty list + return None + + max_len = max(da.sizes['time'] for da in previous_states) + + padded_previous_states = [ + da.assign_coords( + time=range(-da.sizes['time'], 0) + ).reindex(time=range(-max_len, 0), fill_value=0) + for da in previous_states + ] + return xr.concat(padded_previous_states, dim='flow').any(dim='flow').astype(int) diff --git a/flixopt/modeling.py b/flixopt/modeling.py index 0c989d01e..c3839749c 100644 --- a/flixopt/modeling.py +++ b/flixopt/modeling.py @@ -55,13 +55,15 @@ def to_binary( @staticmethod def count_consecutive_states( binary_values: xr.DataArray, + dim: str = 'time', epsilon: float = None, ) -> float: """ Counts the number of consecutive states in a binary time series. Args: - binary_values: Binary DataArray with 'time' dim + binary_values: Binary DataArray + dim: Dimension to count consecutive states over epsilon: Tolerance for zero detection (uses CONFIG.modeling.EPSILON if None) Returns: @@ -70,14 +72,14 @@ def count_consecutive_states( if epsilon is None: epsilon = CONFIG.modeling.EPSILON - binary_values = binary_values.any(dim=[d for d in binary_values.dims if d != 'time']) + binary_values = binary_values.any(dim=[d for d in binary_values.dims if d != dim]) # Handle scalar case if binary_values.ndim == 0: return float(binary_values.item()) # Check if final state is off - if np.isclose(binary_values.isel(time=-1).item(), 0, atol=epsilon): + if np.isclose(binary_values.isel({dim: -1}).item(), 0, atol=epsilon).all(): return 0.0 # Find consecutive 'on' period from the end @@ -92,9 +94,9 @@ def count_consecutive_states( # Start after last zero start_idx = zero_indices[-1] + 1 - consecutive_values = binary_values.isel(time=slice(start_idx, None)) + consecutive_values = binary_values.isel({dim:slice(start_idx, None)}) - return float(consecutive_values.sum().item()) + return float(consecutive_values.sum().item()) #TODO: Som only over one dim? class ModelingUtilities: @@ -260,7 +262,6 @@ def sum_up_variable( model: FlowSystemModel, variable_to_count: linopy.Variable, name: str = None, - short_name: str = None, bounds: Tuple[NonTemporalData, NonTemporalData] = None, factor: TemporalData = 1, ) -> Tuple[linopy.Variable, linopy.Constraint]: diff --git a/tests/test_component.py b/tests/test_component.py index f65c93414..25e496694 100644 --- a/tests/test_component.py +++ b/tests/test_component.py @@ -121,15 +121,28 @@ def test_on_with_multiple_flows(self, basic_flow_system_linopy): assert_conequal(model.constraints['TestComponent(Out2)|flow_rate|lb'], model.variables['TestComponent(Out2)|flow_rate'] >= model.variables['TestComponent(Out2)|on'] * 0.3 * 300) assert_conequal(model.constraints['TestComponent(Out2)|flow_rate|ub'], model.variables['TestComponent(Out2)|flow_rate'] <= model.variables['TestComponent(Out2)|on'] * 300 * ub_out2) - assert_conequal(model.constraints['TestComponent|on|lb'], - model.variables['TestComponent|on'] * 1e-5 <= model.variables['TestComponent(In1)|flow_rate'] + model.variables['TestComponent(Out1)|flow_rate'] + model.variables['TestComponent(Out2)|flow_rate']) - # TODO: Might there be a better way to no use 1e-5? - assert_conequal(model.constraints['TestComponent|on|ub'], - model.variables['TestComponent|on'] * (100 + 200 + 300 * ub_out2)/3 - >= (model.variables['TestComponent(In1)|flow_rate'] - + model.variables['TestComponent(Out1)|flow_rate'] - + model.variables['TestComponent(Out2)|flow_rate']) / 3 - ) + assert_conequal( + model.constraints['TestComponent|on|lb'], + model.variables['TestComponent|on'] + >= ( + model.variables['TestComponent(In1)|on'] + + model.variables['TestComponent(Out1)|on'] + + model.variables['TestComponent(Out2)|on'] + ) + / (3 + 1e-5), + ) + assert_conequal( + model.constraints['TestComponent|on|ub'], + model.variables['TestComponent|on'] + <= ( + model.variables['TestComponent(In1)|on'] + + model.variables['TestComponent(Out1)|on'] + + model.variables['TestComponent(Out2)|on'] + ) + + 1e-5, + ) + + def test_on_with_single_flow(self, basic_flow_system_linopy): """Test that flow model constraints are correctly generated.""" @@ -159,8 +172,7 @@ def test_on_with_single_flow(self, basic_flow_system_linopy): 'TestComponent(In1)|flow_rate|lb', 'TestComponent(In1)|flow_rate|ub', 'TestComponent(In1)|on_hours_total', - 'TestComponent|on|lb', - 'TestComponent|on|ub', + 'TestComponent|on', 'TestComponent|on_hours_total', } @@ -172,20 +184,91 @@ def test_on_with_single_flow(self, basic_flow_system_linopy): assert_conequal( model.constraints['TestComponent(In1)|flow_rate|lb'], - model.variables['TestComponent(In1)|on'] * 0.1 * 100 <= model.variables['TestComponent(In1)|flow_rate'], + model.variables['TestComponent(In1)|flow_rate'] >= model.variables['TestComponent(In1)|on'] * 0.1 * 100, ) assert_conequal( model.constraints['TestComponent(In1)|flow_rate|ub'], - model.variables['TestComponent(In1)|on'] * 100 >= model.variables['TestComponent(In1)|flow_rate'], + model.variables['TestComponent(In1)|flow_rate'] <= model.variables['TestComponent(In1)|on'] * 100, ) + assert_conequal( + model.constraints['TestComponent|on'], + model.variables['TestComponent|on'] == model.variables['TestComponent(In1)|on'], + ) + + def test_previous_states_with_multiple_flows(self, basic_flow_system_linopy): + """Test that flow model constraints are correctly generated.""" + flow_system = basic_flow_system_linopy + timesteps = flow_system.timesteps + ub_out2 = np.linspace(1, 1.5, 10).round(2) + inputs = [ + fx.Flow('In1', 'Fernwärme', relative_minimum=np.ones(10) * 0.1, size=100, previous_flow_rate=np.array([0, 0, 1e-6, 1e-5, 1e-4, 3,4])), + ] + outputs = [ + fx.Flow('Out1', 'Gas', relative_minimum=np.ones(10) * 0.2, size=200, previous_flow_rate=xr.DataArray([3,4,5], dims='time')), + fx.Flow('Out2', 'Gas', relative_minimum=np.ones(10) * 0.3, + relative_maximum = ub_out2, size=300, previous_flow_rate=20), + ] + comp = flixopt.elements.Component('TestComponent', inputs=inputs, outputs=outputs, + on_off_parameters=fx.OnOffParameters()) + flow_system.add_elements(comp) + model = create_linopy_model(flow_system) + + assert set(comp.model.variables) == { + 'TestComponent(In1)|flow_rate', + 'TestComponent(In1)|total_flow_hours', + 'TestComponent(In1)|on', + 'TestComponent(In1)|on_hours_total', + 'TestComponent(Out1)|flow_rate', + 'TestComponent(Out1)|total_flow_hours', + 'TestComponent(Out1)|on', + 'TestComponent(Out1)|on_hours_total', + 'TestComponent(Out2)|flow_rate', + 'TestComponent(Out2)|total_flow_hours', + 'TestComponent(Out2)|on', + 'TestComponent(Out2)|on_hours_total', + 'TestComponent|on', + 'TestComponent|on_hours_total', + } + + assert set(comp.model.constraints) == { + 'TestComponent(In1)|total_flow_hours', + 'TestComponent(In1)|flow_rate|lb', + 'TestComponent(In1)|flow_rate|ub', + 'TestComponent(In1)|on_hours_total', + 'TestComponent(Out1)|total_flow_hours', + 'TestComponent(Out1)|flow_rate|lb', + 'TestComponent(Out1)|flow_rate|ub', + 'TestComponent(Out1)|on_hours_total', + 'TestComponent(Out2)|total_flow_hours', + 'TestComponent(Out2)|flow_rate|lb', + 'TestComponent(Out2)|flow_rate|ub', + 'TestComponent(Out2)|on_hours_total', + 'TestComponent|on|lb', + 'TestComponent|on|ub', + 'TestComponent|on_hours_total', + } + + assert_var_equal(model['TestComponent(Out2)|flow_rate'], + model.add_variables(lower=0, upper=300 * ub_out2, coords=(timesteps,))) + assert_var_equal(model['TestComponent|on'], model.add_variables(binary=True, coords = (timesteps,))) + assert_var_equal(model['TestComponent(Out2)|on'], model.add_variables(binary=True, coords=(timesteps,))) + + assert_conequal(model.constraints['TestComponent(Out2)|flow_rate|lb'], model.variables['TestComponent(Out2)|flow_rate'] >= model.variables['TestComponent(Out2)|on'] * 0.3 * 300) + assert_conequal(model.constraints['TestComponent(Out2)|flow_rate|ub'], model.variables['TestComponent(Out2)|flow_rate'] <= model.variables['TestComponent(Out2)|on'] * 300 * ub_out2) + assert_conequal( model.constraints['TestComponent|on|lb'], - model.variables['TestComponent|on'] * 0.1 * 100 <= model.variables['TestComponent(In1)|flow_rate'], + model.variables['TestComponent|on'] >= (model.variables['TestComponent(In1)|on'] + model.variables['TestComponent(Out1)|on'] + model.variables['TestComponent(Out2)|on']) / (3 + 1e-5), ) assert_conequal( model.constraints['TestComponent|on|ub'], - model.variables['TestComponent|on'] * 100 >= model.variables['TestComponent(In1)|flow_rate'], + model.variables['TestComponent|on'] + <= ( + model.variables['TestComponent(In1)|on'] + + model.variables['TestComponent(Out1)|on'] + + model.variables['TestComponent(Out2)|on'] + ) + 1e-5, ) From 370ac9414788cacf1fd40b55ffac95c9d8b48cef Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 21 Jul 2025 17:00:02 +0200 Subject: [PATCH 223/336] Imropove repr and submodel acess --- flixopt/structure.py | 26 +++++++++++--------------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/flixopt/structure.py b/flixopt/structure.py index 8fdce6ad0..34de27f35 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -838,8 +838,14 @@ def sub_models_direct(self) -> Dict[str, 'Model']: @property def sub_models(self) -> List['Model']: """All sub-models of the model""" - direct = list(self.sub_models_direct.values()) - return direct + [model for sub_model in direct for model in sub_model.sub_models] + direct_submodels = list(self._sub_models.values()) + + # Recursively collect nested sub-models + nested_submodels = [] + for submodel in direct_submodels: + nested_submodels.extend(submodel.sub_models) # This calls the property recursively + + return direct_submodels + nested_submodels @property def constraints(self) -> linopy.Constraints: @@ -863,16 +869,6 @@ def variables(self) -> linopy.Variables: return self._model.variables[names] - @staticmethod - def _extract_short_name(item: Union[linopy.Variable, linopy.Constraint]) -> str: - """Extract short name from variable's full name""" - # Assumes format like "model_prefix|short_name" - name = str(item.name) - if '|' in name: - return name.split('|')[-1] # Take last part after | - else: - return name # Use full name if no | separator - def __repr__(self) -> str: """ Return a string representation of the linopy model. @@ -885,14 +881,14 @@ def __repr__(self) -> str: sub_models_string = ' \n' else: sub_models_string = '' - for sub_model in self.sub_models: - sub_models_string += f'\n * {sub_model.label_of_model}' + for sub_model_name, sub_model in self.sub_models_direct.items(): + sub_models_string += f'\n * {sub_model_name} [{sub_model.__class__.__name__}]' return ( f"{model_string}\n{'=' * len(model_string)}\n\n" f"Variables:\n----------\n{var_string}\n" f"Constraints:\n------------\n{con_string}\n" - f"Submodels:\n----------\n{sub_models_string}" + f"Submodels:\n----------{sub_models_string}" ) @property From 0f89ff07953d9ca114cd0080aa6e2165f351b213 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 21 Jul 2025 17:12:44 +0200 Subject: [PATCH 224/336] Update access pattern in tests --- flixopt/components.py | 1 + flixopt/features.py | 2 +- tests/test_linear_converter.py | 12 ++++++------ 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/flixopt/components.py b/flixopt/components.py index 6631cb214..b733d2d39 100644 --- a/flixopt/components.py +++ b/flixopt/components.py @@ -475,6 +475,7 @@ def do_modeling(self): PiecewiseModel( model=self._model, label_of_element=self.label_of_element, + label_of_model=f'{self.label_of_element}', piecewise_variables=piecewise_conversion, zero_point=self.on_off.on if self.on_off is not None else False, as_time_series=True, diff --git a/flixopt/features.py b/flixopt/features.py index a9e2dc589..dd095a9e2 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -329,10 +329,10 @@ def __init__( self, model: FlowSystemModel, label_of_element: str, + label_of_model: str, piecewise_variables: Dict[str, Piecewise], zero_point: Optional[Union[bool, linopy.Variable]], as_time_series: bool, - label_of_model: str = '', ): """ Modeling a Piecewise relation between miultiple variables. diff --git a/tests/test_linear_converter.py b/tests/test_linear_converter.py index a01c17ef2..11b5b5673 100644 --- a/tests/test_linear_converter.py +++ b/tests/test_linear_converter.py @@ -189,8 +189,8 @@ def test_linear_converter_with_on_off(self, basic_flow_system_linopy): # Check on_hours_total constraint assert_conequal( model.constraints['Converter|on_hours_total'], - converter.model.on_off.variables['Converter|on_hours_total'] == - (converter.model.on_off.variables['Converter|on'] * model.hours_per_step).sum() + model.variables['Converter|on_hours_total'] == + (model.variables['Converter|on'] * model.hours_per_step).sum() ) # Check conversion constraint @@ -204,7 +204,7 @@ def test_linear_converter_with_on_off(self, basic_flow_system_linopy): assert_conequal( model.constraints['Converter->Costs(operation)'], model.variables['Converter->Costs(operation)'] == - converter.model.on_off.variables['Converter|on'] * model.hours_per_step * 5 + model.variables['Converter|on'] * model.hours_per_step * 5 ) def test_linear_converter_multidimensional(self, basic_flow_system_linopy): @@ -539,8 +539,8 @@ def test_piecewise_conversion_with_onoff(self, basic_flow_system_linopy): assert 'Converter|on_hours_total' in model.constraints assert_conequal( model.constraints['Converter|on_hours_total'], - converter.model.on_off.variables['Converter|on_hours_total'] == - (converter.model.on_off.variables['Converter|on'] * model.hours_per_step).sum() + model['Converter|on_hours_total'] == + (model['Converter|on'] * model.hours_per_step).sum() ) # Verify that the costs effect is applied @@ -548,7 +548,7 @@ def test_piecewise_conversion_with_onoff(self, basic_flow_system_linopy): assert_conequal( model.constraints['Converter->Costs(operation)'], model.variables['Converter->Costs(operation)'] == - converter.model.on_off.variables['Converter|on'] * model.hours_per_step * 5 + model.variables['Converter|on'] * model.hours_per_step * 5 ) From 4781cff3d5499bc88a28a9297e8397e40fb756e4 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 21 Jul 2025 20:44:25 +0200 Subject: [PATCH 225/336] Fix PiecewiseEffects and StorageModel --- flixopt/components.py | 9 +++++++-- flixopt/features.py | 11 +++++------ 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/flixopt/components.py b/flixopt/components.py index b733d2d39..1377d1f83 100644 --- a/flixopt/components.py +++ b/flixopt/components.py @@ -15,6 +15,7 @@ from .features import InvestmentModel, OnOffModel, PiecewiseModel from .interface import InvestParameters, OnOffParameters, PiecewiseConversion from .structure import FlowSystemModel, register_class_for_io +from.modeling import BoundingPatterns if TYPE_CHECKING: from .flow_system import FlowSystem @@ -535,12 +536,16 @@ def do_modeling(self): model=self._model, label_of_element=self.label_of_element, parameters=self.element.capacity_in_flow_hours, - defining_variable=self.charge_state, - relative_bounds_of_defining_variable=self.relative_charge_state_bounds, ), short_name='investment', ) self._investment.do_modeling() + BoundingPatterns.scaled_bounds( + self, + variable=self.charge_state, + scaling_variable=self.investment.size, + relative_bounds=self.relative_charge_state_bounds, + ) # Initial charge state self._initial_and_final_charge_state() diff --git a/flixopt/features.py b/flixopt/features.py index dd095a9e2..475c0e553 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -72,6 +72,7 @@ def create_variables_and_constraints(self): PiecewiseEffectsModel( model=self._model, label_of_element=self.label_of_element, + label_of_model=f'{self.label_of_element}|PiecewiseEffects', piecewise_origin=(self.size.name, self.parameters.piecewise_effects.piecewise_origin), piecewise_shares=self.parameters.piecewise_effects.piecewise_shares, zero_point=self.is_invested, @@ -176,10 +177,8 @@ def create_variables_and_constraints(self): switch_on=self.switch_on, switch_off=self.switch_off, name=f'{self.label_of_model}|switch', - previous_state=ModelingUtilities.get_most_recent_state( - self._previous_states.isel(time=-1) - ) if self._previous_states is not None else 0, - ) + previous_state=self._previous_states.isel(time=-1) if self._previous_states is not None else 0, + ) if self.parameters.switch_on_total_max is not None: count = self.add_variables(lower=0, upper=self.parameters.switch_on_total_max, coords=self._model.get_coords(('year', 'scenario')), short_name='switch|count') @@ -410,12 +409,12 @@ def __init__( self, model: FlowSystemModel, label_of_element: str, + label_of_model: str, piecewise_origin: Tuple[str, Piecewise], piecewise_shares: Dict[str, Piecewise], zero_point: Optional[Union[bool, linopy.Variable]], - label: str = 'PiecewiseEffects', ): - super().__init__(model, label_of_element, label) + super().__init__(model, label_of_element=label_of_element, label_of_model=label_of_model) assert len(piecewise_origin[1]) == len(list(piecewise_shares.values())[0]), ( 'Piece length of variable_segments and share_segments must be equal' ) From 333ab83bd25c19427584304bc270492e4bba6a48 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 21 Jul 2025 21:00:51 +0200 Subject: [PATCH 226/336] Fix StorageModel and Remove PreventSimultaniousUseModel --- flixopt/components.py | 1 + flixopt/elements.py | 9 ++++++--- flixopt/features.py | 38 -------------------------------------- flixopt/modeling.py | 8 ++++---- 4 files changed, 11 insertions(+), 45 deletions(-) diff --git a/flixopt/components.py b/flixopt/components.py index 1377d1f83..81570f9f3 100644 --- a/flixopt/components.py +++ b/flixopt/components.py @@ -535,6 +535,7 @@ def do_modeling(self): InvestmentModel( model=self._model, label_of_element=self.label_of_element, + label_of_model=self.label_of_element, parameters=self.element.capacity_in_flow_hours, ), short_name='investment', diff --git a/flixopt/elements.py b/flixopt/elements.py index 02d0bf115..663434ad8 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -13,7 +13,7 @@ from .config import CONFIG from .core import PlausibilityError, Scalar, TemporalData, TemporalDataUser from .effects import TemporalEffectsUser -from .features import InvestmentModel, OnOffModel, PreventSimultaneousUsageModel, ModelingPrimitives +from .features import InvestmentModel, OnOffModel, ModelingPrimitives from .interface import InvestParameters, OnOffParameters from .structure import Element, ElementModel, FlowSystemModel, register_class_for_io from .modeling import BoundingPatterns, ModelingUtilitiesAbstract @@ -630,8 +630,11 @@ def do_modeling(self): if self.element.prevent_simultaneous_flows: # Simultanious Useage --> Only One FLow is On at a time, but needs a Binary for every flow on_variables = [flow.model.on_off.on for flow in self.element.prevent_simultaneous_flows] - simultaneous_use = self.register_sub_model(PreventSimultaneousUsageModel(self._model, on_variables, self.label_full), short_name='prevent_simultaneous_use') - simultaneous_use.do_modeling() + ModelingPrimitives.mutual_exclusivity_constraint( + self, + binary_variables=[flow.model.on_off.on for flow in self.element.prevent_simultaneous_flows], + short_name='prevent_simultaneous_use', + ) def results_structure(self): return { diff --git a/flixopt/features.py b/flixopt/features.py index 475c0e553..a31550e63 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -560,41 +560,3 @@ def add_share( self._eq_total.lhs -= self.shares[name] else: self._eq_total_per_timestep.lhs -= self.shares[name] - - -class PreventSimultaneousUsageModel(Model): - """ - Prevents multiple Multiple Binary variables from being 1 at the same time - - Only 'classic type is modeled for now (# "classic" -> alle Flows brauchen Binärvariable:) - In 'new', the binary Variables need to be forced beforehand, which is not that straight forward... --> TODO maybe - - - # "new": - # eq: flow_1.on(t) + flow_2.on(t) + .. + flow_i.val(t)/flow_i.max <= 1 (1 Flow ohne Binärvariable!) - - # Anmerkung: Patrick Schönfeld (oemof, custom/link.py) macht bei 2 Flows ohne Binärvariable dies: - # 1) bin + flow1/flow1_max <= 1 - # 2) bin - flow2/flow2_max >= 0 - # 3) geht nur, wenn alle flow.min >= 0 - # --> könnte man auch umsetzen (statt force_on_variable() für die Flows, aber sollte aufs selbe wie "new" kommen) - """ - - def __init__( - self, - model: FlowSystemModel, - variables: List[linopy.Variable], - label_of_element: str, - label: str = 'PreventSimultaneousUsage', - ): - super().__init__(model, label_of_element, label) - self._simultanious_use_variables = variables - assert len(self._simultanious_use_variables) >= 2, ( - f'Model {self.__class__.__name__} must get at least two variables' - ) - for variable in self._simultanious_use_variables: # classic - assert variable.attrs['binary'], f'Variable {variable} must be binary for use in {self.__class__.__name__}' - - def do_modeling(self): - # eq: sum(flow_i.on(t)) <= 1.1 (1 wird etwas größer gewählt wg. Binärvariablengenauigkeit) - self.add_constraints(sum(self._simultanious_use_variables) <= 1.1, short_name='prevent_simultaneous_use') diff --git a/flixopt/modeling.py b/flixopt/modeling.py index c3839749c..d1b739487 100644 --- a/flixopt/modeling.py +++ b/flixopt/modeling.py @@ -387,7 +387,8 @@ def consecutive_duration_tracking( @staticmethod def mutual_exclusivity_constraint( - model: FlowSystemModel, name: str, binary_variables: List[linopy.Variable], tolerance: float = 1 + model: Model, binary_variables: List[linopy.Variable], tolerance: float = 1, + short_name: str = 'mutual_exclusivity', ) -> linopy.Constraint: """ Creates mutual exclusivity constraint for binary variables. @@ -401,6 +402,7 @@ def mutual_exclusivity_constraint( Args: binary_variables: List of binary variables that should be mutually exclusive tolerance: Upper bound + short_name: Short name of the constraint Returns: variables: {} (no new variables created) @@ -419,9 +421,7 @@ def mutual_exclusivity_constraint( ) # Create mutual exclusivity constraint - mutual_exclusivity = model.add_constraints( - sum(binary_variables) <= tolerance, name=f'{name}|mutual_exclusivity' - ) + mutual_exclusivity = model.add_constraints(sum(binary_variables) <= tolerance, short_name=short_name) return mutual_exclusivity From 9702303c534f31c27244a48126a9a8b9bd72f2e1 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 21 Jul 2025 21:01:25 +0200 Subject: [PATCH 227/336] Fix Aggregation and SegmentedCalculation --- flixopt/aggregation.py | 51 ++++++++++++++---------------------------- flixopt/calculation.py | 2 +- 2 files changed, 18 insertions(+), 35 deletions(-) diff --git a/flixopt/aggregation.py b/flixopt/aggregation.py index 47ac1336d..26fb921c9 100644 --- a/flixopt/aggregation.py +++ b/flixopt/aggregation.py @@ -301,7 +301,7 @@ def __init__( """ Modeling-Element for "index-equating"-equations """ - super().__init__(model, label_of_element='Aggregation', label_full='Aggregation') + super().__init__(model, label_of_element='Aggregation', label_of_model='Aggregation') self.flow_system = flow_system self.aggregation_parameters = aggregation_parameters self.aggregation_data = aggregation_data @@ -343,12 +343,9 @@ def _equate_indices(self, variable: linopy.Variable, indices: Tuple[np.ndarray, # Gleichung: # eq1: x(p1,t) - x(p3,t) = 0 # wobei p1 und p3 im gleichen Cluster sind und t = 0..N_p - con = self.add( - self._model.add_constraints( - variable.isel(time=indices[0]) - variable.isel(time=indices[1]) == 0, - name=f'{self.label_full}|equate_indices|{variable.name}', - ), - f'equate_indices|{variable.name}', + con = self.add_constraints( + variable.isel(time=indices[0]) - variable.isel(time=indices[1]) == 0, + short_name=f'equate_indices|{variable.name}', ) # Korrektur: (bisher nur für Binärvariablen:) @@ -356,22 +353,16 @@ def _equate_indices(self, variable: linopy.Variable, indices: Tuple[np.ndarray, variable.name in self._model.variables.binaries and self.aggregation_parameters.percentage_of_period_freedom > 0 ): - var_k1 = self.add( - self._model.add_variables( - binary=True, - coords={'time': variable.isel(time=indices[0]).indexes['time']}, - name=f'{self.label_full}|correction1|{variable.name}', - ), - f'correction1|{variable.name}', + var_k1 = self.add_variables( + binary=True, + coords={'time': variable.isel(time=indices[0]).indexes['time']}, + short_name=f'correction1|{variable.name}', ) - var_k0 = self.add( - self._model.add_variables( - binary=True, - coords={'time': variable.isel(time=indices[0]).indexes['time']}, - name=f'{self.label_full}|correction0|{variable.name}', - ), - f'correction0|{variable.name}', + var_k0 = self.add_variables( + binary=True, + coords={'time': variable.isel(time=indices[0]).indexes['time']}, + short_name=f'correction0|{variable.name}', ) # equation extends ... @@ -384,20 +375,12 @@ def _equate_indices(self, variable: linopy.Variable, indices: Tuple[np.ndarray, # interlock var_k1 and var_K2: # eq: var_k0(t)+var_k1(t) <= 1.1 - self.add( - self._model.add_constraints( - var_k0 + var_k1 <= 1.1, name=f'{self.label_full}|lock_k0_and_k1|{variable.name}' - ), - f'lock_k0_and_k1|{variable.name}', - ) + self.add_constraints(var_k0 + var_k1 <= 1.1, short_name=f'lock_k0_and_k1|{variable.name}') # Begrenzung der Korrektur-Anzahl: # eq: sum(K) <= n_Corr_max - self.add( - self._model.add_constraints( - sum(var_k0) + sum(var_k1) - <= round(self.aggregation_parameters.percentage_of_period_freedom / 100 * length), - name=f'{self.label_full}|limit_corrections|{variable.name}', - ), - f'limit_corrections|{variable.name}', + self.add_constraints( + sum(var_k0) + sum(var_k1) + <= round(self.aggregation_parameters.percentage_of_period_freedom / 100 * length), + short_name=f'limit_corrections|{variable.name}', ) diff --git a/flixopt/calculation.py b/flixopt/calculation.py index 438fbeea5..61747ffe7 100644 --- a/flixopt/calculation.py +++ b/flixopt/calculation.py @@ -488,7 +488,7 @@ def do_modeling_and_solve( invest_elements = [ model.label_full for component in calculation.flow_system.components.values() - for model in component.model.all_sub_models + for model in component.model.sub_models if isinstance(model, InvestmentModel) ] if invest_elements: From 91bd4610df55878615a8993a55680c54bf2909a5 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 21 Jul 2025 21:01:50 +0200 Subject: [PATCH 228/336] Update tests --- tests/test_component.py | 2 +- tests/test_storage.py | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/tests/test_component.py b/tests/test_component.py index 25e496694..3bf1699ec 100644 --- a/tests/test_component.py +++ b/tests/test_component.py @@ -205,7 +205,7 @@ def test_previous_states_with_multiple_flows(self, basic_flow_system_linopy): fx.Flow('In1', 'Fernwärme', relative_minimum=np.ones(10) * 0.1, size=100, previous_flow_rate=np.array([0, 0, 1e-6, 1e-5, 1e-4, 3,4])), ] outputs = [ - fx.Flow('Out1', 'Gas', relative_minimum=np.ones(10) * 0.2, size=200, previous_flow_rate=xr.DataArray([3,4,5], dims='time')), + fx.Flow('Out1', 'Gas', relative_minimum=np.ones(10) * 0.2, size=200, previous_flow_rate=[3,4,5]), fx.Flow('Out2', 'Gas', relative_minimum=np.ones(10) * 0.3, relative_maximum = ub_out2, size=300, previous_flow_rate=20), ] diff --git a/tests/test_storage.py b/tests/test_storage.py index 1b9b3b875..3a6b2a06c 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -291,7 +291,7 @@ def test_storage_with_investment(self, basic_flow_system_linopy): assert var_name in model.variables, f"Missing investment variable: {var_name}" # Check investment constraints exist - for con_name in {'InvestStorage|is_invested_ub', 'InvestStorage|is_invested_lb'}: + for con_name in {'InvestStorage|size|ub', 'InvestStorage|size|lb'}: assert con_name in model.constraints, f"Missing investment constraint: {con_name}" # Check variable properties @@ -303,9 +303,9 @@ def test_storage_with_investment(self, basic_flow_system_linopy): model['InvestStorage|is_invested'], model.add_variables(binary=True) ) - assert_conequal(model.constraints['InvestStorage|is_invested_ub'], + assert_conequal(model.constraints['InvestStorage|size|ub'], model.variables['InvestStorage|size'] <= model.variables['InvestStorage|is_invested'] * 100) - assert_conequal(model.constraints['InvestStorage|is_invested_lb'], + assert_conequal(model.constraints['InvestStorage|size|lb'], model.variables['InvestStorage|size'] >= model.variables['InvestStorage|is_invested'] * 20) def test_storage_with_final_state_constraints(self, basic_flow_system_linopy): @@ -417,17 +417,17 @@ def test_simultaneous_charge_discharge(self, basic_flow_system_linopy, prevent_s assert var_name in model.variables, f'Missing binary variable: {var_name}' # Check for constraints that enforce either charging or discharging - constraint_name = 'SimultaneousStorage|PreventSimultaneousUsage|prevent_simultaneous_use' + constraint_name = 'SimultaneousStorage|prevent_simultaneous_use' assert constraint_name in model.constraints, 'Missing constraint to prevent simultaneous operation' - assert_conequal(model.constraints['SimultaneousStorage|PreventSimultaneousUsage|prevent_simultaneous_use'], - model.variables['SimultaneousStorage(Q_th_in)|on'] + model.variables['SimultaneousStorage(Q_th_out)|on'] <= 1.1) + assert_conequal(model.constraints['SimultaneousStorage|prevent_simultaneous_use'], + model.variables['SimultaneousStorage(Q_th_in)|on'] + model.variables['SimultaneousStorage(Q_th_out)|on'] <= 1) @pytest.mark.parametrize( 'optional,minimum_size,expected_vars,expected_constraints', [ - (True, None, {'InvestStorage|is_invested'}, {'InvestStorage|is_invested_lb'}), - (True, 20, {'InvestStorage|is_invested'}, {'InvestStorage|is_invested_lb'}), + (True, None, {'InvestStorage|is_invested'}, {'InvestStorage|size|lb'}), + (True, 20, {'InvestStorage|is_invested'}, {'InvestStorage|size|lb'}), (False, None, set(), set()), (False, 20, set(), set()), ], From 94314c3866a980d824cf3caa87a616b443c156c2 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 21 Jul 2025 21:03:31 +0200 Subject: [PATCH 229/336] Loosen precision in tests --- tests/conftest.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index 5d98cdcb5..902e01c12 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -33,7 +33,7 @@ def solver_fixture(request): # Custom assertion function def assert_almost_equal_numeric( - actual, desired, err_msg, relative_error_range_in_percent=0.011, absolute_tolerance=1e-9 + actual, desired, err_msg, relative_error_range_in_percent=0.011, absolute_tolerance=1e-7 ): """ Custom assertion function for comparing numeric values with relative and absolute tolerances @@ -122,6 +122,7 @@ def simple_flow_system() -> fx.FlowSystem: return flow_system + @pytest.fixture def simple_flow_system_scenarios() -> fx.FlowSystem: """ From 50cc2cbbb4e324ac145ddba5a0d7f81282268de8 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 21 Jul 2025 21:16:20 +0200 Subject: [PATCH 230/336] Update test_on_hours_computation.py and some types --- flixopt/elements.py | 2 +- flixopt/modeling.py | 2 +- tests/test_on_hours_computation.py | 138 ++++++++++++++--------------- 3 files changed, 68 insertions(+), 74 deletions(-) diff --git a/flixopt/elements.py b/flixopt/elements.py index 663434ad8..62e723d98 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -524,7 +524,7 @@ def investment(self) -> Optional[InvestmentModel]: return self.sub_models_direct['investment'] @property - def previous_states(self) -> Optional[xr.DataArray]: + def previous_states(self) -> Optional[TemporalData]: """Previous states of the flow rate""" #TODO: This would be nicer to handle in the Flow itself, and allow DataArrays as well. previous_flow_rate = self.element.previous_flow_rate diff --git a/flixopt/modeling.py b/flixopt/modeling.py index d1b739487..b4ce4d5db 100644 --- a/flixopt/modeling.py +++ b/flixopt/modeling.py @@ -103,7 +103,7 @@ class ModelingUtilities: @staticmethod def compute_consecutive_hours_in_state( - binary_values: Union[xr.DataArray, np.ndarray, int], + binary_values: TemporalData, hours_per_timestep: Union[int, float], epsilon: float = None, ) -> float: diff --git a/tests/test_on_hours_computation.py b/tests/test_on_hours_computation.py index c8fa113aa..578fd7792 100644 --- a/tests/test_on_hours_computation.py +++ b/tests/test_on_hours_computation.py @@ -1,43 +1,43 @@ import numpy as np import pytest +import xarray as xr from flixopt.modeling import ModelingUtilities class TestComputeConsecutiveDuration: - """Tests for the compute_consecutive_duration static method.""" + """Tests for the compute_consecutive_hours_in_state static method.""" - @pytest.mark.parametrize("binary_values, hours_per_timestep, expected", [ - # Case 1: Both scalar inputs - (1, 5, 5), - (0, 3, 0), - - # Case 2: Scalar binary, array hours - (1, np.array([1, 2, 3]), 3), - (0, np.array([2, 4, 6]), 0), - - # Case 3: Array binary, scalar hours - (np.array([0, 0, 1, 1, 1, 0]), 2, 0), - (np.array([0, 1, 1, 0, 1, 1]), 1, 2), - (np.array([1, 1, 1]), 2, 6), - - # Case 4: Both array inputs - (np.array([0, 1, 1, 0, 1, 1]), np.array([1, 2, 3, 4, 5, 6]), 11), # 5+6 - (np.array([1, 0, 0, 1, 1, 1]), np.array([2, 2, 2, 3, 4, 5]), 12), # 3+4+5 - - # Case 5: Edge cases - (np.array([1]), np.array([4]), 4), - (np.array([0]), np.array([3]), 0), - ]) + @pytest.mark.parametrize( + 'binary_values, hours_per_timestep, expected', + [ + # Case 1: Single timestep DataArrays + (xr.DataArray([1], dims=['time']), 5, 5), + (xr.DataArray([0], dims=['time']), 3, 0), + # Case 2: Array binary, scalar hours + (xr.DataArray([0, 0, 1, 1, 1, 0], dims=['time']), 2, 0), + (xr.DataArray([0, 1, 1, 0, 1, 1], dims=['time']), 1, 2), + (xr.DataArray([1, 1, 1], dims=['time']), 2, 6), + # Case 3: Edge cases + (xr.DataArray([1], dims=['time']), 4, 4), + (xr.DataArray([0], dims=['time']), 3, 0), + # Case 4: More complex patterns + (xr.DataArray([1, 0, 0, 1, 1, 1], dims=['time']), 2, 6), # 3 consecutive at end * 2 hours + (xr.DataArray([0, 1, 1, 1, 0, 0], dims=['time']), 1, 0), # ends with 0 + ], + ) def test_compute_duration(self, binary_values, hours_per_timestep, expected): - """Test compute_consecutive_duration with various inputs.""" + """Test compute_consecutive_hours_in_state with various inputs.""" result = ModelingUtilities.compute_consecutive_hours_in_state(binary_values, hours_per_timestep) assert np.isclose(result, expected) - @pytest.mark.parametrize("binary_values, hours_per_timestep", [ - # Case: Incompatible array lengths - (np.array([1, 1, 1, 1, 1]), np.array([1, 2])), - ]) + @pytest.mark.parametrize( + 'binary_values, hours_per_timestep', + [ + # Case: hours_per_timestep must be scalar + (xr.DataArray([1, 1, 1, 1, 1], dims=['time']), np.array([1, 2])), + ], + ) def test_compute_duration_raises_error(self, binary_values, hours_per_timestep): """Test error conditions.""" with pytest.raises(TypeError): @@ -45,61 +45,55 @@ def test_compute_duration_raises_error(self, binary_values, hours_per_timestep): class TestComputePreviousOnStates: - """Tests for the compute_previous_on_states static method.""" + """Tests for the compute_previous_states static method.""" @pytest.mark.parametrize( 'previous_values, expected', [ - # Case 1: Empty list - ([], np.array([0])), - - # Case 2: All None values - ([None, None], np.array([0])), - - # Case 3: Single value arrays - ([np.array([0])], np.array([0])), - ([np.array([1])], np.array([1])), - ([np.array([0.001])], np.array([1])), # Using default epsilon - ([np.array([1e-4])], np.array([1])), - ([np.array([1e-8])], np.array([0])), - - # Case 4: Multiple 1D arrays - ([np.array([0, 5, 0]), np.array([0, 0, 1])], np.array([0, 1, 1])), - ([np.array([0.1, 0, 0.3]), None, np.array([0, 0, 0])], np.array([1, 0, 1])), - ([np.array([0, 0, 0]), np.array([0, 1, 0])], np.array([0, 1, 0])), - ([np.array([0.1, 0, 0]), np.array([0, 0, 0.2])], np.array([1, 0, 1])), - - # Case 6: Mix of None, 1D and 2D arrays - ([None, np.array([0, 0, 0]), np.array([0, 1, 0]), np.array([0, 0, 0])], np.array([0, 1, 0])), - ([np.array([0, 0, 0]), None, np.array([0, 0, 0]), np.array([0, 0, 0])], np.array([0, 0, 0])), + # Case 1: Single value DataArrays + (xr.DataArray([0], dims=['time']), xr.DataArray([0], dims=['time'])), + (xr.DataArray([1], dims=['time']), xr.DataArray([1], dims=['time'])), + (xr.DataArray([0.001], dims=['time']), xr.DataArray([1], dims=['time'])), # Using default epsilon + (xr.DataArray([1e-4], dims=['time']), xr.DataArray([1], dims=['time'])), + (xr.DataArray([1e-8], dims=['time']), xr.DataArray([0], dims=['time'])), + # Case 1: Multiple timestep DataArrays + (xr.DataArray([0, 5, 0], dims=['time']), xr.DataArray([0, 1, 0], dims=['time'])), + (xr.DataArray([0.1, 0, 0.3], dims=['time']), xr.DataArray([1, 0, 1], dims=['time'])), + (xr.DataArray([0, 0, 0], dims=['time']), xr.DataArray([0, 0, 0], dims=['time'])), + (xr.DataArray([0.1, 0, 0.2], dims=['time']), xr.DataArray([1, 0, 1], dims=['time'])), ], ) def test_compute_previous_on_states(self, previous_values, expected): - """Test compute_previous_on_states with various inputs.""" + """Test compute_previous_states with various inputs.""" result = ModelingUtilities.compute_previous_states(previous_values) - np.testing.assert_array_equal(result, expected) - - @pytest.mark.parametrize("previous_values, epsilon, expected", [ - # Testing with different epsilon values - ([np.array([1e-6, 1e-4, 1e-2])], 1e-3, np.array([0, 0, 1])), - ([np.array([1e-6, 1e-4, 1e-2])], 1e-5, np.array([0, 1, 1])), - ([np.array([1e-6, 1e-4, 1e-2])], 1e-1, np.array([0, 0, 0])), + xr.testing.assert_equal(result, expected) - # Mixed case with custom epsilon - ([np.array([0.05, 0.005, 0.0005])], 0.01, np.array([1, 0, 0])), - ]) + @pytest.mark.parametrize( + 'previous_values, epsilon, expected', + [ + # Testing with different epsilon values + (xr.DataArray([1e-6, 1e-4, 1e-2], dims=['time']), 1e-3, xr.DataArray([0, 0, 1], dims=['time'])), + (xr.DataArray([1e-6, 1e-4, 1e-2], dims=['time']), 1e-5, xr.DataArray([0, 1, 1], dims=['time'])), + (xr.DataArray([1e-6, 1e-4, 1e-2], dims=['time']), 1e-1, xr.DataArray([0, 0, 0], dims=['time'])), + # Mixed case with custom epsilon + (xr.DataArray([0.05, 0.005, 0.0005], dims=['time']), 0.01, xr.DataArray([1, 0, 0], dims=['time'])), + ], + ) def test_compute_previous_on_states_with_epsilon(self, previous_values, epsilon, expected): - """Test compute_previous_on_states with custom epsilon values.""" - result = StateModel.compute_previous_states(previous_values, epsilon) - np.testing.assert_array_equal(result, expected) + """Test compute_previous_states with custom epsilon values.""" + result = ModelingUtilities.compute_previous_states(previous_values, epsilon) + xr.testing.assert_equal(result, expected) - @pytest.mark.parametrize("previous_values, expected_shape", [ - # Check that output shapes match expected dimensions - ([np.array([0, 1, 0, 1])], (4,)), - ([np.array([0, 1]), np.array([1, 0]), np.array([0, 0])], (2,)), - ([np.array([0, 1]), np.array([1, 0])], (2,)), - ]) + @pytest.mark.parametrize( + 'previous_values, expected_shape', + [ + # Check that output shapes match expected dimensions + (xr.DataArray([0, 1, 0, 1], dims=['time']), (4,)), + (xr.DataArray([0, 1], dims=['time']), (2,)), + (xr.DataArray([1, 0], dims=['time']), (2,)), + ], + ) def test_output_shapes(self, previous_values, expected_shape): """Test that output array has the correct shape.""" - result = StateModel.compute_previous_states(previous_values) + result = ModelingUtilities.compute_previous_states(previous_values) assert result.shape == expected_shape From e52f8002e96071483e4dbeb52bca99f7538c0205 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 21 Jul 2025 21:34:20 +0200 Subject: [PATCH 231/336] Rename class Model to Submodel --- flixopt/aggregation.py | 4 ++-- flixopt/calculation.py | 2 +- flixopt/components.py | 2 +- flixopt/effects.py | 4 ++-- flixopt/elements.py | 2 +- flixopt/features.py | 12 ++++++------ flixopt/modeling.py | 8 ++++---- flixopt/structure.py | 14 +++++++------- 8 files changed, 24 insertions(+), 24 deletions(-) diff --git a/flixopt/aggregation.py b/flixopt/aggregation.py index 26fb921c9..eb44ad707 100644 --- a/flixopt/aggregation.py +++ b/flixopt/aggregation.py @@ -27,7 +27,7 @@ from .flow_system import FlowSystem from .structure import ( Element, - Model, + Submodel, FlowSystemModel, ) @@ -285,7 +285,7 @@ def use_low_peaks(self): return self.time_series_for_low_peaks is not None -class AggregationModel(Model): +class AggregationModel(Submodel): """The AggregationModel holds equations and variables related to the Aggregation of a FLowSystem. It creates Equations that equates indices of variables, and introduces penalties related to binary variables, that escape the equation to their related binaries in other periods""" diff --git a/flixopt/calculation.py b/flixopt/calculation.py index 61747ffe7..141a8ead5 100644 --- a/flixopt/calculation.py +++ b/flixopt/calculation.py @@ -300,7 +300,7 @@ def do_modeling(self) -> 'AggregatedCalculation': # Model the System self.model = self.flow_system.create_model() self.model.do_modeling() - # Add Aggregation Model after modeling the rest + # Add Aggregation Submodel after modeling the rest self.aggregation = AggregationModel( self.model, self.aggregation_parameters, self.flow_system, self.aggregation, self.components_to_clusterize ) diff --git a/flixopt/components.py b/flixopt/components.py index 81570f9f3..42f1cfdd5 100644 --- a/flixopt/components.py +++ b/flixopt/components.py @@ -487,7 +487,7 @@ def do_modeling(self): class StorageModel(ComponentModel): - """Model of Storage""" + """Submodel of Storage""" def __init__(self, model: FlowSystemModel, element: Storage): super().__init__(model, element) diff --git a/flixopt/effects.py b/flixopt/effects.py index 13ee524e5..2b1b2ed6e 100644 --- a/flixopt/effects.py +++ b/flixopt/effects.py @@ -15,7 +15,7 @@ from .core import Scalar, TemporalData, TemporalDataUser from .features import ShareAllocationModel -from .structure import Element, ElementModel, Interface, Model, FlowSystemModel, register_class_for_io +from .structure import Element, ElementModel, Interface, Submodel, FlowSystemModel, register_class_for_io if TYPE_CHECKING: from .flow_system import FlowSystem @@ -375,7 +375,7 @@ def calculate_effect_share_factors(self) -> Tuple[ return shares_operation, shares_invest -class EffectCollectionModel(Model): +class EffectCollectionModel(Submodel): """ Handling all Effects """ diff --git a/flixopt/elements.py b/flixopt/elements.py index 62e723d98..c53f7c84f 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -30,7 +30,7 @@ class Component(Element): A Component contains incoming and outgoing [`Flows`][flixopt.elements.Flow]. It defines how these Flows interact with each other. The On or Off state of the Component is defined by all its Flows. Its on, if any of its FLows is On. It's mathematically advisable to define the On/Off state in a FLow rather than a Component if possible, - as this introduces less binary variables to the Model + as this introduces less binary variables to the Submodel Constraints to the On/Off state are defined by the [`on_off_parameters`][flixopt.interface.OnOffParameters]. """ diff --git a/flixopt/features.py b/flixopt/features.py index a31550e63..99928e410 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -12,7 +12,7 @@ from .config import CONFIG from .core import NonTemporalData, Scalar, TemporalData, FlowSystemDimensions from .interface import InvestParameters, OnOffParameters, Piecewise, PiecewiseEffects -from .structure import Model, FlowSystemModel, BaseFeatureModel +from .structure import Submodel, FlowSystemModel, BaseFeatureModel from .modeling import ModelingUtilities, ModelingPrimitives, BoundingPatterns logger = logging.getLogger('flixopt') @@ -228,7 +228,7 @@ def add_effects(self): target='operation', ) - # Properties access variables from Model's tracking system + # Properties access variables from Submodel's tracking system @property def total_on_hours(self) -> Optional[linopy.Variable]: @@ -282,7 +282,7 @@ def _get_previous_off_duration(self): return ModelingUtilities.compute_consecutive_hours_in_state(self._previous_states * -1 + 1, hours_per_step) -class PieceModel(Model): +class PieceModel(Submodel): """Class for modeling a linear piece of one or more variables in parallel""" def __init__( @@ -323,7 +323,7 @@ def do_modeling(self): self.add_constraints(self.inside_piece == self.lambda0 + self.lambda1, short_name='inside_piece') -class PiecewiseModel(Model): +class PiecewiseModel(Submodel): def __init__( self, model: FlowSystemModel, @@ -404,7 +404,7 @@ def do_modeling(self): ) -class PiecewiseEffectsModel(Model): +class PiecewiseEffectsModel(Submodel): def __init__( self, model: FlowSystemModel, @@ -461,7 +461,7 @@ def do_modeling(self): ) -class ShareAllocationModel(Model): +class ShareAllocationModel(Submodel): def __init__( self, model: FlowSystemModel, diff --git a/flixopt/modeling.py b/flixopt/modeling.py index b4ce4d5db..262b0d17d 100644 --- a/flixopt/modeling.py +++ b/flixopt/modeling.py @@ -7,7 +7,7 @@ from .config import CONFIG from .core import NonTemporalData, Scalar, TemporalData, FlowSystemDimensions -from .structure import Model, FlowSystemModel, BaseFeatureModel +from .structure import Submodel, FlowSystemModel, BaseFeatureModel logger = logging.getLogger('flixopt') @@ -181,7 +181,7 @@ class ModelingPrimitives: @staticmethod def expression_tracking_variable( - model: Model, + model: Submodel, tracked_expression, name: str = None, short_name: str = None, @@ -219,7 +219,7 @@ def expression_tracking_variable( @staticmethod def state_transition_variables( - model: Union[FlowSystemModel, Model], + model: Submodel, state_variable: linopy.Variable, switch_on: linopy.Variable, switch_off: linopy.Variable, @@ -387,7 +387,7 @@ def consecutive_duration_tracking( @staticmethod def mutual_exclusivity_constraint( - model: Model, binary_variables: List[linopy.Variable], tolerance: float = 1, + model: Submodel, binary_variables: List[linopy.Variable], tolerance: float = 1, short_name: str = 'mutual_exclusivity', ) -> linopy.Constraint: """ diff --git a/flixopt/structure.py b/flixopt/structure.py index 34de27f35..e6ed849b3 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -696,7 +696,7 @@ def _valid_label(label: str) -> str: return label -class Model: +class Submodel: """Stores Variables and Constraints.""" def __init__( @@ -714,7 +714,7 @@ def __init__( self._variables: Dict[str, linopy.Variable] = {} # Mapping from short name to variable self._constraints: Dict[str, linopy.Constraint] = {} # Mapping from short name to constraint - self._sub_models: Dict[str, 'Model'] = {} + self._sub_models: Dict[str, 'Submodel'] = {} logger.debug(f'Created {self.__class__.__name__} "{self.label_full}"') @@ -760,7 +760,7 @@ def register_constraint(self, constraint: linopy.Constraint, short_name: str = N self._constraints[short_name] = constraint return constraint - def register_sub_model(self, sub_model: 'Model', short_name: str) -> 'Model': + def register_sub_model(self, sub_model: 'Submodel', short_name: str) -> 'Submodel': """Register a sub-model with the model""" if short_name is None: short_name = sub_model.__class__.__name__ @@ -831,12 +831,12 @@ def constraints_direct(self) -> linopy.Constraints: return self._model.constraints[[con.name for con in self._constraints.values()]] @property - def sub_models_direct(self) -> Dict[str, 'Model']: + def sub_models_direct(self) -> Dict[str, 'Submodel']: """All sub-models of the model, excluding those of sub-models""" return self._sub_models @property - def sub_models(self) -> List['Model']: + def sub_models(self) -> List['Submodel']: """All sub-models of the model""" direct_submodels = list(self._sub_models.values()) @@ -896,7 +896,7 @@ def hours_per_step(self): return self._model.hours_per_step -class BaseFeatureModel(Model): +class BaseFeatureModel(Submodel): """Minimal base class for feature models that use factory patterns""" def __init__(self, model: FlowSystemModel, label_of_element: str, parameters, label_of_model: Optional[str] = None): @@ -925,7 +925,7 @@ def add_effects(self): pass # Default: no effects -class ElementModel(Model): +class ElementModel(Submodel): """Stores the mathematical Variables and Constraints for Elements""" def __init__(self, model: FlowSystemModel, element: Element): From 928125640bc8295ecbdfb8f520a974692a7421ab Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 21 Jul 2025 21:36:23 +0200 Subject: [PATCH 232/336] rename sub_model to submodel everywhere --- flixopt/structure.py | 20 ++++++++++---------- tests/test_linear_converter.py | 4 ++-- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/flixopt/structure.py b/flixopt/structure.py index e6ed849b3..7da8ade78 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -760,14 +760,14 @@ def register_constraint(self, constraint: linopy.Constraint, short_name: str = N self._constraints[short_name] = constraint return constraint - def register_sub_model(self, sub_model: 'Submodel', short_name: str) -> 'Submodel': + def register_sub_model(self, submodel: 'Submodel', short_name: str) -> 'Submodel': """Register a sub-model with the model""" if short_name is None: - short_name = sub_model.__class__.__name__ + short_name = submodel.__class__.__name__ if short_name in self._sub_models: raise ValueError(f'Short name "{short_name}" already assigned to model') - self._sub_models[short_name] = sub_model - return sub_model + self._sub_models[short_name] = submodel + return submodel def __getitem__(self, key: str) -> linopy.Variable: """Get a variable by its short name""" @@ -852,8 +852,8 @@ def constraints(self) -> linopy.Constraints: """All constraints of the model, including those of sub-models""" names = list(self.constraints_direct) + [ constraint_name - for sub_model in self.sub_models - for constraint_name in sub_model.constraints_direct + for submodel in self.sub_models + for constraint_name in submodel.constraints_direct ] return self._model.constraints[names] @@ -863,8 +863,8 @@ def variables(self) -> linopy.Variables: """All variables of the model, including those of sub-models""" names = list(self.variables_direct) + [ variable_name - for sub_model in self.sub_models - for variable_name in sub_model.variables_direct + for submodel in self.sub_models + for variable_name in submodel.variables_direct ] return self._model.variables[names] @@ -881,8 +881,8 @@ def __repr__(self) -> str: sub_models_string = ' \n' else: sub_models_string = '' - for sub_model_name, sub_model in self.sub_models_direct.items(): - sub_models_string += f'\n * {sub_model_name} [{sub_model.__class__.__name__}]' + for submodel_name, submodel in self.sub_models_direct.items(): + sub_models_string += f'\n * {submodel_name} [{submodel.__class__.__name__}]' return ( f"{model_string}\n{'=' * len(model_string)}\n\n" diff --git a/tests/test_linear_converter.py b/tests/test_linear_converter.py index 11b5b5673..e15b11c1b 100644 --- a/tests/test_linear_converter.py +++ b/tests/test_linear_converter.py @@ -360,7 +360,7 @@ def test_piecewise_conversion(self, basic_flow_system_linopy): # Create model with the piecewise conversion model = create_linopy_model(flow_system) - # Verify that PiecewiseModel was created and added as a sub_model + # Verify that PiecewiseModel was created and added as a submodel assert converter.model.piecewise_conversion is not None # Get the PiecewiseModel instance @@ -472,7 +472,7 @@ def test_piecewise_conversion_with_onoff(self, basic_flow_system_linopy): # Create model with the piecewise conversion model = create_linopy_model(flow_system) - # Verify that PiecewiseModel was created and added as a sub_model + # Verify that PiecewiseModel was created and added as a submodel assert converter.model.piecewise_conversion is not None # Get the PiecewiseModel instance From 9001c6ab129b9f8c98e7f738c603205679cfcd45 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 21 Jul 2025 21:37:34 +0200 Subject: [PATCH 233/336] rename self.model to self.submodel everywhere --- flixopt/calculation.py | 4 ++-- flixopt/components.py | 6 +++--- flixopt/effects.py | 4 ++-- flixopt/elements.py | 6 +++--- flixopt/flow_system.py | 2 +- flixopt/results.py | 2 +- 6 files changed, 12 insertions(+), 12 deletions(-) diff --git a/flixopt/calculation.py b/flixopt/calculation.py index 141a8ead5..53c02beca 100644 --- a/flixopt/calculation.py +++ b/flixopt/calculation.py @@ -180,7 +180,7 @@ def do_modeling(self) -> 'FullCalculation': t_start = timeit.default_timer() self.flow_system.connect_and_transform() - self.model = self.flow_system.create_model() + self.submodel = self.flow_system.create_model() self.model.do_modeling() self.durations['modeling'] = round(timeit.default_timer() - t_start, 2) @@ -298,7 +298,7 @@ def do_modeling(self) -> 'AggregatedCalculation': self._perform_aggregation() # Model the System - self.model = self.flow_system.create_model() + self.submodel = self.flow_system.create_model() self.model.do_modeling() # Add Aggregation Submodel after modeling the rest self.aggregation = AggregationModel( diff --git a/flixopt/components.py b/flixopt/components.py index 42f1cfdd5..fe776e02d 100644 --- a/flixopt/components.py +++ b/flixopt/components.py @@ -61,7 +61,7 @@ def __init__( def create_model(self, model: FlowSystemModel) -> 'LinearConverterModel': self._plausibility_checks() - self.model = LinearConverterModel(model, self) + self.submodel = LinearConverterModel(model, self) return self.model def _plausibility_checks(self) -> None: @@ -203,7 +203,7 @@ def __init__( def create_model(self, model: FlowSystemModel) -> 'StorageModel': self._plausibility_checks() - self.model = StorageModel(model, self) + self.submodel = StorageModel(model, self) return self.model def transform_data(self, flow_system: 'FlowSystem') -> None: @@ -380,7 +380,7 @@ def _plausibility_checks(self): def create_model(self, model) -> 'TransmissionModel': self._plausibility_checks() - self.model = TransmissionModel(model, self) + self.submodel = TransmissionModel(model, self) return self.model def transform_data(self, flow_system: 'FlowSystem') -> None: diff --git a/flixopt/effects.py b/flixopt/effects.py index 2b1b2ed6e..9f8e2506f 100644 --- a/flixopt/effects.py +++ b/flixopt/effects.py @@ -129,7 +129,7 @@ def transform_data(self, flow_system: 'FlowSystem'): def create_model(self, model: FlowSystemModel) -> 'EffectModel': self._plausibility_checks() - self.model = EffectModel(model, self) + self.submodel = EffectModel(model, self) return self.model def _plausibility_checks(self) -> None: @@ -216,7 +216,7 @@ def __init__(self, *effects: List[Effect]): def create_model(self, model: FlowSystemModel) -> 'EffectCollectionModel': self._plausibility_checks() - self.model = EffectCollectionModel(model, self) + self.submodel = EffectCollectionModel(model, self) return self.model def add_effects(self, *effects: Effect) -> None: diff --git a/flixopt/elements.py b/flixopt/elements.py index c53f7c84f..4dbf67aea 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -67,7 +67,7 @@ def __init__( def create_model(self, model: FlowSystemModel) -> 'ComponentModel': self._plausibility_checks() - self.model = ComponentModel(model, self) + self.submodel = ComponentModel(model, self) return self.model def transform_data(self, flow_system: 'FlowSystem') -> None: @@ -112,7 +112,7 @@ def __init__( def create_model(self, model: FlowSystemModel) -> 'BusModel': self._plausibility_checks() - self.model = BusModel(model, self) + self.submodel = BusModel(model, self) return self.model def transform_data(self, flow_system: 'FlowSystem'): @@ -229,7 +229,7 @@ def __init__( def create_model(self, model: FlowSystemModel) -> 'FlowModel': self._plausibility_checks() - self.model = FlowModel(model, self) + self.submodel = FlowModel(model, self) return self.model def transform_data(self, flow_system: 'FlowSystem'): diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index 877db6fdc..454a552b3 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -451,7 +451,7 @@ def add_elements(self, *elements: Element) -> None: def create_model(self) -> FlowSystemModel: if not self.connected_and_transformed: raise RuntimeError('FlowSystem is not connected_and_transformed. Call FlowSystem.connect_and_transform() first.') - self.model = FlowSystemModel(self) + self.submodel = FlowSystemModel(self) return self.model def plot_network( diff --git a/flixopt/results.py b/flixopt/results.py index 941d0d6dc..9ede9ab49 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -167,7 +167,7 @@ def __init__( self.flow_system_data = flow_system_data self.summary = summary self.name = name - self.model = model + self.submodel = model self.folder = pathlib.Path(folder) if folder is not None else pathlib.Path.cwd() / 'results' self.components = { label: ComponentResults(self, **infos) for label, infos in self.solution.attrs['Components'].items() From 286a8b7f3455972750c8ce1744d5f17632518ed7 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 21 Jul 2025 21:56:26 +0200 Subject: [PATCH 234/336] Rename .model with .submodel if its only a submodel --- .../example_calculation_types.py | 2 +- flixopt/aggregation.py | 8 +- flixopt/calculation.py | 28 +-- flixopt/components.py | 24 +- flixopt/effects.py | 26 +- flixopt/elements.py | 30 +-- flixopt/flow_system.py | 2 +- flixopt/results.py | 2 +- flixopt/structure.py | 10 +- tests/test_bus.py | 8 +- tests/test_component.py | 50 ++-- tests/test_effect.py | 12 +- tests/test_flow.py | 232 +++++++++--------- tests/test_functional.py | 88 +++---- tests/test_integration.py | 22 +- tests/test_linear_converter.py | 28 +-- 16 files changed, 286 insertions(+), 286 deletions(-) diff --git a/examples/03_Calculation_types/example_calculation_types.py b/examples/03_Calculation_types/example_calculation_types.py index 8bbdf1773..9f5828cec 100644 --- a/examples/03_Calculation_types/example_calculation_types.py +++ b/examples/03_Calculation_types/example_calculation_types.py @@ -188,7 +188,7 @@ def get_solutions(calcs: List, variable: str) -> xr.Dataset: if calc.name == 'Segmented': dataarrays.append(calc.results.solution_without_overlap(variable).rename(calc.name)) else: - dataarrays.append(calc.results.model.variables[variable].solution.rename(calc.name)) + dataarrays.append(calc.results.submodel.variables[variable].solution.rename(calc.name)) return xr.merge(dataarrays) # --- Plotting for comparison --- diff --git a/flixopt/aggregation.py b/flixopt/aggregation.py index eb44ad707..e4f7a598a 100644 --- a/flixopt/aggregation.py +++ b/flixopt/aggregation.py @@ -323,14 +323,14 @@ def do_modeling(self): if isinstance(component, Storage) and not self.aggregation_parameters.fix_storage_flows: continue # Fix Nothing in The Storage - all_variables_of_component = set(component.model.variables) + all_variables_of_component = set(component.submodel.variables) if self.aggregation_parameters.aggregate_data_and_fix_non_binary_vars: - relevant_variables = component.model.variables[all_variables_of_component & time_variables] + relevant_variables = component.submodel.variables[all_variables_of_component & time_variables] else: - relevant_variables = component.model.variables[all_variables_of_component & binary_time_variables] + relevant_variables = component.submodel.variables[all_variables_of_component & binary_time_variables] for variable in relevant_variables: - self._equate_indices(component.model.variables[variable], indices) + self._equate_indices(component.submodel.variables[variable], indices) penalty = self.aggregation_parameters.penalty_of_period_freedom if (self.aggregation_parameters.percentage_of_period_freedom > 0) and penalty != 0: diff --git a/flixopt/calculation.py b/flixopt/calculation.py index 53c02beca..5e505ff0f 100644 --- a/flixopt/calculation.py +++ b/flixopt/calculation.py @@ -81,7 +81,7 @@ def __init__( flow_system._used_in_calculation = True self.flow_system = flow_system - self.model: Optional[FlowSystemModel] = None + self.submodel: Optional[FlowSystemModel] = None self.durations = {'modeling': 0.0, 'solving': 0.0, 'saving': 0.0} self.folder = pathlib.Path.cwd() / 'results' if folder is None else pathlib.Path(folder) @@ -106,9 +106,9 @@ def main_results(self) -> Dict[str, Union[Scalar, Dict]]: 'Penalty': self.model.effects.penalty.total.solution.values, 'Effects': { f'{effect.label} [{effect.unit}]': { - 'operation': effect.model.operation.total.solution.values, - 'invest': effect.model.invest.total.solution.values, - 'total': effect.model.total.solution.values, + 'operation': effect.submodel.operation.total.solution.values, + 'invest': effect.submodel.invest.total.solution.values, + 'total': effect.submodel.total.solution.values, } for effect in self.flow_system.effects }, @@ -116,28 +116,28 @@ def main_results(self) -> Dict[str, Union[Scalar, Dict]]: 'Invested': { model.label_of_element: model.size.solution for component in self.flow_system.components.values() - for model in component.model.sub_models + for model in component.submodel.sub_models if isinstance(model, InvestmentModel) and model.size.solution.max() >= CONFIG.modeling.EPSILON }, 'Not invested': { model.label_of_element: model.size.solution for component in self.flow_system.components.values() - for model in component.model.sub_models + for model in component.submodel.sub_models if isinstance(model, InvestmentModel) and model.size.solution.max() < CONFIG.modeling.EPSILON }, }, 'Buses with excess': [ { bus.label_full: { - 'input': bus.model.excess_input.solution.sum('time'), - 'output': bus.model.excess_output.solution.sum('time'), + 'input': bus.submodel.excess_input.solution.sum('time'), + 'output': bus.submodel.excess_output.solution.sum('time'), } } for bus in self.flow_system.buses.values() if bus.with_excess and ( - bus.model.excess_input.solution.sum() > 1e-3 - or bus.model.excess_output.solution.sum() > 1e-3 + bus.submodel.excess_input.solution.sum() > 1e-3 + or bus.submodel.excess_output.solution.sum() > 1e-3 ) ], } @@ -180,7 +180,7 @@ def do_modeling(self) -> 'FullCalculation': t_start = timeit.default_timer() self.flow_system.connect_and_transform() - self.submodel = self.flow_system.create_model() + self.model = self.flow_system.create_model() self.model.do_modeling() self.durations['modeling'] = round(timeit.default_timer() - t_start, 2) @@ -298,7 +298,7 @@ def do_modeling(self) -> 'AggregatedCalculation': self._perform_aggregation() # Model the System - self.submodel = self.flow_system.create_model() + self.model = self.flow_system.create_model() self.model.do_modeling() # Add Aggregation Submodel after modeling the rest self.aggregation = AggregationModel( @@ -488,7 +488,7 @@ def do_modeling_and_solve( invest_elements = [ model.label_full for component in calculation.flow_system.components.values() - for model in component.model.sub_models + for model in component.submodel.sub_models if isinstance(model, InvestmentModel) ] if invest_elements: @@ -532,7 +532,7 @@ def _transfer_start_values(self, i: int): for current_flow in current_flow_system.flows.values(): next_flow = next_flow_system.flows[current_flow.label_full] - next_flow.previous_flow_rate = current_flow.model.flow_rate.solution.sel( + next_flow.previous_flow_rate = current_flow.submodel.flow_rate.solution.sel( time=slice(start_previous_values, end_previous_values) ).values start_values_of_this_segment[current_flow.label_full] = next_flow.previous_flow_rate diff --git a/flixopt/components.py b/flixopt/components.py index fe776e02d..52e676323 100644 --- a/flixopt/components.py +++ b/flixopt/components.py @@ -62,7 +62,7 @@ def __init__( def create_model(self, model: FlowSystemModel) -> 'LinearConverterModel': self._plausibility_checks() self.submodel = LinearConverterModel(model, self) - return self.model + return self.submodel def _plausibility_checks(self) -> None: super()._plausibility_checks() @@ -204,7 +204,7 @@ def __init__( def create_model(self, model: FlowSystemModel) -> 'StorageModel': self._plausibility_checks() self.submodel = StorageModel(model, self) - return self.model + return self.submodel def transform_data(self, flow_system: 'FlowSystem') -> None: super().transform_data(flow_system) @@ -381,7 +381,7 @@ def _plausibility_checks(self): def create_model(self, model) -> 'TransmissionModel': self._plausibility_checks() self.submodel = TransmissionModel(model, self) - return self.model + return self.submodel def transform_data(self, flow_system: 'FlowSystem') -> None: super().transform_data(flow_system) @@ -420,7 +420,7 @@ def do_modeling(self): if self.element.balanced: # eq: in1.size = in2.size self.add_constraints( - self.element.in1.model._investment.size == self.element.in2.model._investment.size, + self.element.in1.submodel._investment.size == self.element.in2.submodel._investment.size, short_name='same_size', ) @@ -428,12 +428,12 @@ def create_transmission_equation(self, name: str, in_flow: Flow, out_flow: Flow) """Creates an Equation for the Transmission efficiency and adds it to the model""" # eq: out(t) + on(t)*loss_abs(t) = in(t)*(1 - loss_rel(t)) con_transmission = self.add_constraints( - out_flow.model.flow_rate == -in_flow.model.flow_rate * (self.element.relative_losses - 1), + out_flow.submodel.flow_rate == -in_flow.submodel.flow_rate * (self.element.relative_losses - 1), short_name=name, ) if self.element.absolute_losses is not None: - con_transmission.lhs += in_flow.model.on_off.on * self.element.absolute_losses + con_transmission.lhs += in_flow.submodel.on_off.on * self.element.absolute_losses return con_transmission @@ -460,15 +460,15 @@ def do_modeling(self): used_outputs: Set = all_output_flows & used_flows self.add_constraints( - sum([flow.model.flow_rate * conv_factors[flow.label] for flow in used_inputs]) - == sum([flow.model.flow_rate * conv_factors[flow.label] for flow in used_outputs]), + sum([flow.submodel.flow_rate * conv_factors[flow.label] for flow in used_inputs]) + == sum([flow.submodel.flow_rate * conv_factors[flow.label] for flow in used_outputs]), short_name=f'conversion_{i}', ) else: # TODO: Improve Inclusion of OnOffParameters. Instead of creating a Binary in every flow, the binary could only be part of the Piece itself piecewise_conversion = { - self.element.flows[flow].model.flow_rate.name: piecewise + self.element.flows[flow].submodel.flow_rate.name: piecewise for flow, piecewise in self.element.piecewise_conversion.items() } @@ -510,15 +510,15 @@ def do_modeling(self): # eq: nettoFlow(t) - discharging(t) + charging(t) = 0 self.add_constraints( self.netto_discharge - == self.element.discharging.model.flow_rate - self.element.charging.model.flow_rate, + == self.element.discharging.submodel.flow_rate - self.element.charging.submodel.flow_rate, short_name='netto_discharge', ) charge_state = self.charge_state rel_loss = self.element.relative_loss_per_hour hours_per_step = self._model.hours_per_step - charge_rate = self.element.charging.model.flow_rate - discharge_rate = self.element.discharging.model.flow_rate + charge_rate = self.element.charging.submodel.flow_rate + discharge_rate = self.element.discharging.submodel.flow_rate eff_charge = self.element.eta_charge eff_discharge = self.element.eta_discharge diff --git a/flixopt/effects.py b/flixopt/effects.py index 9f8e2506f..f9b122b1b 100644 --- a/flixopt/effects.py +++ b/flixopt/effects.py @@ -130,7 +130,7 @@ def transform_data(self, flow_system: 'FlowSystem'): def create_model(self, model: FlowSystemModel) -> 'EffectModel': self._plausibility_checks() self.submodel = EffectModel(model, self) - return self.model + return self.submodel def _plausibility_checks(self) -> None: # TODO: Check for plausibility @@ -211,13 +211,13 @@ def __init__(self, *effects: List[Effect]): self._standard_effect: Optional[Effect] = None self._objective_effect: Optional[Effect] = None - self.model: Optional[EffectCollectionModel] = None + self.submodel: Optional[EffectCollectionModel] = None self.add_effects(*effects) def create_model(self, model: FlowSystemModel) -> 'EffectCollectionModel': self._plausibility_checks() self.submodel = EffectCollectionModel(model, self) - return self.model + return self.submodel def add_effects(self, *effects: Effect) -> None: for effect in list(effects): @@ -393,13 +393,13 @@ def add_share_to_effects( ) -> None: for effect, expression in expressions.items(): if target == 'operation': - self.effects[effect].model.operation.add_share( + self.effects[effect].submodel.operation.add_share( name, expression, dims=('time', 'year', 'scenario'), ) elif target == 'invest': - self.effects[effect].model.invest.add_share( + self.effects[effect].submodel.invest.add_share( name, expression, dims=('year', 'scenario'), @@ -419,13 +419,13 @@ def do_modeling(self): ShareAllocationModel(self._model, dims=(), label_of_element='Penalty'), short_name='penalty', ) - for model in [effect.model for effect in self.effects] + [self.penalty]: + for model in [effect.submodel for effect in self.effects] + [self.penalty]: model.do_modeling() self._add_share_between_effects() self._model.add_objective( - (self.effects.objective_effect.model.total * self._model.weights).sum() + (self.effects.objective_effect.submodel.total * self._model.weights).sum() + self.penalty.total.sum() ) @@ -433,16 +433,16 @@ def _add_share_between_effects(self): for origin_effect in self.effects: # 1. operation: -> hier sind es Zeitreihen (share_TS) for target_effect, time_series in origin_effect.specific_share_to_other_effects_operation.items(): - self.effects[target_effect].model.operation.add_share( - origin_effect.model.operation.label_full, - origin_effect.model.operation.total_per_timestep * time_series, + self.effects[target_effect].submodel.operation.add_share( + origin_effect.submodel.operation.label_full, + origin_effect.submodel.operation.total_per_timestep * time_series, dims=('time', 'year', 'scenario'), ) # 2. invest: -> hier ist es Scalar (share) for target_effect, factor in origin_effect.specific_share_to_other_effects_invest.items(): - self.effects[target_effect].model.invest.add_share( - origin_effect.model.invest.label_full, - origin_effect.model.invest.total * factor, + self.effects[target_effect].submodel.invest.add_share( + origin_effect.submodel.invest.label_full, + origin_effect.submodel.invest.total * factor, dims=('year', 'scenario'), ) diff --git a/flixopt/elements.py b/flixopt/elements.py index 4dbf67aea..b952093ba 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -68,7 +68,7 @@ def __init__( def create_model(self, model: FlowSystemModel) -> 'ComponentModel': self._plausibility_checks() self.submodel = ComponentModel(model, self) - return self.model + return self.submodel def transform_data(self, flow_system: 'FlowSystem') -> None: if self.on_off_parameters is not None: @@ -113,7 +113,7 @@ def __init__( def create_model(self, model: FlowSystemModel) -> 'BusModel': self._plausibility_checks() self.submodel = BusModel(model, self) - return self.model + return self.submodel def transform_data(self, flow_system: 'FlowSystem'): self.excess_penalty_per_flow_hour = flow_system.fit_to_model_coords( @@ -230,7 +230,7 @@ def __init__( def create_model(self, model: FlowSystemModel) -> 'FlowModel': self._plausibility_checks() self.submodel = FlowModel(model, self) - return self.model + return self.submodel def transform_data(self, flow_system: 'FlowSystem'): self.relative_minimum = flow_system.fit_to_model_coords( @@ -551,9 +551,9 @@ def __init__(self, model: FlowSystemModel, element: Bus): def do_modeling(self) -> None: # inputs == outputs for flow in self.element.inputs + self.element.outputs: - self.register_variable(flow.model.flow_rate, flow.label_full) - inputs = sum([flow.model.flow_rate for flow in self.element.inputs]) - outputs = sum([flow.model.flow_rate for flow in self.element.outputs]) + self.register_variable(flow.submodel.flow_rate, flow.label_full) + inputs = sum([flow.submodel.flow_rate for flow in self.element.inputs]) + outputs = sum([flow.submodel.flow_rate for flow in self.element.outputs]) eq_bus_balance = self.add_constraints(inputs == outputs, short_name='balance') # Fehlerplus/-minus: @@ -570,8 +570,8 @@ def do_modeling(self) -> None: self._model.effects.add_share_to_penalty(self.label_of_element, (self.excess_output * excess_penalty).sum()) def results_structure(self): - inputs = [flow.model.flow_rate.name for flow in self.element.inputs] - outputs = [flow.model.flow_rate.name for flow in self.element.outputs] + inputs = [flow.submodel.flow_rate.name for flow in self.element.inputs] + outputs = [flow.submodel.flow_rate.name for flow in self.element.outputs] if self.excess_input is not None: inputs.append(self.excess_input.name) if self.excess_output is not None: @@ -606,9 +606,9 @@ def do_modeling(self): if self.element.on_off_parameters: on = self.add_variables(binary=True, short_name='on', coords=self._model.get_coords()) if len(all_flows) == 1: - self.add_constraints(on == all_flows[0].model.on_off.on, short_name='on') + self.add_constraints(on == all_flows[0].submodel.on_off.on, short_name='on') else: - flow_ons = [flow.model.on_off.on for flow in all_flows] + flow_ons = [flow.submodel.on_off.on for flow in all_flows] #TODO: Is the EPSILON even necessary? self.add_constraints(on <= sum(flow_ons) + CONFIG.modeling.EPSILON, short_name='on|ub') self.add_constraints(on >= sum(flow_ons) / (len(flow_ons) + CONFIG.modeling.EPSILON), short_name='on|lb') @@ -629,18 +629,18 @@ def do_modeling(self): if self.element.prevent_simultaneous_flows: # Simultanious Useage --> Only One FLow is On at a time, but needs a Binary for every flow - on_variables = [flow.model.on_off.on for flow in self.element.prevent_simultaneous_flows] + on_variables = [flow.submodel.on_off.on for flow in self.element.prevent_simultaneous_flows] ModelingPrimitives.mutual_exclusivity_constraint( self, - binary_variables=[flow.model.on_off.on for flow in self.element.prevent_simultaneous_flows], + binary_variables=[flow.submodel.on_off.on for flow in self.element.prevent_simultaneous_flows], short_name='prevent_simultaneous_use', ) def results_structure(self): return { **super().results_structure(), - 'inputs': [flow.model.flow_rate.name for flow in self.element.inputs], - 'outputs': [flow.model.flow_rate.name for flow in self.element.outputs], + 'inputs': [flow.submodel.flow_rate.name for flow in self.element.inputs], + 'outputs': [flow.submodel.flow_rate.name for flow in self.element.outputs], 'flows': [flow.label_full for flow in self.element.inputs + self.element.outputs], } @@ -650,7 +650,7 @@ def previous_states(self) -> Optional[xr.DataArray]: if self.element.on_off_parameters is None: raise ValueError(f'OnOffModel not present in \n{self}\nCant access previous_states') - previous_states = [flow.model.on_off._previous_states for flow in self.element.inputs + self.element.outputs] + previous_states = [flow.submodel.on_off._previous_states for flow in self.element.inputs + self.element.outputs] previous_states = [da for da in previous_states if da is not None] if not previous_states: # Empty list diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index 454a552b3..0a10b3ceb 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -452,7 +452,7 @@ def create_model(self) -> FlowSystemModel: if not self.connected_and_transformed: raise RuntimeError('FlowSystem is not connected_and_transformed. Call FlowSystem.connect_and_transform() first.') self.submodel = FlowSystemModel(self) - return self.model + return self.submodel def plot_network( self, diff --git a/flixopt/results.py b/flixopt/results.py index 9ede9ab49..941d0d6dc 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -167,7 +167,7 @@ def __init__( self.flow_system_data = flow_system_data self.summary = summary self.name = name - self.submodel = model + self.model = model self.folder = pathlib.Path(folder) if folder is not None else pathlib.Path.cwd() / 'results' self.components = { label: ComponentResults(self, **infos) for label, infos in self.solution.attrs['Components'].items() diff --git a/flixopt/structure.py b/flixopt/structure.py index 7da8ade78..61becd11f 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -74,21 +74,21 @@ def solution(self): solution['objective'] = self.objective.value solution.attrs = { 'Components': { - comp.label_full: comp.model.results_structure() + comp.label_full: comp.submodel.results_structure() for comp in sorted( self.flow_system.components.values(), key=lambda component: component.label_full.upper() ) }, 'Buses': { - bus.label_full: bus.model.results_structure() + bus.label_full: bus.submodel.results_structure() for bus in sorted(self.flow_system.buses.values(), key=lambda bus: bus.label_full.upper()) }, 'Effects': { - effect.label_full: effect.model.results_structure() + effect.label_full: effect.submodel.results_structure() for effect in sorted(self.flow_system.effects, key=lambda effect: effect.label_full.upper()) }, 'Flows': { - flow.label_full: flow.model.results_structure() + flow.label_full: flow.submodel.results_structure() for flow in sorted(self.flow_system.flows.values(), key=lambda flow: flow.label_full.upper()) }, } @@ -661,7 +661,7 @@ def __init__(self, label: str, meta_data: Dict = None): """ self.label = Element._valid_label(label) self.meta_data = meta_data if meta_data is not None else {} - self.model: Optional[ElementModel] = None + self.submodel: Optional[ElementModel] = None def _plausibility_checks(self) -> None: """This function is used to do some basic plausibility checks for each Element during initialization""" diff --git a/tests/test_bus.py b/tests/test_bus.py index 136f9d2cc..fb1cfcda3 100644 --- a/tests/test_bus.py +++ b/tests/test_bus.py @@ -20,8 +20,8 @@ def test_bus(self, basic_flow_system_linopy): fx.Source('GastarifTest', source=fx.Flow('Q_Gas', 'TestBus'))) model = create_linopy_model(flow_system) - assert set(bus.model.variables) == {'WärmelastTest(Q_th_Last)|flow_rate', 'GastarifTest(Q_Gas)|flow_rate'} - assert set(bus.model.constraints) == {'TestBus|balance'} + assert set(bus.submodel.variables) == {'WärmelastTest(Q_th_Last)|flow_rate', 'GastarifTest(Q_Gas)|flow_rate'} + assert set(bus.submodel.constraints) == {'TestBus|balance'} assert_conequal( model.constraints['TestBus|balance'], @@ -38,11 +38,11 @@ def test_bus_penalty(self, basic_flow_system_linopy): fx.Source('GastarifTest', source=fx.Flow('Q_Gas', 'TestBus'))) model = create_linopy_model(flow_system) - assert set(bus.model.variables) == {'TestBus|excess_input', + assert set(bus.submodel.variables) == {'TestBus|excess_input', 'TestBus|excess_output', 'WärmelastTest(Q_th_Last)|flow_rate', 'GastarifTest(Q_Gas)|flow_rate'} - assert set(bus.model.constraints) == {'TestBus|balance'} + assert set(bus.submodel.constraints) == {'TestBus|balance'} assert_var_equal(model.variables['TestBus|excess_input'], model.add_variables(lower=0, coords = (timesteps,))) assert_var_equal(model.variables['TestBus|excess_output'], model.add_variables(lower=0, coords=(timesteps,))) diff --git a/tests/test_component.py b/tests/test_component.py index 3bf1699ec..14b1544dd 100644 --- a/tests/test_component.py +++ b/tests/test_component.py @@ -53,12 +53,12 @@ def test_component(self, basic_flow_system_linopy): 'TestComponent(Out1)|flow_rate', 'TestComponent(Out1)|total_flow_hours', 'TestComponent(Out2)|flow_rate', - 'TestComponent(Out2)|total_flow_hours'} == set(comp.model.variables) + 'TestComponent(Out2)|total_flow_hours'} == set(comp.submodel.variables) assert {'TestComponent(In1)|total_flow_hours', 'TestComponent(In2)|total_flow_hours', 'TestComponent(Out1)|total_flow_hours', - 'TestComponent(Out2)|total_flow_hours'} == set(comp.model.constraints) + 'TestComponent(Out2)|total_flow_hours'} == set(comp.submodel.constraints) def test_on_with_multiple_flows(self, basic_flow_system_linopy): """Test that flow model constraints are correctly generated.""" @@ -78,7 +78,7 @@ def test_on_with_multiple_flows(self, basic_flow_system_linopy): flow_system.add_elements(comp) model = create_linopy_model(flow_system) - assert set(comp.model.variables) == { + assert set(comp.submodel.variables) == { 'TestComponent(In1)|flow_rate', 'TestComponent(In1)|total_flow_hours', 'TestComponent(In1)|on', @@ -95,7 +95,7 @@ def test_on_with_multiple_flows(self, basic_flow_system_linopy): 'TestComponent|on_hours_total', } - assert set(comp.model.constraints) == { + assert set(comp.submodel.constraints) == { 'TestComponent(In1)|total_flow_hours', 'TestComponent(In1)|flow_rate|lb', 'TestComponent(In1)|flow_rate|ub', @@ -158,7 +158,7 @@ def test_on_with_single_flow(self, basic_flow_system_linopy): flow_system.add_elements(comp) model = create_linopy_model(flow_system) - assert set(comp.model.variables) == { + assert set(comp.submodel.variables) == { 'TestComponent(In1)|flow_rate', 'TestComponent(In1)|total_flow_hours', 'TestComponent(In1)|on', @@ -167,7 +167,7 @@ def test_on_with_single_flow(self, basic_flow_system_linopy): 'TestComponent|on_hours_total', } - assert set(comp.model.constraints) == { + assert set(comp.submodel.constraints) == { 'TestComponent(In1)|total_flow_hours', 'TestComponent(In1)|flow_rate|lb', 'TestComponent(In1)|flow_rate|ub', @@ -214,7 +214,7 @@ def test_previous_states_with_multiple_flows(self, basic_flow_system_linopy): flow_system.add_elements(comp) model = create_linopy_model(flow_system) - assert set(comp.model.variables) == { + assert set(comp.submodel.variables) == { 'TestComponent(In1)|flow_rate', 'TestComponent(In1)|total_flow_hours', 'TestComponent(In1)|on', @@ -231,7 +231,7 @@ def test_previous_states_with_multiple_flows(self, basic_flow_system_linopy): 'TestComponent|on_hours_total', } - assert set(comp.model.constraints) == { + assert set(comp.submodel.constraints) == { 'TestComponent(In1)|total_flow_hours', 'TestComponent(In1)|flow_rate|lb', 'TestComponent(In1)|flow_rate|ub', @@ -296,14 +296,14 @@ def test_transmission_basic(self, basic_flow_system, highs_solver): # Assertions assert_almost_equal_numeric( - transmission.in1.model.on_off.on.solution.values, + transmission.in1.submodel.on_off.on.solution.values, np.array([1, 1, 1, 1, 1, 1, 1, 1, 1, 1]), 'On does not work properly', ) assert_almost_equal_numeric( - transmission.in1.model.flow_rate.solution.values * 0.8 - 20, - transmission.out1.model.flow_rate.solution.values, + transmission.in1.submodel.flow_rate.solution.values * 0.8 - 20, + transmission.out1.submodel.flow_rate.solution.values, 'Losses are not computed correctly', ) @@ -351,27 +351,27 @@ def test_transmission_balanced(self, basic_flow_system, highs_solver): # Assertions assert_almost_equal_numeric( - transmission.in1.model.on_off.on.solution.values, + transmission.in1.submodel.on_off.on.solution.values, np.array([1, 1, 1, 0, 0, 0, 0, 0, 0, 0]), 'On does not work properly', ) assert_almost_equal_numeric( calculation.results.model.variables['Rohr(Rohr1b)|flow_rate'].solution.values, - transmission.out1.model.flow_rate.solution.values, + transmission.out1.submodel.flow_rate.solution.values, 'Flow rate of Rohr__Rohr1b is not correct', ) assert_almost_equal_numeric( - transmission.in1.model.flow_rate.solution.values * 0.8 - - np.array([20 if val > 0.1 else 0 for val in transmission.in1.model.flow_rate.solution.values]), - transmission.out1.model.flow_rate.solution.values, + transmission.in1.submodel.flow_rate.solution.values * 0.8 + - np.array([20 if val > 0.1 else 0 for val in transmission.in1.submodel.flow_rate.solution.values]), + transmission.out1.submodel.flow_rate.solution.values, 'Losses are not computed correctly', ) assert_almost_equal_numeric( - transmission.in1.model._investment.size.solution.item(), - transmission.in2.model._investment.size.solution.item(), + transmission.in1.submodel._investment.size.solution.item(), + transmission.in2.submodel._investment.size.solution.item(), 'The Investments are not equated correctly', ) @@ -419,28 +419,28 @@ def test_transmission_unbalanced(self, basic_flow_system, highs_solver): # Assertions assert_almost_equal_numeric( - transmission.in1.model.on_off.on.solution.values, + transmission.in1.submodel.on_off.on.solution.values, np.array([1, 1, 1, 0, 0, 0, 0, 0, 0, 0]), 'On does not work properly', ) assert_almost_equal_numeric( calculation.results.model.variables['Rohr(Rohr1b)|flow_rate'].solution.values, - transmission.out1.model.flow_rate.solution.values, + transmission.out1.submodel.flow_rate.solution.values, 'Flow rate of Rohr__Rohr1b is not correct', ) assert_almost_equal_numeric( - transmission.in1.model.flow_rate.solution.values * 0.8 - - np.array([20 if val > 0.1 else 0 for val in transmission.in1.model.flow_rate.solution.values]), - transmission.out1.model.flow_rate.solution.values, + transmission.in1.submodel.flow_rate.solution.values * 0.8 + - np.array([20 if val > 0.1 else 0 for val in transmission.in1.submodel.flow_rate.solution.values]), + transmission.out1.submodel.flow_rate.solution.values, 'Losses are not computed correctly', ) - assert transmission.in1.model._investment.size.solution.item() > 11 + assert transmission.in1.submodel._investment.size.solution.item() > 11 assert_almost_equal_numeric( - transmission.in2.model._investment.size.solution.item(), + transmission.in2.submodel._investment.size.solution.item(), 10, 'Sizing does not work properly', ) diff --git a/tests/test_effect.py b/tests/test_effect.py index 8c75813e7..cce8ac939 100644 --- a/tests/test_effect.py +++ b/tests/test_effect.py @@ -19,11 +19,11 @@ def test_minimal(self, basic_flow_system_linopy): flow_system.add_elements(effect) model = create_linopy_model(flow_system) - assert set(effect.model.variables) == {'Effect1(invest)|total', + assert set(effect.submodel.variables) == {'Effect1(invest)|total', 'Effect1(operation)|total', 'Effect1(operation)|total_per_timestep', 'Effect1|total',} - assert set(effect.model.constraints) == {'Effect1(invest)|total', + assert set(effect.submodel.constraints) == {'Effect1(invest)|total', 'Effect1(operation)|total', 'Effect1(operation)|total_per_timestep', 'Effect1|total',} @@ -58,11 +58,11 @@ def test_bounds(self, basic_flow_system_linopy): flow_system.add_elements(effect) model = create_linopy_model(flow_system) - assert set(effect.model.variables) == {'Effect1(invest)|total', + assert set(effect.submodel.variables) == {'Effect1(invest)|total', 'Effect1(operation)|total', 'Effect1(operation)|total_per_timestep', 'Effect1|total',} - assert set(effect.model.constraints) == {'Effect1(invest)|total', + assert set(effect.submodel.constraints) == {'Effect1(invest)|total', 'Effect1(operation)|total', 'Effect1(operation)|total_per_timestep', 'Effect1|total',} @@ -100,7 +100,7 @@ def test_shares(self, basic_flow_system_linopy): flow_system.add_elements(effect1, effect2, effect3) model = create_linopy_model(flow_system) - assert set(effect2.model.variables) == { + assert set(effect2.submodel.variables) == { 'Effect2(invest)|total', 'Effect2(operation)|total', 'Effect2(operation)|total_per_timestep', @@ -108,7 +108,7 @@ def test_shares(self, basic_flow_system_linopy): 'Effect1(invest)->Effect2(invest)', 'Effect1(operation)->Effect2(operation)', } - assert set(effect2.model.constraints) == { + assert set(effect2.submodel.constraints) == { 'Effect2(invest)|total', 'Effect2(operation)|total', 'Effect2(operation)|total_per_timestep', diff --git a/tests/test_flow.py b/tests/test_flow.py index 50154859d..43ecbe34f 100644 --- a/tests/test_flow.py +++ b/tests/test_flow.py @@ -23,14 +23,14 @@ def test_flow_minimal(self, basic_flow_system_linopy): assert_conequal( model.constraints['Sink(Wärme)|total_flow_hours'], - flow.model.variables['Sink(Wärme)|total_flow_hours'] == (flow.model.variables['Sink(Wärme)|flow_rate'] * model.hours_per_step).sum() + flow.submodel.variables['Sink(Wärme)|total_flow_hours'] == (flow.submodel.variables['Sink(Wärme)|flow_rate'] * model.hours_per_step).sum() ) - assert_var_equal(flow.model.flow_rate, + assert_var_equal(flow.submodel.flow_rate, model.add_variables(lower=0, upper=100, coords=(timesteps,))) - assert_var_equal(flow.model.total_flow_hours, model.add_variables(lower=0)) + assert_var_equal(flow.submodel.total_flow_hours, model.add_variables(lower=0)) - assert set(flow.model.variables) == set(['Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|flow_rate']) - assert set(flow.model.constraints) == set(['Sink(Wärme)|total_flow_hours']) + assert set(flow.submodel.variables) == set(['Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|flow_rate']) + assert set(flow.submodel.constraints) == set(['Sink(Wärme)|total_flow_hours']) def test_flow(self, basic_flow_system_linopy): flow_system = basic_flow_system_linopy @@ -53,17 +53,17 @@ def test_flow(self, basic_flow_system_linopy): # total_flow_hours assert_conequal( model.constraints['Sink(Wärme)|total_flow_hours'], - flow.model.variables['Sink(Wärme)|total_flow_hours'] - == (flow.model.variables['Sink(Wärme)|flow_rate'] * model.hours_per_step).sum(), + flow.submodel.variables['Sink(Wärme)|total_flow_hours'] + == (flow.submodel.variables['Sink(Wärme)|flow_rate'] * model.hours_per_step).sum(), ) assert_var_equal( - flow.model.total_flow_hours, + flow.submodel.total_flow_hours, model.add_variables(lower=10, upper=1000) ) assert_var_equal( - flow.model.flow_rate, + flow.submodel.flow_rate, model.add_variables(lower=np.linspace(0, 0.5, timesteps.size) * 100, upper=np.linspace(0.5, 1, timesteps.size) * 100, coords=(timesteps,)) @@ -71,18 +71,18 @@ def test_flow(self, basic_flow_system_linopy): assert_conequal( model.constraints['Sink(Wärme)|load_factor_min'], - flow.model.variables['Sink(Wärme)|total_flow_hours'] + flow.submodel.variables['Sink(Wärme)|total_flow_hours'] >= model.hours_per_step.sum('time') * 0.1 * 100, ) assert_conequal( model.constraints['Sink(Wärme)|load_factor_max'], - flow.model.variables['Sink(Wärme)|total_flow_hours'] + flow.submodel.variables['Sink(Wärme)|total_flow_hours'] <= model.hours_per_step.sum('time') * 0.9 * 100, ) - assert set(flow.model.variables) == set(['Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|flow_rate']) - assert set(flow.model.constraints) == set(['Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|load_factor_max', 'Sink(Wärme)|load_factor_min']) + assert set(flow.submodel.variables) == set(['Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|flow_rate']) + assert set(flow.submodel.constraints) == set(['Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|load_factor_max', 'Sink(Wärme)|load_factor_min']) def test_effects_per_flow_hour(self, basic_flow_system_linopy): flow_system = basic_flow_system_linopy @@ -100,19 +100,19 @@ def test_effects_per_flow_hour(self, basic_flow_system_linopy): model = create_linopy_model(flow_system) costs, co2 = flow_system.effects['Costs'], flow_system.effects['CO2'] - assert set(flow.model.variables) == {'Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|flow_rate'} - assert set(flow.model.constraints) == {'Sink(Wärme)|total_flow_hours'} + assert set(flow.submodel.variables) == {'Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|flow_rate'} + assert set(flow.submodel.constraints) == {'Sink(Wärme)|total_flow_hours'} - assert 'Sink(Wärme)->Costs(operation)' in set(costs.model.constraints) - assert 'Sink(Wärme)->CO2(operation)' in set(co2.model.constraints) + assert 'Sink(Wärme)->Costs(operation)' in set(costs.submodel.constraints) + assert 'Sink(Wärme)->CO2(operation)' in set(co2.submodel.constraints) assert_conequal( model.constraints['Sink(Wärme)->Costs(operation)'], - model.variables['Sink(Wärme)->Costs(operation)'] == flow.model.variables['Sink(Wärme)|flow_rate'] * model.hours_per_step * costs_per_flow_hour) + model.variables['Sink(Wärme)->Costs(operation)'] == flow.submodel.variables['Sink(Wärme)|flow_rate'] * model.hours_per_step * costs_per_flow_hour) assert_conequal( model.constraints['Sink(Wärme)->CO2(operation)'], - model.variables['Sink(Wärme)->CO2(operation)'] == flow.model.variables['Sink(Wärme)|flow_rate'] * model.hours_per_step * co2_per_flow_hour) + model.variables['Sink(Wärme)->CO2(operation)'] == flow.submodel.variables['Sink(Wärme)|flow_rate'] * model.hours_per_step * co2_per_flow_hour) class TestFlowInvestModel: @@ -133,14 +133,14 @@ def test_flow_invest(self, basic_flow_system_linopy): flow_system.add_elements(fx.Sink('Sink', sink=flow)) model = create_linopy_model(flow_system) - assert set(flow.model.variables) == set( + assert set(flow.submodel.variables) == set( [ 'Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|flow_rate', 'Sink(Wärme)|size', ] ) - assert set(flow.model.constraints) == set( + assert set(flow.submodel.constraints) == set( [ 'Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|flow_rate|ub', @@ -153,7 +153,7 @@ def test_flow_invest(self, basic_flow_system_linopy): # flow_rate assert_var_equal( - flow.model.flow_rate, + flow.submodel.flow_rate, model.add_variables( lower=np.linspace(0.1, 0.5, timesteps.size) * 20, upper=np.linspace(0.5, 1, timesteps.size) * 100, @@ -162,14 +162,14 @@ def test_flow_invest(self, basic_flow_system_linopy): ) assert_conequal( model.constraints['Sink(Wärme)|flow_rate|lb'], - flow.model.variables['Sink(Wärme)|flow_rate'] - >= flow.model.variables['Sink(Wärme)|size'] + flow.submodel.variables['Sink(Wärme)|flow_rate'] + >= flow.submodel.variables['Sink(Wärme)|size'] * xr.DataArray(np.linspace(0.1, 0.5, timesteps.size), coords=(timesteps,)), ) assert_conequal( model.constraints['Sink(Wärme)|flow_rate|ub'], - flow.model.variables['Sink(Wärme)|flow_rate'] - <= flow.model.variables['Sink(Wärme)|size'] + flow.submodel.variables['Sink(Wärme)|flow_rate'] + <= flow.submodel.variables['Sink(Wärme)|size'] * xr.DataArray(np.linspace(0.5, 1, timesteps.size), coords=(timesteps,)), ) @@ -188,10 +188,10 @@ def test_flow_invest_optional(self, basic_flow_system_linopy): flow_system.add_elements(fx.Sink('Sink', sink=flow)) model = create_linopy_model(flow_system) - assert set(flow.model.variables) == set( + assert set(flow.submodel.variables) == set( ['Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|flow_rate', 'Sink(Wärme)|size', 'Sink(Wärme)|is_invested'] ) - assert set(flow.model.constraints) == set( + assert set(flow.submodel.constraints) == set( [ 'Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|size|lb', @@ -207,7 +207,7 @@ def test_flow_invest_optional(self, basic_flow_system_linopy): # flow_rate assert_var_equal( - flow.model.flow_rate, + flow.submodel.flow_rate, model.add_variables( lower=0, # Optional investment upper=np.linspace(0.5, 1, timesteps.size) * 100, @@ -216,25 +216,25 @@ def test_flow_invest_optional(self, basic_flow_system_linopy): ) assert_conequal( model.constraints['Sink(Wärme)|flow_rate|lb'], - flow.model.variables['Sink(Wärme)|flow_rate'] - >= flow.model.variables['Sink(Wärme)|size'] + flow.submodel.variables['Sink(Wärme)|flow_rate'] + >= flow.submodel.variables['Sink(Wärme)|size'] * xr.DataArray(np.linspace(0.1, 0.5, timesteps.size), coords=(timesteps,)), ) assert_conequal( model.constraints['Sink(Wärme)|flow_rate|ub'], - flow.model.variables['Sink(Wärme)|flow_rate'] - <= flow.model.variables['Sink(Wärme)|size'] + flow.submodel.variables['Sink(Wärme)|flow_rate'] + <= flow.submodel.variables['Sink(Wärme)|size'] * xr.DataArray(np.linspace(0.5, 1, timesteps.size), coords=(timesteps,)), ) # Is invested assert_conequal( model.constraints['Sink(Wärme)|size|ub'], - flow.model.variables['Sink(Wärme)|size'] <= flow.model.variables['Sink(Wärme)|is_invested'] * 100, + flow.submodel.variables['Sink(Wärme)|size'] <= flow.submodel.variables['Sink(Wärme)|is_invested'] * 100, ) assert_conequal( model.constraints['Sink(Wärme)|size|lb'], - flow.model.variables['Sink(Wärme)|size'] >= flow.model.variables['Sink(Wärme)|is_invested'] * 20, + flow.submodel.variables['Sink(Wärme)|size'] >= flow.submodel.variables['Sink(Wärme)|is_invested'] * 20, ) def test_flow_invest_optional_wo_min_size(self, basic_flow_system_linopy): @@ -252,10 +252,10 @@ def test_flow_invest_optional_wo_min_size(self, basic_flow_system_linopy): flow_system.add_elements(fx.Sink('Sink', sink=flow)) model = create_linopy_model(flow_system) - assert set(flow.model.variables) == set( + assert set(flow.submodel.variables) == set( ['Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|flow_rate', 'Sink(Wärme)|size', 'Sink(Wärme)|is_invested'] ) - assert set(flow.model.constraints) == set( + assert set(flow.submodel.constraints) == set( [ 'Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|size|ub', @@ -271,7 +271,7 @@ def test_flow_invest_optional_wo_min_size(self, basic_flow_system_linopy): # flow_rate assert_var_equal( - flow.model.flow_rate, + flow.submodel.flow_rate, model.add_variables( lower=0, # Optional investment upper=np.linspace(0.5, 1, timesteps.size) * 100, @@ -280,25 +280,25 @@ def test_flow_invest_optional_wo_min_size(self, basic_flow_system_linopy): ) assert_conequal( model.constraints['Sink(Wärme)|flow_rate|lb'], - flow.model.variables['Sink(Wärme)|flow_rate'] - >= flow.model.variables['Sink(Wärme)|size'] + flow.submodel.variables['Sink(Wärme)|flow_rate'] + >= flow.submodel.variables['Sink(Wärme)|size'] * xr.DataArray(np.linspace(0.1, 0.5, timesteps.size), coords=(timesteps,)), ) assert_conequal( model.constraints['Sink(Wärme)|flow_rate|ub'], - flow.model.variables['Sink(Wärme)|flow_rate'] - <= flow.model.variables['Sink(Wärme)|size'] + flow.submodel.variables['Sink(Wärme)|flow_rate'] + <= flow.submodel.variables['Sink(Wärme)|size'] * xr.DataArray(np.linspace(0.5, 1, timesteps.size), coords=(timesteps,)), ) # Is invested assert_conequal( model.constraints['Sink(Wärme)|size|ub'], - flow.model.variables['Sink(Wärme)|size'] <= flow.model.variables['Sink(Wärme)|is_invested'] * 100, + flow.submodel.variables['Sink(Wärme)|size'] <= flow.submodel.variables['Sink(Wärme)|is_invested'] * 100, ) assert_conequal( model.constraints['Sink(Wärme)|size|lb'], - flow.model.variables['Sink(Wärme)|size'] >= flow.model.variables['Sink(Wärme)|is_invested'] * 1e-5, + flow.submodel.variables['Sink(Wärme)|size'] >= flow.submodel.variables['Sink(Wärme)|is_invested'] * 1e-5, ) def test_flow_invest_wo_min_size_non_optional(self, basic_flow_system_linopy): @@ -316,10 +316,10 @@ def test_flow_invest_wo_min_size_non_optional(self, basic_flow_system_linopy): flow_system.add_elements(fx.Sink('Sink', sink=flow)) model = create_linopy_model(flow_system) - assert set(flow.model.variables) == set( + assert set(flow.submodel.variables) == set( ['Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|flow_rate', 'Sink(Wärme)|size'] ) - assert set(flow.model.constraints) == set( + assert set(flow.submodel.constraints) == set( [ 'Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|flow_rate|lb', @@ -331,7 +331,7 @@ def test_flow_invest_wo_min_size_non_optional(self, basic_flow_system_linopy): # flow_rate assert_var_equal( - flow.model.flow_rate, + flow.submodel.flow_rate, model.add_variables( lower=np.linspace(0.1, 0.5, timesteps.size) * 1e-5, upper=np.linspace(0.5, 1, timesteps.size) * 100, @@ -340,14 +340,14 @@ def test_flow_invest_wo_min_size_non_optional(self, basic_flow_system_linopy): ) assert_conequal( model.constraints['Sink(Wärme)|flow_rate|lb'], - flow.model.variables['Sink(Wärme)|flow_rate'] - >= flow.model.variables['Sink(Wärme)|size'] + flow.submodel.variables['Sink(Wärme)|flow_rate'] + >= flow.submodel.variables['Sink(Wärme)|size'] * xr.DataArray(np.linspace(0.1, 0.5, timesteps.size), coords=(timesteps,)), ) assert_conequal( model.constraints['Sink(Wärme)|flow_rate|ub'], - flow.model.variables['Sink(Wärme)|flow_rate'] - <= flow.model.variables['Sink(Wärme)|size'] + flow.submodel.variables['Sink(Wärme)|flow_rate'] + <= flow.submodel.variables['Sink(Wärme)|size'] * xr.DataArray(np.linspace(0.5, 1, timesteps.size), coords=(timesteps,)), ) @@ -367,13 +367,13 @@ def test_flow_invest_fixed_size(self, basic_flow_system_linopy): flow_system.add_elements(fx.Sink('Sink', sink=flow)) model = create_linopy_model(flow_system) - assert set(flow.model.variables) == {'Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|flow_rate', 'Sink(Wärme)|size'} + assert set(flow.submodel.variables) == {'Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|flow_rate', 'Sink(Wärme)|size'} # Check that size is fixed to 75 - assert_var_equal(flow.model.variables['Sink(Wärme)|size'], model.add_variables(lower=75, upper=75)) + assert_var_equal(flow.submodel.variables['Sink(Wärme)|size'], model.add_variables(lower=75, upper=75)) # Check flow rate bounds - assert_var_equal(flow.model.flow_rate, model.add_variables(lower=0.2 * 75, upper=0.9 * 75, coords=(timesteps,))) + assert_var_equal(flow.submodel.flow_rate, model.add_variables(lower=0.2 * 75, upper=0.9 * 75, coords=(timesteps,))) def test_flow_invest_with_effects(self, basic_flow_system_linopy): """Test flow with investment effects.""" @@ -405,13 +405,13 @@ def test_flow_invest_with_effects(self, basic_flow_system_linopy): assert_conequal( model.constraints['Sink(Wärme)->Costs(invest)'], model.variables['Sink(Wärme)->Costs(invest)'] - == flow.model.variables['Sink(Wärme)|is_invested'] * 1000 + flow.model.variables['Sink(Wärme)|size'] * 500, + == flow.submodel.variables['Sink(Wärme)|is_invested'] * 1000 + flow.submodel.variables['Sink(Wärme)|size'] * 500, ) assert_conequal( model.constraints['Sink(Wärme)->CO2(invest)'], model.variables['Sink(Wärme)->CO2(invest)'] - == flow.model.variables['Sink(Wärme)|is_invested'] * 5 + flow.model.variables['Sink(Wärme)|size'] * 0.1, + == flow.submodel.variables['Sink(Wärme)|is_invested'] * 5 + flow.submodel.variables['Sink(Wärme)|size'] * 0.1, ) def test_flow_invest_divest_effects(self, basic_flow_system_linopy): @@ -458,11 +458,11 @@ def test_flow_on(self, basic_flow_system_linopy): flow_system.add_elements(fx.Sink('Sink', sink=flow)) model = create_linopy_model(flow_system) - assert set(flow.model.variables) == set( + assert set(flow.submodel.variables) == set( ['Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|flow_rate', 'Sink(Wärme)|on', 'Sink(Wärme)|on_hours_total'] ) - assert set(flow.model.constraints) == set( + assert set(flow.submodel.constraints) == set( [ 'Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|on_hours_total', @@ -472,7 +472,7 @@ def test_flow_on(self, basic_flow_system_linopy): ) # flow_rate assert_var_equal( - flow.model.flow_rate, + flow.submodel.flow_rate, model.add_variables( lower=0, upper=0.8 * 100, @@ -482,7 +482,7 @@ def test_flow_on(self, basic_flow_system_linopy): # OnOff assert_var_equal( - flow.model.on_off.on, + flow.submodel.on_off.on, model.add_variables(binary=True, coords=(timesteps,)), ) assert_var_equal( @@ -491,17 +491,17 @@ def test_flow_on(self, basic_flow_system_linopy): ) assert_conequal( model.constraints['Sink(Wärme)|flow_rate|lb'], - flow.model.variables['Sink(Wärme)|flow_rate'] >= flow.model.variables['Sink(Wärme)|on'] * 0.2 * 100, + flow.submodel.variables['Sink(Wärme)|flow_rate'] >= flow.submodel.variables['Sink(Wärme)|on'] * 0.2 * 100, ) assert_conequal( model.constraints['Sink(Wärme)|flow_rate|ub'], - flow.model.variables['Sink(Wärme)|flow_rate'] <= flow.model.variables['Sink(Wärme)|on'] * 0.8 * 100, + flow.submodel.variables['Sink(Wärme)|flow_rate'] <= flow.submodel.variables['Sink(Wärme)|on'] * 0.8 * 100, ) assert_conequal( model.constraints['Sink(Wärme)|on_hours_total'], - flow.model.variables['Sink(Wärme)|on_hours_total'] - == (flow.model.variables['Sink(Wärme)|on'] * model.hours_per_step).sum(), + flow.submodel.variables['Sink(Wärme)|on_hours_total'] + == (flow.submodel.variables['Sink(Wärme)|on'] * model.hours_per_step).sum(), ) def test_effects_per_running_hour(self, basic_flow_system_linopy): @@ -522,32 +522,32 @@ def test_effects_per_running_hour(self, basic_flow_system_linopy): model = create_linopy_model(flow_system) costs, co2 = flow_system.effects['Costs'], flow_system.effects['CO2'] - assert set(flow.model.variables) == { + assert set(flow.submodel.variables) == { 'Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|flow_rate', 'Sink(Wärme)|on', 'Sink(Wärme)|on_hours_total', } - assert set(flow.model.constraints) == { + assert set(flow.submodel.constraints) == { 'Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|flow_rate|lb', 'Sink(Wärme)|flow_rate|ub', 'Sink(Wärme)|on_hours_total', } - assert 'Sink(Wärme)->Costs(operation)' in set(costs.model.constraints) - assert 'Sink(Wärme)->CO2(operation)' in set(co2.model.constraints) + assert 'Sink(Wärme)->Costs(operation)' in set(costs.submodel.constraints) + assert 'Sink(Wärme)->CO2(operation)' in set(co2.submodel.constraints) assert_conequal( model.constraints['Sink(Wärme)->Costs(operation)'], model.variables['Sink(Wärme)->Costs(operation)'] - == flow.model.variables['Sink(Wärme)|on'] * model.hours_per_step * costs_per_running_hour, + == flow.submodel.variables['Sink(Wärme)|on'] * model.hours_per_step * costs_per_running_hour, ) assert_conequal( model.constraints['Sink(Wärme)->CO2(operation)'], model.variables['Sink(Wärme)->CO2(operation)'] - == flow.model.variables['Sink(Wärme)|on'] * model.hours_per_step * co2_per_running_hour, + == flow.submodel.variables['Sink(Wärme)|on'] * model.hours_per_step * co2_per_running_hour, ) def test_consecutive_on_hours(self, basic_flow_system_linopy): @@ -568,14 +568,14 @@ def test_consecutive_on_hours(self, basic_flow_system_linopy): flow_system.add_elements( fx.Sink('Sink', sink=flow)) model = create_linopy_model(flow_system) - assert {'Sink(Wärme)|consecutive_on_hours', 'Sink(Wärme)|on'}.issubset(set(flow.model.variables)) + assert {'Sink(Wärme)|consecutive_on_hours', 'Sink(Wärme)|on'}.issubset(set(flow.submodel.variables)) assert {'Sink(Wärme)|consecutive_on_hours|ub', 'Sink(Wärme)|consecutive_on_hours|forward', 'Sink(Wärme)|consecutive_on_hours|backward', 'Sink(Wärme)|consecutive_on_hours|initial', 'Sink(Wärme)|consecutive_on_hours|lb', - }.issubset(set(flow.model.constraints)) + }.issubset(set(flow.submodel.constraints)) assert_var_equal( model.variables['Sink(Wärme)|consecutive_on_hours'], @@ -635,14 +635,14 @@ def test_consecutive_on_hours_previous(self, basic_flow_system_linopy): flow_system.add_elements( fx.Sink('Sink', sink=flow)) model = create_linopy_model(flow_system) - assert {'Sink(Wärme)|consecutive_on_hours', 'Sink(Wärme)|on'}.issubset(set(flow.model.variables)) + assert {'Sink(Wärme)|consecutive_on_hours', 'Sink(Wärme)|on'}.issubset(set(flow.submodel.variables)) assert {'Sink(Wärme)|consecutive_on_hours|lb', 'Sink(Wärme)|consecutive_on_hours|forward', 'Sink(Wärme)|consecutive_on_hours|backward', 'Sink(Wärme)|consecutive_on_hours|initial', 'Sink(Wärme)|consecutive_on_hours|lb', - }.issubset(set(flow.model.constraints)) + }.issubset(set(flow.submodel.constraints)) assert_var_equal( model.variables['Sink(Wärme)|consecutive_on_hours'], @@ -701,7 +701,7 @@ def test_consecutive_off_hours(self, basic_flow_system_linopy): flow_system.add_elements( fx.Sink('Sink', sink=flow)) model = create_linopy_model(flow_system) - assert {'Sink(Wärme)|consecutive_off_hours', 'Sink(Wärme)|off'}.issubset(set(flow.model.variables)) + assert {'Sink(Wärme)|consecutive_off_hours', 'Sink(Wärme)|off'}.issubset(set(flow.submodel.variables)) assert { 'Sink(Wärme)|consecutive_off_hours|ub', @@ -709,7 +709,7 @@ def test_consecutive_off_hours(self, basic_flow_system_linopy): 'Sink(Wärme)|consecutive_off_hours|backward', 'Sink(Wärme)|consecutive_off_hours|initial', 'Sink(Wärme)|consecutive_off_hours|lb' - }.issubset(set(flow.model.constraints)) + }.issubset(set(flow.submodel.constraints)) assert_var_equal( model.variables['Sink(Wärme)|consecutive_off_hours'], @@ -769,7 +769,7 @@ def test_consecutive_off_hours_previous(self, basic_flow_system_linopy): flow_system.add_elements( fx.Sink('Sink', sink=flow)) model = create_linopy_model(flow_system) - assert {'Sink(Wärme)|consecutive_off_hours', 'Sink(Wärme)|off'}.issubset(set(flow.model.variables)) + assert {'Sink(Wärme)|consecutive_off_hours', 'Sink(Wärme)|off'}.issubset(set(flow.submodel.variables)) assert { 'Sink(Wärme)|consecutive_off_hours|ub', @@ -777,7 +777,7 @@ def test_consecutive_off_hours_previous(self, basic_flow_system_linopy): 'Sink(Wärme)|consecutive_off_hours|backward', 'Sink(Wärme)|consecutive_off_hours|initial', 'Sink(Wärme)|consecutive_off_hours|lb' - }.issubset(set(flow.model.constraints)) + }.issubset(set(flow.submodel.constraints)) assert_var_equal( model.variables['Sink(Wärme)|consecutive_off_hours'], @@ -837,7 +837,7 @@ def test_switch_on_constraints(self, basic_flow_system_linopy): # Check that variables exist assert {'Sink(Wärme)|switch|on', 'Sink(Wärme)|switch|off', 'Sink(Wärme)|switch|count'}.issubset( - set(flow.model.variables) + set(flow.submodel.variables) ) # Check that constraints exist @@ -846,16 +846,16 @@ def test_switch_on_constraints(self, basic_flow_system_linopy): 'Sink(Wärme)|switch|initial', 'Sink(Wärme)|switch|mutex', 'Sink(Wärme)|switch|count', - }.issubset(set(flow.model.constraints)) + }.issubset(set(flow.submodel.constraints)) # Check switch_on_nr variable bounds - assert_var_equal(flow.model.variables['Sink(Wärme)|switch|count'], model.add_variables(lower=0, upper=5)) + assert_var_equal(flow.submodel.variables['Sink(Wärme)|switch|count'], model.add_variables(lower=0, upper=5)) # Verify switch_on_nr constraint (limits number of startups) assert_conequal( model.constraints['Sink(Wärme)|switch|count'], - flow.model.variables['Sink(Wärme)|switch|count'] - == flow.model.variables['Sink(Wärme)|switch|on'].sum('time'), + flow.submodel.variables['Sink(Wärme)|switch|count'] + == flow.submodel.variables['Sink(Wärme)|switch|on'].sum('time'), ) # Check that startup cost effect constraint exists @@ -864,7 +864,7 @@ def test_switch_on_constraints(self, basic_flow_system_linopy): # Verify the startup cost effect constraint assert_conequal( model.constraints['Sink(Wärme)->Costs(operation)'], - model.variables['Sink(Wärme)->Costs(operation)'] == flow.model.variables['Sink(Wärme)|switch|on'] * 100, + model.variables['Sink(Wärme)->Costs(operation)'] == flow.submodel.variables['Sink(Wärme)|switch|on'] * 100, ) def test_on_hours_limits(self, basic_flow_system_linopy): @@ -885,19 +885,19 @@ def test_on_hours_limits(self, basic_flow_system_linopy): model = create_linopy_model(flow_system) # Check that variables exist - assert {'Sink(Wärme)|on', 'Sink(Wärme)|on_hours_total'}.issubset(set(flow.model.variables)) + assert {'Sink(Wärme)|on', 'Sink(Wärme)|on_hours_total'}.issubset(set(flow.submodel.variables)) # Check that constraints exist assert 'Sink(Wärme)|on_hours_total' in model.constraints # Check on_hours_total variable bounds - assert_var_equal(flow.model.variables['Sink(Wärme)|on_hours_total'], model.add_variables(lower=20, upper=100)) + assert_var_equal(flow.submodel.variables['Sink(Wärme)|on_hours_total'], model.add_variables(lower=20, upper=100)) # Check on_hours_total constraint assert_conequal( model.constraints['Sink(Wärme)|on_hours_total'], - flow.model.variables['Sink(Wärme)|on_hours_total'] - == (flow.model.variables['Sink(Wärme)|on'] * model.hours_per_step).sum(), + flow.submodel.variables['Sink(Wärme)|on_hours_total'] + == (flow.submodel.variables['Sink(Wärme)|on'] * model.hours_per_step).sum(), ) @@ -918,7 +918,7 @@ def test_flow_on_invest_optional(self, basic_flow_system_linopy): flow_system.add_elements(fx.Sink('Sink', sink=flow)) model = create_linopy_model(flow_system) - assert set(flow.model.variables) == set( + assert set(flow.submodel.variables) == set( [ 'Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|flow_rate', @@ -929,7 +929,7 @@ def test_flow_on_invest_optional(self, basic_flow_system_linopy): ] ) - assert set(flow.model.constraints) == set( + assert set(flow.submodel.constraints) == set( [ 'Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|on_hours_total', @@ -944,7 +944,7 @@ def test_flow_on_invest_optional(self, basic_flow_system_linopy): # flow_rate assert_var_equal( - flow.model.flow_rate, + flow.submodel.flow_rate, model.add_variables( lower=0, upper=0.8 * 200, @@ -954,7 +954,7 @@ def test_flow_on_invest_optional(self, basic_flow_system_linopy): # OnOff assert_var_equal( - flow.model.on_off.on, + flow.submodel.on_off.on, model.add_variables(binary=True, coords=(timesteps,)), ) assert_var_equal( @@ -963,24 +963,24 @@ def test_flow_on_invest_optional(self, basic_flow_system_linopy): ) assert_conequal( model.constraints['Sink(Wärme)|size|lb'], - flow.model.variables['Sink(Wärme)|size'] >= flow.model.variables['Sink(Wärme)|is_invested'] * 20, + flow.submodel.variables['Sink(Wärme)|size'] >= flow.submodel.variables['Sink(Wärme)|is_invested'] * 20, ) assert_conequal( model.constraints['Sink(Wärme)|size|ub'], - flow.model.variables['Sink(Wärme)|size']<= flow.model.variables['Sink(Wärme)|is_invested'] * 200, + flow.submodel.variables['Sink(Wärme)|size']<= flow.submodel.variables['Sink(Wärme)|is_invested'] * 200, ) assert_conequal( model.constraints['Sink(Wärme)|flow_rate|lb1'], - flow.model.variables['Sink(Wärme)|on'] * 0.2 * 20 <= flow.model.variables['Sink(Wärme)|flow_rate'], + flow.submodel.variables['Sink(Wärme)|on'] * 0.2 * 20 <= flow.submodel.variables['Sink(Wärme)|flow_rate'], ) assert_conequal( model.constraints['Sink(Wärme)|flow_rate|ub1'], - flow.model.variables['Sink(Wärme)|on'] * 0.8 * 200 >= flow.model.variables['Sink(Wärme)|flow_rate'], + flow.submodel.variables['Sink(Wärme)|on'] * 0.8 * 200 >= flow.submodel.variables['Sink(Wärme)|flow_rate'], ) assert_conequal( model.constraints['Sink(Wärme)|on_hours_total'], - flow.model.variables['Sink(Wärme)|on_hours_total'] - == (flow.model.variables['Sink(Wärme)|on'] * model.hours_per_step).sum(), + flow.submodel.variables['Sink(Wärme)|on_hours_total'] + == (flow.submodel.variables['Sink(Wärme)|on'] * model.hours_per_step).sum(), ) # Investment @@ -989,12 +989,12 @@ def test_flow_on_invest_optional(self, basic_flow_system_linopy): mega = 0.2 * 200 # Relative minimum * maximum size assert_conequal( model.constraints['Sink(Wärme)|flow_rate|lb2'], - flow.model.variables['Sink(Wärme)|flow_rate'] - >= flow.model.variables['Sink(Wärme)|on'] * mega + flow.model.variables['Sink(Wärme)|size'] * 0.2 - mega, + flow.submodel.variables['Sink(Wärme)|flow_rate'] + >= flow.submodel.variables['Sink(Wärme)|on'] * mega + flow.submodel.variables['Sink(Wärme)|size'] * 0.2 - mega, ) assert_conequal( model.constraints['Sink(Wärme)|flow_rate|ub2'], - flow.model.variables['Sink(Wärme)|flow_rate'] <= flow.model.variables['Sink(Wärme)|size'] * 0.8, + flow.submodel.variables['Sink(Wärme)|flow_rate'] <= flow.submodel.variables['Sink(Wärme)|size'] * 0.8, ) def test_flow_on_invest_non_optional(self, basic_flow_system_linopy): @@ -1011,7 +1011,7 @@ def test_flow_on_invest_non_optional(self, basic_flow_system_linopy): flow_system.add_elements(fx.Sink('Sink', sink=flow)) model = create_linopy_model(flow_system) - assert set(flow.model.variables) == set( + assert set(flow.submodel.variables) == set( [ 'Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|flow_rate', @@ -1021,7 +1021,7 @@ def test_flow_on_invest_non_optional(self, basic_flow_system_linopy): ] ) - assert set(flow.model.constraints) == set( + assert set(flow.submodel.constraints) == set( [ 'Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|on_hours_total', @@ -1034,7 +1034,7 @@ def test_flow_on_invest_non_optional(self, basic_flow_system_linopy): # flow_rate assert_var_equal( - flow.model.flow_rate, + flow.submodel.flow_rate, model.add_variables( lower=0, upper=0.8 * 200, @@ -1044,7 +1044,7 @@ def test_flow_on_invest_non_optional(self, basic_flow_system_linopy): # OnOff assert_var_equal( - flow.model.on_off.on, + flow.submodel.on_off.on, model.add_variables(binary=True, coords=(timesteps,)), ) assert_var_equal( @@ -1053,16 +1053,16 @@ def test_flow_on_invest_non_optional(self, basic_flow_system_linopy): ) assert_conequal( model.constraints['Sink(Wärme)|flow_rate|lb1'], - flow.model.variables['Sink(Wärme)|on'] * 0.2 * 20 <= flow.model.variables['Sink(Wärme)|flow_rate'], + flow.submodel.variables['Sink(Wärme)|on'] * 0.2 * 20 <= flow.submodel.variables['Sink(Wärme)|flow_rate'], ) assert_conequal( model.constraints['Sink(Wärme)|flow_rate|ub1'], - flow.model.variables['Sink(Wärme)|on'] * 0.8 * 200 >= flow.model.variables['Sink(Wärme)|flow_rate'], + flow.submodel.variables['Sink(Wärme)|on'] * 0.8 * 200 >= flow.submodel.variables['Sink(Wärme)|flow_rate'], ) assert_conequal( model.constraints['Sink(Wärme)|on_hours_total'], - flow.model.variables['Sink(Wärme)|on_hours_total'] - == (flow.model.variables['Sink(Wärme)|on'] * model.hours_per_step).sum(), + flow.submodel.variables['Sink(Wärme)|on_hours_total'] + == (flow.submodel.variables['Sink(Wärme)|on'] * model.hours_per_step).sum(), ) # Investment @@ -1071,12 +1071,12 @@ def test_flow_on_invest_non_optional(self, basic_flow_system_linopy): mega = 0.2 * 200 # Relative minimum * maximum size assert_conequal( model.constraints['Sink(Wärme)|flow_rate|lb2'], - flow.model.variables['Sink(Wärme)|flow_rate'] - >= flow.model.variables['Sink(Wärme)|on'] * mega + flow.model.variables['Sink(Wärme)|size'] * 0.2 - mega, + flow.submodel.variables['Sink(Wärme)|flow_rate'] + >= flow.submodel.variables['Sink(Wärme)|on'] * mega + flow.submodel.variables['Sink(Wärme)|size'] * 0.2 - mega, ) assert_conequal( model.constraints['Sink(Wärme)|flow_rate|ub2'], - flow.model.variables['Sink(Wärme)|flow_rate'] <= flow.model.variables['Sink(Wärme)|size'] * 0.8, + flow.submodel.variables['Sink(Wärme)|flow_rate'] <= flow.submodel.variables['Sink(Wärme)|size'] * 0.8, ) @@ -1098,7 +1098,7 @@ def test_fixed_relative_profile(self, basic_flow_system_linopy): flow_system.add_elements(fx.Sink('Sink', sink=flow)) model = create_linopy_model(flow_system) - assert_var_equal(flow.model.variables['Sink(Wärme)|flow_rate'], + assert_var_equal(flow.submodel.variables['Sink(Wärme)|flow_rate'], model.add_variables(lower=profile * 100, upper=profile * 100, coords=(timesteps,)) @@ -1124,15 +1124,15 @@ def test_fixed_profile_with_investment(self, basic_flow_system_linopy): model = create_linopy_model(flow_system) assert_var_equal( - flow.model.variables['Sink(Wärme)|flow_rate'], + flow.submodel.variables['Sink(Wärme)|flow_rate'], model.add_variables(lower=0, upper=profile * 200, coords=(timesteps,)), ) # The constraint should link flow_rate to size * profile assert_conequal( model.constraints['Sink(Wärme)|flow_rate|fixed'], - flow.model.variables['Sink(Wärme)|flow_rate'] - == flow.model.variables['Sink(Wärme)|size'] * xr.DataArray(profile, coords=(timesteps,)), + flow.submodel.variables['Sink(Wärme)|flow_rate'] + == flow.submodel.variables['Sink(Wärme)|size'] * xr.DataArray(profile, coords=(timesteps,)), ) diff --git a/tests/test_functional.py b/tests/test_functional.py index 9542d656b..2315867f1 100644 --- a/tests/test_functional.py +++ b/tests/test_functional.py @@ -155,21 +155,21 @@ def test_fixed_size(solver_fixture, time_steps_fixture): boiler = flow_system.all_elements['Boiler'] costs = flow_system.effects['costs'] assert_allclose( - costs.model.total.solution.item(), + costs.submodel.total.solution.item(), 80 + 1000 * 1 + 10, rtol=1e-5, atol=1e-10, err_msg='The total costs does not have the right value', ) assert_allclose( - boiler.Q_th.model._investment.size.solution.item(), + boiler.Q_th.submodel._investment.size.solution.item(), 1000, rtol=1e-5, atol=1e-10, err_msg='"Boiler__Q_th__Investment_size" does not have the right value', ) assert_allclose( - boiler.Q_th.model._investment.is_invested.solution.item(), + boiler.Q_th.submodel._investment.is_invested.solution.item(), 1, rtol=1e-5, atol=1e-10, @@ -196,21 +196,21 @@ def test_optimize_size(solver_fixture, time_steps_fixture): boiler = flow_system.all_elements['Boiler'] costs = flow_system.effects['costs'] assert_allclose( - costs.model.total.solution.item(), + costs.submodel.total.solution.item(), 80 + 20 * 1 + 10, rtol=1e-5, atol=1e-10, err_msg='The total costs does not have the right value', ) assert_allclose( - boiler.Q_th.model._investment.size.solution.item(), + boiler.Q_th.submodel._investment.size.solution.item(), 20, rtol=1e-5, atol=1e-10, err_msg='"Boiler__Q_th__Investment_size" does not have the right value', ) assert_allclose( - boiler.Q_th.model._investment.is_invested.solution.item(), + boiler.Q_th.submodel._investment.is_invested.solution.item(), 1, rtol=1e-5, atol=1e-10, @@ -237,21 +237,21 @@ def test_size_bounds(solver_fixture, time_steps_fixture): boiler = flow_system.all_elements['Boiler'] costs = flow_system.effects['costs'] assert_allclose( - costs.model.total.solution.item(), + costs.submodel.total.solution.item(), 80 + 40 * 1 + 10, rtol=1e-5, atol=1e-10, err_msg='The total costs does not have the right value', ) assert_allclose( - boiler.Q_th.model._investment.size.solution.item(), + boiler.Q_th.submodel._investment.size.solution.item(), 40, rtol=1e-5, atol=1e-10, err_msg='"Boiler__Q_th__Investment_size" does not have the right value', ) assert_allclose( - boiler.Q_th.model._investment.is_invested.solution.item(), + boiler.Q_th.submodel._investment.is_invested.solution.item(), 1, rtol=1e-5, atol=1e-10, @@ -289,21 +289,21 @@ def test_optional_invest(solver_fixture, time_steps_fixture): boiler_optional = flow_system.all_elements['Boiler_optional'] costs = flow_system.effects['costs'] assert_allclose( - costs.model.total.solution.item(), + costs.submodel.total.solution.item(), 80 + 40 * 1 + 10, rtol=1e-5, atol=1e-10, err_msg='The total costs does not have the right value', ) assert_allclose( - boiler.Q_th.model._investment.size.solution.item(), + boiler.Q_th.submodel._investment.size.solution.item(), 40, rtol=1e-5, atol=1e-10, err_msg='"Boiler__Q_th__Investment_size" does not have the right value', ) assert_allclose( - boiler.Q_th.model._investment.is_invested.solution.item(), + boiler.Q_th.submodel._investment.is_invested.solution.item(), 1, rtol=1e-5, atol=1e-10, @@ -311,14 +311,14 @@ def test_optional_invest(solver_fixture, time_steps_fixture): ) assert_allclose( - boiler_optional.Q_th.model._investment.size.solution.item(), + boiler_optional.Q_th.submodel._investment.size.solution.item(), 0, rtol=1e-5, atol=1e-10, err_msg='"Boiler__Q_th__Investment_size" does not have the right value', ) assert_allclose( - boiler_optional.Q_th.model._investment.is_invested.solution.item(), + boiler_optional.Q_th.submodel._investment.is_invested.solution.item(), 0, rtol=1e-5, atol=1e-10, @@ -342,7 +342,7 @@ def test_on(solver_fixture, time_steps_fixture): boiler = flow_system.all_elements['Boiler'] costs = flow_system.effects['costs'] assert_allclose( - costs.model.total.solution.item(), + costs.submodel.total.solution.item(), 80, rtol=1e-5, atol=1e-10, @@ -350,14 +350,14 @@ def test_on(solver_fixture, time_steps_fixture): ) assert_allclose( - boiler.Q_th.model.on_off.on.solution.values, + boiler.Q_th.submodel.on_off.on.solution.values, [0, 1, 1, 0, 1], rtol=1e-5, atol=1e-10, err_msg='"Boiler__Q_th__on" does not have the right value', ) assert_allclose( - boiler.Q_th.model.flow_rate.solution.values, + boiler.Q_th.submodel.flow_rate.solution.values, [0, 10, 20, 0, 10], rtol=1e-5, atol=1e-10, @@ -386,7 +386,7 @@ def test_off(solver_fixture, time_steps_fixture): boiler = flow_system.all_elements['Boiler'] costs = flow_system.effects['costs'] assert_allclose( - costs.model.total.solution.item(), + costs.submodel.total.solution.item(), 80, rtol=1e-5, atol=1e-10, @@ -394,21 +394,21 @@ def test_off(solver_fixture, time_steps_fixture): ) assert_allclose( - boiler.Q_th.model.on_off.on.solution.values, + boiler.Q_th.submodel.on_off.on.solution.values, [0, 1, 1, 0, 1], rtol=1e-5, atol=1e-10, err_msg='"Boiler__Q_th__on" does not have the right value', ) assert_allclose( - boiler.Q_th.model.on_off.off.solution.values, - 1 - boiler.Q_th.model.on_off.on.solution.values, + boiler.Q_th.submodel.on_off.off.solution.values, + 1 - boiler.Q_th.submodel.on_off.on.solution.values, rtol=1e-5, atol=1e-10, err_msg='"Boiler__Q_th__off" does not have the right value', ) assert_allclose( - boiler.Q_th.model.flow_rate.solution.values, + boiler.Q_th.submodel.flow_rate.solution.values, [0, 10, 20, 0, 10], rtol=1e-5, atol=1e-10, @@ -437,7 +437,7 @@ def test_switch_on_off(solver_fixture, time_steps_fixture): boiler = flow_system.all_elements['Boiler'] costs = flow_system.effects['costs'] assert_allclose( - costs.model.total.solution.item(), + costs.submodel.total.solution.item(), 80, rtol=1e-5, atol=1e-10, @@ -445,28 +445,28 @@ def test_switch_on_off(solver_fixture, time_steps_fixture): ) assert_allclose( - boiler.Q_th.model.on_off.on.solution.values, + boiler.Q_th.submodel.on_off.on.solution.values, [0, 1, 1, 0, 1], rtol=1e-5, atol=1e-10, err_msg='"Boiler__Q_th__on" does not have the right value', ) assert_allclose( - boiler.Q_th.model.on_off.switch_on.solution.values, + boiler.Q_th.submodel.on_off.switch_on.solution.values, [0, 1, 0, 0, 1], rtol=1e-5, atol=1e-10, err_msg='"Boiler__Q_th__switch_on" does not have the right value', ) assert_allclose( - boiler.Q_th.model.on_off.switch_off.solution.values, + boiler.Q_th.submodel.on_off.switch_off.solution.values, [0, 0, 0, 1, 0], rtol=1e-5, atol=1e-10, err_msg='"Boiler__Q_th__switch_on" does not have the right value', ) assert_allclose( - boiler.Q_th.model.flow_rate.solution.values, + boiler.Q_th.submodel.flow_rate.solution.values, [0, 10, 20, 0, 10], rtol=1e-5, atol=1e-10, @@ -501,7 +501,7 @@ def test_on_total_max(solver_fixture, time_steps_fixture): boiler = flow_system.all_elements['Boiler'] costs = flow_system.effects['costs'] assert_allclose( - costs.model.total.solution.item(), + costs.submodel.total.solution.item(), 140, rtol=1e-5, atol=1e-10, @@ -509,14 +509,14 @@ def test_on_total_max(solver_fixture, time_steps_fixture): ) assert_allclose( - boiler.Q_th.model.on_off.on.solution.values, + boiler.Q_th.submodel.on_off.on.solution.values, [0, 0, 1, 0, 0], rtol=1e-5, atol=1e-10, err_msg='"Boiler__Q_th__on" does not have the right value', ) assert_allclose( - boiler.Q_th.model.flow_rate.solution.values, + boiler.Q_th.submodel.flow_rate.solution.values, [0, 0, 20, 0, 0], rtol=1e-5, atol=1e-10, @@ -560,7 +560,7 @@ def test_on_total_bounds(solver_fixture, time_steps_fixture): boiler_backup = flow_system.all_elements['Boiler_backup'] costs = flow_system.effects['costs'] assert_allclose( - costs.model.total.solution.item(), + costs.submodel.total.solution.item(), 114, rtol=1e-5, atol=1e-10, @@ -568,14 +568,14 @@ def test_on_total_bounds(solver_fixture, time_steps_fixture): ) assert_allclose( - boiler.Q_th.model.on_off.on.solution.values, + boiler.Q_th.submodel.on_off.on.solution.values, [0, 0, 1, 0, 1], rtol=1e-5, atol=1e-10, err_msg='"Boiler__Q_th__on" does not have the right value', ) assert_allclose( - boiler.Q_th.model.flow_rate.solution.values, + boiler.Q_th.submodel.flow_rate.solution.values, [0, 0, 20, 0, 12 - 1e-5], rtol=1e-5, atol=1e-10, @@ -583,14 +583,14 @@ def test_on_total_bounds(solver_fixture, time_steps_fixture): ) assert_allclose( - sum(boiler_backup.Q_th.model.on_off.on.solution.values), + sum(boiler_backup.Q_th.submodel.on_off.on.solution.values), 3, rtol=1e-5, atol=1e-10, err_msg='"Boiler_backup__Q_th__on" does not have the right value', ) assert_allclose( - boiler_backup.Q_th.model.flow_rate.solution.values, + boiler_backup.Q_th.submodel.flow_rate.solution.values, [0, 10, 1.0e-05, 0, 1.0e-05], rtol=1e-5, atol=1e-10, @@ -628,7 +628,7 @@ def test_consecutive_on_off(solver_fixture, time_steps_fixture): boiler_backup = flow_system.all_elements['Boiler_backup'] costs = flow_system.effects['costs'] assert_allclose( - costs.model.total.solution.item(), + costs.submodel.total.solution.item(), 190, rtol=1e-5, atol=1e-10, @@ -636,14 +636,14 @@ def test_consecutive_on_off(solver_fixture, time_steps_fixture): ) assert_allclose( - boiler.Q_th.model.on_off.on.solution.values, + boiler.Q_th.submodel.on_off.on.solution.values, [1, 1, 0, 1, 1], rtol=1e-5, atol=1e-10, err_msg='"Boiler__Q_th__on" does not have the right value', ) assert_allclose( - boiler.Q_th.model.flow_rate.solution.values, + boiler.Q_th.submodel.flow_rate.solution.values, [5, 10, 0, 18, 12], rtol=1e-5, atol=1e-10, @@ -651,7 +651,7 @@ def test_consecutive_on_off(solver_fixture, time_steps_fixture): ) assert_allclose( - boiler_backup.Q_th.model.flow_rate.solution.values, + boiler_backup.Q_th.submodel.flow_rate.solution.values, [0, 0, 20, 0, 0], rtol=1e-5, atol=1e-10, @@ -691,7 +691,7 @@ def test_consecutive_off(solver_fixture, time_steps_fixture): boiler_backup = flow_system.all_elements['Boiler_backup'] costs = flow_system.effects['costs'] assert_allclose( - costs.model.total.solution.item(), + costs.submodel.total.solution.item(), 110, rtol=1e-5, atol=1e-10, @@ -699,21 +699,21 @@ def test_consecutive_off(solver_fixture, time_steps_fixture): ) assert_allclose( - boiler_backup.Q_th.model.on_off.on.solution.values, + boiler_backup.Q_th.submodel.on_off.on.solution.values, [0, 0, 1, 0, 0], rtol=1e-5, atol=1e-10, err_msg='"Boiler_backup__Q_th__on" does not have the right value', ) assert_allclose( - boiler_backup.Q_th.model.on_off.off.solution.values, + boiler_backup.Q_th.submodel.on_off.off.solution.values, [1, 1, 0, 1, 1], rtol=1e-5, atol=1e-10, err_msg='"Boiler_backup__Q_th__off" does not have the right value', ) assert_allclose( - boiler_backup.Q_th.model.flow_rate.solution.values, + boiler_backup.Q_th.submodel.flow_rate.solution.values, [0, 0, 1e-5, 0, 0], rtol=1e-5, atol=1e-10, @@ -721,7 +721,7 @@ def test_consecutive_off(solver_fixture, time_steps_fixture): ) assert_allclose( - boiler.Q_th.model.flow_rate.solution.values, + boiler.Q_th.submodel.flow_rate.solution.values, [5, 0, 20 - 1e-5, 18, 12], rtol=1e-5, atol=1e-10, diff --git a/tests/test_integration.py b/tests/test_integration.py index e3d44d764..babc7b131 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -23,12 +23,12 @@ def test_simple_flow_system(self, simple_flow_system, highs_solver): # Cost assertions assert_almost_equal_numeric( - effects['costs'].model.total.solution.item(), 81.88394666666667, 'costs doesnt match expected value' + effects['costs'].submodel.total.solution.item(), 81.88394666666667, 'costs doesnt match expected value' ) # CO2 assertions assert_almost_equal_numeric( - effects['CO2'].model.total.solution.item(), 255.09184, 'CO2 doesnt match expected value' + effects['CO2'].submodel.total.solution.item(), 255.09184, 'CO2 doesnt match expected value' ) def test_model_components(self, simple_flow_system, highs_solver): @@ -40,14 +40,14 @@ def test_model_components(self, simple_flow_system, highs_solver): # Boiler assertions assert_almost_equal_numeric( - comps['Boiler'].Q_th.model.flow_rate.solution.values, + comps['Boiler'].Q_th.submodel.flow_rate.solution.values, [0, 0, 0, 28.4864, 35, 0, 0, 0, 0], 'Q_th doesnt match expected value', ) # CHP unit assertions assert_almost_equal_numeric( - comps['CHP_unit'].Q_th.model.flow_rate.solution.values, + comps['CHP_unit'].Q_th.submodel.flow_rate.solution.values, [30.0, 26.66666667, 75.0, 75.0, 75.0, 20.0, 20.0, 20.0, 20.0], 'Q_th doesnt match expected value', ) @@ -217,36 +217,36 @@ def test_piecewise_conversion(self, flow_system_piecewise_conversion, highs_solv # Compare expected values with actual values assert_almost_equal_numeric( - effects['costs'].model.total.solution.item(), -10710.997365760755, 'costs doesnt match expected value' + effects['costs'].submodel.total.solution.item(), -10710.997365760755, 'costs doesnt match expected value' ) assert_almost_equal_numeric( - effects['CO2'].model.total.solution.item(), 1278.7939026086956, 'CO2 doesnt match expected value' + effects['CO2'].submodel.total.solution.item(), 1278.7939026086956, 'CO2 doesnt match expected value' ) assert_almost_equal_numeric( - comps['Kessel'].Q_th.model.flow_rate.solution.values, + comps['Kessel'].Q_th.submodel.flow_rate.solution.values, [0, 0, 0, 45, 0, 0, 0, 0, 0], 'Kessel doesnt match expected value', ) kwk_flows = {flow.label: flow for flow in comps['KWK'].inputs + comps['KWK'].outputs} assert_almost_equal_numeric( - kwk_flows['Q_th'].model.flow_rate.solution.values, + kwk_flows['Q_th'].submodel.flow_rate.solution.values, [45.0, 45.0, 64.5962087, 100.0, 61.3136, 45.0, 45.0, 12.86469565, 0.0], 'KWK Q_th doesnt match expected value', ) assert_almost_equal_numeric( - kwk_flows['P_el'].model.flow_rate.solution.values, + kwk_flows['P_el'].submodel.flow_rate.solution.values, [40.0, 40.0, 47.12589407, 60.0, 45.93221818, 40.0, 40.0, 10.91784108, -0.0], 'KWK P_el doesnt match expected value', ) assert_almost_equal_numeric( - comps['Speicher'].model.netto_discharge.solution.values, + comps['Speicher'].submodel.netto_discharge.solution.values, [-15.0, -45.0, 25.4037913, -35.0, 48.6864, -25.0, -25.0, 7.13530435, 20.0], 'Speicher nettoFlow doesnt match expected value', ) assert_almost_equal_numeric( - comps['Speicher'].model.variables['Speicher|PiecewiseEffects|costs'].solution.values, + comps['Speicher'].submodel.variables['Speicher|PiecewiseEffects|costs'].solution.values, 454.74666666666667, 'Speicher investCosts_segmented_costs doesnt match expected value', ) diff --git a/tests/test_linear_converter.py b/tests/test_linear_converter.py index e15b11c1b..7f65d8fc2 100644 --- a/tests/test_linear_converter.py +++ b/tests/test_linear_converter.py @@ -46,7 +46,7 @@ def test_basic_linear_converter(self, basic_flow_system_linopy): # Check conversion constraint (input * 0.8 == output * 1.0) assert_conequal( model.constraints['Converter|conversion_0'], - input_flow.model.flow_rate * 0.8 == output_flow.model.flow_rate * 1.0 + input_flow.submodel.flow_rate * 0.8 == output_flow.submodel.flow_rate * 1.0 ) def test_linear_converter_time_varying(self, basic_flow_system_linopy): @@ -88,7 +88,7 @@ def test_linear_converter_time_varying(self, basic_flow_system_linopy): # Check conversion constraint (input * efficiency_series == output * 1.0) assert_conequal( model.constraints['Converter|conversion_0'], - input_flow.model.flow_rate * efficiency_series == output_flow.model.flow_rate * 1.0 + input_flow.submodel.flow_rate * efficiency_series == output_flow.submodel.flow_rate * 1.0 ) def test_linear_converter_multiple_factors(self, basic_flow_system_linopy): @@ -133,19 +133,19 @@ def test_linear_converter_multiple_factors(self, basic_flow_system_linopy): # Check conversion constraint 1 (input1 * 0.8 == output1 * 1.0) assert_conequal( model.constraints['Converter|conversion_0'], - input_flow1.model.flow_rate * 0.8 == output_flow1.model.flow_rate * 1.0 + input_flow1.submodel.flow_rate * 0.8 == output_flow1.submodel.flow_rate * 1.0 ) # Check conversion constraint 2 (input2 * 0.5 == output2 * 1.0) assert_conequal( model.constraints['Converter|conversion_1'], - input_flow2.model.flow_rate * 0.5 == output_flow2.model.flow_rate * 1.0 + input_flow2.submodel.flow_rate * 0.5 == output_flow2.submodel.flow_rate * 1.0 ) # Check conversion constraint 3 (input1 * 0.2 == output2 * 0.3) assert_conequal( model.constraints['Converter|conversion_2'], - input_flow1.model.flow_rate * 0.2 == output_flow2.model.flow_rate * 0.3 + input_flow1.submodel.flow_rate * 0.2 == output_flow2.submodel.flow_rate * 0.3 ) def test_linear_converter_with_on_off(self, basic_flow_system_linopy): @@ -196,7 +196,7 @@ def test_linear_converter_with_on_off(self, basic_flow_system_linopy): # Check conversion constraint assert_conequal( model.constraints['Converter|conversion_0'], - input_flow.model.flow_rate * 0.8 == output_flow.model.flow_rate * 1.0 + input_flow.submodel.flow_rate * 0.8 == output_flow.submodel.flow_rate * 1.0 ) # Check on_off effects @@ -252,17 +252,17 @@ def test_linear_converter_multidimensional(self, basic_flow_system_linopy): # Check the conversion equations assert_conequal( model.constraints['MultiConverter|conversion_0'], - input_flow1.model.flow_rate * 0.7 == output_flow1.model.flow_rate * 1.0 + input_flow1.submodel.flow_rate * 0.7 == output_flow1.submodel.flow_rate * 1.0 ) assert_conequal( model.constraints['MultiConverter|conversion_1'], - input_flow2.model.flow_rate * 0.3 == output_flow2.model.flow_rate * 1.0 + input_flow2.submodel.flow_rate * 0.3 == output_flow2.submodel.flow_rate * 1.0 ) assert_conequal( model.constraints['MultiConverter|conversion_2'], - input_flow1.model.flow_rate * 0.1 == output_flow2.model.flow_rate * 0.5 + input_flow1.submodel.flow_rate * 0.1 == output_flow2.submodel.flow_rate * 0.5 ) def test_edge_case_time_varying_conversion(self, basic_flow_system_linopy): @@ -311,7 +311,7 @@ def test_edge_case_time_varying_conversion(self, basic_flow_system_linopy): # Verify the constraint has the time-varying coefficient assert_conequal( model.constraints['VariableConverter|conversion_0'], - input_flow.model.flow_rate * fluctuating_cop == output_flow.model.flow_rate * 1.0 + input_flow.submodel.flow_rate * fluctuating_cop == output_flow.submodel.flow_rate * 1.0 ) def test_piecewise_conversion(self, basic_flow_system_linopy): @@ -361,10 +361,10 @@ def test_piecewise_conversion(self, basic_flow_system_linopy): model = create_linopy_model(flow_system) # Verify that PiecewiseModel was created and added as a submodel - assert converter.model.piecewise_conversion is not None + assert converter.submodel.piecewise_conversion is not None # Get the PiecewiseModel instance - piecewise_model = converter.model.piecewise_conversion + piecewise_model = converter.submodel.piecewise_conversion # Check that we have the expected pieces (2 in this case) assert len(piecewise_model.pieces) == 2 @@ -473,10 +473,10 @@ def test_piecewise_conversion_with_onoff(self, basic_flow_system_linopy): model = create_linopy_model(flow_system) # Verify that PiecewiseModel was created and added as a submodel - assert converter.model.piecewise_conversion is not None + assert converter.submodel.piecewise_conversion is not None # Get the PiecewiseModel instance - piecewise_model = converter.model.piecewise_conversion + piecewise_model = converter.submodel.piecewise_conversion # Check that we have the expected pieces (2 in this case) assert len(piecewise_model.pieces) == 2 From ae1752b41b26d060ee5511824b3844cdb62ed664 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 21 Jul 2025 22:00:25 +0200 Subject: [PATCH 235/336] Rename .sub_models with .submodels --- flixopt/calculation.py | 6 +++--- flixopt/effects.py | 2 +- flixopt/structure.py | 10 +++++----- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/flixopt/calculation.py b/flixopt/calculation.py index 5e505ff0f..d50bde388 100644 --- a/flixopt/calculation.py +++ b/flixopt/calculation.py @@ -116,13 +116,13 @@ def main_results(self) -> Dict[str, Union[Scalar, Dict]]: 'Invested': { model.label_of_element: model.size.solution for component in self.flow_system.components.values() - for model in component.submodel.sub_models + for model in component.submodel.submodels if isinstance(model, InvestmentModel) and model.size.solution.max() >= CONFIG.modeling.EPSILON }, 'Not invested': { model.label_of_element: model.size.solution for component in self.flow_system.components.values() - for model in component.submodel.sub_models + for model in component.submodel.submodels if isinstance(model, InvestmentModel) and model.size.solution.max() < CONFIG.modeling.EPSILON }, }, @@ -488,7 +488,7 @@ def do_modeling_and_solve( invest_elements = [ model.label_full for component in calculation.flow_system.components.values() - for model in component.submodel.sub_models + for model in component.submodel.submodels if isinstance(model, InvestmentModel) ] if invest_elements: diff --git a/flixopt/effects.py b/flixopt/effects.py index f9b122b1b..77a48e791 100644 --- a/flixopt/effects.py +++ b/flixopt/effects.py @@ -173,7 +173,7 @@ def __init__(self, model: FlowSystemModel, element: Effect): ) def do_modeling(self): - for model in self.sub_models: + for model in self.submodels: model.do_modeling() self.total = self.add_variables( diff --git a/flixopt/structure.py b/flixopt/structure.py index 61becd11f..f57a13031 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -836,14 +836,14 @@ def sub_models_direct(self) -> Dict[str, 'Submodel']: return self._sub_models @property - def sub_models(self) -> List['Submodel']: + def submodels(self) -> List['Submodel']: """All sub-models of the model""" direct_submodels = list(self._sub_models.values()) # Recursively collect nested sub-models nested_submodels = [] for submodel in direct_submodels: - nested_submodels.extend(submodel.sub_models) # This calls the property recursively + nested_submodels.extend(submodel.submodels) # This calls the property recursively return direct_submodels + nested_submodels @@ -852,7 +852,7 @@ def constraints(self) -> linopy.Constraints: """All constraints of the model, including those of sub-models""" names = list(self.constraints_direct) + [ constraint_name - for submodel in self.sub_models + for submodel in self.submodels for constraint_name in submodel.constraints_direct ] @@ -863,7 +863,7 @@ def variables(self) -> linopy.Variables: """All variables of the model, including those of sub-models""" names = list(self.variables_direct) + [ variable_name - for submodel in self.sub_models + for submodel in self.submodels for variable_name in submodel.variables_direct ] @@ -877,7 +877,7 @@ def __repr__(self) -> str: con_string = self.constraints.__repr__().split("\n", 2)[2] model_string = f"Linopy {self._model.type} submodel: {self.label_of_model}" - if len(self.sub_models) == 0: + if len(self.submodels) == 0: sub_models_string = ' \n' else: sub_models_string = '' From 1822384f1ffa77e3845fe9a7951eb263acd2b693 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 22 Jul 2025 10:49:03 +0200 Subject: [PATCH 236/336] Improve repr --- flixopt/structure.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/flixopt/structure.py b/flixopt/structure.py index f57a13031..37e20ae4d 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -875,21 +875,21 @@ def __repr__(self) -> str: """ var_string = self.variables.__repr__().split("\n", 2)[2] con_string = self.constraints.__repr__().split("\n", 2)[2] - model_string = f"Linopy {self._model.type} submodel: {self.label_of_model}" + model_string = f"Submodel of Linopy {self._model.type}:" if len(self.submodels) == 0: sub_models_string = ' \n' else: sub_models_string = '' for submodel_name, submodel in self.sub_models_direct.items(): - sub_models_string += f'\n * {submodel_name} [{submodel.__class__.__name__}]' + sub_models_string += f'\n * {submodel.__class__.__name__}: "{submodel_name}" [{len(submodel.variables)} Vars + {len(submodel.constraints)} Cons]' - return ( - f"{model_string}\n{'=' * len(model_string)}\n\n" - f"Variables:\n----------\n{var_string}\n" - f"Constraints:\n------------\n{con_string}\n" - f"Submodels:\n----------{sub_models_string}" - ) + text = {f"Variables: [{len(self.variables)}/{len(self._model.variables)}]": var_string, + f"Constraints: [{len(self.constraints)}/{len(self._model.constraints)}]": con_string, + f"Submodels: [{len(self.submodels)}]": sub_models_string} + comb = '\n'.join(f"{key}\n{'-' * len(key)}\n{value}" for key, value in text.items()) + + return f"{model_string}\n{'=' * len(model_string)}\n\n{comb}" @property def hours_per_step(self): From 2aa9d4b559a2afb6e0d4a64e55e03447cf93669b Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 22 Jul 2025 10:55:11 +0200 Subject: [PATCH 237/336] Improve repr --- flixopt/structure.py | 37 +++++++++++++++++++++++++++---------- 1 file changed, 27 insertions(+), 10 deletions(-) diff --git a/flixopt/structure.py b/flixopt/structure.py index 37e20ae4d..76cfc2392 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -873,23 +873,40 @@ def __repr__(self) -> str: """ Return a string representation of the linopy model. """ - var_string = self.variables.__repr__().split("\n", 2)[2] - con_string = self.constraints.__repr__().split("\n", 2)[2] - model_string = f"Submodel of Linopy {self._model.type}:" + # Extract content from variables and constraints representations + var_string = self.variables.__repr__().split('\n', 2)[2] + con_string = self.constraints.__repr__().split('\n', 2)[2] + model_string = f'Submodel of Linopy {self._model.type}:' + # Build submodels section if len(self.submodels) == 0: sub_models_string = ' \n' else: - sub_models_string = '' + submodel_lines = [] for submodel_name, submodel in self.sub_models_direct.items(): - sub_models_string += f'\n * {submodel.__class__.__name__}: "{submodel_name}" [{len(submodel.variables)} Vars + {len(submodel.constraints)} Cons]' + class_name = submodel.__class__.__name__ + submodel_lines.append(f' * {class_name}: "{submodel_name}" [{len(submodel.variables)} Vars + {len(submodel.constraints)} Cons]') + sub_models_string = '\n' + '\n'.join(submodel_lines) + + # Create sections with counts and content + sections = { + f'Variables: [{len(self.variables)}/{len(self._model.variables)}]': var_string, + f'Constraints: [{len(self.constraints)}/{len(self._model.constraints)}]': con_string, + f'Submodels: [{len(self.sub_models_direct)}]': sub_models_string, + } + + # Format sections with headers and underlines + formatted_sections = [] + for section_header, section_content in sections.items(): + underline = '-' * len(section_header) + formatted_section = f'{section_header}\n{underline}\n{section_content}' + formatted_sections.append(formatted_section) - text = {f"Variables: [{len(self.variables)}/{len(self._model.variables)}]": var_string, - f"Constraints: [{len(self.constraints)}/{len(self._model.constraints)}]": con_string, - f"Submodels: [{len(self.submodels)}]": sub_models_string} - comb = '\n'.join(f"{key}\n{'-' * len(key)}\n{value}" for key, value in text.items()) + # Combine everything with proper formatting + all_sections = '\n'.join(formatted_sections) + header_separator = '=' * len(model_string) - return f"{model_string}\n{'=' * len(model_string)}\n\n{comb}" + return f'{model_string}\n{header_separator}\n\n{all_sections}' @property def hours_per_step(self): From 5ca9707d75dee0d1f61f4db3652b26df10a25cf5 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 22 Jul 2025 11:24:05 +0200 Subject: [PATCH 238/336] Include def do_modeling() into __init__() of models --- flixopt/components.py | 32 ++++++++++++--------------- flixopt/effects.py | 14 +++++------- flixopt/elements.py | 25 ++++++++++------------ flixopt/features.py | 50 ++++++++++++++++++++++++------------------- flixopt/structure.py | 36 +++++++++++-------------------- 5 files changed, 71 insertions(+), 86 deletions(-) diff --git a/flixopt/components.py b/flixopt/components.py index 52e676323..16e74ade0 100644 --- a/flixopt/components.py +++ b/flixopt/components.py @@ -395,19 +395,18 @@ def transform_data(self, flow_system: 'FlowSystem') -> None: class TransmissionModel(ComponentModel): def __init__(self, model: FlowSystemModel, element: Transmission): - super().__init__(model, element) + if (element.absolute_losses is not None) and np.any(element.absolute_losses != 0): + for flow in element.inputs + element.outputs: + if flow.on_off_parameters is None: + flow.on_off_parameters = OnOffParameters() self.element: Transmission = element self.on_off: Optional[OnOffModel] = None - def do_modeling(self): - """Initiates all FlowModels""" - # Force On Variable if absolute losses are present - if (self.element.absolute_losses is not None) and np.any(self.element.absolute_losses != 0): - for flow in self.element.inputs + self.element.outputs: - if flow.on_off_parameters is None: - flow.on_off_parameters = OnOffParameters() + super().__init__(model, element) - super().do_modeling() + def _do_modeling(self): + """Initiates all FlowModels""" + super()._do_modeling() # first direction self.create_transmission_equation('dir1', self.element.in1, self.element.out1) @@ -440,14 +439,13 @@ def create_transmission_equation(self, name: str, in_flow: Flow, out_flow: Flow) class LinearConverterModel(ComponentModel): def __init__(self, model: FlowSystemModel, element: LinearConverter): - super().__init__(model, element) self.element: LinearConverter = element self.on_off: Optional[OnOffModel] = None self.piecewise_conversion: Optional[PiecewiseConversion] = None + super().__init__(model, element) - def do_modeling(self): - super().do_modeling() - + def _do_modeling(self): + super()._do_modeling() # conversion_factors: if self.element.conversion_factors: all_input_flows = set(self.element.inputs) @@ -483,7 +481,6 @@ def do_modeling(self): ), short_name='PiecewiseConversion', ) - self.piecewise_conversion.do_modeling() class StorageModel(ComponentModel): @@ -491,10 +488,9 @@ class StorageModel(ComponentModel): def __init__(self, model: FlowSystemModel, element: Storage): super().__init__(model, element) - self.element: Storage = element - def do_modeling(self): - super().do_modeling() + def _do_modeling(self): + super()._do_modeling() lb, ub = self.absolute_charge_state_bounds self.add_variables( @@ -540,7 +536,7 @@ def do_modeling(self): ), short_name='investment', ) - self._investment.do_modeling() + BoundingPatterns.scaled_bounds( self, variable=self.charge_state, diff --git a/flixopt/effects.py b/flixopt/effects.py index 77a48e791..b5ea81e3e 100644 --- a/flixopt/effects.py +++ b/flixopt/effects.py @@ -140,7 +140,8 @@ def _plausibility_checks(self) -> None: class EffectModel(ElementModel): def __init__(self, model: FlowSystemModel, element: Effect): super().__init__(model, element) - self.element: Effect = element + + def _do_modeling(self): self.total: Optional[linopy.Variable] = None self.invest: ShareAllocationModel = self.register_sub_model( ShareAllocationModel( @@ -172,10 +173,6 @@ def __init__(self, model: FlowSystemModel, element: Effect): short_name='operation', ) - def do_modeling(self): - for model in self.submodels: - model.do_modeling() - self.total = self.add_variables( lower=self.element.minimum_total if self.element.minimum_total is not None else -np.inf, upper=self.element.maximum_total if self.element.maximum_total is not None else np.inf, @@ -381,9 +378,9 @@ class EffectCollectionModel(Submodel): """ def __init__(self, model: FlowSystemModel, effects: EffectCollection): - super().__init__(model, label_of_element='Effects') self.effects = effects self.penalty: Optional[ShareAllocationModel] = None + super().__init__(model, label_of_element='Effects') def add_share_to_effects( self, @@ -412,15 +409,14 @@ def add_share_to_penalty(self, name: str, expression: linopy.LinearExpression) - raise TypeError(f'Penalty shares must be scalar expressions! ({expression.ndim=})') self.penalty.add_share(name, expression, dims=()) - def do_modeling(self): + def _do_modeling(self): + super()._do_modeling() for effect in self.effects: effect.create_model(self._model) self.penalty = self.register_sub_model( ShareAllocationModel(self._model, dims=(), label_of_element='Penalty'), short_name='penalty', ) - for model in [effect.submodel for effect in self.effects] + [self.penalty]: - model.do_modeling() self._add_share_between_effects() diff --git a/flixopt/elements.py b/flixopt/elements.py index b952093ba..bd4c27eca 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -315,9 +315,9 @@ def invest_is_optional(self) -> bool: class FlowModel(ElementModel): def __init__(self, model: FlowSystemModel, element: Flow): super().__init__(model, element) - self.element: Flow = element - def do_modeling(self): + def _do_modeling(self): + super()._do_modeling() # Main flow rate variable self.add_variables( lower=self.absolute_flow_rate_bounds[0], @@ -359,7 +359,7 @@ def _create_on_off_model(self): label_of_model=self.label_of_element, ), short_name='on_off', - ).do_modeling() + ) def _create_investment_model(self): self.register_sub_model( @@ -370,7 +370,7 @@ def _create_investment_model(self): label_of_model=self.label_of_element, ), 'investment', - ).do_modeling() + ) def _constraint_flow_rate(self): if not self.with_investment and not self.with_on_off: @@ -543,12 +543,12 @@ def previous_states(self) -> Optional[TemporalData]: class BusModel(ElementModel): def __init__(self, model: FlowSystemModel, element: Bus): - super().__init__(model, element) - self.element: Bus = element self.excess_input: Optional[linopy.Variable] = None self.excess_output: Optional[linopy.Variable] = None + super().__init__(model, element) - def do_modeling(self) -> None: + def _do_modeling(self) -> None: + super()._do_modeling() # inputs == outputs for flow in self.element.inputs + self.element.outputs: self.register_variable(flow.submodel.flow_rate, flow.label_full) @@ -582,12 +582,12 @@ def results_structure(self): class ComponentModel(ElementModel): def __init__(self, model: FlowSystemModel, element: Component): - super().__init__(model, element) - self.element: Component = element self.on_off: Optional[OnOffModel] = None + super().__init__(model, element) - def do_modeling(self): + def _do_modeling(self): """Initiates all FlowModels""" + super()._do_modeling() all_flows = self.element.inputs + self.element.outputs if self.element.on_off_parameters: for flow in all_flows: @@ -600,8 +600,7 @@ def do_modeling(self): flow.on_off_parameters = OnOffParameters() for flow in all_flows: - flow_model = self.register_sub_model(flow.create_model(self._model), short_name=flow.label) - flow_model.do_modeling() + self.register_sub_model(flow.create_model(self._model), short_name=flow.label) if self.element.on_off_parameters: on = self.add_variables(binary=True, short_name='on', coords=self._model.get_coords()) @@ -625,8 +624,6 @@ def do_modeling(self): short_name='on_off', ) - self.on_off.do_modeling() - if self.element.prevent_simultaneous_flows: # Simultanious Useage --> Only One FLow is On at a time, but needs a Binary for every flow on_variables = [flow.submodel.on_off.on for flow in self.element.prevent_simultaneous_flows] diff --git a/flixopt/features.py b/flixopt/features.py index 99928e410..fdf62bf75 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -36,17 +36,18 @@ def __init__( model: The optimization model instance label_of_element: The label of the parent (Element). Used to construct the full label of the model. parameters: The parameters of the feature model. - defining_variable: The variable to be invested - relative_bounds_of_defining_variable: The bounds of the variable, with respect to the minimum/maximum investment sizes label_of_model: The label of the model. This is needed to construct the full label of the model. """ - super().__init__(model, label_of_element=label_of_element, parameters=parameters, label_of_model=label_of_model) - self.piecewise_effects: Optional[PiecewiseEffectsModel] = None + super().__init__(model, label_of_element=label_of_element, parameters=parameters, label_of_model=label_of_model) + def _do_modeling(self): + super()._do_modeling() + self._create_variables_and_constraints() + self._add_effects() - def create_variables_and_constraints(self): + def _create_variables_and_constraints(self): size_min, size_max = (self.parameters.minimum_or_fixed_size, self.parameters.maximum_or_fixed_size) self.add_variables( short_name='size', @@ -79,9 +80,8 @@ def create_variables_and_constraints(self): ), short_name='segments', ) - self.piecewise_effects.do_modeling() - def add_effects(self): + def _add_effects(self): """Add investment effects""" if self.parameters.fix_effects: self._model.effects.add_share_to_effects( @@ -147,11 +147,13 @@ def __init__( previous_states: The previous flow_rates label_of_model: The label of the model. This is needed to construct the full label of the model. """ - super().__init__(model, label_of_element, parameters=parameters, label_of_model=label_of_model) self.on = on_variable self._previous_states = previous_states + super().__init__(model, label_of_element, parameters=parameters, label_of_model=label_of_model) + + def _do_modeling(self): + super()._do_modeling() - def create_variables_and_constraints(self): if self.parameters.use_off: off = self.add_variables(binary=True, short_name='off', coords=self._model.get_coords()) self.add_constraints(self.on + off == 1, short_name='complementary') @@ -207,7 +209,9 @@ def create_variables_and_constraints(self): ) #TODO: - def add_effects(self): + self._add_effects() + + def _add_effects(self): """Add operational effects""" if self.parameters.effects_per_running_hour: self._model.effects.add_share_to_effects( @@ -292,13 +296,15 @@ def __init__( label_of_model: str, as_time_series: bool = True, ): - super().__init__(model, label_of_element, label_of_model) self.inside_piece: Optional[linopy.Variable] = None self.lambda0: Optional[linopy.Variable] = None self.lambda1: Optional[linopy.Variable] = None self._as_time_series = as_time_series - def do_modeling(self): + super().__init__(model, label_of_element, label_of_model) + + def _do_modeling(self): + super()._do_modeling() dims =('time', 'year','scenario') if self._as_time_series else ('year','scenario') self.inside_piece = self.add_variables( binary=True, @@ -346,15 +352,16 @@ def __init__( zero_point: A variable that can be used to define a zero point for the Piecewise relation. If None or False, no zero point is defined. as_time_series: Whether the Piecewise relation is defined for a TimeSeries or a single variable. """ - super().__init__(model, label_of_element=label_of_element, label_of_model=label_of_model) self._piecewise_variables = piecewise_variables self._zero_point = zero_point self._as_time_series = as_time_series self.pieces: List[PieceModel] = [] self.zero_point: Optional[linopy.Variable] = None + super().__init__(model, label_of_element=label_of_element, label_of_model=label_of_model) - def do_modeling(self): + def _do_modeling(self): + super()._do_modeling() for i in range(len(list(self._piecewise_variables.values())[0])): new_piece = self.register_sub_model( PieceModel( @@ -366,7 +373,6 @@ def do_modeling(self): short_name=f'Piece_{i}', ) self.pieces.append(new_piece) - new_piece.do_modeling() for var_name in self._piecewise_variables: variable = self._model.variables[var_name] @@ -414,7 +420,6 @@ def __init__( piecewise_shares: Dict[str, Piecewise], zero_point: Optional[Union[bool, linopy.Variable]], ): - super().__init__(model, label_of_element=label_of_element, label_of_model=label_of_model) assert len(piecewise_origin[1]) == len(list(piecewise_shares.values())[0]), ( 'Piece length of variable_segments and share_segments must be equal' ) @@ -425,7 +430,9 @@ def __init__( self.piecewise_model: Optional[PiecewiseModel] = None - def do_modeling(self): + super().__init__(model, label_of_element=label_of_element, label_of_model=label_of_model) + + def _do_modeling(self): self.shares = { effect: self.add_variables(coords=self._model.get_coords(['year', 'scenario']), short_name=effect) for effect in self._piecewise_shares @@ -451,8 +458,6 @@ def do_modeling(self): short_name='PiecewiseEffects', ) - self.piecewise_model.do_modeling() - # Shares self._model.effects.add_share_to_effects( name=self.label_of_element, @@ -473,8 +478,6 @@ def __init__( max_per_hour: Optional[TemporalData] = None, min_per_hour: Optional[TemporalData] = None, ): - super().__init__(model, label_of_element=label_of_element, label_of_model=label_of_model) - if 'time' not in dims and (max_per_hour is not None or min_per_hour is not None): raise ValueError('Both max_per_hour and min_per_hour cannot be used when has_time_dim is False') @@ -493,7 +496,10 @@ def __init__( self._max_per_hour = max_per_hour if max_per_hour is not None else np.inf self._min_per_hour = min_per_hour if min_per_hour is not None else -np.inf - def do_modeling(self): + super().__init__(model, label_of_element=label_of_element, label_of_model=label_of_model) + + def _do_modeling(self): + super()._do_modeling() self.total = self.add_variables( lower=self._total_min, upper=self._total_max, diff --git a/flixopt/structure.py b/flixopt/structure.py index 76cfc2392..0a0b7d4df 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -60,13 +60,10 @@ def __init__(self, flow_system: 'FlowSystem'): def do_modeling(self): self.effects = self.flow_system.effects.create_model(self) - self.effects.do_modeling() - component_models = [component.create_model(self) for component in self.flow_system.components.values()] - bus_models = [bus.create_model(self) for bus in self.flow_system.buses.values()] - for component_model in component_models: - component_model.do_modeling() - for bus_model in bus_models: # Buses after Components, because FlowModels are created in ComponentModels - bus_model.do_modeling() + for component in self.flow_system.components.values(): + component.create_model(self) + for bus in self.flow_system.buses.values(): + bus.create_model(self) @property def solution(self): @@ -716,7 +713,9 @@ def __init__( self._constraints: Dict[str, linopy.Constraint] = {} # Mapping from short name to constraint self._sub_models: Dict[str, 'Submodel'] = {} - logger.debug(f'Created {self.__class__.__name__} "{self.label_full}"') + + logger.debug(f'Creating {self.__class__.__name__} "{self.label_full}"') + self._do_modeling() def add_variables(self, short_name: str = None, **kwargs) -> linopy.Variable: """Create and register a variable in one step""" @@ -912,6 +911,10 @@ def __repr__(self) -> str: def hours_per_step(self): return self._model.hours_per_step + def _do_modeling(self): + """Template method""" + pass + class BaseFeatureModel(Submodel): """Minimal base class for feature models that use factory patterns""" @@ -925,21 +928,8 @@ def __init__(self, model: FlowSystemModel, label_of_element: str, parameters, la Defaults to {label_of_element}|{self.__class__.__name__} parameters: The parameters of the feature model. """ - super().__init__(model, label_of_element, label_of_model or f'{label_of_element}|{self.__class__.__name__}') self.parameters = parameters - - def do_modeling(self): - """Template method - creates variables and constraints, then effects""" - self.create_variables_and_constraints() - self.add_effects() - - def create_variables_and_constraints(self): - """Override in subclasses to create variables and constraints""" - raise NotImplementedError('Subclasses must implement create_variables_and_constraints()') - - def add_effects(self): - """Override in subclasses to add effects""" - pass # Default: no effects + super().__init__(model, label_of_element, label_of_model or f'{label_of_element}|{self.__class__.__name__}') class ElementModel(Submodel): @@ -951,8 +941,8 @@ def __init__(self, model: FlowSystemModel, element: Element): model: The FlowSystemModel that is used to create the model. element: The element this model is created for. """ - super().__init__(model, label_of_element=element.label_full) self.element = element + super().__init__(model, label_of_element=element.label_full) def results_structure(self): return { From 7e043995610852eeaaa40b11dfec98c8548f25a8 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 22 Jul 2025 11:35:35 +0200 Subject: [PATCH 239/336] Make properties private --- flixopt/components.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/flixopt/components.py b/flixopt/components.py index 16e74ade0..4e69f1bcd 100644 --- a/flixopt/components.py +++ b/flixopt/components.py @@ -492,7 +492,7 @@ def __init__(self, model: FlowSystemModel, element: Storage): def _do_modeling(self): super()._do_modeling() - lb, ub = self.absolute_charge_state_bounds + lb, ub = self._absolute_charge_state_bounds self.add_variables( lower=lb, upper=ub, @@ -541,7 +541,7 @@ def _do_modeling(self): self, variable=self.charge_state, scaling_variable=self.investment.size, - relative_bounds=self.relative_charge_state_bounds, + relative_bounds=self._relative_charge_state_bounds, ) # Initial charge state @@ -577,8 +577,8 @@ def _initial_and_final_charge_state(self): ) @property - def absolute_charge_state_bounds(self) -> Tuple[TemporalData, TemporalData]: - relative_lower_bound, relative_upper_bound = self.relative_charge_state_bounds + def _absolute_charge_state_bounds(self) -> Tuple[TemporalData, TemporalData]: + relative_lower_bound, relative_upper_bound = self._relative_charge_state_bounds if not isinstance(self.element.capacity_in_flow_hours, InvestParameters): return ( relative_lower_bound * self.element.capacity_in_flow_hours, @@ -591,7 +591,7 @@ def absolute_charge_state_bounds(self) -> Tuple[TemporalData, TemporalData]: ) @property - def relative_charge_state_bounds(self) -> Tuple[xr.DataArray, xr.DataArray]: + def _relative_charge_state_bounds(self) -> Tuple[xr.DataArray, xr.DataArray]: """ Get relative charge state bounds with final timestep values. From 4f95ebc9ef69e8bb00d4c79a0e07d375f50f18d1 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 22 Jul 2025 11:42:19 +0200 Subject: [PATCH 240/336] Improve Inheritance of Models --- flixopt/features.py | 12 +++++++----- flixopt/modeling.py | 2 +- flixopt/structure.py | 19 +------------------ 3 files changed, 9 insertions(+), 24 deletions(-) diff --git a/flixopt/features.py b/flixopt/features.py index fdf62bf75..7115c54a8 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -12,13 +12,13 @@ from .config import CONFIG from .core import NonTemporalData, Scalar, TemporalData, FlowSystemDimensions from .interface import InvestParameters, OnOffParameters, Piecewise, PiecewiseEffects -from .structure import Submodel, FlowSystemModel, BaseFeatureModel +from .structure import Submodel, FlowSystemModel from .modeling import ModelingUtilities, ModelingPrimitives, BoundingPatterns logger = logging.getLogger('flixopt') -class InvestmentModel(BaseFeatureModel): +class InvestmentModel(Submodel): """Investment model using factory patterns but keeping old interface""" def __init__( @@ -40,7 +40,8 @@ def __init__( """ self.piecewise_effects: Optional[PiecewiseEffectsModel] = None - super().__init__(model, label_of_element=label_of_element, parameters=parameters, label_of_model=label_of_model) + self.parameters = parameters + super().__init__(model, label_of_element=label_of_element, label_of_model=label_of_model) def _do_modeling(self): super()._do_modeling() @@ -123,7 +124,7 @@ def is_invested(self) -> Optional[linopy.Variable]: return self._variables['is_invested'] -class OnOffModel(BaseFeatureModel): +class OnOffModel(Submodel): """OnOff model using factory patterns""" def __init__( @@ -149,7 +150,8 @@ def __init__( """ self.on = on_variable self._previous_states = previous_states - super().__init__(model, label_of_element, parameters=parameters, label_of_model=label_of_model) + self.parameters = parameters + super().__init__(model, label_of_element, label_of_model=label_of_model) def _do_modeling(self): super()._do_modeling() diff --git a/flixopt/modeling.py b/flixopt/modeling.py index 262b0d17d..a8a0b6f44 100644 --- a/flixopt/modeling.py +++ b/flixopt/modeling.py @@ -7,7 +7,7 @@ from .config import CONFIG from .core import NonTemporalData, Scalar, TemporalData, FlowSystemDimensions -from .structure import Submodel, FlowSystemModel, BaseFeatureModel +from .structure import Submodel, FlowSystemModel logger = logging.getLogger('flixopt') diff --git a/flixopt/structure.py b/flixopt/structure.py index 0a0b7d4df..f38f04815 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -912,26 +912,9 @@ def hours_per_step(self): return self._model.hours_per_step def _do_modeling(self): - """Template method""" + """Called at the end of initialization. Override in subclasses to create variables and constraints.""" pass - -class BaseFeatureModel(Submodel): - """Minimal base class for feature models that use factory patterns""" - - def __init__(self, model: FlowSystemModel, label_of_element: str, parameters, label_of_model: Optional[str] = None): - """Initialize the BaseFeatureModel. - Args: - model: The FlowSystemModel that is used to create the model. - label_of_element: The label of the parent (Element). Used to create shares. - label_of_model: The label of the model. Used as a prefix in all variables and constraints. - Defaults to {label_of_element}|{self.__class__.__name__} - parameters: The parameters of the feature model. - """ - self.parameters = parameters - super().__init__(model, label_of_element, label_of_model or f'{label_of_element}|{self.__class__.__name__}') - - class ElementModel(Submodel): """Stores the mathematical Variables and Constraints for Elements""" From 2d9c9200d1aa9cd1e4730aec543a2764e90f5f7b Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 22 Jul 2025 16:31:36 +0200 Subject: [PATCH 241/336] V3.0.0/plotting (#285) * Use indexer to reliably plot solutions with and wihtout scenarios/years --- flixopt/results.py | 81 +++++++++++++++++++++++----------------------- 1 file changed, 40 insertions(+), 41 deletions(-) diff --git a/flixopt/results.py b/flixopt/results.py index 941d0d6dc..d042c95f1 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -652,15 +652,9 @@ def plot_heatmap( """ dataarray = self.solution[variable_name] - dataarray, suffix_parts = _apply_indexer_to_data(dataarray, indexer) - - # Create name - suffix = '--' + '-'.join(suffix_parts) if suffix_parts else '' - name = variable_name if not suffix_parts else f'{variable_name}--{'-'.join(suffix_parts)}' if suffix else variable_name - return plot_heatmap( dataarray=dataarray, - name=name, + name=variable_name, folder=self.folder, heatmap_timeframes=heatmap_timeframes, heatmap_timesteps_per_frame=heatmap_timesteps_per_frame, @@ -668,6 +662,7 @@ def plot_heatmap( save=save, show=show, engine=engine, + indexer=indexer, ) def plot_network( @@ -848,7 +843,8 @@ def plot_node_balance( show: Whether to show the plot or not. colors: The colors to use for the plot. See `flixopt.plotting.ColorType` for options. engine: The engine to use for plotting. Can be either 'plotly' or 'matplotlib'. - scenario: The scenario to plot. Defaults to the first scenario. Has no effect without scenarios present + indexer: Optional selection dict, e.g., {'scenario': 'base', 'year': 2024}. + If None, uses first value for each dimension (except time). mode: The mode to use for the dataset. Can be 'flow_rate' or 'flow_hours'. - 'flow_rate': Returns the flow_rates of the Node. - 'flow_hours': Returns the flow_hours of the Node. [flow_hours(t) = flow_rate(t) * dt(t)]. Renames suffixes to |flow_hours. @@ -856,12 +852,10 @@ def plot_node_balance( """ ds = self.node_balance(with_last_timestep=True, mode=mode, drop_suffix=drop_suffix) - title = f'{self.label} (flow rates)' if mode == 'flow_rate' else f'{self.label} (flow hours)' + ds, suffix_parts = _apply_indexer_to_data(ds, indexer) + suffix = '--' + '-'.join(suffix_parts) if suffix_parts else '' - if 'scenario' in ds.indexes: - chosen_scenario = scenario or self._calculation_results.scenarios[0] - ds = ds.sel(scenario=chosen_scenario).drop_vars('scenario') - title = f'{title} - {chosen_scenario}' + title = f'{self.label} (flow rates){suffix}' if mode == 'flow_rate' else f'{self.label} (flow hours){suffix}' if engine == 'plotly': figure_like = plotting.with_plotly( @@ -911,8 +905,8 @@ def plot_node_balance_pie( save: Whether to save the figure. show: Whether to show the figure. engine: Plotting engine to use. Only 'plotly' is implemented atm. - scenario: If scenarios are present: The scenario to plot. If None, the first scenario is used. - drop_suffix: Whether to drop the suffix from the variable names. + indexer: Optional selection dict, e.g., {'scenario': 'base', 'year': 2024}. + If None, uses first value for each dimension. """ inputs = sanitize_dataset( ds=self.solution[self.inputs] * self._calculation_results.hours_per_timestep, @@ -928,16 +922,15 @@ def plot_node_balance_pie( zero_small_values=True, drop_suffix='|', ) - inputs = inputs.sum('time') - outputs = outputs.sum('time') - title = f'{self.label} (total flow hours)' + inputs, suffix_parts = _apply_indexer_to_data(inputs, indexer) + outputs, suffix_parts = _apply_indexer_to_data(outputs, indexer) + suffix = '--' + '-'.join(suffix_parts) if suffix_parts else '' - if 'scenario' in inputs.indexes: - chosen_scenario = scenario or self._calculation_results.scenarios[0] - inputs = inputs.sel(scenario=chosen_scenario).drop_vars('scenario') - outputs = outputs.sel(scenario=chosen_scenario).drop_vars('scenario') - title = f'{title} - {chosen_scenario}' + title = f'{self.label} (total flow hours){suffix}' + + inputs = inputs.sum('time') + outputs = outputs.sum('time') if engine == 'plotly': figure_like = plotting.dual_pie_with_plotly( @@ -1060,7 +1053,8 @@ def plot_charge_state( colors: The c engine: Plotting engine to use. Only 'plotly' is implemented atm. style: The plotting mode for the flow_rate - scenario: The scenario to plot. Defaults to the first scenario. Has no effect without scenarios present + indexer: Optional selection dict, e.g., {'scenario': 'base', 'year': 2024}. + If None, uses first value for each dimension. Raises: ValueError: If the Component is not a Storage. @@ -1071,18 +1065,18 @@ def plot_charge_state( ds = self.node_balance(with_last_timestep=True) charge_state = self.charge_state - scenario_suffix = '' - if 'scenario' in ds.indexes: - chosen_scenario = scenario or self._calculation_results.scenarios[0] - ds = ds.sel(scenario=chosen_scenario).drop_vars('scenario') - charge_state = charge_state.sel(scenario=chosen_scenario).drop_vars('scenario') - scenario_suffix = f'--{chosen_scenario}' + ds, suffix_parts = _apply_indexer_to_data(ds, indexer) + charge_state, suffix_parts = _apply_indexer_to_data(charge_state, indexer) + suffix = '--' + '-'.join(suffix_parts) if suffix_parts else '' + + title=f'Operation Balance of {self.label}{suffix}' + if engine == 'plotly': fig = plotting.with_plotly( ds.to_dataframe(), colors=colors, style=style, - title=f'Operation Balance of {self.label}{scenario_suffix}', + title=title, ) # TODO: Use colors for charge state? @@ -1098,7 +1092,7 @@ def plot_charge_state( ds.to_dataframe(), colors=colors, style=style, - title=f'Operation Balance of {self.label}{scenario_suffix}', + title=title, ) charge_state = charge_state.to_dataframe() @@ -1108,7 +1102,7 @@ def plot_charge_state( return plotting.export_figure( fig, - default_path=self._calculation_results.folder / f'{self.label} (charge state){scenario_suffix}', + default_path=self._calculation_results.folder / title, default_filetype='.html', user_path=None if isinstance(save, bool) else pathlib.Path(save), show=show, @@ -1331,6 +1325,7 @@ def plot_heatmap( save: Union[bool, pathlib.Path] = False, show: bool = True, engine: plotting.PlottingEngine = 'plotly', + indexer: Optional[Dict[str, Any]] = None, ): """ Plots a heatmap of the solution of a variable. @@ -1346,6 +1341,10 @@ def plot_heatmap( show: Whether to show the plot or not. engine: The engine to use for plotting. Can be either 'plotly' or 'matplotlib'. """ + dataarray, suffix_parts = _apply_indexer_to_data(dataarray, indexer, drop=True) + suffix = '--' + '-'.join(suffix_parts) if suffix_parts else '' + name = name if not suffix_parts else name + suffix + heatmap_data = plotting.heat_map_data_from_df( dataarray.to_dataframe(name), heatmap_timeframes, heatmap_timesteps_per_frame, 'ffill' ) @@ -1609,7 +1608,7 @@ def apply_filter(array, coord_name: str, coord_values: Union[Any, List[Any]]): return da -def _apply_indexer_to_data(data: xr.DataArray, indexer: Optional[Dict[str, Any]] = None): +def _apply_indexer_to_data(data: Union[xr.DataArray, xr.Dataset], indexer: Optional[Dict[str, Any]] = None, drop=False): """ Apply indexer selection or auto-select first values for non-time dimensions. @@ -1618,14 +1617,14 @@ def _apply_indexer_to_data(data: xr.DataArray, indexer: Optional[Dict[str, Any]] indexer: Optional selection dict Returns: - Tuple of (selected_data, suffix_parts_list) + Tuple of (selected_data, selection_string) """ - suffix_parts = [] + selection_string = [] if indexer is not None: # User provided indexer - data = data.sel(indexer) - suffix_parts.extend(f"{v}[{k}]" for k, v in indexer.items()) + data = data.sel(indexer, drop=drop) + selection_string.extend(f"{v}[{k}]" for k, v in indexer.items()) else: # Auto-select first value for each dimension except 'time' selection = {} @@ -1633,8 +1632,8 @@ def _apply_indexer_to_data(data: xr.DataArray, indexer: Optional[Dict[str, Any]] if dim != 'time' and dim in data.coords: first_value = data.coords[dim].values[0] selection[dim] = first_value - suffix_parts.append(f"{first_value}[{dim}]") + selection_string.append(f"{first_value}[{dim}]") if selection: - data = data.sel(selection) + data = data.sel(selection, drop=drop) - return data, suffix_parts + return data, selection_string From 5fd05e383f5d731a21ab0e4385649314d6153817 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 22 Jul 2025 16:35:31 +0200 Subject: [PATCH 242/336] ruff check --- flixopt/aggregation.py | 2 +- flixopt/components.py | 2 +- flixopt/effects.py | 2 +- flixopt/elements.py | 5 ++--- flixopt/features.py | 6 +++--- flixopt/flow_system.py | 2 +- flixopt/modeling.py | 4 ++-- flixopt/structure.py | 4 ++-- tests/test_flow.py | 1 - 9 files changed, 13 insertions(+), 15 deletions(-) diff --git a/flixopt/aggregation.py b/flixopt/aggregation.py index e4f7a598a..411c9ede7 100644 --- a/flixopt/aggregation.py +++ b/flixopt/aggregation.py @@ -27,8 +27,8 @@ from .flow_system import FlowSystem from .structure import ( Element, - Submodel, FlowSystemModel, + Submodel, ) if TYPE_CHECKING: diff --git a/flixopt/components.py b/flixopt/components.py index 4e69f1bcd..e14e78daf 100644 --- a/flixopt/components.py +++ b/flixopt/components.py @@ -14,8 +14,8 @@ from .elements import Component, ComponentModel, Flow from .features import InvestmentModel, OnOffModel, PiecewiseModel from .interface import InvestParameters, OnOffParameters, PiecewiseConversion +from .modeling import BoundingPatterns from .structure import FlowSystemModel, register_class_for_io -from.modeling import BoundingPatterns if TYPE_CHECKING: from .flow_system import FlowSystem diff --git a/flixopt/effects.py b/flixopt/effects.py index b5ea81e3e..d363cd9fd 100644 --- a/flixopt/effects.py +++ b/flixopt/effects.py @@ -15,7 +15,7 @@ from .core import Scalar, TemporalData, TemporalDataUser from .features import ShareAllocationModel -from .structure import Element, ElementModel, Interface, Submodel, FlowSystemModel, register_class_for_io +from .structure import Element, ElementModel, FlowSystemModel, Interface, Submodel, register_class_for_io if TYPE_CHECKING: from .flow_system import FlowSystem diff --git a/flixopt/elements.py b/flixopt/elements.py index bd4c27eca..0d403f78d 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -13,10 +13,10 @@ from .config import CONFIG from .core import PlausibilityError, Scalar, TemporalData, TemporalDataUser from .effects import TemporalEffectsUser -from .features import InvestmentModel, OnOffModel, ModelingPrimitives +from .features import InvestmentModel, ModelingPrimitives, OnOffModel from .interface import InvestParameters, OnOffParameters -from .structure import Element, ElementModel, FlowSystemModel, register_class_for_io from .modeling import BoundingPatterns, ModelingUtilitiesAbstract +from .structure import Element, ElementModel, FlowSystemModel, register_class_for_io if TYPE_CHECKING: from .flow_system import FlowSystem @@ -626,7 +626,6 @@ def _do_modeling(self): if self.element.prevent_simultaneous_flows: # Simultanious Useage --> Only One FLow is On at a time, but needs a Binary for every flow - on_variables = [flow.submodel.on_off.on for flow in self.element.prevent_simultaneous_flows] ModelingPrimitives.mutual_exclusivity_constraint( self, binary_variables=[flow.submodel.on_off.on for flow in self.element.prevent_simultaneous_flows], diff --git a/flixopt/features.py b/flixopt/features.py index 7115c54a8..fe9ff9c1c 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -10,10 +10,10 @@ import numpy as np from .config import CONFIG -from .core import NonTemporalData, Scalar, TemporalData, FlowSystemDimensions +from .core import FlowSystemDimensions, NonTemporalData, Scalar, TemporalData from .interface import InvestParameters, OnOffParameters, Piecewise, PiecewiseEffects -from .structure import Submodel, FlowSystemModel -from .modeling import ModelingUtilities, ModelingPrimitives, BoundingPatterns +from .modeling import BoundingPatterns, ModelingPrimitives, ModelingUtilities +from .structure import FlowSystemModel, Submodel logger = logging.getLogger('flixopt') diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index 0a10b3ceb..42faadcf6 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -32,7 +32,7 @@ TemporalEffectsUser, ) from .elements import Bus, Component, Flow -from .structure import Element, Interface, FlowSystemModel +from .structure import Element, FlowSystemModel, Interface if TYPE_CHECKING: import pyvis diff --git a/flixopt/modeling.py b/flixopt/modeling.py index a8a0b6f44..f67fc9d19 100644 --- a/flixopt/modeling.py +++ b/flixopt/modeling.py @@ -6,8 +6,8 @@ import xarray as xr from .config import CONFIG -from .core import NonTemporalData, Scalar, TemporalData, FlowSystemDimensions -from .structure import Submodel, FlowSystemModel +from .core import FlowSystemDimensions, NonTemporalData, Scalar, TemporalData +from .structure import FlowSystemModel, Submodel logger = logging.getLogger('flixopt') diff --git a/flixopt/structure.py b/flixopt/structure.py index f38f04815..16e8ad0dd 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -8,7 +8,7 @@ import logging import pathlib from io import StringIO -from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Tuple, Union, Collection +from typing import TYPE_CHECKING, Any, Collection, Dict, List, Literal, Optional, Tuple, Union import linopy import numpy as np @@ -19,7 +19,7 @@ from . import io as fx_io from .config import CONFIG -from .core import NonTemporalData, Scalar, TemporalDataUser, TimeSeriesData, get_dataarray_stats, FlowSystemDimensions +from .core import FlowSystemDimensions, NonTemporalData, Scalar, TemporalDataUser, TimeSeriesData, get_dataarray_stats if TYPE_CHECKING: # for type checking and preventing circular imports from .effects import EffectCollectionModel diff --git a/tests/test_flow.py b/tests/test_flow.py index 43ecbe34f..d22d81993 100644 --- a/tests/test_flow.py +++ b/tests/test_flow.py @@ -641,7 +641,6 @@ def test_consecutive_on_hours_previous(self, basic_flow_system_linopy): 'Sink(Wärme)|consecutive_on_hours|forward', 'Sink(Wärme)|consecutive_on_hours|backward', 'Sink(Wärme)|consecutive_on_hours|initial', - 'Sink(Wärme)|consecutive_on_hours|lb', }.issubset(set(flow.submodel.constraints)) assert_var_equal( From 20a1964b2f592a00828ac2242165208979f5263c Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 22 Jul 2025 16:39:16 +0200 Subject: [PATCH 243/336] Improve typehints --- flixopt/effects.py | 2 ++ flixopt/elements.py | 6 ++++++ 2 files changed, 8 insertions(+) diff --git a/flixopt/effects.py b/flixopt/effects.py index d363cd9fd..9c5b60f36 100644 --- a/flixopt/effects.py +++ b/flixopt/effects.py @@ -138,6 +138,8 @@ def _plausibility_checks(self) -> None: class EffectModel(ElementModel): + element: Effect # Type hint + def __init__(self, model: FlowSystemModel, element: Effect): super().__init__(model, element) diff --git a/flixopt/elements.py b/flixopt/elements.py index 0d403f78d..2d29a4f2d 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -313,6 +313,8 @@ def invest_is_optional(self) -> bool: class FlowModel(ElementModel): + element: Flow # Type hint + def __init__(self, model: FlowSystemModel, element: Flow): super().__init__(model, element) @@ -542,6 +544,8 @@ def previous_states(self) -> Optional[TemporalData]: class BusModel(ElementModel): + element: Bus # Type hint + def __init__(self, model: FlowSystemModel, element: Bus): self.excess_input: Optional[linopy.Variable] = None self.excess_output: Optional[linopy.Variable] = None @@ -581,6 +585,8 @@ def results_structure(self): class ComponentModel(ElementModel): + element: Component # Type hint + def __init__(self, model: FlowSystemModel, element: Component): self.on_off: Optional[OnOffModel] = None super().__init__(model, element) From 9b05f8fcb51bd3dbde01c2ce33c83eb59c02bb3f Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 22 Jul 2025 17:07:00 +0200 Subject: [PATCH 244/336] Update CHANGELOG.md --- CHANGELOG.md | 75 ++++++++++++++++++++++++++++++++-------------------- 1 file changed, 47 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 31d6a526a..3404a142f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,45 +7,47 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased - New Model dimensions] -## What's New -### Scenarios +### Changed +* **BREAKING**: `relative_minimum_charge_state` and `relative_maximum_charge_state` don't have an extra timestep anymore. The final charge state can now be constrained by parameters `relative_minimum_final_charge_state` and `relative_maximum_final_charge_state` instead +* **BREAKING**: Calculation.do_modeling() now returns the Calculation object instead of its linopy.Model +* **BREAKING**: Renamed class `SystemModel` to `FlowSystemModel` +* **BREAKING**: Renamed class `Model` to `Submodel` +* FlowSystems can not be shared across multiple Calculations anymore. A copy of the FlowSystem is created instead, making every Calculation independent +* Each Subcalculation in `SegmentedCalculation` now has its own distinct `FlowSystem` object +* Type system overhaul - added clear separation between temporal and non-temporal data throughout codebase for better clarity +* Enhanced FlowSystem interface with improved `__repr__()` and `__str__()` methods + +#### Internal: +* **BREAKING**: Calculation.do_modeling() now returns the Calculation object instead of its linopy.Model +* **BREAKING**: Renamed class `SystemModel` to `FlowSystemModel` +* **BREAKING**: Renamed class `Model` to `Submodel` +* FlowSystem data management simplified - removed `time_series_collection` pattern in favor of direct timestep properties +* Change modeling hierarchy to allow for more flexibility in future development. This leads to minimal changes in the access and creation of Submodels and their variables. +* Added new module `.modeling`that contains Modelling primitives and utilities + + +### Added + +#### Scenarios Scenarios are a new feature of flixopt. They can be used to model uncertainties in the flow system, such as: * Different demand profiles * Different price forecasts * Different weather conditions -They might also be used to model an evolving system with multiple investment periods. Each **scenario** might be a new year, a new month, or a new day, with a different set of investment decisions to take. - -The weighted sum of the total objective effect of each scenario is used as the objective of the optimization. -#### Investments and scenarios -Scenarios allow for more flexibility in investment decisions. -You can decide to allow different investment decisions for each scenario, or to allow a single investment decision for a subset of all scenarios, while not allowing for an invest in others. -This enables the following use cases: -* Find the best investment decision for each scenario individually +Common use cases are: * Find the best overall investment decision for possible scenarios (robust decision-making) -* Find the best overall investment decision for a subset of all scenarios - -The last one might be useful if you want to model a system with multiple investment periods, where one investment decision is made for more than one scenario. -This might occur when scenarios represent years or months, while an investment decision influences the system for multiple years or months. - - -### Other new features -* Balanced storage - Storage charging and discharging sizes can now be forced to be equal in when optimizing their size. -* Feature 2 - Description +* Find the best dispatch for the most important assets under uncertain price and weather conditions +The weighted sum of the total objective effect of each scenario is used as the objective of the optimization. -## [Unreleased - Data Management and IO] +#### Years (Investment periods) +A flixopt model might be modeled with a "year" dimension. +This enables to model transformation pathways over multiple years. -### Changed -* **BREAKING**: `relative_minimum_charge_state` and `relative_maximum_charge_state` don't have an extra timestep anymore. The final charge state can now be constrained by parameters `relative_minimum_final_charge_state` and `relative_maximum_final_charge_state` instead -* **BREAKING**: Calculation.do_modeling() now returns the Calculation object instead of its linopy.Model -* FlowSystems can not be shared across multiple Calculations anymore. A copy of the FlowSystem is created instead, making every Calculation independent -* Type system overhaul - added clear separation between temporal and non-temporal data throughout codebase for better clarity -* FlowSystem data management simplified - removed `time_series_collection` pattern in favor of direct timestep properties -* Enhanced FlowSystem interface with improved `__repr__()` and `__str__()` methods +%%%%% TODO: New Interfaces to model sizes changing over time, annuity, etc. -### Added +#### Improved Data handling: IO, resampling and more through xarray * Complete serialization infrastructure through `Interface` base class * IO for all Interfaces and the FlowSystem with round-trip serialization support * Automatic DataArray extraction and restoration @@ -68,21 +70,38 @@ This might occur when scenarios represent years or months, while an investment d * `fit_effects_to_model_coords()` method for effect data processing * `connect_and_transform()` method replacing several operations +#### Internal: Improved Model organisation and access +* Clearer separation between the main Model and "Submodels" +* Improved access to the Submodels and their variables, constraints and submodels +* Added __repr__() for Submodels to easily inspect its content +* + + +#### Other new features +* Balanced storage - Storage charging and discharging sizes can now be forced to be equal in when optimizing their size. + +#### Examples: +* Added Example for 2-stage Investment decisions leveraging the resampling of a FlowSystem + + ### Fixed * Enhanced NetCDF I/O with proper attribute preservation for DataArrays * Improved error handling and validation in serialization processes * Better type consistency across all framework components + ### Know Issues * Plotly >= 6 may raise errors if "nbformat" is not installed. We pinned plotly to <6, but this may be fixed in the future. * IO for single Interfaces/Elemenets to Datasets might not work properly if the Interface/Element is not part of a fully transformed and connected FlowSystem. This arrises from Numeric Data not being stored as xr.DataArray by the user. To avoid this, always use the `to_dataset()` on Elements inside a FlowSystem thats connected and transformed. + ### Deprecated * The `agg_group` and `agg_weight` parameters of `TimeSeriesData` are deprecated and will be removed in a future version. Use `aggregation_group` and `aggregation_weight` instead. * The `active_timesteps` parameter of `Calculation` is deprecated and will be removed in a future version. Use the new `sel(time=...)` method on the FlowSystem instead. * The assignment of Bus Objects to Flow.bus is deprecated and will be removed in a future version. Use the label of the Bus instead. * The usage of Effects objects in Dicts to assign shares to Effects is deprecated and will be removed in a future version. Use the label of the Effect instead. + ## [2.1.5] - 2025-07-08 ### Fixed From 4d7fd29d3557be215474bbeff135cef016e9f6e9 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 22 Jul 2025 17:13:08 +0200 Subject: [PATCH 245/336] Bugfix from renaming to .submodel --- flixopt/calculation.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/flixopt/calculation.py b/flixopt/calculation.py index d50bde388..874e2eab7 100644 --- a/flixopt/calculation.py +++ b/flixopt/calculation.py @@ -81,7 +81,7 @@ def __init__( flow_system._used_in_calculation = True self.flow_system = flow_system - self.submodel: Optional[FlowSystemModel] = None + self.model: Optional[FlowSystemModel] = None self.durations = {'modeling': 0.0, 'solving': 0.0, 'saving': 0.0} self.folder = pathlib.Path.cwd() / 'results' if folder is None else pathlib.Path(folder) @@ -540,7 +540,7 @@ def _transfer_start_values(self, i: int): for current_comp in current_flow_system.components.values(): next_comp = next_flow_system.components[current_comp.label_full] if isinstance(next_comp, Storage): - next_comp.initial_charge_state = current_comp.model.charge_state.solution.sel(time=start).item() + next_comp.initial_charge_state = current_comp.submodel.charge_state.solution.sel(time=start).item() start_values_of_this_segment[current_comp.label_full] = next_comp.initial_charge_state self._transfered_start_values.append(start_values_of_this_segment) From 3626517ad8429c4095b6a99ea8d494c7bd331030 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 23 Jul 2025 08:50:36 +0200 Subject: [PATCH 246/336] Bugfix from renaming to .submodel --- examples/03_Calculation_types/example_calculation_types.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/03_Calculation_types/example_calculation_types.py b/examples/03_Calculation_types/example_calculation_types.py index 9f5828cec..8bbdf1773 100644 --- a/examples/03_Calculation_types/example_calculation_types.py +++ b/examples/03_Calculation_types/example_calculation_types.py @@ -188,7 +188,7 @@ def get_solutions(calcs: List, variable: str) -> xr.Dataset: if calc.name == 'Segmented': dataarrays.append(calc.results.solution_without_overlap(variable).rename(calc.name)) else: - dataarrays.append(calc.results.submodel.variables[variable].solution.rename(calc.name)) + dataarrays.append(calc.results.model.variables[variable].solution.rename(calc.name)) return xr.merge(dataarrays) # --- Plotting for comparison --- From 50fbb67d8bc2c92c1f9ae016e71ebca6719258ad Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 23 Jul 2025 08:50:54 +0200 Subject: [PATCH 247/336] Improve indexer in results plotting --- flixopt/results.py | 43 +++++++++++++++++++++++++++++++------------ 1 file changed, 31 insertions(+), 12 deletions(-) diff --git a/flixopt/results.py b/flixopt/results.py index d042c95f1..361e7cfde 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -626,7 +626,8 @@ def plot_heatmap( show: Whether to show the plot or not. engine: The engine to use for plotting. Can be either 'plotly' or 'matplotlib'. indexer: Optional selection dict, e.g., {'scenario': 'base', 'year': 2024}. - If None, uses first value for each dimension. + If None, uses first value for each dimension. + If empty dict {}, uses all values. Examples: Basic usage (uses first scenario, first year, all time): @@ -844,15 +845,16 @@ def plot_node_balance( colors: The colors to use for the plot. See `flixopt.plotting.ColorType` for options. engine: The engine to use for plotting. Can be either 'plotly' or 'matplotlib'. indexer: Optional selection dict, e.g., {'scenario': 'base', 'year': 2024}. - If None, uses first value for each dimension (except time). + If None, uses first value for each dimension (except time). + If empty dict {}, uses all values. mode: The mode to use for the dataset. Can be 'flow_rate' or 'flow_hours'. - 'flow_rate': Returns the flow_rates of the Node. - 'flow_hours': Returns the flow_hours of the Node. [flow_hours(t) = flow_rate(t) * dt(t)]. Renames suffixes to |flow_hours. drop_suffix: Whether to drop the suffix from the variable names. """ - ds = self.node_balance(with_last_timestep=True, mode=mode, drop_suffix=drop_suffix) + ds = self.node_balance(with_last_timestep=True, mode=mode, drop_suffix=drop_suffix, indexer=indexer) - ds, suffix_parts = _apply_indexer_to_data(ds, indexer) + ds, suffix_parts = _apply_indexer_to_data(ds, indexer, drop=True) suffix = '--' + '-'.join(suffix_parts) if suffix_parts else '' title = f'{self.label} (flow rates){suffix}' if mode == 'flow_rate' else f'{self.label} (flow hours){suffix}' @@ -906,7 +908,8 @@ def plot_node_balance_pie( show: Whether to show the figure. engine: Plotting engine to use. Only 'plotly' is implemented atm. indexer: Optional selection dict, e.g., {'scenario': 'base', 'year': 2024}. - If None, uses first value for each dimension. + If None, uses first value for each dimension. + If empty dict {}, uses all values. """ inputs = sanitize_dataset( ds=self.solution[self.inputs] * self._calculation_results.hours_per_timestep, @@ -923,8 +926,8 @@ def plot_node_balance_pie( drop_suffix='|', ) - inputs, suffix_parts = _apply_indexer_to_data(inputs, indexer) - outputs, suffix_parts = _apply_indexer_to_data(outputs, indexer) + inputs, suffix_parts = _apply_indexer_to_data(inputs, indexer, drop=True) + outputs, suffix_parts = _apply_indexer_to_data(outputs, indexer, drop=True) suffix = '--' + '-'.join(suffix_parts) if suffix_parts else '' title = f'{self.label} (total flow hours){suffix}' @@ -976,6 +979,7 @@ def node_balance( with_last_timestep: bool = False, mode: Literal['flow_rate', 'flow_hours'] = 'flow_rate', drop_suffix: bool = False, + indexer: Optional[Dict['FlowSystemDimensions', Any]] = None, ) -> xr.Dataset: """ Returns a dataset with the node balance of the Component or Bus. @@ -988,6 +992,9 @@ def node_balance( - 'flow_rate': Returns the flow_rates of the Node. - 'flow_hours': Returns the flow_hours of the Node. [flow_hours(t) = flow_rate(t) * dt(t)]. Renames suffixes to |flow_hours. drop_suffix: Whether to drop the suffix from the variable names. + indexer: Optional selection dict, e.g., {'scenario': 'base', 'year': 2024}. + If None, uses first value for each dimension. + If empty dict {}, uses all values. """ ds = self.solution[self.inputs + self.outputs] @@ -1007,6 +1014,8 @@ def node_balance( drop_suffix='|' if drop_suffix else None, ) + ds, _ = _apply_indexer_to_data(ds, indexer, drop=True) + if mode == 'flow_hours': ds = ds * self._calculation_results.hours_per_timestep ds = ds.rename_vars({var: var.replace('flow_rate', 'flow_hours') for var in ds.data_vars}) @@ -1054,7 +1063,8 @@ def plot_charge_state( engine: Plotting engine to use. Only 'plotly' is implemented atm. style: The plotting mode for the flow_rate indexer: Optional selection dict, e.g., {'scenario': 'base', 'year': 2024}. - If None, uses first value for each dimension. + If None, uses first value for each dimension. + If empty dict {}, uses all values. Raises: ValueError: If the Component is not a Storage. @@ -1062,11 +1072,11 @@ def plot_charge_state( if not self.is_storage: raise ValueError(f'Cant plot charge_state. "{self.label}" is not a storage') - ds = self.node_balance(with_last_timestep=True) + ds = self.node_balance(with_last_timestep=True, indexer=indexer) charge_state = self.charge_state - ds, suffix_parts = _apply_indexer_to_data(ds, indexer) - charge_state, suffix_parts = _apply_indexer_to_data(charge_state, indexer) + ds, suffix_parts = _apply_indexer_to_data(ds, indexer, drop=True) + charge_state, suffix_parts = _apply_indexer_to_data(charge_state, indexer, drop=True) suffix = '--' + '-'.join(suffix_parts) if suffix_parts else '' title=f'Operation Balance of {self.label}{suffix}' @@ -1340,6 +1350,9 @@ def plot_heatmap( save: Whether to save the plot or not. If a path is provided, the plot will be saved at that location. show: Whether to show the plot or not. engine: The engine to use for plotting. Can be either 'plotly' or 'matplotlib'. + indexer: Optional selection dict, e.g., {'scenario': 'base', 'year': 2024}. + If None, uses first value for each dimension. + If empty dict {}, uses all values. """ dataarray, suffix_parts = _apply_indexer_to_data(dataarray, indexer, drop=True) suffix = '--' + '-'.join(suffix_parts) if suffix_parts else '' @@ -1608,13 +1621,19 @@ def apply_filter(array, coord_name: str, coord_values: Union[Any, List[Any]]): return da -def _apply_indexer_to_data(data: Union[xr.DataArray, xr.Dataset], indexer: Optional[Dict[str, Any]] = None, drop=False): +def _apply_indexer_to_data( + data: Union[xr.DataArray, xr.Dataset], + indexer: Optional[Dict[str, Any]] = None, + drop=False + ) -> Tuple[Union[xr.DataArray, xr.Dataset], List[str]]: """ Apply indexer selection or auto-select first values for non-time dimensions. Args: data: xarray Dataset or DataArray indexer: Optional selection dict + If None, uses first value for each dimension (except time). + If empty dict {}, uses all values. Returns: Tuple of (selected_data, selection_string) From 9368985bb6f9b07a40a5afb52c2f09d2be31d5b5 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 23 Jul 2025 10:18:48 +0200 Subject: [PATCH 248/336] rename register_submodel() to .add_submodels() adn add SUbmodels collection class --- flixopt/calculation.py | 6 +- flixopt/components.py | 8 +-- flixopt/effects.py | 6 +- flixopt/elements.py | 16 +++--- flixopt/features.py | 6 +- flixopt/structure.py | 123 ++++++++++++++++++++++++++++++----------- 6 files changed, 111 insertions(+), 54 deletions(-) diff --git a/flixopt/calculation.py b/flixopt/calculation.py index 874e2eab7..3137b71ec 100644 --- a/flixopt/calculation.py +++ b/flixopt/calculation.py @@ -116,13 +116,13 @@ def main_results(self) -> Dict[str, Union[Scalar, Dict]]: 'Invested': { model.label_of_element: model.size.solution for component in self.flow_system.components.values() - for model in component.submodel.submodels + for model in component.submodel.all_submodels if isinstance(model, InvestmentModel) and model.size.solution.max() >= CONFIG.modeling.EPSILON }, 'Not invested': { model.label_of_element: model.size.solution for component in self.flow_system.components.values() - for model in component.submodel.submodels + for model in component.submodel.all_submodels if isinstance(model, InvestmentModel) and model.size.solution.max() < CONFIG.modeling.EPSILON }, }, @@ -488,7 +488,7 @@ def do_modeling_and_solve( invest_elements = [ model.label_full for component in calculation.flow_system.components.values() - for model in component.submodel.submodels + for model in component.submodel.all_submodels if isinstance(model, InvestmentModel) ] if invest_elements: diff --git a/flixopt/components.py b/flixopt/components.py index e14e78daf..495edb5d7 100644 --- a/flixopt/components.py +++ b/flixopt/components.py @@ -470,7 +470,7 @@ def _do_modeling(self): for flow, piecewise in self.element.piecewise_conversion.items() } - self.piecewise_conversion = self.register_sub_model( + self.piecewise_conversion = self.add_submodels( PiecewiseModel( model=self._model, label_of_element=self.label_of_element, @@ -527,7 +527,7 @@ def _do_modeling(self): ) if isinstance(self.element.capacity_in_flow_hours, InvestParameters): - self.register_sub_model( + self.add_submodels( InvestmentModel( model=self._model, label_of_element=self.label_of_element, @@ -627,9 +627,9 @@ def _investment(self) -> Optional[InvestmentModel]: @property def investment(self) -> Optional[InvestmentModel]: """OnOff feature""" - if 'investment' not in self.sub_models_direct: + if 'investment' not in self.submodels: return None - return self.sub_models_direct['investment'] + return self.submodels['investment'] @property def charge_state(self) -> linopy.Variable: diff --git a/flixopt/effects.py b/flixopt/effects.py index 9c5b60f36..db72ff8aa 100644 --- a/flixopt/effects.py +++ b/flixopt/effects.py @@ -145,7 +145,7 @@ def __init__(self, model: FlowSystemModel, element: Effect): def _do_modeling(self): self.total: Optional[linopy.Variable] = None - self.invest: ShareAllocationModel = self.register_sub_model( + self.invest: ShareAllocationModel = self.add_submodels( ShareAllocationModel( model=self._model, dims=('year', 'scenario'), @@ -157,7 +157,7 @@ def _do_modeling(self): short_name='invest', ) - self.operation: ShareAllocationModel = self.register_sub_model( + self.operation: ShareAllocationModel = self.add_submodels( ShareAllocationModel( model=self._model, dims=('time', 'year', 'scenario'), @@ -415,7 +415,7 @@ def _do_modeling(self): super()._do_modeling() for effect in self.effects: effect.create_model(self._model) - self.penalty = self.register_sub_model( + self.penalty = self.add_submodels( ShareAllocationModel(self._model, dims=(), label_of_element='Penalty'), short_name='penalty', ) diff --git a/flixopt/elements.py b/flixopt/elements.py index 2d29a4f2d..7acf8b0a9 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -351,7 +351,7 @@ def _do_modeling(self): def _create_on_off_model(self): on = self.add_variables(binary=True, short_name='on', coords=self._model.get_coords()) - self.register_sub_model( + self.add_submodels( OnOffModel( model=self._model, label_of_element=self.label_of_element, @@ -364,7 +364,7 @@ def _create_on_off_model(self): ) def _create_investment_model(self): - self.register_sub_model( + self.add_submodels( InvestmentModel( model=self._model, label_of_element=self.label_of_element, @@ -509,9 +509,9 @@ def absolute_flow_rate_bounds(self) -> Tuple[TemporalData, TemporalData]: @property def on_off(self) -> Optional[OnOffModel]: """OnOff feature""" - if 'on_off' not in self.sub_models_direct: + if 'on_off' not in self.submodels: return None - return self.sub_models_direct['on_off'] + return self.submodels['on_off'] @property def _investment(self) -> Optional[InvestmentModel]: @@ -521,9 +521,9 @@ def _investment(self) -> Optional[InvestmentModel]: @property def investment(self) -> Optional[InvestmentModel]: """OnOff feature""" - if 'investment' not in self.sub_models_direct: + if 'investment' not in self.submodels: return None - return self.sub_models_direct['investment'] + return self.submodels['investment'] @property def previous_states(self) -> Optional[TemporalData]: @@ -606,7 +606,7 @@ def _do_modeling(self): flow.on_off_parameters = OnOffParameters() for flow in all_flows: - self.register_sub_model(flow.create_model(self._model), short_name=flow.label) + self.add_submodels(flow.create_model(self._model), short_name=flow.label) if self.element.on_off_parameters: on = self.add_variables(binary=True, short_name='on', coords=self._model.get_coords()) @@ -618,7 +618,7 @@ def _do_modeling(self): self.add_constraints(on <= sum(flow_ons) + CONFIG.modeling.EPSILON, short_name='on|ub') self.add_constraints(on >= sum(flow_ons) / (len(flow_ons) + CONFIG.modeling.EPSILON), short_name='on|lb') - self.on_off = self.register_sub_model( + self.on_off = self.add_submodels( OnOffModel( model=self._model, label_of_element=self.label_of_element, diff --git a/flixopt/features.py b/flixopt/features.py index fe9ff9c1c..0c7606120 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -70,7 +70,7 @@ def _create_variables_and_constraints(self): ) if self.parameters.piecewise_effects: - self.piecewise_effects = self.register_sub_model( + self.piecewise_effects = self.add_submodels( PiecewiseEffectsModel( model=self._model, label_of_element=self.label_of_element, @@ -365,7 +365,7 @@ def __init__( def _do_modeling(self): super()._do_modeling() for i in range(len(list(self._piecewise_variables.values())[0])): - new_piece = self.register_sub_model( + new_piece = self.add_submodels( PieceModel( model=self._model, label_of_element=self.label_of_element, @@ -448,7 +448,7 @@ def _do_modeling(self): }, } - self.piecewise_model = self.register_sub_model( + self.piecewise_model = self.add_submodels( PiecewiseModel( model=self._model, label_of_element=self.label_of_element, diff --git a/flixopt/structure.py b/flixopt/structure.py index 16e8ad0dd..889b6e7aa 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -7,8 +7,9 @@ import json import logging import pathlib +from dataclasses import dataclass from io import StringIO -from typing import TYPE_CHECKING, Any, Collection, Dict, List, Literal, Optional, Tuple, Union +from typing import TYPE_CHECKING, Any, Collection, Dict, List, Literal, Optional, Tuple, Union, Protocol, runtime_checkable, ItemsView, Iterator import linopy import numpy as np @@ -43,7 +44,34 @@ def register_class_for_io(cls): return cls -class FlowSystemModel(linopy.Model): +class SubmodelsMixin: + """Mixin that provides submodel functionality for both FlowSystemModel and Submodel.""" + + submodels: 'Submodels' + @property + def all_submodels(self) -> List['Submodel']: + """Get all submodels including nested ones recursively.""" + direct_submodels = list(self.submodels.values()) + + # Recursively collect nested sub-models + nested_submodels = [] + for submodel in direct_submodels: + nested_submodels.extend(submodel.all_submodels) + + return direct_submodels + nested_submodels + + def add_submodels(self, submodel: 'Submodel', short_name: str = None) -> 'Submodel': + """Register a sub-model with the model""" + if short_name is None: + short_name = submodel.__class__.__name__ + if short_name in self.submodels: + raise ValueError(f'Short name "{short_name}" already assigned to model') + self.submodels.add(submodel, name=short_name) + + return submodel + + +class FlowSystemModel(linopy.Model, SubmodelsMixin): """ The FlowSystemModel is the linopy Model that is used to create the mathematical model of the flow_system. It is used to create and store the variables and constraints for the flow_system. @@ -57,6 +85,7 @@ def __init__(self, flow_system: 'FlowSystem'): super().__init__(force_dim_names=True) self.flow_system = flow_system self.effects: Optional[EffectCollectionModel] = None + self.submodels: Submodels = Submodels({}) def do_modeling(self): self.effects = self.flow_system.effects.create_model(self) @@ -693,7 +722,7 @@ def _valid_label(label: str) -> str: return label -class Submodel: +class Submodel(SubmodelsMixin): """Stores Variables and Constraints.""" def __init__( @@ -711,8 +740,7 @@ def __init__( self._variables: Dict[str, linopy.Variable] = {} # Mapping from short name to variable self._constraints: Dict[str, linopy.Constraint] = {} # Mapping from short name to constraint - self._sub_models: Dict[str, 'Submodel'] = {} - + self.submodels: Submodels = Submodels({}) logger.debug(f'Creating {self.__class__.__name__} "{self.label_full}"') self._do_modeling() @@ -759,15 +787,6 @@ def register_constraint(self, constraint: linopy.Constraint, short_name: str = N self._constraints[short_name] = constraint return constraint - def register_sub_model(self, submodel: 'Submodel', short_name: str) -> 'Submodel': - """Register a sub-model with the model""" - if short_name is None: - short_name = submodel.__class__.__name__ - if short_name in self._sub_models: - raise ValueError(f'Short name "{short_name}" already assigned to model') - self._sub_models[short_name] = submodel - return submodel - def __getitem__(self, key: str) -> linopy.Variable: """Get a variable by its short name""" if key in self._variables: @@ -829,29 +848,12 @@ def constraints_direct(self) -> linopy.Constraints: """Costraints of the model, excluding those of sub-models""" return self._model.constraints[[con.name for con in self._constraints.values()]] - @property - def sub_models_direct(self) -> Dict[str, 'Submodel']: - """All sub-models of the model, excluding those of sub-models""" - return self._sub_models - - @property - def submodels(self) -> List['Submodel']: - """All sub-models of the model""" - direct_submodels = list(self._sub_models.values()) - - # Recursively collect nested sub-models - nested_submodels = [] - for submodel in direct_submodels: - nested_submodels.extend(submodel.submodels) # This calls the property recursively - - return direct_submodels + nested_submodels - @property def constraints(self) -> linopy.Constraints: """All constraints of the model, including those of sub-models""" names = list(self.constraints_direct) + [ constraint_name - for submodel in self.submodels + for submodel in self.submodels.values() for constraint_name in submodel.constraints_direct ] @@ -862,7 +864,7 @@ def variables(self) -> linopy.Variables: """All variables of the model, including those of sub-models""" names = list(self.variables_direct) + [ variable_name - for submodel in self.submodels + for submodel in self.submodels.values() for variable_name in submodel.variables_direct ] @@ -915,6 +917,61 @@ def _do_modeling(self): """Called at the end of initialization. Override in subclasses to create variables and constraints.""" pass + +@dataclass(repr=False) +class Submodels: + """A simple collection for storing submodels with easy access and representation.""" + + data: Dict[str, 'Submodel'] + + def __getitem__(self, name: str) -> 'Submodel': + """Get a submodel by its name.""" + return self.data[name] + + def __getattr__(self, name: str) -> 'Submodel': + """Get a submodel by attribute access.""" + if name in self.data: + return self.data[name] + raise AttributeError(f"Submodels has no attribute '{name}'") + + def __len__(self) -> int: + return len(self.data) + + def __iter__(self) -> Iterator[str]: + return iter(self.data) + + def __contains__(self, name: str) -> bool: + return name in self.data + + def __repr__(self) -> str: + """Simple representation of the submodels collection.""" + if not self.data: + return 'Submodels:\n----------\n \n' + + sub_models_string = '' + for name, submodel in self.data.items(): + sub_models_string += f'\n * {name} ({submodel.__class__.__name__}) [{len(submodel.variables)} Vars + {len(submodel.constraints)} Cons)' + + return f'Submodels:\n----------{sub_models_string}\n' + + def items(self) -> ItemsView[str, 'Submodel']: + return self.data.items() + + def keys(self): + return self.data.keys() + + def values(self): + return self.data.values() + + def add(self, submodel: 'Submodel', name: str) -> None: + """Add a submodel to the collection.""" + self.data[name] = submodel + + def get(self, name: str, default=None): + """Get submodel by name, returning default if not found.""" + return self.data.get(name, default) + + class ElementModel(Submodel): """Stores the mathematical Variables and Constraints for Elements""" From e4ec4107fe4c01042aee08cdbb667129a0729d06 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 23 Jul 2025 11:47:04 +0200 Subject: [PATCH 249/336] Add nice repr to FlowSystemModel and Submodel --- flixopt/structure.py | 72 +++++++++++++++++++++++++++----------------- 1 file changed, 44 insertions(+), 28 deletions(-) diff --git a/flixopt/structure.py b/flixopt/structure.py index 889b6e7aa..94f5f2bda 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -169,6 +169,32 @@ def weights(self) -> Union[int, xr.DataArray]: return self.flow_system.weights + def __repr__(self) -> str: + """ + Return a string representation of the FlowSystemModel, borrowed from linopy.Model. + """ + # Extract content from existing representations + sections = { + f'Variables: [{len(self.variables)}]': self.variables.__repr__().split('\n', 2)[2], + f'Constraints: [{len(self.constraints)}]': self.constraints.__repr__().split('\n', 2)[2], + f'Submodels: [{len(self.submodels)}]': self.submodels.__repr__().split('\n', 2)[2], + 'Status': self.status, + } + + # Format sections with headers and underlines + formatted_sections = [] + for section_header, section_content in sections.items(): + formatted_sections.append( + (f'{section_header}\n' + f'{"-" * len(section_header)}\n' + f'{section_content}') + ) + + title = f'FlowSystemModel ({self.type})' + return (f'{title}\n' + f'{"=" * len(title)}\n\n' + f'{"\n".join(formatted_sections)}') + class Interface: """ @@ -874,40 +900,27 @@ def __repr__(self) -> str: """ Return a string representation of the linopy model. """ - # Extract content from variables and constraints representations - var_string = self.variables.__repr__().split('\n', 2)[2] - con_string = self.constraints.__repr__().split('\n', 2)[2] - model_string = f'Submodel of Linopy {self._model.type}:' - - # Build submodels section - if len(self.submodels) == 0: - sub_models_string = ' \n' - else: - submodel_lines = [] - for submodel_name, submodel in self.sub_models_direct.items(): - class_name = submodel.__class__.__name__ - submodel_lines.append(f' * {class_name}: "{submodel_name}" [{len(submodel.variables)} Vars + {len(submodel.constraints)} Cons]') - sub_models_string = '\n' + '\n'.join(submodel_lines) - - # Create sections with counts and content + # Extract content from existing representations sections = { - f'Variables: [{len(self.variables)}/{len(self._model.variables)}]': var_string, - f'Constraints: [{len(self.constraints)}/{len(self._model.constraints)}]': con_string, - f'Submodels: [{len(self.sub_models_direct)}]': sub_models_string, + f'Variables: [{len(self.variables)}/{len(self._model.variables)}]': self.variables.__repr__().split('\n', 2)[2], + f'Constraints: [{len(self.constraints)}/{len(self._model.constraints)}]': self.constraints.__repr__().split('\n', 2)[2], + f'Submodels: [{len(self.submodels)}]': self.submodels.__repr__().split('\n', 2)[2], } # Format sections with headers and underlines formatted_sections = [] for section_header, section_content in sections.items(): - underline = '-' * len(section_header) - formatted_section = f'{section_header}\n{underline}\n{section_content}' - formatted_sections.append(formatted_section) + formatted_sections.append( + (f'{section_header}\n' + f'{"-" * len(section_header)}\n' + f'{section_content}') + ) - # Combine everything with proper formatting - all_sections = '\n'.join(formatted_sections) - header_separator = '=' * len(model_string) + model_string = f'Submodel "{self.label_of_model}":' - return f'{model_string}\n{header_separator}\n\n{all_sections}' + return (f'{model_string}\n' + f'{"=" * len(model_string)}\n\n' + f'{"\n".join(formatted_sections)}') @property def hours_per_step(self): @@ -945,14 +958,17 @@ def __contains__(self, name: str) -> bool: def __repr__(self) -> str: """Simple representation of the submodels collection.""" + title = 'flixopt.structure.Submodels:' + underline = '-' * len(title) + if not self.data: - return 'Submodels:\n----------\n \n' + return f'{title}\n{underline}\n \n' sub_models_string = '' for name, submodel in self.data.items(): sub_models_string += f'\n * {name} ({submodel.__class__.__name__}) [{len(submodel.variables)} Vars + {len(submodel.constraints)} Cons)' - return f'Submodels:\n----------{sub_models_string}\n' + return f'{title}\n{underline}{sub_models_string}\n' def items(self) -> ItemsView[str, 'Submodel']: return self.data.items() From 66283cb33e070820cea70fdd014e04c0707990a0 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 23 Jul 2025 14:05:06 +0200 Subject: [PATCH 250/336] Bugfix .variables and .constraints --- flixopt/components.py | 11 ++++++----- flixopt/features.py | 8 +++++--- flixopt/structure.py | 28 +++++++++++++++++++++------- 3 files changed, 32 insertions(+), 15 deletions(-) diff --git a/flixopt/components.py b/flixopt/components.py index 495edb5d7..c0e905c5f 100644 --- a/flixopt/components.py +++ b/flixopt/components.py @@ -394,13 +394,13 @@ def transform_data(self, flow_system: 'FlowSystem') -> None: class TransmissionModel(ComponentModel): + element: Transmission + def __init__(self, model: FlowSystemModel, element: Transmission): if (element.absolute_losses is not None) and np.any(element.absolute_losses != 0): for flow in element.inputs + element.outputs: if flow.on_off_parameters is None: flow.on_off_parameters = OnOffParameters() - self.element: Transmission = element - self.on_off: Optional[OnOffModel] = None super().__init__(model, element) @@ -438,9 +438,9 @@ def create_transmission_equation(self, name: str, in_flow: Flow, out_flow: Flow) class LinearConverterModel(ComponentModel): + element: LinearConverter + def __init__(self, model: FlowSystemModel, element: LinearConverter): - self.element: LinearConverter = element - self.on_off: Optional[OnOffModel] = None self.piecewise_conversion: Optional[PiecewiseConversion] = None super().__init__(model, element) @@ -485,6 +485,7 @@ def _do_modeling(self): class StorageModel(ComponentModel): """Submodel of Storage""" + element: Storage def __init__(self, model: FlowSystemModel, element: Storage): super().__init__(model, element) @@ -549,7 +550,7 @@ def _do_modeling(self): if self.element.balanced: self.add_constraints( - self.element.charging.model._investment.size * 1 == self.element.discharging.model._investment.size * 1, + self.element.charging.submodel._investment.size * 1 == self.element.discharging.submodel._investment.size * 1, short_name='balanced_sizes', ) diff --git a/flixopt/features.py b/flixopt/features.py index 0c7606120..7b862375c 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -161,13 +161,15 @@ def _do_modeling(self): self.add_constraints(self.on + off == 1, short_name='complementary') # 3. Total duration tracking using existing pattern - duration_expr = (self.on * self._model.hours_per_step).sum('time') ModelingPrimitives.expression_tracking_variable( - self, duration_expr, short_name='on_hours_total', + self, + tracked_expression=(self.on * self._model.hours_per_step).sum('time'), bounds=( self.parameters.on_hours_total_min if self.parameters.on_hours_total_min is not None else 0, self.parameters.on_hours_total_max if self.parameters.on_hours_total_max is not None else np.inf, - ), #TODO: self._model.hours_per_step.sum('time').item() + self._get_previous_on_duration()) + ),#TODO: self._model.hours_per_step.sum('time').item() + self._get_previous_on_duration()) + short_name='on_hours_total', + coords=self.get_coords(['year', 'scenario']), ) # 4. Switch tracking using existing pattern diff --git a/flixopt/structure.py b/flixopt/structure.py index 94f5f2bda..344d86c01 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -9,7 +9,21 @@ import pathlib from dataclasses import dataclass from io import StringIO -from typing import TYPE_CHECKING, Any, Collection, Dict, List, Literal, Optional, Tuple, Union, Protocol, runtime_checkable, ItemsView, Iterator +from typing import ( + TYPE_CHECKING, + Any, + Collection, + Dict, + ItemsView, + Iterator, + List, + Literal, + Optional, + Protocol, + Tuple, + Union, + runtime_checkable, +) import linopy import numpy as np @@ -46,8 +60,8 @@ def register_class_for_io(cls): class SubmodelsMixin: """Mixin that provides submodel functionality for both FlowSystemModel and Submodel.""" - submodels: 'Submodels' + @property def all_submodels(self) -> List['Submodel']: """Get all submodels including nested ones recursively.""" @@ -871,27 +885,27 @@ def variables_direct(self) -> linopy.Variables: @property def constraints_direct(self) -> linopy.Constraints: - """Costraints of the model, excluding those of sub-models""" + """Constraints of the model, excluding those of sub-models""" return self._model.constraints[[con.name for con in self._constraints.values()]] @property def constraints(self) -> linopy.Constraints: - """All constraints of the model, including those of sub-models""" + """All constraints of the model, including those of all sub-models""" names = list(self.constraints_direct) + [ constraint_name for submodel in self.submodels.values() - for constraint_name in submodel.constraints_direct + for constraint_name in submodel.constraints ] return self._model.constraints[names] @property def variables(self) -> linopy.Variables: - """All variables of the model, including those of sub-models""" + """All variables of the model, including those of all sub-models""" names = list(self.variables_direct) + [ variable_name for submodel in self.submodels.values() - for variable_name in submodel.variables_direct + for variable_name in submodel.variables ] return self._model.variables[names] From a84dfadf9eb450f81d0603963c92e468006b36d1 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 23 Jul 2025 14:08:34 +0200 Subject: [PATCH 251/336] Add type checks to modeling.py --- flixopt/modeling.py | 124 ++++++++++++-------------------------------- 1 file changed, 33 insertions(+), 91 deletions(-) diff --git a/flixopt/modeling.py b/flixopt/modeling.py index f67fc9d19..8c03da9f4 100644 --- a/flixopt/modeling.py +++ b/flixopt/modeling.py @@ -199,6 +199,9 @@ def expression_tracking_variable( variables: {'tracker': tracker_var} constraints: {'tracking': constraint} """ + if not isinstance(model, Submodel): + raise ValueError('ModelingPrimitives.expression_tracking_variable() can only be used with a Submodel') + coords = coords or ['year', 'scenario'] if not bounds: @@ -239,6 +242,9 @@ def state_transition_variables( variables: {'switch_on': binary_var, 'switch_off': binary_var} constraints: {'transition': constraint, 'initial': constraint, 'mutex': constraint} """ + if not isinstance(model, Submodel): + raise ValueError('ModelingPrimitives.state_transition_variables() can only be used with a Submodel') + # State transition constraints for t > 0 transition = model.add_constraints( switch_on.isel(time=slice(1, None)) - switch_off.isel(time=slice(1, None)) @@ -259,7 +265,7 @@ def state_transition_variables( @staticmethod def sum_up_variable( - model: FlowSystemModel, + model: Submodel, variable_to_count: linopy.Variable, name: str = None, bounds: Tuple[NonTemporalData, NonTemporalData] = None, @@ -275,6 +281,9 @@ def sum_up_variable( bounds: The bounds of the constraint factor: The factor to be applied to the variable """ + if not isinstance(model, Submodel): + raise ValueError('ModelingPrimitives.sum_up_variable() can only be used with a Submodel') + if bounds is None: bounds = (0, np.inf) else: @@ -293,7 +302,7 @@ def sum_up_variable( @staticmethod def consecutive_duration_tracking( - model: FlowSystemModel, + model: Submodel, state_variable: linopy.Variable, name: str = None, short_name: str = None, @@ -324,6 +333,9 @@ def consecutive_duration_tracking( variables: {'duration': duration_var} constraints: {'ub': constraint, 'forward': constraint, 'backward': constraint, ...} """ + if not isinstance(model, Submodel): + raise ValueError('ModelingPrimitives.sum_up_variable() can only be used with a Submodel') + hours_per_step = model.hours_per_step mega = hours_per_step.sum('time') + previous_duration # Big-M value @@ -411,6 +423,9 @@ def mutual_exclusivity_constraint( Raises: AssertionError: If fewer than 2 variables provided or variables aren't binary """ + if not isinstance(model, Submodel): + raise ValueError('ModelingPrimitives.sum_up_variable() can only be used with a Submodel') + assert len(binary_variables) >= 2, ( f'Mutual exclusivity requires at least 2 variables, got {len(binary_variables)}' ) @@ -431,7 +446,7 @@ class BoundingPatterns: @staticmethod def basic_bounds( - model: FlowSystemModel, + model: Submodel, variable: linopy.Variable, bounds: Tuple[TemporalData, TemporalData], name: str = None, @@ -452,6 +467,9 @@ def basic_bounds( - variables (Dict): Empty dict - constraints (Dict[str, linopy.Constraint]): 'ub', 'lb' """ + if not isinstance(model, Submodel): + raise ValueError('BoundingPatterns.basic_bounds() can only be used with a Submodel') + lower_bound, upper_bound = bounds name = name or f'{variable.name}' @@ -462,7 +480,7 @@ def basic_bounds( @staticmethod def bounds_with_state( - model: FlowSystemModel, + model: Submodel, variable: linopy.Variable, bounds: Tuple[TemporalData, TemporalData], variable_state: linopy.Variable, @@ -489,6 +507,9 @@ def bounds_with_state( - variables (Dict): Empty dict - constraints (Dict[str, linopy.Constraint]): 'ub', 'lb' """ + if not isinstance(model, Submodel): + raise ValueError('BoundingPatterns.bounds_with_state() can only be used with a Submodel') + lower_bound, upper_bound = bounds name = name or f'{variable.name}' @@ -507,7 +528,7 @@ def bounds_with_state( @staticmethod def scaled_bounds( - model: FlowSystemModel, + model: Submodel, variable: linopy.Variable, scaling_variable: linopy.Variable, relative_bounds: Tuple[TemporalData, TemporalData], @@ -534,6 +555,9 @@ def scaled_bounds( - variables (Dict): Empty dict - constraints (Dict[str, linopy.Constraint]): 'ub', 'lb' """ + if not isinstance(model, Submodel): + raise ValueError('BoundingPatterns.scaled_bounds() can only be used with a Submodel') + rel_lower, rel_upper = relative_bounds name = name or f'{variable.name}' @@ -547,7 +571,7 @@ def scaled_bounds( @staticmethod def scaled_bounds_with_state( - model: FlowSystemModel, + model: Submodel, variable: linopy.Variable, scaling_variable: linopy.Variable, relative_bounds: Tuple[TemporalData, TemporalData], @@ -580,6 +604,9 @@ def scaled_bounds_with_state( Returns: List[linopy.Constraint]: List of constraint objects """ + if not isinstance(model, Submodel): + raise ValueError('BoundingPatterns.active_bounds_with_state() can only be used with a Submodel') + rel_lower, rel_upper = relative_bounds scaling_min, scaling_max = scaling_bounds name = name or f'{variable.name}' @@ -600,88 +627,3 @@ def scaled_bounds_with_state( binary_lower = model.add_constraints(variable_state * big_m_lower <= variable, name=f'{name}|lb1') return [scaling_lower, scaling_upper, binary_lower, binary_upper] - - @staticmethod - def auto_bounds( - model: FlowSystemModel, - variable: linopy.Variable, - bounds: Tuple[TemporalData, TemporalData], - scaling_variable: linopy.Variable = None, - scaling_state: linopy.Variable = None, - scaling_bounds: Tuple[TemporalData, TemporalData] = None, - variable_state: linopy.Variable = None, - ) -> List[linopy.Constraint]: - """Automatically select the appropriate bounds method. - - Parameter Combinations: - 1. Only bounds → basic_bounds() - 2. bounds + scaling_variable → scaled_bounds() - 3. bounds + variable_state → bounds_with_state() - 4. bounds + scaling_variable + variable_state → binary_scaled_bounds() - 5. bounds + scaling_variable + scaling_state + variable_state → scaled_bounds_with_state_on_both_scaling_and_variable() - - Args: - model: The optimization model instance - variable: Variable to be bounded - bounds: Tuple of (lower, upper) bounds or relative factors - scaling_variable: Optional variable to scale bounds by - scaling_state: Optional binary variable for scaling_variable state - scaling_bounds: Required for cases 4,5 - bounds of scaling variable - variable_state: Optional binary variable for variable state - - Returns: - Tuple from the selected method - - Raises: - ValueError: If required parameters are missing - """ - # Case 5: Dual binary control - if scaling_variable is not None and scaling_state is not None and variable_state is not None: - if scaling_bounds is None: - raise ValueError('scaling_bounds is required for dual binary control') - return BoundingPatterns.scaled_bounds_with_state_on_both_scaling_and_variable( - model=model, - variable=variable, - scaling_variable=scaling_variable, - relative_bounds=bounds, - scaling_state=scaling_state, - variable_state=variable_state, - scaling_bounds=scaling_bounds, - ) - - # Case 4: Binary scaled bounds - if scaling_variable is not None and variable_state is not None: - if scaling_bounds is None: - raise ValueError('scaling_bounds is required for binary scaled bounds') - return BoundingPatterns.binary_scaled_bounds( - model=model, - variable=variable, - scaling_variable=scaling_variable, - relative_bounds=bounds, - variable_state=variable_state, - scaling_bounds=scaling_bounds, - ) - - # Case 3: Binary controlled bounds - if variable_state is not None and scaling_variable is None: - return BoundingPatterns.bounds_with_state( - model=model, - variable=variable, - bounds=bounds, - variable_state=variable_state, - ) - - # Case 2: Scaled bounds - if scaling_variable is not None and variable_state is None: - return BoundingPatterns.scaled_bounds( - model=model, - variable=variable, - scaling_variable=scaling_variable, - relative_bounds=bounds, - ) - - # Case 1: Basic bounds - if scaling_variable is None and variable_state is None: - return BoundingPatterns.basic_bounds(model, variable, bounds) - - raise ValueError('Invalid combination of arguments') From d2182aa23dbf558998e6680f6f98ff7ca95faba9 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 23 Jul 2025 14:10:32 +0200 Subject: [PATCH 252/336] Improve assertion in tests --- tests/conftest.py | 22 ++++++++++++ tests/test_component.py | 75 +++++++++++++++++++++++------------------ 2 files changed, 64 insertions(+), 33 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 902e01c12..d6a5df7fa 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,6 +5,7 @@ """ import os +from typing import Iterable import linopy.testing import numpy as np @@ -575,3 +576,24 @@ def assert_var_equal(actual: linopy.Variable, desired: linopy.Variable): if actual.coord_dims != desired.coord_dims: raise AssertionError(f"{name} coordinate dimensions don't match: {actual.coord_dims} != {desired.coord_dims}") + + +def assert_sets_equal(set1: Iterable, set2: Iterable, msg=""): + """Assert two sets are equal with custom error message.""" + set1, set2 = set(set1), set(set2) + + extra = set1 - set2 + missing = set2 - set1 + + if extra or missing: + parts = [] + if extra: + parts.append(f"Extra: {sorted(extra)}") + if missing: + parts.append(f"Missing: {sorted(missing)}") + + error_msg = ", ".join(parts) + if msg: + error_msg = f"{msg}: {error_msg}" + + raise AssertionError(error_msg) diff --git a/tests/test_component.py b/tests/test_component.py index 14b1544dd..5f1adf727 100644 --- a/tests/test_component.py +++ b/tests/test_component.py @@ -9,6 +9,7 @@ from .conftest import ( assert_almost_equal_numeric, assert_conequal, + assert_sets_equal, assert_var_equal, create_calculation_and_solve, create_linopy_model, @@ -78,40 +79,48 @@ def test_on_with_multiple_flows(self, basic_flow_system_linopy): flow_system.add_elements(comp) model = create_linopy_model(flow_system) - assert set(comp.submodel.variables) == { - 'TestComponent(In1)|flow_rate', - 'TestComponent(In1)|total_flow_hours', - 'TestComponent(In1)|on', - 'TestComponent(In1)|on_hours_total', - 'TestComponent(Out1)|flow_rate', - 'TestComponent(Out1)|total_flow_hours', - 'TestComponent(Out1)|on', - 'TestComponent(Out1)|on_hours_total', - 'TestComponent(Out2)|flow_rate', - 'TestComponent(Out2)|total_flow_hours', - 'TestComponent(Out2)|on', - 'TestComponent(Out2)|on_hours_total', - 'TestComponent|on', - 'TestComponent|on_hours_total', - } + assert_sets_equal( + set(comp.submodel.variables), + { + 'TestComponent(In1)|flow_rate', + 'TestComponent(In1)|total_flow_hours', + 'TestComponent(In1)|on', + 'TestComponent(In1)|on_hours_total', + 'TestComponent(Out1)|flow_rate', + 'TestComponent(Out1)|total_flow_hours', + 'TestComponent(Out1)|on', + 'TestComponent(Out1)|on_hours_total', + 'TestComponent(Out2)|flow_rate', + 'TestComponent(Out2)|total_flow_hours', + 'TestComponent(Out2)|on', + 'TestComponent(Out2)|on_hours_total', + 'TestComponent|on', + 'TestComponent|on_hours_total', + }, + msg='Incorrect variables', + ) - assert set(comp.submodel.constraints) == { - 'TestComponent(In1)|total_flow_hours', - 'TestComponent(In1)|flow_rate|lb', - 'TestComponent(In1)|flow_rate|ub', - 'TestComponent(In1)|on_hours_total', - 'TestComponent(Out1)|total_flow_hours', - 'TestComponent(Out1)|flow_rate|lb', - 'TestComponent(Out1)|flow_rate|ub', - 'TestComponent(Out1)|on_hours_total', - 'TestComponent(Out2)|total_flow_hours', - 'TestComponent(Out2)|flow_rate|lb', - 'TestComponent(Out2)|flow_rate|ub', - 'TestComponent(Out2)|on_hours_total', - 'TestComponent|on|lb', - 'TestComponent|on|ub', - 'TestComponent|on_hours_total', - } + assert_sets_equal( + set(comp.submodel.constraints), + { + 'TestComponent(In1)|total_flow_hours', + 'TestComponent(In1)|flow_rate|lb', + 'TestComponent(In1)|flow_rate|ub', + 'TestComponent(In1)|on_hours_total', + 'TestComponent(Out1)|total_flow_hours', + 'TestComponent(Out1)|flow_rate|lb', + 'TestComponent(Out1)|flow_rate|ub', + 'TestComponent(Out1)|on_hours_total', + 'TestComponent(Out2)|total_flow_hours', + 'TestComponent(Out2)|flow_rate|lb', + 'TestComponent(Out2)|flow_rate|ub', + 'TestComponent(Out2)|on_hours_total', + 'TestComponent|on|lb', + 'TestComponent|on|ub', + 'TestComponent|on_hours_total', + }, + msg='Incorrect constraints', + ) assert_var_equal(model['TestComponent(Out2)|flow_rate'], model.add_variables(lower=0, upper=300 * ub_out2, coords=(timesteps,))) From 75c05ee5a5404bae6427052a407ee4eb635bf367 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 23 Jul 2025 14:18:54 +0200 Subject: [PATCH 253/336] Improve docstrings and register ElementModels directly in FlowSystemModel --- flixopt/structure.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/flixopt/structure.py b/flixopt/structure.py index 344d86c01..21416a6d1 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -763,7 +763,10 @@ def _valid_label(label: str) -> str: class Submodel(SubmodelsMixin): - """Stores Variables and Constraints.""" + """Stores Variables and Constraints. Its a subset of a FlowSystemModel. + Variables and constraints are stored in the main FLowSystemModel, and are referenced here. + Can have other Submodels assigned, and can be a Submodel of another Submodel. + """ def __init__( self, model: FlowSystemModel, label_of_element: str, label_of_model = None @@ -1003,7 +1006,10 @@ def get(self, name: str, default=None): class ElementModel(Submodel): - """Stores the mathematical Variables and Constraints for Elements""" + """ + Stores the mathematical Variables and Constraints for Elements. + ElementModels are directly registered in the main FLowSystemModel + """ def __init__(self, model: FlowSystemModel, element: Element): """ @@ -1012,7 +1018,8 @@ def __init__(self, model: FlowSystemModel, element: Element): element: The element this model is created for. """ self.element = element - super().__init__(model, label_of_element=element.label_full) + super().__init__(model, label_of_element=element.label_full, label_of_model=element.label_full) + self._model.add_submodels(self, short_name=self.label_of_model) def results_structure(self): return { From 66a6ff19cbf925ff434f17088f2c796c2005fedb Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 23 Jul 2025 14:30:30 +0200 Subject: [PATCH 254/336] Improve __repr__() --- flixopt/structure.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/flixopt/structure.py b/flixopt/structure.py index 21416a6d1..0e82fa346 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -975,15 +975,23 @@ def __contains__(self, name: str) -> bool: def __repr__(self) -> str: """Simple representation of the submodels collection.""" - title = 'flixopt.structure.Submodels:' + if not self.data: + return 'flixopt.structure.Submodels:\n----------------------------\n \n' + + total_vars = sum(len(submodel.variables) for submodel in self.data.values()) + total_cons = sum(len(submodel.constraints) for submodel in self.data.values()) + + title = f'flixopt.structure.Submodels ({total_vars} vars, {total_cons} constraints, {len(self.data)} submodels):' underline = '-' * len(title) if not self.data: return f'{title}\n{underline}\n \n' - sub_models_string = '' for name, submodel in self.data.items(): - sub_models_string += f'\n * {name} ({submodel.__class__.__name__}) [{len(submodel.variables)} Vars + {len(submodel.constraints)} Cons)' + type_name = submodel.__class__.__name__ + var_count = len(submodel.variables) + con_count = len(submodel.constraints) + sub_models_string += f'\n * {name} [{type_name}] ({var_count}v/{con_count}c)' return f'{title}\n{underline}{sub_models_string}\n' From e2e1f1318f016db5e80d937f5db5cf61533f6df3 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 23 Jul 2025 14:41:47 +0200 Subject: [PATCH 255/336] ruff check --- tests/test_effect.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/test_effect.py b/tests/test_effect.py index cce8ac939..81a220d12 100644 --- a/tests/test_effect.py +++ b/tests/test_effect.py @@ -5,7 +5,13 @@ import flixopt as fx -from .conftest import assert_conequal, assert_var_equal, create_calculation_and_solve, create_linopy_model +from .conftest import ( + assert_conequal, + assert_sets_equal, + assert_var_equal, + create_calculation_and_solve, + create_linopy_model, +) class TestEffectModel: From 62b18b614a6448a7e5e7035548690309af35f480 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 23 Jul 2025 15:28:01 +0200 Subject: [PATCH 256/336] Use new method to compare sets in tests --- tests/test_component.py | 152 ++++++++++++--------- tests/test_effect.py | 92 ++++++++----- tests/test_flow.py | 286 +++++++++++++++++++++++++++------------- 3 files changed, 343 insertions(+), 187 deletions(-) diff --git a/tests/test_component.py b/tests/test_component.py index 5f1adf727..9b3674cd5 100644 --- a/tests/test_component.py +++ b/tests/test_component.py @@ -47,19 +47,27 @@ def test_component(self, basic_flow_system_linopy): flow_system.add_elements(comp) _ = create_linopy_model(flow_system) - assert {'TestComponent(In1)|flow_rate', - 'TestComponent(In1)|total_flow_hours', - 'TestComponent(In2)|flow_rate', - 'TestComponent(In2)|total_flow_hours', - 'TestComponent(Out1)|flow_rate', - 'TestComponent(Out1)|total_flow_hours', - 'TestComponent(Out2)|flow_rate', - 'TestComponent(Out2)|total_flow_hours'} == set(comp.submodel.variables) - - assert {'TestComponent(In1)|total_flow_hours', - 'TestComponent(In2)|total_flow_hours', - 'TestComponent(Out1)|total_flow_hours', - 'TestComponent(Out2)|total_flow_hours'} == set(comp.submodel.constraints) + assert_sets_equal( + set(comp.submodel.variables), + {'TestComponent(In1)|flow_rate', + 'TestComponent(In1)|total_flow_hours', + 'TestComponent(In2)|flow_rate', + 'TestComponent(In2)|total_flow_hours', + 'TestComponent(Out1)|flow_rate', + 'TestComponent(Out1)|total_flow_hours', + 'TestComponent(Out2)|flow_rate', + 'TestComponent(Out2)|total_flow_hours'}, + msg='Incorrect variables' + ) + + assert_sets_equal( + set(comp.submodel.constraints), + {'TestComponent(In1)|total_flow_hours', + 'TestComponent(In2)|total_flow_hours', + 'TestComponent(Out1)|total_flow_hours', + 'TestComponent(Out2)|total_flow_hours'}, + msg='Incorrect constraints' + ) def test_on_with_multiple_flows(self, basic_flow_system_linopy): """Test that flow model constraints are correctly generated.""" @@ -167,23 +175,31 @@ def test_on_with_single_flow(self, basic_flow_system_linopy): flow_system.add_elements(comp) model = create_linopy_model(flow_system) - assert set(comp.submodel.variables) == { - 'TestComponent(In1)|flow_rate', - 'TestComponent(In1)|total_flow_hours', - 'TestComponent(In1)|on', - 'TestComponent(In1)|on_hours_total', - 'TestComponent|on', - 'TestComponent|on_hours_total', - } - - assert set(comp.submodel.constraints) == { - 'TestComponent(In1)|total_flow_hours', - 'TestComponent(In1)|flow_rate|lb', - 'TestComponent(In1)|flow_rate|ub', - 'TestComponent(In1)|on_hours_total', - 'TestComponent|on', - 'TestComponent|on_hours_total', - } + assert_sets_equal( + set(comp.submodel.variables), + { + 'TestComponent(In1)|flow_rate', + 'TestComponent(In1)|total_flow_hours', + 'TestComponent(In1)|on', + 'TestComponent(In1)|on_hours_total', + 'TestComponent|on', + 'TestComponent|on_hours_total', + }, + msg='Incorrect variables' + ) + + assert_sets_equal( + set(comp.submodel.constraints), + { + 'TestComponent(In1)|total_flow_hours', + 'TestComponent(In1)|flow_rate|lb', + 'TestComponent(In1)|flow_rate|ub', + 'TestComponent(In1)|on_hours_total', + 'TestComponent|on', + 'TestComponent|on_hours_total', + }, + msg='Incorrect constraints' + ) assert_var_equal( model['TestComponent(In1)|flow_rate'], model.add_variables(lower=0, upper=100, coords=(timesteps,)) @@ -223,40 +239,48 @@ def test_previous_states_with_multiple_flows(self, basic_flow_system_linopy): flow_system.add_elements(comp) model = create_linopy_model(flow_system) - assert set(comp.submodel.variables) == { - 'TestComponent(In1)|flow_rate', - 'TestComponent(In1)|total_flow_hours', - 'TestComponent(In1)|on', - 'TestComponent(In1)|on_hours_total', - 'TestComponent(Out1)|flow_rate', - 'TestComponent(Out1)|total_flow_hours', - 'TestComponent(Out1)|on', - 'TestComponent(Out1)|on_hours_total', - 'TestComponent(Out2)|flow_rate', - 'TestComponent(Out2)|total_flow_hours', - 'TestComponent(Out2)|on', - 'TestComponent(Out2)|on_hours_total', - 'TestComponent|on', - 'TestComponent|on_hours_total', - } - - assert set(comp.submodel.constraints) == { - 'TestComponent(In1)|total_flow_hours', - 'TestComponent(In1)|flow_rate|lb', - 'TestComponent(In1)|flow_rate|ub', - 'TestComponent(In1)|on_hours_total', - 'TestComponent(Out1)|total_flow_hours', - 'TestComponent(Out1)|flow_rate|lb', - 'TestComponent(Out1)|flow_rate|ub', - 'TestComponent(Out1)|on_hours_total', - 'TestComponent(Out2)|total_flow_hours', - 'TestComponent(Out2)|flow_rate|lb', - 'TestComponent(Out2)|flow_rate|ub', - 'TestComponent(Out2)|on_hours_total', - 'TestComponent|on|lb', - 'TestComponent|on|ub', - 'TestComponent|on_hours_total', - } + assert_sets_equal( + set(comp.submodel.variables), + { + 'TestComponent(In1)|flow_rate', + 'TestComponent(In1)|total_flow_hours', + 'TestComponent(In1)|on', + 'TestComponent(In1)|on_hours_total', + 'TestComponent(Out1)|flow_rate', + 'TestComponent(Out1)|total_flow_hours', + 'TestComponent(Out1)|on', + 'TestComponent(Out1)|on_hours_total', + 'TestComponent(Out2)|flow_rate', + 'TestComponent(Out2)|total_flow_hours', + 'TestComponent(Out2)|on', + 'TestComponent(Out2)|on_hours_total', + 'TestComponent|on', + 'TestComponent|on_hours_total', + }, + msg='Incorrect variables' + ) + + assert_sets_equal( + set(comp.submodel.constraints), + { + 'TestComponent(In1)|total_flow_hours', + 'TestComponent(In1)|flow_rate|lb', + 'TestComponent(In1)|flow_rate|ub', + 'TestComponent(In1)|on_hours_total', + 'TestComponent(Out1)|total_flow_hours', + 'TestComponent(Out1)|flow_rate|lb', + 'TestComponent(Out1)|flow_rate|ub', + 'TestComponent(Out1)|on_hours_total', + 'TestComponent(Out2)|total_flow_hours', + 'TestComponent(Out2)|flow_rate|lb', + 'TestComponent(Out2)|flow_rate|ub', + 'TestComponent(Out2)|on_hours_total', + 'TestComponent|on|lb', + 'TestComponent|on|ub', + 'TestComponent|on_hours_total', + }, + msg='Incorrect constraints' + ) assert_var_equal(model['TestComponent(Out2)|flow_rate'], model.add_variables(lower=0, upper=300 * ub_out2, coords=(timesteps,))) diff --git a/tests/test_effect.py b/tests/test_effect.py index 81a220d12..dfcc2ea66 100644 --- a/tests/test_effect.py +++ b/tests/test_effect.py @@ -25,14 +25,23 @@ def test_minimal(self, basic_flow_system_linopy): flow_system.add_elements(effect) model = create_linopy_model(flow_system) - assert set(effect.submodel.variables) == {'Effect1(invest)|total', - 'Effect1(operation)|total', - 'Effect1(operation)|total_per_timestep', - 'Effect1|total',} - assert set(effect.submodel.constraints) == {'Effect1(invest)|total', - 'Effect1(operation)|total', - 'Effect1(operation)|total_per_timestep', - 'Effect1|total',} + assert_sets_equal( + set(effect.submodel.variables), + {'Effect1(invest)|total', + 'Effect1(operation)|total', + 'Effect1(operation)|total_per_timestep', + 'Effect1|total'}, + msg='Incorrect variables' + ) + + assert_sets_equal( + set(effect.submodel.constraints), + {'Effect1(invest)|total', + 'Effect1(operation)|total', + 'Effect1(operation)|total_per_timestep', + 'Effect1|total'}, + msg='Incorrect constraints' + ) assert_var_equal(model.variables['Effect1|total'], model.add_variables()) assert_var_equal(model.variables['Effect1(invest)|total'], model.add_variables()) @@ -64,14 +73,23 @@ def test_bounds(self, basic_flow_system_linopy): flow_system.add_elements(effect) model = create_linopy_model(flow_system) - assert set(effect.submodel.variables) == {'Effect1(invest)|total', - 'Effect1(operation)|total', - 'Effect1(operation)|total_per_timestep', - 'Effect1|total',} - assert set(effect.submodel.constraints) == {'Effect1(invest)|total', - 'Effect1(operation)|total', - 'Effect1(operation)|total_per_timestep', - 'Effect1|total',} + assert_sets_equal( + set(effect.submodel.variables), + {'Effect1(invest)|total', + 'Effect1(operation)|total', + 'Effect1(operation)|total_per_timestep', + 'Effect1|total'}, + msg='Incorrect variables' + ) + + assert_sets_equal( + set(effect.submodel.constraints), + {'Effect1(invest)|total', + 'Effect1(operation)|total', + 'Effect1(operation)|total_per_timestep', + 'Effect1|total'}, + msg='Incorrect constraints' + ) assert_var_equal(model.variables['Effect1|total'], model.add_variables(lower=3.0, upper=3.1)) assert_var_equal(model.variables['Effect1(invest)|total'], model.add_variables(lower=2.0, upper=2.1)) @@ -106,22 +124,31 @@ def test_shares(self, basic_flow_system_linopy): flow_system.add_elements(effect1, effect2, effect3) model = create_linopy_model(flow_system) - assert set(effect2.submodel.variables) == { - 'Effect2(invest)|total', - 'Effect2(operation)|total', - 'Effect2(operation)|total_per_timestep', - 'Effect2|total', - 'Effect1(invest)->Effect2(invest)', - 'Effect1(operation)->Effect2(operation)', - } - assert set(effect2.submodel.constraints) == { - 'Effect2(invest)|total', - 'Effect2(operation)|total', - 'Effect2(operation)|total_per_timestep', - 'Effect2|total', - 'Effect1(invest)->Effect2(invest)', - 'Effect1(operation)->Effect2(operation)', - } + assert_sets_equal( + set(effect2.submodel.variables), + { + 'Effect2(invest)|total', + 'Effect2(operation)|total', + 'Effect2(operation)|total_per_timestep', + 'Effect2|total', + 'Effect1(invest)->Effect2(invest)', + 'Effect1(operation)->Effect2(operation)', + }, + msg='Incorrect variables for effect2' + ) + + assert_sets_equal( + set(effect2.submodel.constraints), + { + 'Effect2(invest)|total', + 'Effect2(operation)|total', + 'Effect2(operation)|total_per_timestep', + 'Effect2|total', + 'Effect1(invest)->Effect2(invest)', + 'Effect1(operation)->Effect2(operation)', + }, + msg='Incorrect constraints for effect2' + ) assert_conequal( model.constraints['Effect2(invest)|total'], @@ -227,4 +254,3 @@ def test_shares(self, basic_flow_system_linopy): xr.testing.assert_allclose(results.effects_per_component('total').sum('component')['Effect3'], results.solution['Effect3|total']) - diff --git a/tests/test_flow.py b/tests/test_flow.py index d22d81993..2ceb99e33 100644 --- a/tests/test_flow.py +++ b/tests/test_flow.py @@ -5,7 +5,7 @@ import flixopt as fx -from .conftest import assert_conequal, assert_var_equal, create_linopy_model +from .conftest import assert_conequal, assert_sets_equal, assert_var_equal, create_linopy_model class TestFlowModel: @@ -29,8 +29,16 @@ def test_flow_minimal(self, basic_flow_system_linopy): model.add_variables(lower=0, upper=100, coords=(timesteps,))) assert_var_equal(flow.submodel.total_flow_hours, model.add_variables(lower=0)) - assert set(flow.submodel.variables) == set(['Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|flow_rate']) - assert set(flow.submodel.constraints) == set(['Sink(Wärme)|total_flow_hours']) + assert_sets_equal( + set(flow.submodel.variables), + {'Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|flow_rate'}, + msg='Incorrect variables' + ) + assert_sets_equal( + set(flow.submodel.constraints), + {'Sink(Wärme)|total_flow_hours'}, + msg='Incorrect constraints' + ) def test_flow(self, basic_flow_system_linopy): flow_system = basic_flow_system_linopy @@ -81,8 +89,16 @@ def test_flow(self, basic_flow_system_linopy): <= model.hours_per_step.sum('time') * 0.9 * 100, ) - assert set(flow.submodel.variables) == set(['Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|flow_rate']) - assert set(flow.submodel.constraints) == set(['Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|load_factor_max', 'Sink(Wärme)|load_factor_min']) + assert_sets_equal( + set(flow.submodel.variables), + {'Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|flow_rate'}, + msg='Incorrect variables' + ) + assert_sets_equal( + set(flow.submodel.constraints), + {'Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|load_factor_max', 'Sink(Wärme)|load_factor_min'}, + msg='Incorrect constraints' + ) def test_effects_per_flow_hour(self, basic_flow_system_linopy): flow_system = basic_flow_system_linopy @@ -100,8 +116,16 @@ def test_effects_per_flow_hour(self, basic_flow_system_linopy): model = create_linopy_model(flow_system) costs, co2 = flow_system.effects['Costs'], flow_system.effects['CO2'] - assert set(flow.submodel.variables) == {'Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|flow_rate'} - assert set(flow.submodel.constraints) == {'Sink(Wärme)|total_flow_hours'} + assert_sets_equal( + set(flow.submodel.variables), + {'Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|flow_rate'}, + msg='Incorrect variables' + ) + assert_sets_equal( + set(flow.submodel.constraints), + {'Sink(Wärme)|total_flow_hours'}, + msg='Incorrect constraints' + ) assert 'Sink(Wärme)->Costs(operation)' in set(costs.submodel.constraints) assert 'Sink(Wärme)->CO2(operation)' in set(co2.submodel.constraints) @@ -133,19 +157,23 @@ def test_flow_invest(self, basic_flow_system_linopy): flow_system.add_elements(fx.Sink('Sink', sink=flow)) model = create_linopy_model(flow_system) - assert set(flow.submodel.variables) == set( - [ + assert_sets_equal( + set(flow.submodel.variables), + { 'Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|flow_rate', 'Sink(Wärme)|size', - ] + }, + msg='Incorrect variables' ) - assert set(flow.submodel.constraints) == set( - [ + assert_sets_equal( + set(flow.submodel.constraints), + { 'Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|flow_rate|ub', 'Sink(Wärme)|flow_rate|lb', - ] + }, + msg='Incorrect constraints' ) # size @@ -188,17 +216,21 @@ def test_flow_invest_optional(self, basic_flow_system_linopy): flow_system.add_elements(fx.Sink('Sink', sink=flow)) model = create_linopy_model(flow_system) - assert set(flow.submodel.variables) == set( - ['Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|flow_rate', 'Sink(Wärme)|size', 'Sink(Wärme)|is_invested'] + assert_sets_equal( + set(flow.submodel.variables), + {'Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|flow_rate', 'Sink(Wärme)|size', 'Sink(Wärme)|is_invested'}, + msg='Incorrect variables' ) - assert set(flow.submodel.constraints) == set( - [ + assert_sets_equal( + set(flow.submodel.constraints), + { 'Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|size|lb', 'Sink(Wärme)|size|ub', 'Sink(Wärme)|flow_rate|lb', 'Sink(Wärme)|flow_rate|ub', - ] + }, + msg='Incorrect constraints' ) assert_var_equal(model['Sink(Wärme)|size'], model.add_variables(lower=0, upper=100)) @@ -252,17 +284,21 @@ def test_flow_invest_optional_wo_min_size(self, basic_flow_system_linopy): flow_system.add_elements(fx.Sink('Sink', sink=flow)) model = create_linopy_model(flow_system) - assert set(flow.submodel.variables) == set( - ['Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|flow_rate', 'Sink(Wärme)|size', 'Sink(Wärme)|is_invested'] + assert_sets_equal( + set(flow.submodel.variables), + {'Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|flow_rate', 'Sink(Wärme)|size', 'Sink(Wärme)|is_invested'}, + msg='Incorrect variables' ) - assert set(flow.submodel.constraints) == set( - [ + assert_sets_equal( + set(flow.submodel.constraints), + { 'Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|size|ub', 'Sink(Wärme)|size|lb', 'Sink(Wärme)|flow_rate|lb', 'Sink(Wärme)|flow_rate|ub', - ] + }, + msg='Incorrect constraints' ) assert_var_equal(model['Sink(Wärme)|size'], model.add_variables(lower=0, upper=100)) @@ -316,15 +352,19 @@ def test_flow_invest_wo_min_size_non_optional(self, basic_flow_system_linopy): flow_system.add_elements(fx.Sink('Sink', sink=flow)) model = create_linopy_model(flow_system) - assert set(flow.submodel.variables) == set( - ['Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|flow_rate', 'Sink(Wärme)|size'] + assert_sets_equal( + set(flow.submodel.variables), + {'Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|flow_rate', 'Sink(Wärme)|size'}, + msg='Incorrect variables' ) - assert set(flow.submodel.constraints) == set( - [ + assert_sets_equal( + set(flow.submodel.constraints), + { 'Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|flow_rate|lb', 'Sink(Wärme)|flow_rate|ub', - ] + }, + msg='Incorrect constraints' ) assert_var_equal(model['Sink(Wärme)|size'], model.add_variables(lower=1e-5, upper=100)) @@ -367,7 +407,11 @@ def test_flow_invest_fixed_size(self, basic_flow_system_linopy): flow_system.add_elements(fx.Sink('Sink', sink=flow)) model = create_linopy_model(flow_system) - assert set(flow.submodel.variables) == {'Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|flow_rate', 'Sink(Wärme)|size'} + assert_sets_equal( + set(flow.submodel.variables), + {'Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|flow_rate', 'Sink(Wärme)|size'}, + msg='Incorrect variables' + ) # Check that size is fixed to 75 assert_var_equal(flow.submodel.variables['Sink(Wärme)|size'], model.add_variables(lower=75, upper=75)) @@ -458,17 +502,21 @@ def test_flow_on(self, basic_flow_system_linopy): flow_system.add_elements(fx.Sink('Sink', sink=flow)) model = create_linopy_model(flow_system) - assert set(flow.submodel.variables) == set( - ['Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|flow_rate', 'Sink(Wärme)|on', 'Sink(Wärme)|on_hours_total'] + assert_sets_equal( + set(flow.submodel.variables), + {'Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|flow_rate', 'Sink(Wärme)|on', 'Sink(Wärme)|on_hours_total'}, + msg='Incorrect variables' ) - assert set(flow.submodel.constraints) == set( - [ + assert_sets_equal( + set(flow.submodel.constraints), + { 'Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|on_hours_total', 'Sink(Wärme)|flow_rate|lb', 'Sink(Wärme)|flow_rate|ub', - ] + }, + msg='Incorrect constraints' ) # flow_rate assert_var_equal( @@ -522,18 +570,26 @@ def test_effects_per_running_hour(self, basic_flow_system_linopy): model = create_linopy_model(flow_system) costs, co2 = flow_system.effects['Costs'], flow_system.effects['CO2'] - assert set(flow.submodel.variables) == { - 'Sink(Wärme)|total_flow_hours', - 'Sink(Wärme)|flow_rate', - 'Sink(Wärme)|on', - 'Sink(Wärme)|on_hours_total', - } - assert set(flow.submodel.constraints) == { - 'Sink(Wärme)|total_flow_hours', - 'Sink(Wärme)|flow_rate|lb', - 'Sink(Wärme)|flow_rate|ub', - 'Sink(Wärme)|on_hours_total', - } + assert_sets_equal( + set(flow.submodel.variables), + { + 'Sink(Wärme)|total_flow_hours', + 'Sink(Wärme)|flow_rate', + 'Sink(Wärme)|on', + 'Sink(Wärme)|on_hours_total', + }, + msg='Incorrect variables' + ) + assert_sets_equal( + set(flow.submodel.constraints), + { + 'Sink(Wärme)|total_flow_hours', + 'Sink(Wärme)|flow_rate|lb', + 'Sink(Wärme)|flow_rate|ub', + 'Sink(Wärme)|on_hours_total', + }, + msg='Incorrect constraints' + ) assert 'Sink(Wärme)->Costs(operation)' in set(costs.submodel.constraints) assert 'Sink(Wärme)->CO2(operation)' in set(co2.submodel.constraints) @@ -570,12 +626,19 @@ def test_consecutive_on_hours(self, basic_flow_system_linopy): assert {'Sink(Wärme)|consecutive_on_hours', 'Sink(Wärme)|on'}.issubset(set(flow.submodel.variables)) - assert {'Sink(Wärme)|consecutive_on_hours|ub', - 'Sink(Wärme)|consecutive_on_hours|forward', - 'Sink(Wärme)|consecutive_on_hours|backward', - 'Sink(Wärme)|consecutive_on_hours|initial', - 'Sink(Wärme)|consecutive_on_hours|lb', - }.issubset(set(flow.submodel.constraints)) + assert_sets_equal( + {'Sink(Wärme)|consecutive_on_hours|ub', + 'Sink(Wärme)|consecutive_on_hours|forward', + 'Sink(Wärme)|consecutive_on_hours|backward', + 'Sink(Wärme)|consecutive_on_hours|initial', + 'Sink(Wärme)|consecutive_on_hours|lb'} & set(flow.submodel.constraints), + {'Sink(Wärme)|consecutive_on_hours|ub', + 'Sink(Wärme)|consecutive_on_hours|forward', + 'Sink(Wärme)|consecutive_on_hours|backward', + 'Sink(Wärme)|consecutive_on_hours|initial', + 'Sink(Wärme)|consecutive_on_hours|lb'}, + msg='Missing consecutive on hours constraints' + ) assert_var_equal( model.variables['Sink(Wärme)|consecutive_on_hours'], @@ -637,11 +700,17 @@ def test_consecutive_on_hours_previous(self, basic_flow_system_linopy): assert {'Sink(Wärme)|consecutive_on_hours', 'Sink(Wärme)|on'}.issubset(set(flow.submodel.variables)) - assert {'Sink(Wärme)|consecutive_on_hours|lb', - 'Sink(Wärme)|consecutive_on_hours|forward', - 'Sink(Wärme)|consecutive_on_hours|backward', - 'Sink(Wärme)|consecutive_on_hours|initial', - }.issubset(set(flow.submodel.constraints)) + assert_sets_equal( + {'Sink(Wärme)|consecutive_on_hours|lb', + 'Sink(Wärme)|consecutive_on_hours|forward', + 'Sink(Wärme)|consecutive_on_hours|backward', + 'Sink(Wärme)|consecutive_on_hours|initial'} & set(flow.submodel.constraints), + {'Sink(Wärme)|consecutive_on_hours|lb', + 'Sink(Wärme)|consecutive_on_hours|forward', + 'Sink(Wärme)|consecutive_on_hours|backward', + 'Sink(Wärme)|consecutive_on_hours|initial'}, + msg='Missing consecutive on hours constraints for previous states' + ) assert_var_equal( model.variables['Sink(Wärme)|consecutive_on_hours'], @@ -702,13 +771,23 @@ def test_consecutive_off_hours(self, basic_flow_system_linopy): assert {'Sink(Wärme)|consecutive_off_hours', 'Sink(Wärme)|off'}.issubset(set(flow.submodel.variables)) - assert { - 'Sink(Wärme)|consecutive_off_hours|ub', - 'Sink(Wärme)|consecutive_off_hours|forward', - 'Sink(Wärme)|consecutive_off_hours|backward', - 'Sink(Wärme)|consecutive_off_hours|initial', - 'Sink(Wärme)|consecutive_off_hours|lb' - }.issubset(set(flow.submodel.constraints)) + assert_sets_equal( + { + 'Sink(Wärme)|consecutive_off_hours|ub', + 'Sink(Wärme)|consecutive_off_hours|forward', + 'Sink(Wärme)|consecutive_off_hours|backward', + 'Sink(Wärme)|consecutive_off_hours|initial', + 'Sink(Wärme)|consecutive_off_hours|lb' + } & set(flow.submodel.constraints), + { + 'Sink(Wärme)|consecutive_off_hours|ub', + 'Sink(Wärme)|consecutive_off_hours|forward', + 'Sink(Wärme)|consecutive_off_hours|backward', + 'Sink(Wärme)|consecutive_off_hours|initial', + 'Sink(Wärme)|consecutive_off_hours|lb' + }, + msg='Missing consecutive off hours constraints' + ) assert_var_equal( model.variables['Sink(Wärme)|consecutive_off_hours'], @@ -770,13 +849,23 @@ def test_consecutive_off_hours_previous(self, basic_flow_system_linopy): assert {'Sink(Wärme)|consecutive_off_hours', 'Sink(Wärme)|off'}.issubset(set(flow.submodel.variables)) - assert { - 'Sink(Wärme)|consecutive_off_hours|ub', - 'Sink(Wärme)|consecutive_off_hours|forward', - 'Sink(Wärme)|consecutive_off_hours|backward', - 'Sink(Wärme)|consecutive_off_hours|initial', - 'Sink(Wärme)|consecutive_off_hours|lb' - }.issubset(set(flow.submodel.constraints)) + assert_sets_equal( + { + 'Sink(Wärme)|consecutive_off_hours|ub', + 'Sink(Wärme)|consecutive_off_hours|forward', + 'Sink(Wärme)|consecutive_off_hours|backward', + 'Sink(Wärme)|consecutive_off_hours|initial', + 'Sink(Wärme)|consecutive_off_hours|lb' + } & set(flow.submodel.constraints), + { + 'Sink(Wärme)|consecutive_off_hours|ub', + 'Sink(Wärme)|consecutive_off_hours|forward', + 'Sink(Wärme)|consecutive_off_hours|backward', + 'Sink(Wärme)|consecutive_off_hours|initial', + 'Sink(Wärme)|consecutive_off_hours|lb' + }, + msg='Missing consecutive off hours constraints for previous states' + ) assert_var_equal( model.variables['Sink(Wärme)|consecutive_off_hours'], @@ -840,12 +929,21 @@ def test_switch_on_constraints(self, basic_flow_system_linopy): ) # Check that constraints exist - assert { - 'Sink(Wärme)|switch|transition', - 'Sink(Wärme)|switch|initial', - 'Sink(Wärme)|switch|mutex', - 'Sink(Wärme)|switch|count', - }.issubset(set(flow.submodel.constraints)) + assert_sets_equal( + { + 'Sink(Wärme)|switch|transition', + 'Sink(Wärme)|switch|initial', + 'Sink(Wärme)|switch|mutex', + 'Sink(Wärme)|switch|count', + } & set(flow.submodel.constraints), + { + 'Sink(Wärme)|switch|transition', + 'Sink(Wärme)|switch|initial', + 'Sink(Wärme)|switch|mutex', + 'Sink(Wärme)|switch|count', + }, + msg='Missing switch constraints' + ) # Check switch_on_nr variable bounds assert_var_equal(flow.submodel.variables['Sink(Wärme)|switch|count'], model.add_variables(lower=0, upper=5)) @@ -917,19 +1015,22 @@ def test_flow_on_invest_optional(self, basic_flow_system_linopy): flow_system.add_elements(fx.Sink('Sink', sink=flow)) model = create_linopy_model(flow_system) - assert set(flow.submodel.variables) == set( - [ + assert_sets_equal( + set(flow.submodel.variables), + { 'Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|flow_rate', 'Sink(Wärme)|is_invested', 'Sink(Wärme)|size', 'Sink(Wärme)|on', 'Sink(Wärme)|on_hours_total', - ] + }, + msg='Incorrect variables' ) - assert set(flow.submodel.constraints) == set( - [ + assert_sets_equal( + set(flow.submodel.constraints), + { 'Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|on_hours_total', 'Sink(Wärme)|flow_rate|lb1', @@ -938,7 +1039,8 @@ def test_flow_on_invest_optional(self, basic_flow_system_linopy): 'Sink(Wärme)|size|ub', 'Sink(Wärme)|flow_rate|lb2', 'Sink(Wärme)|flow_rate|ub2', - ] + }, + msg='Incorrect constraints' ) # flow_rate @@ -1010,25 +1112,29 @@ def test_flow_on_invest_non_optional(self, basic_flow_system_linopy): flow_system.add_elements(fx.Sink('Sink', sink=flow)) model = create_linopy_model(flow_system) - assert set(flow.submodel.variables) == set( - [ + assert_sets_equal( + set(flow.submodel.variables), + { 'Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|flow_rate', 'Sink(Wärme)|size', 'Sink(Wärme)|on', 'Sink(Wärme)|on_hours_total', - ] + }, + msg='Incorrect variables' ) - assert set(flow.submodel.constraints) == set( - [ + assert_sets_equal( + set(flow.submodel.constraints), + { 'Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|on_hours_total', 'Sink(Wärme)|flow_rate|lb1', 'Sink(Wärme)|flow_rate|ub1', 'Sink(Wärme)|flow_rate|lb2', 'Sink(Wärme)|flow_rate|ub2', - ] + }, + msg='Incorrect constraints' ) # flow_rate @@ -1136,4 +1242,4 @@ def test_fixed_profile_with_investment(self, basic_flow_system_linopy): if __name__ == '__main__': - pytest.main() + pytest.main() \ No newline at end of file From 5f0b503d6e1f34e704d0356a4db381202d73c9a5 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 23 Jul 2025 15:28:32 +0200 Subject: [PATCH 257/336] ruff check --- tests/test_flow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_flow.py b/tests/test_flow.py index 2ceb99e33..2cc26e52a 100644 --- a/tests/test_flow.py +++ b/tests/test_flow.py @@ -1242,4 +1242,4 @@ def test_fixed_profile_with_investment(self, basic_flow_system_linopy): if __name__ == '__main__': - pytest.main() \ No newline at end of file + pytest.main() From 15a08e956fdc2c6571d3e02f3babbc236c668ab9 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 23 Jul 2025 15:55:52 +0200 Subject: [PATCH 258/336] Update Contribute.md, some dependencies and add pre-commit --- .pre-commit-config.yaml | 15 +++++++++++++++ README.md | 7 ++++++- docs/SUMMARY.md | 1 + docs/contribute.md | 34 +++++++++++++++++++++++----------- pyproject.toml | 3 ++- 5 files changed, 47 insertions(+), 13 deletions(-) create mode 100644 .pre-commit-config.yaml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 000000000..c7f512a55 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,15 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-added-large-files + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.12.4 + hooks: + - id: ruff-check + args: [ --fix ] + - id: ruff-format \ No newline at end of file diff --git a/README.md b/README.md index 1312dace9..615322211 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ The documentation is available at [https://flixopt.github.io/flixopt/latest/](ht --- -## 🛠️ Solver Integration +## 🎯️ Solver Integration By default, FlixOpt uses the open-source solver [HiGHS](https://highs.dev/) which is installed by default. However, it is compatible with additional solvers such as: @@ -78,6 +78,11 @@ For detailed licensing and installation instructions, refer to the respective so --- +## 🛠 Development Setup +Look into our docs for [development setup](https://flixopt.github.io/flixopt/latest/contribute/#development-setup) + +--- + ## 📖 Citation If you use FlixOpt in your research or project, please cite the following: diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index fffb84610..0e86a81c8 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -4,4 +4,5 @@ - [Examples](examples/) - [FAQ](faq/) - [API-Reference](api-reference/) +- [Contribute](contribute.md) - [Release Notes](changelog.md) \ No newline at end of file diff --git a/docs/contribute.md b/docs/contribute.md index 439fefe1d..d23d39d38 100644 --- a/docs/contribute.md +++ b/docs/contribute.md @@ -4,17 +4,29 @@ We warmly welcome contributions from the community! This guide will help you get ## Development Setup 1. Clone the repository `git clone https://github.com/flixOpt/flixopt.git` -2. Install the development dependencies `pip install -editable .[dev, docs]` -3. Run `pytest` and `ruff check .` to ensure your code passes all tests - -## Documentation -FlixOpt uses [mkdocs](https://www.mkdocs.org/) to generate documentation. To preview the documentation locally, run `mkdocs serve` in the root directory. - -## Helpful Commands -- `mkdocs serve` to preview the documentation locally. Navigate to `http://127.0.0.1:8000/` to view the documentation. -- `pytest` to run the test suite (You can also run the provided python script `run_all_test.py`) -- `ruff check .` to run the linter -- `ruff check . --fix` to automatically fix linting issues +2. Install the development dependencies `pip install -e ".[dev]"` +3. Install pre-commit hooks `pre-commit install` (one-time setup) +4. Run `pytest` to ensure your code passes all tests + +## Code Quality +We use [Ruff](https://github.com/astral-sh/ruff) for linting and formatting. After the one-time setup above, **code quality checks run automatically on every commit**. + +To run manually: +- `ruff check --fix .` to check and fix linting issues +- `ruff format .` to format code + +## Documentation (Optional) +FlixOpt uses [mkdocs](https://www.mkdocs.org/) to generate documentation. +To work on documentation: +```bash +pip install -e ".[docs]" +mkdocs serve +``` +Then navigate to http://127.0.0.1:8000/ + +## Testing +- `pytest` to run the test suite +- You can also run the provided python script `run_all_test.py` --- # Best practices diff --git a/pyproject.toml b/pyproject.toml index 8c846dc03..3c7a6e18d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -62,6 +62,7 @@ dev = [ "tsam >= 2.3.1, < 3.0.0", # Time series aggregation "scipy >= 1.15.1, < 2.0.0", # Used by tsam. Prior versions have conflict with highspy. See https://github.com/scipy/scipy/issues/22257 "gurobipy >= 10.0.0", + "pre-commit >= 4.0.0", ] full = [ @@ -82,7 +83,7 @@ docs = [ "markdown-include >= 0.8.0", "pymdown-extensions >= 10.0.0", "pygments >= 2.14.0", - "mike >= 1.1.0, < 2", + "mike >= 2.0.0", ] [project.urls] From 97de53c8c4e708031e018308414d6784b62bdcd2 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 23 Jul 2025 15:56:23 +0200 Subject: [PATCH 259/336] Pre commit hook --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c7f512a55..bf913fbb4 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -12,4 +12,4 @@ repos: hooks: - id: ruff-check args: [ --fix ] - - id: ruff-format \ No newline at end of file + - id: ruff-format From 25f726e125245337c6757a2789d3f94b711e19cf Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 23 Jul 2025 16:05:05 +0200 Subject: [PATCH 260/336] Run Pre-Commit Hook for the first time --- .github/CONTRIBUTING.md | 4 +- .github/ISSUE_TEMPLATE/bug_report.yml | 12 +- .github/ISSUE_TEMPLATE/config.yml | 2 +- .github/ISSUE_TEMPLATE/feature_request.yml | 2 +- .github/pull_request_template.md | 2 +- .github/workflows/python-app.yaml | 2 +- .pre-commit-config.yaml | 1 + CHANGELOG.md | 5 +- README.md | 18 +- docs/SUMMARY.md | 2 +- docs/contribute.md | 6 +- docs/examples/00-Minimal Example.md | 2 +- docs/examples/01-Basic Example.md | 2 +- docs/examples/02-Complex Example.md | 2 +- docs/examples/index.md | 2 +- docs/faq/contribute.md | 40 ++- docs/faq/index.md | 2 +- docs/images/flixopt-icon.svg | 2 +- docs/javascripts/mathjax.js | 2 +- docs/user-guide/Mathematical Notation/Bus.md | 2 +- .../Effects, Penalty & Objective.md | 26 +- docs/user-guide/Mathematical Notation/Flow.md | 2 +- .../Mathematical Notation/LinearConverter.md | 4 +- .../Mathematical Notation/Piecewise.md | 2 +- .../Mathematical Notation/Storage.md | 2 +- .../user-guide/Mathematical Notation/index.md | 2 +- .../Mathematical Notation/others.md | 2 +- docs/user-guide/index.md | 4 +- .../02_Complex/complex_example_results.py | 1 - .../example_calculation_types.py | 9 +- examples/04_Scenarios/scenario_example.py | 15 +- .../two_stage_optimization.py | 62 ++-- flixopt/calculation.py | 30 +- flixopt/components.py | 42 ++- flixopt/core.py | 32 +- flixopt/effects.py | 32 +- flixopt/elements.py | 52 ++-- flixopt/features.py | 31 +- flixopt/flow_system.py | 40 +-- flixopt/interface.py | 8 +- flixopt/modeling.py | 44 ++- flixopt/plotting.py | 14 +- flixopt/results.py | 126 ++++---- flixopt/structure.py | 51 ++-- mkdocs.yml | 2 +- pics/flixopt-icon.svg | 2 +- scripts/extract_release_notes.py | 10 +- tests/conftest.py | 32 +- tests/test_bus.py | 40 ++- tests/test_component.py | 147 +++++---- tests/test_cycle_detection.py | 188 +++++------- tests/test_dataconverter.py | 160 ++++------ tests/test_effect.py | 248 +++++++++------ tests/test_effects_shares_summation.py | 26 +- tests/test_flow.py | 284 ++++++++++-------- tests/test_io.py | 11 +- tests/test_linear_converter.py | 177 ++++------- tests/test_results_plots.py | 2 + tests/test_scenarios.py | 153 +++++----- tests/test_storage.py | 132 ++++---- tests/todos.txt | 4 +- 61 files changed, 1245 insertions(+), 1118 deletions(-) diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 6ea202fd6..2a51618d9 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -58,7 +58,7 @@ def create_storage( ) -> Storage: """ Create a battery storage component. - + Args: label: Unique identifier capacity_kwh: Storage capacity [kWh] @@ -82,4 +82,4 @@ def create_storage( --- -**Every contribution helps advance sustainable energy solutions! 🌱⚡** \ No newline at end of file +**Every contribution helps advance sustainable energy solutions! 🌱⚡** diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index e7facb6a7..1c5054e49 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -36,11 +36,11 @@ body: Please provide a minimal reproducible example. See how to [craft minimal bug reports](https://matthewrocklin.com/minimal-bug-reports). placeholder: > import flixopt as fx - + # Create simple energy system that reproduces the bug timesteps = pd.date_range('2024-01-01', periods=24, freq='h') flow_system = fx.FlowSystem(timesteps) - + # Add your components here... render: python - type: textarea @@ -69,13 +69,13 @@ body: attributes: label: Installed Versions description: > - Please share information on your environment. Paste the output below. + Please share information on your environment. Paste the output below. For conda: `conda env export` and for pip: `pip freeze`. value: >
- + ``` Replace this with your environment info ``` - -
\ No newline at end of file + + diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index d031f8bfe..5ddb107b1 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -5,4 +5,4 @@ contact_links: about: Ask questions and discuss with the community - name: 📖 Documentation url: https://flixopt.github.io/flixopt/latest/ - about: Check our documentation for guides and examples \ No newline at end of file + about: Check our documentation for guides and examples diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 112a8102c..fd63ae163 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -85,4 +85,4 @@ body: attributes: label: Additional context description: > - Add any other context, research papers, or examples about the feature request here. \ No newline at end of file + Add any other context, research papers, or examples about the feature request here. diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 7efaeac97..d5e15137c 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -17,4 +17,4 @@ Closes #(issue number) ## Checklist - [ ] My code follows the project style - [ ] I have updated documentation if needed -- [ ] I have added tests for new functionality (if applicable) \ No newline at end of file +- [ ] I have added tests for new functionality (if applicable) diff --git a/.github/workflows/python-app.yaml b/.github/workflows/python-app.yaml index 3e7ae84ba..a60a2959a 100644 --- a/.github/workflows/python-app.yaml +++ b/.github/workflows/python-app.yaml @@ -220,4 +220,4 @@ jobs: VERSION=${GITHUB_REF#refs/tags/v} echo "Deploying docs after successful PyPI publish: $VERSION" mike deploy --push --update-aliases $VERSION latest - mike set-default --push latest \ No newline at end of file + mike set-default --push latest diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index bf913fbb4..e39033067 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -5,6 +5,7 @@ repos: - id: trailing-whitespace - id: end-of-file-fixer - id: check-yaml + exclude: ^mkdocs\.yml$ # Skip mkdocs.yml - id: check-added-large-files - repo: https://github.com/astral-sh/ruff-pre-commit diff --git a/CHANGELOG.md b/CHANGELOG.md index 3404a142f..dd94fb7c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -74,7 +74,6 @@ This enables to model transformation pathways over multiple years. * Clearer separation between the main Model and "Submodels" * Improved access to the Submodels and their variables, constraints and submodels * Added __repr__() for Submodels to easily inspect its content -* #### Other new features @@ -121,7 +120,7 @@ This enables to model transformation pathways over multiple years. ## [2.1.2] - 2025-06-14 ### Fixed -- Storage losses per hour where not calculated correctly, as mentioned by @brokenwings01. This might have lead to issues with modeling large losses and long timesteps. +- Storage losses per hour where not calculated correctly, as mentioned by @brokenwings01. This might have lead to issues with modeling large losses and long timesteps. - Old implementation: $c(\text{t}_{i}) \cdot (1-\dot{\text{c}}_\text{rel,loss}(\text{t}_i)) \cdot \Delta \text{t}_{i}$ - Correct implementation: $c(\text{t}_{i}) \cdot (1-\dot{\text{c}}_\text{rel,loss}(\text{t}_i)) ^{\Delta \text{t}_{i}}$ @@ -190,4 +189,4 @@ This enables to model transformation pathways over multiple years. ### Removed - **BREAKING**: Pyomo dependency (replaced by linopy) -- Period concepts in time management (simplified to timesteps) \ No newline at end of file +- Period concepts in time management (simplified to timesteps) diff --git a/README.md b/README.md index 615322211..68966910b 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ **flixopt** provides a user-friendly interface with options for advanced users. -It was originally developed by [TU Dresden](https://github.com/gewv-tu-dresden) as part of the SMARTBIOGRID project, funded by the German Federal Ministry for Economic Affairs and Energy (FKZ: 03KB159B). Building on the Matlab-based flixOptMat framework (developed in the FAKS project), FlixOpt also incorporates concepts from [oemof/solph](https://github.com/oemof/oemof-solph). +It was originally developed by [TU Dresden](https://github.com/gewv-tu-dresden) as part of the SMARTBIOGRID project, funded by the German Federal Ministry for Economic Affairs and Energy (FKZ: 03KB159B). Building on the Matlab-based flixOptMat framework (developed in the FAKS project), FlixOpt also incorporates concepts from [oemof/solph](https://github.com/oemof/oemof-solph). --- @@ -43,7 +43,7 @@ It was originally developed by [TU Dresden](https://github.com/gewv-tu-dresden) - **Calculation Modes** - **Full** - Solve the model with highest accuracy and computational requirements. - - **Segmented** - Speed up solving by using a rolling horizon. + - **Segmented** - Speed up solving by using a rolling horizon. - **Aggregated** - Speed up solving by identifying typical periods using [TSAM](https://github.com/FZJ-IEK3-VSA/tsam). Suitable for large models. --- @@ -67,14 +67,14 @@ The documentation is available at [https://flixopt.github.io/flixopt/latest/](ht ## 🎯️ Solver Integration -By default, FlixOpt uses the open-source solver [HiGHS](https://highs.dev/) which is installed by default. However, it is compatible with additional solvers such as: +By default, FlixOpt uses the open-source solver [HiGHS](https://highs.dev/) which is installed by default. However, it is compatible with additional solvers such as: -- [Gurobi](https://www.gurobi.com/) -- [CBC](https://github.com/coin-or/Cbc) +- [Gurobi](https://www.gurobi.com/) +- [CBC](https://github.com/coin-or/Cbc) - [GLPK](https://www.gnu.org/software/glpk/) - [CPLEX](https://www.ibm.com/analytics/cplex-optimizer) -For detailed licensing and installation instructions, refer to the respective solver documentation. +For detailed licensing and installation instructions, refer to the respective solver documentation. --- @@ -85,7 +85,7 @@ Look into our docs for [development setup](https://flixopt.github.io/flixopt/lat ## 📖 Citation -If you use FlixOpt in your research or project, please cite the following: +If you use FlixOpt in your research or project, please cite the following: -- **Main Citation:** [DOI:10.18086/eurosun.2022.04.07](https://doi.org/10.18086/eurosun.2022.04.07) -- **Short Overview:** [DOI:10.13140/RG.2.2.14948.24969](https://doi.org/10.13140/RG.2.2.14948.24969) +- **Main Citation:** [DOI:10.18086/eurosun.2022.04.07](https://doi.org/10.18086/eurosun.2022.04.07) +- **Short Overview:** [DOI:10.13140/RG.2.2.14948.24969](https://doi.org/10.13140/RG.2.2.14948.24969) diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index 0e86a81c8..0ae413dd8 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -5,4 +5,4 @@ - [FAQ](faq/) - [API-Reference](api-reference/) - [Contribute](contribute.md) -- [Release Notes](changelog.md) \ No newline at end of file +- [Release Notes](changelog.md) diff --git a/docs/contribute.md b/docs/contribute.md index d23d39d38..ff31c9f1f 100644 --- a/docs/contribute.md +++ b/docs/contribute.md @@ -42,8 +42,8 @@ Then navigate to http://127.0.0.1:8000/ ## Branches As we start to think FlixOpt in **Releases**, we decided to introduce multiple **dev**-branches instead of only one: Following the **Semantic Versioning** guidelines, we introduced: -- `next/patch`: This is where all pull requests for the next patch release (1.0.x) go. -- `next/minor`: This is where all pull requests for the next minor release (1.x.0) go. +- `next/patch`: This is where all pull requests for the next patch release (1.0.x) go. +- `next/minor`: This is where all pull requests for the next minor release (1.x.0) go. - `next/major`: This is where all pull requests for the next major release (x.0.0) go. Everything else remains in `feature/...`-branches. @@ -56,6 +56,6 @@ At some point, `next/minor` or `next/major` will get merged into `main` using a ## Releases As stated, we follow **Semantic Versioning**. Right after one of the 3 [release branches](#branches) is merged into main, a **Tag** should be added to the merge commit and pushed to the main branch. The tag has the form `v1.2.3`. -With this tag, a release with **Release Notes** must be created. +With this tag, a release with **Release Notes** must be created. *This is our current best practice* diff --git a/docs/examples/00-Minimal Example.md b/docs/examples/00-Minimal Example.md index c61283951..a568cd9c9 100644 --- a/docs/examples/00-Minimal Example.md +++ b/docs/examples/00-Minimal Example.md @@ -2,4 +2,4 @@ ```python {! ../examples/00_Minmal/minimal_example.py !} -``` \ No newline at end of file +``` diff --git a/docs/examples/01-Basic Example.md b/docs/examples/01-Basic Example.md index 600f2516a..6c6bfbee3 100644 --- a/docs/examples/01-Basic Example.md +++ b/docs/examples/01-Basic Example.md @@ -2,4 +2,4 @@ ```python {! ../examples/01_Simple/simple_example.py !} -``` \ No newline at end of file +``` diff --git a/docs/examples/02-Complex Example.md b/docs/examples/02-Complex Example.md index d5373c083..48868cdb0 100644 --- a/docs/examples/02-Complex Example.md +++ b/docs/examples/02-Complex Example.md @@ -7,4 +7,4 @@ This saves the results of a calculation to file and reloads them to analyze the ## Load the Results from file ```python {! ../examples/02_Complex/complex_example_results.py !} -``` \ No newline at end of file +``` diff --git a/docs/examples/index.md b/docs/examples/index.md index 8d535771f..1df12dc28 100644 --- a/docs/examples/index.md +++ b/docs/examples/index.md @@ -2,4 +2,4 @@ Here you can find a collection of examples that demonstrate how to use FlixOpt. -We work on improving this gallery. If you have something to share, please contact us! \ No newline at end of file +We work on improving this gallery. If you have something to share, please contact us! diff --git a/docs/faq/contribute.md b/docs/faq/contribute.md index 439fefe1d..ff31c9f1f 100644 --- a/docs/faq/contribute.md +++ b/docs/faq/contribute.md @@ -4,17 +4,29 @@ We warmly welcome contributions from the community! This guide will help you get ## Development Setup 1. Clone the repository `git clone https://github.com/flixOpt/flixopt.git` -2. Install the development dependencies `pip install -editable .[dev, docs]` -3. Run `pytest` and `ruff check .` to ensure your code passes all tests - -## Documentation -FlixOpt uses [mkdocs](https://www.mkdocs.org/) to generate documentation. To preview the documentation locally, run `mkdocs serve` in the root directory. - -## Helpful Commands -- `mkdocs serve` to preview the documentation locally. Navigate to `http://127.0.0.1:8000/` to view the documentation. -- `pytest` to run the test suite (You can also run the provided python script `run_all_test.py`) -- `ruff check .` to run the linter -- `ruff check . --fix` to automatically fix linting issues +2. Install the development dependencies `pip install -e ".[dev]"` +3. Install pre-commit hooks `pre-commit install` (one-time setup) +4. Run `pytest` to ensure your code passes all tests + +## Code Quality +We use [Ruff](https://github.com/astral-sh/ruff) for linting and formatting. After the one-time setup above, **code quality checks run automatically on every commit**. + +To run manually: +- `ruff check --fix .` to check and fix linting issues +- `ruff format .` to format code + +## Documentation (Optional) +FlixOpt uses [mkdocs](https://www.mkdocs.org/) to generate documentation. +To work on documentation: +```bash +pip install -e ".[docs]" +mkdocs serve +``` +Then navigate to http://127.0.0.1:8000/ + +## Testing +- `pytest` to run the test suite +- You can also run the provided python script `run_all_test.py` --- # Best practices @@ -30,8 +42,8 @@ FlixOpt uses [mkdocs](https://www.mkdocs.org/) to generate documentation. To pre ## Branches As we start to think FlixOpt in **Releases**, we decided to introduce multiple **dev**-branches instead of only one: Following the **Semantic Versioning** guidelines, we introduced: -- `next/patch`: This is where all pull requests for the next patch release (1.0.x) go. -- `next/minor`: This is where all pull requests for the next minor release (1.x.0) go. +- `next/patch`: This is where all pull requests for the next patch release (1.0.x) go. +- `next/minor`: This is where all pull requests for the next minor release (1.x.0) go. - `next/major`: This is where all pull requests for the next major release (x.0.0) go. Everything else remains in `feature/...`-branches. @@ -44,6 +56,6 @@ At some point, `next/minor` or `next/major` will get merged into `main` using a ## Releases As stated, we follow **Semantic Versioning**. Right after one of the 3 [release branches](#branches) is merged into main, a **Tag** should be added to the merge commit and pushed to the main branch. The tag has the form `v1.2.3`. -With this tag, a release with **Release Notes** must be created. +With this tag, a release with **Release Notes** must be created. *This is our current best practice* diff --git a/docs/faq/index.md b/docs/faq/index.md index 85d44e6af..6a245edd3 100644 --- a/docs/faq/index.md +++ b/docs/faq/index.md @@ -1,3 +1,3 @@ # Frequently Asked Questions -## Work in progress \ No newline at end of file +## Work in progress diff --git a/docs/images/flixopt-icon.svg b/docs/images/flixopt-icon.svg index 04a6a6851..08fe340f9 100644 --- a/docs/images/flixopt-icon.svg +++ b/docs/images/flixopt-icon.svg @@ -1 +1 @@ -flixOpt \ No newline at end of file +flixOpt diff --git a/docs/javascripts/mathjax.js b/docs/javascripts/mathjax.js index bb7094d50..af5180b57 100644 --- a/docs/javascripts/mathjax.js +++ b/docs/javascripts/mathjax.js @@ -15,4 +15,4 @@ document$.subscribe(() => { MathJax.typesetClear() MathJax.texReset() MathJax.typesetPromise() -}) \ No newline at end of file +}) diff --git a/docs/user-guide/Mathematical Notation/Bus.md b/docs/user-guide/Mathematical Notation/Bus.md index 840c90a08..6ba17eede 100644 --- a/docs/user-guide/Mathematical Notation/Bus.md +++ b/docs/user-guide/Mathematical Notation/Bus.md @@ -30,4 +30,4 @@ With: - $\phi_\text{in}(\text{t}_i)$ and $\phi_\text{out}(\text{t}_i)$ being the missing or excess flow-rate at time $\text{t}_i$, respectively - $\text{t}_i$ being the time step - $s_{b \rightarrow \Phi}(\text{t}_i)$ being the penalty term -- $\text a_{b \rightarrow \Phi}(\text{t}_i)$ being the penalty coefficient (`excess_penalty_per_flow_hour`) \ No newline at end of file +- $\text a_{b \rightarrow \Phi}(\text{t}_i)$ being the penalty coefficient (`excess_penalty_per_flow_hour`) diff --git a/docs/user-guide/Mathematical Notation/Effects, Penalty & Objective.md b/docs/user-guide/Mathematical Notation/Effects, Penalty & Objective.md index 1f2f0abdb..9e306394a 100644 --- a/docs/user-guide/Mathematical Notation/Effects, Penalty & Objective.md +++ b/docs/user-guide/Mathematical Notation/Effects, Penalty & Objective.md @@ -8,17 +8,17 @@ These arise from so called **Shares**, which originate from **Elements** like [F Assiziated effects could be: - costs - given in [€/kWh]... - ...or emissions - given in [kg/kWh]. -- +- Effects are allocated seperatly for investments and operation. ### Shares to Effects $$ \label{eq:Share_invest} -s_{l \rightarrow e, \text{inv}} = \sum_{v \in \mathcal{V}_{l, \text{inv}}} v \cdot \text a_{v \rightarrow e} +s_{l \rightarrow e, \text{inv}} = \sum_{v \in \mathcal{V}_{l, \text{inv}}} v \cdot \text a_{v \rightarrow e} $$ $$ \label{eq:Share_operation} -s_{l \rightarrow e, \text{op}}(\text{t}_i) = \sum_{v \in \mathcal{V}_{l,\text{op}}} v(\text{t}_i) \cdot \text a_{v \rightarrow e}(\text{t}_i) +s_{l \rightarrow e, \text{op}}(\text{t}_i) = \sum_{v \in \mathcal{V}_{l,\text{op}}} v(\text{t}_i) \cdot \text a_{v \rightarrow e}(\text{t}_i) $$ With: @@ -36,26 +36,26 @@ With: ### Shares between different Effects -Furthermore, the Effect $x$ can contribute a share to another Effect ${e} \in \mathcal{E}\backslash x$. -This share is defined by the factor $\text r_{x \rightarrow e}$. +Furthermore, the Effect $x$ can contribute a share to another Effect ${e} \in \mathcal{E}\backslash x$. +This share is defined by the factor $\text r_{x \rightarrow e}$. -For example, the Effect "CO$_2$ emissions" (unit: kg) -can cause an additional share to Effect "monetary costs" (unit: €). +For example, the Effect "CO$_2$ emissions" (unit: kg) +can cause an additional share to Effect "monetary costs" (unit: €). In this case, the factor $\text a_{x \rightarrow e}$ is the specific CO$_2$ price in €/kg. However, circular references have to be avoided. The overall sum of investment shares of an Effect $e$ is given by $\eqref{Effect_invest}$ $$ \label{eq:Effect_invest} -E_{e, \text{inv}} = -\sum_{l \in \mathcal{L}} s_{l \rightarrow e,\text{inv}} + +E_{e, \text{inv}} = +\sum_{l \in \mathcal{L}} s_{l \rightarrow e,\text{inv}} + \sum_{x \in \mathcal{E}\backslash e} E_{x, \text{inv}} \cdot \text{r}_{x \rightarrow e,\text{inv}} $$ The overall sum of operation shares is given by $\eqref{eq:Effect_Operation}$ $$ \label{eq:Effect_Operation} -E_{e, \text{op}}(\text{t}_{i}) = -\sum_{l \in \mathcal{L}} s_{l \rightarrow e, \text{op}}(\text{t}_i) + +E_{e, \text{op}}(\text{t}_{i}) = +\sum_{l \in \mathcal{L}} s_{l \rightarrow e, \text{op}}(\text{t}_i) + \sum_{x \in \mathcal{E}\backslash e} E_{x, \text{op}}(\text{t}_i) \cdot \text{r}_{x \rightarrow {e},\text{op}}(\text{t}_i) $$ @@ -100,7 +100,7 @@ $$ Additionally to the user defined [Effects](#effects), a Penalty $\Phi$ is part of every FlixOpt Model. Its used to prevent unsolvable problems and simplify troubleshooting. -Shares to the penalty can originate from every Element and are constructed similarly to +Shares to the penalty can originate from every Element and are constructed similarly to $\eqref{Share_invest}$ and $\eqref{Share_operation}$. $$ \label{eq:Penalty} @@ -129,4 +129,4 @@ With: This approach allows for a multi-criteria optimization using both... - ... the **Weigted Sum**Method, as the chosen **Objective Effect** can incorporate other Effects. - - ... the ($\epsilon$-constraint method) by constraining effects. \ No newline at end of file + - ... the ($\epsilon$-constraint method) by constraining effects. diff --git a/docs/user-guide/Mathematical Notation/Flow.md b/docs/user-guide/Mathematical Notation/Flow.md index 4b755d005..142904a1d 100644 --- a/docs/user-guide/Mathematical Notation/Flow.md +++ b/docs/user-guide/Mathematical Notation/Flow.md @@ -23,4 +23,4 @@ $$ This mathematical Formulation can be extended or changed when using [OnOffParameters](#onoffparameters) to define the On/Off state of the Flow, or [InvestParameters](#investments), -which changes the size of the Flow from a constant to an optimization variable. \ No newline at end of file +which changes the size of the Flow from a constant to an optimization variable. diff --git a/docs/user-guide/Mathematical Notation/LinearConverter.md b/docs/user-guide/Mathematical Notation/LinearConverter.md index a8cea843e..124d37c8c 100644 --- a/docs/user-guide/Mathematical Notation/LinearConverter.md +++ b/docs/user-guide/Mathematical Notation/LinearConverter.md @@ -10,7 +10,7 @@ With: - $p_{f_\text{in}}(\text{t}_i)$ and $p_{f_\text{out}}(\text{t}_i)$ being the flow-rate at time $\text{t}_i$ for flow $f_\text{in}$ and $f_\text{out}$, respectively - $\text a_{f_\text{in}}(\text{t}_i)$ and $\text b_{f_\text{out}}(\text{t}_i)$ being the ratio of the flow-rate at time $\text{t}_i$ for flow $f_\text{in}$ and $f_\text{out}$, respectively -With one incoming **Flow** and one outgoing **Flow**, this can be simplified to: +With one incoming **Flow** and one outgoing **Flow**, this can be simplified to: $$ \label{eq:Linear-Transformer-Ratio-simple} \text a(\text{t}_i) \cdot p_{f_\text{in}}(\text{t}_i) = p_{f_\text{out}}(\text{t}_i) @@ -18,4 +18,4 @@ $$ where $\text a$ can be interpreted as the conversion efficiency of the **LinearTransformer**. #### Piecewise Concersion factors -The conversion efficiency can be defined as a piecewise linear approximation. See [Piecewise](Piecewise.md) for more details. \ No newline at end of file +The conversion efficiency can be defined as a piecewise linear approximation. See [Piecewise](Piecewise.md) for more details. diff --git a/docs/user-guide/Mathematical Notation/Piecewise.md b/docs/user-guide/Mathematical Notation/Piecewise.md index 4e73cfece..688ac8cea 100644 --- a/docs/user-guide/Mathematical Notation/Piecewise.md +++ b/docs/user-guide/Mathematical Notation/Piecewise.md @@ -40,7 +40,7 @@ Which can also be described as $v \in \{0\} \cup [\text{v}_{\text{start_k}}, \te ## Combining multiple Piecewises -Piecewise allows representing non-linear relationships. +Piecewise allows representing non-linear relationships. This is a powerful technique in linear optimization to model non-linear behaviors while maintaining the problem's linearity. Therefore, each Piecewise must have the same number of Pieces $k$. diff --git a/docs/user-guide/Mathematical Notation/Storage.md b/docs/user-guide/Mathematical Notation/Storage.md index db78b6ab3..577f12150 100644 --- a/docs/user-guide/Mathematical Notation/Storage.md +++ b/docs/user-guide/Mathematical Notation/Storage.md @@ -41,4 +41,4 @@ Where: - $p_{f_\text{in}}(\text{t}_i)$ is the input flow rate at time $\text{t}_i$ - $\eta_\text{in}(\text{t}_i)$ is the charging efficiency at time $\text{t}_i$ - $p_{f_\text{out}}(\text{t}_i)$ is the output flow rate at time $\text{t}_i$ -- $\eta_\text{out}(\text{t}_i)$ is the discharging efficiency at time $\text{t}_i$ \ No newline at end of file +- $\eta_\text{out}(\text{t}_i)$ is the discharging efficiency at time $\text{t}_i$ diff --git a/docs/user-guide/Mathematical Notation/index.md b/docs/user-guide/Mathematical Notation/index.md index 4dabe2af2..b76a1ba1f 100644 --- a/docs/user-guide/Mathematical Notation/index.md +++ b/docs/user-guide/Mathematical Notation/index.md @@ -14,7 +14,7 @@ FlixOpt uses the following naming conventions: ## Timesteps Time steps are defined as a sequence of discrete time steps $\text{t}_i \in \mathcal{T} \quad \text{for} \quad i \in \{1, 2, \dots, \text{n}\}$ (left-aligned in its timespan). -From this sequence, the corresponding time intervals $\Delta \text{t}_i \in \Delta \mathcal{T}$ are derived as +From this sequence, the corresponding time intervals $\Delta \text{t}_i \in \Delta \mathcal{T}$ are derived as $$\Delta \text{t}_i = \text{t}_{i+1} - \text{t}_i \quad \text{for} \quad i \in \{1, 2, \dots, \text{n}-1\}$$ diff --git a/docs/user-guide/Mathematical Notation/others.md b/docs/user-guide/Mathematical Notation/others.md index 0cd82de94..bdc602308 100644 --- a/docs/user-guide/Mathematical Notation/others.md +++ b/docs/user-guide/Mathematical Notation/others.md @@ -1,3 +1,3 @@ # Work in Progress -This is a work in progress. \ No newline at end of file +This is a work in progress. diff --git a/docs/user-guide/index.md b/docs/user-guide/index.md index 8789779b2..bc1738997 100644 --- a/docs/user-guide/index.md +++ b/docs/user-guide/index.md @@ -6,7 +6,7 @@ FlixOpt is built around a set of core concepts that work together to represent a ### FlowSystem -The [`FlowSystem`][flixopt.flow_system.FlowSystem] is the central organizing unit in FlixOpt. +The [`FlowSystem`][flixopt.flow_system.FlowSystem] is the central organizing unit in FlixOpt. Every FlixOpt model starts with creating a FlowSystem. It: - Defines the timesteps for the optimization @@ -40,7 +40,7 @@ Examples: [`Bus`][flixopt.elements.Bus] objects represent nodes or connection points in a FlowSystem. They: - Balance incoming and outgoing flows -- Can represent physical networks like heat, electricity, or gas +- Can represent physical networks like heat, electricity, or gas - Handle infeasible balances gently by allowing the balance to be closed in return for a big Penalty (optional) ### Components diff --git a/examples/02_Complex/complex_example_results.py b/examples/02_Complex/complex_example_results.py index f4428d5ed..1c2766774 100644 --- a/examples/02_Complex/complex_example_results.py +++ b/examples/02_Complex/complex_example_results.py @@ -35,4 +35,3 @@ # Dataframes from results: fw_bus = results['Fernwärme'].node_balance().to_dataframe() all = results.solution.to_dataframe() - diff --git a/examples/03_Calculation_types/example_calculation_types.py b/examples/03_Calculation_types/example_calculation_types.py index 8bbdf1773..4991075b8 100644 --- a/examples/03_Calculation_types/example_calculation_types.py +++ b/examples/03_Calculation_types/example_calculation_types.py @@ -164,12 +164,12 @@ if full: calculation = fx.FullCalculation('Full', flow_system) calculation.do_modeling() - calculation.solve(fx.solvers.HighsSolver(0.01/100, 60)) + calculation.solve(fx.solvers.HighsSolver(0.01 / 100, 60)) calculations.append(calculation) if segmented: calculation = fx.SegmentedCalculation('Segmented', flow_system, segment_length, overlap_length) - calculation.do_modeling_and_solve(fx.solvers.HighsSolver(0.01/100, 60)) + calculation.do_modeling_and_solve(fx.solvers.HighsSolver(0.01 / 100, 60)) calculations.append(calculation) if aggregated: @@ -178,7 +178,7 @@ aggregation_parameters.time_series_for_low_peaks = [TS_electricity_demand, TS_heat_demand] calculation = fx.AggregatedCalculation('Aggregated', flow_system, aggregation_parameters) calculation.do_modeling() - calculation.solve(fx.solvers.HighsSolver(0.01/100, 60)) + calculation.solve(fx.solvers.HighsSolver(0.01 / 100, 60)) calculations.append(calculation) # Get solutions for plotting for different calculations @@ -221,7 +221,8 @@ def get_solutions(calcs: List, variable: str) -> xr.Dataset: ).update_layout(barmode='group').write_html('results/Total Costs.html') fx.plotting.with_plotly( - pd.DataFrame([calc.durations for calc in calculations], index=[calc.name for calc in calculations]), 'stacked_bar' + pd.DataFrame([calc.durations for calc in calculations], index=[calc.name for calc in calculations]), + 'stacked_bar', ).update_layout(title='Duration Comparison', xaxis_title='Calculation type', yaxis_title='Time (s)').write_html( 'results/Speed Comparison.html' ) diff --git a/examples/04_Scenarios/scenario_example.py b/examples/04_Scenarios/scenario_example.py index 5295d2820..62f3e1c82 100644 --- a/examples/04_Scenarios/scenario_example.py +++ b/examples/04_Scenarios/scenario_example.py @@ -16,8 +16,10 @@ # --- Create Time Series Data --- # Heat demand profile (e.g., kW) over time and corresponding power prices - heat_demand_per_h = pd.DataFrame({'Base Case':[30, 0, 90, 110, 110, 20, 20, 20, 20], - 'High Demand':[30, 0, 100, 118, 125, 20, 20, 20, 20]}, index=timesteps) + heat_demand_per_h = pd.DataFrame( + {'Base Case': [30, 0, 90, 110, 110, 20, 20, 20, 20], 'High Demand': [30, 0, 100, 118, 125, 20, 20, 20, 20]}, + index=timesteps, + ) power_prices = np.array([0.08, 0.09, 0.10]) flow_system = fx.FlowSystem(timesteps=timesteps, years=years, scenarios=scenarios, weights=np.array([0.5, 0.6])) @@ -50,7 +52,14 @@ boiler = fx.linear_converters.Boiler( label='Boiler', eta=0.5, - Q_th=fx.Flow(label='Q_th', bus='Fernwärme', size=50, relative_minimum=0.1, relative_maximum=1, on_off_parameters=fx.OnOffParameters()), + Q_th=fx.Flow( + label='Q_th', + bus='Fernwärme', + size=50, + relative_minimum=0.1, + relative_maximum=1, + on_off_parameters=fx.OnOffParameters(), + ), Q_fu=fx.Flow(label='Q_fu', bus='Gas'), ) diff --git a/examples/05_Two-stage-optimization/two_stage_optimization.py b/examples/05_Two-stage-optimization/two_stage_optimization.py index 3548726b4..eee8dd92d 100644 --- a/examples/05_Two-stage-optimization/two_stage_optimization.py +++ b/examples/05_Two-stage-optimization/two_stage_optimization.py @@ -58,16 +58,24 @@ 'BHKW2', eta_th=0.58, eta_el=0.22, - on_off_parameters=fx.OnOffParameters(effects_per_switch_on=1_000, consecutive_on_hours_min=10, consecutive_off_hours_min=10), + on_off_parameters=fx.OnOffParameters( + effects_per_switch_on=1_000, consecutive_on_hours_min=10, consecutive_off_hours_min=10 + ), P_el=fx.Flow('P_el', bus='Strom'), Q_th=fx.Flow('Q_th', bus='Fernwärme'), - Q_fu=fx.Flow('Q_fu', bus='Kohle', - size=fx.InvestParameters(specific_effects={'costs':3_000}, minimum_size=10, maximum_size=500), - relative_minimum=0.3, previous_flow_rate=100), + Q_fu=fx.Flow( + 'Q_fu', + bus='Kohle', + size=fx.InvestParameters(specific_effects={'costs': 3_000}, minimum_size=10, maximum_size=500), + relative_minimum=0.3, + previous_flow_rate=100, + ), ), fx.Storage( 'Speicher', - capacity_in_flow_hours=fx.InvestParameters(minimum_size=10, maximum_size=1000, specific_effects={'costs': 60}), + capacity_in_flow_hours=fx.InvestParameters( + minimum_size=10, maximum_size=1000, specific_effects={'costs': 60} + ), initial_charge_state='lastValueOfSim', eta_charge=1, eta_discharge=1, @@ -76,9 +84,7 @@ charging=fx.Flow('Q_th_load', size=137, bus='Fernwärme'), discharging=fx.Flow('Q_th_unload', size=158, bus='Fernwärme'), ), - fx.Sink( - 'Wärmelast', sink=fx.Flow('Q_th_Last', bus='Fernwärme', size=1, fixed_relative_profile=heat_demand) - ), + fx.Sink('Wärmelast', sink=fx.Flow('Q_th_Last', bus='Fernwärme', size=1, fixed_relative_profile=heat_demand)), fx.Source( 'Gastarif', source=fx.Flow('Q_Gas', bus='Gas', size=1000, effects_per_flow_hour={'costs': gas_price, 'CO2': 0.3}), @@ -89,7 +95,9 @@ ), fx.Source( 'Einspeisung', - source=fx.Flow('P_el', bus='Strom', size=1000, effects_per_flow_hour={'costs': electricity_price + 0.5, 'CO2': 0.3}), + source=fx.Flow( + 'P_el', bus='Strom', size=1000, effects_per_flow_hour={'costs': electricity_price + 0.5, 'CO2': 0.3} + ), ), fx.Sink( 'Stromlast', @@ -97,7 +105,9 @@ ), fx.Source( 'Stromtarif', - source=fx.Flow('P_el', bus='Strom', size=1000, effects_per_flow_hour={'costs': electricity_price, 'CO2': 0.3}), + source=fx.Flow( + 'P_el', bus='Strom', size=1000, effects_per_flow_hour={'costs': electricity_price, 'CO2': 0.3} + ), ), ) @@ -105,7 +115,7 @@ start = timeit.default_timer() calculation_sizing = fx.FullCalculation('Sizing', flow_system.resample('4h')) calculation_sizing.do_modeling() - calculation_sizing.solve(fx.solvers.HighsSolver(0.1/100, 600)) + calculation_sizing.solve(fx.solvers.HighsSolver(0.1 / 100, 600)) timer_sizing = timeit.default_timer() - start calculation_dispatch = fx.FullCalculation('Sizing', flow_system) @@ -123,7 +133,7 @@ start = timeit.default_timer() calculation_combined = fx.FullCalculation('Sizing', flow_system) calculation_combined.do_modeling() - calculation_combined.solve(fx.solvers.HighsSolver(0.1/100, 600)) + calculation_combined.solve(fx.solvers.HighsSolver(0.1 / 100, 600)) timer_combined = timeit.default_timer() - start # Comparison of results @@ -132,11 +142,27 @@ ).assign_coords(mode=['Combined', 'Two-stage']) comparison['Duration [s]'] = xr.DataArray([timer_combined, timer_sizing + timer_dispatch], dims='mode') - comparison_main = comparison[['Duration [s]', 'costs|total', 'costs(invest)|total', 'costs(operation)|total', 'BHKW2(Q_fu)|size', 'Kessel(Q_fu)|size', 'Speicher|size']] - comparison_main = xr.concat([ - comparison_main, - ((comparison_main.sel(mode='Two-stage') - comparison_main.sel(mode='Combined')) - / comparison_main.sel(mode='Combined') * 100).assign_coords(mode='Diff [%]') - ], dim='mode') + comparison_main = comparison[ + [ + 'Duration [s]', + 'costs|total', + 'costs(invest)|total', + 'costs(operation)|total', + 'BHKW2(Q_fu)|size', + 'Kessel(Q_fu)|size', + 'Speicher|size', + ] + ] + comparison_main = xr.concat( + [ + comparison_main, + ( + (comparison_main.sel(mode='Two-stage') - comparison_main.sel(mode='Combined')) + / comparison_main.sel(mode='Combined') + * 100 + ).assign_coords(mode='Diff [%]'), + ], + dim='mode', + ) print(comparison_main.to_pandas().T.round(2)) diff --git a/flixopt/calculation.py b/flixopt/calculation.py index 3137b71ec..d4d4e306d 100644 --- a/flixopt/calculation.py +++ b/flixopt/calculation.py @@ -136,8 +136,7 @@ def main_results(self) -> Dict[str, Union[Scalar, Dict]]: for bus in self.flow_system.buses.values() if bus.with_excess and ( - bus.submodel.excess_input.solution.sum() > 1e-3 - or bus.submodel.excess_output.solution.sum() > 1e-3 + bus.submodel.excess_input.solution.sum() > 1e-3 or bus.submodel.excess_output.solution.sum() > 1e-3 ) ], } @@ -213,7 +212,9 @@ def fix_sizes(self, ds: xr.Dataset, decimal_rounding: Optional[int] = 5) -> 'Ful return self - def solve(self, solver: _Solver, log_file: Optional[pathlib.Path] = None, log_main_results: bool = True) -> 'FullCalculation': + def solve( + self, solver: _Solver, log_file: Optional[pathlib.Path] = None, log_main_results: bool = True + ) -> 'FullCalculation': t_start = timeit.default_timer() self.model.solve( @@ -323,13 +324,9 @@ def _perform_aggregation(self): f'Aggregation failed due to inconsistent time step sizes:' f'delta_t varies from {dt_min} to {dt_max} hours.' ) - steps_per_period = ( - self.aggregation_parameters.hours_per_period - / self.flow_system.hours_per_timestep.max() - ) + steps_per_period = self.aggregation_parameters.hours_per_period / self.flow_system.hours_per_timestep.max() is_integer = ( - self.aggregation_parameters.hours_per_period - % self.flow_system.hours_per_timestep.max() + self.aggregation_parameters.hours_per_period % self.flow_system.hours_per_timestep.max() ).item() == 0 if not (steps_per_period.size == 1 and is_integer): raise ValueError( @@ -360,7 +357,11 @@ def _perform_aggregation(self): if self.aggregation_parameters.aggregate_data_and_fix_non_binary_vars: ds = self.flow_system.to_dataset() for name, series in self.aggregation.aggregated_data.items(): - da = DataConverter.to_dataarray(series, self.flow_system.coords).rename(name).assign_attrs(ds[name].attrs) + da = ( + DataConverter.to_dataarray(series, self.flow_system.coords) + .rename(name) + .assign_attrs(ds[name].attrs) + ) if TimeSeriesData.is_timeseries_data(da): da = TimeSeriesData.from_dataarray(da) @@ -429,7 +430,6 @@ def __init__( self.nr_of_previous_values = nr_of_previous_values self.sub_calculations: List[FullCalculation] = [] - self.segment_names = [ f'Segment_{i + 1}' for i in range(math.ceil(len(self.all_timesteps) / self.timesteps_per_segment)) ] @@ -525,7 +525,7 @@ def _transfer_start_values(self, i: int): logger.debug( f'Start of next segment: {start}. Indices of previous values: {start_previous_values} -> {end_previous_values}' ) - current_flow_system = self.sub_calculations[i -1].flow_system + current_flow_system = self.sub_calculations[i - 1].flow_system next_flow_system = self.sub_calculations[i].flow_system start_values_of_this_segment = {} @@ -560,9 +560,9 @@ def timesteps_per_segment_with_overlap(self): @property def start_values_of_segments(self) -> List[Dict[str, Any]]: """Gives an overview of the start values of all Segments""" - return [ - {name: value for name, value in self._original_start_values.items()} - ] + [start_values for start_values in self._transfered_start_values] + return [{name: value for name, value in self._original_start_values.items()}] + [ + start_values for start_values in self._transfered_start_values + ] @property def all_timesteps(self) -> pd.DatetimeIndex: diff --git a/flixopt/components.py b/flixopt/components.py index c0e905c5f..d483ee28c 100644 --- a/flixopt/components.py +++ b/flixopt/components.py @@ -232,10 +232,14 @@ def transform_data(self, flow_system: 'FlowSystem') -> None: f'{self.label_full}|maximal_final_charge_state', self.maximal_final_charge_state, has_time_dim=False ) self.relative_minimum_final_charge_state = flow_system.fit_to_model_coords( - f'{self.label_full}|relative_minimum_final_charge_state', self.relative_minimum_final_charge_state, has_time_dim=False + f'{self.label_full}|relative_minimum_final_charge_state', + self.relative_minimum_final_charge_state, + has_time_dim=False, ) self.relative_maximum_final_charge_state = flow_system.fit_to_model_coords( - f'{self.label_full}|relative_maximum_final_charge_state', self.relative_maximum_final_charge_state, has_time_dim=False + f'{self.label_full}|relative_maximum_final_charge_state', + self.relative_maximum_final_charge_state, + has_time_dim=False, ) if isinstance(self.capacity_in_flow_hours, InvestParameters): self.capacity_in_flow_hours.transform_data(flow_system, f'{self.label_full}|InvestParameters') @@ -281,16 +285,21 @@ def _plausibility_checks(self) -> None: ) if self.balanced: - if not isinstance(self.charging.size, InvestParameters) or not isinstance(self.discharging.size, InvestParameters): + if not isinstance(self.charging.size, InvestParameters) or not isinstance( + self.discharging.size, InvestParameters + ): raise PlausibilityError( - f'Balancing charging and discharging Flows in {self.label_full} ' - f'is only possible with Investments.') - if (self.charging.size.minimum_size > self.discharging.size.maximum_size or - self.charging.size.maximum_size < self.discharging.size.minimum_size): + f'Balancing charging and discharging Flows in {self.label_full} is only possible with Investments.' + ) + if ( + self.charging.size.minimum_size > self.discharging.size.maximum_size + or self.charging.size.maximum_size < self.discharging.size.minimum_size + ): raise PlausibilityError( f'Balancing charging and discharging Flows in {self.label_full} need compatible minimum and maximum sizes.' f'Got: {self.charging.size.minimum_size=}, {self.charging.size.maximum_size=} and ' - f'{self.charging.size.minimum_size=}, {self.charging.size.maximum_size=}.') + f'{self.charging.size.minimum_size=}, {self.charging.size.maximum_size=}.' + ) @register_class_for_io @@ -369,14 +378,14 @@ def _plausibility_checks(self): raise ValueError('Balanced Transmission needs InvestParameters in both in-Flows') if not isinstance(self.in1.size, InvestParameters) or not isinstance(self.in2.size, InvestParameters): raise ValueError('Balanced Transmission needs InvestParameters in both in-Flows') - if ( - (self.in1.size.minimum_or_fixed_size > self.in2.size.maximum_or_fixed_size).any() or - (self.in1.size.maximum_or_fixed_size < self.in2.size.minimum_or_fixed_size).any() - ): + if (self.in1.size.minimum_or_fixed_size > self.in2.size.maximum_or_fixed_size).any() or ( + self.in1.size.maximum_or_fixed_size < self.in2.size.minimum_or_fixed_size + ).any(): raise ValueError( f'Balanced Transmission needs compatible minimum and maximum sizes.' f'Got: {self.in1.size.minimum_size=}, {self.in1.size.maximum_size=}, {self.in1.size.fixed_size=} and ' - f'{self.in2.size.minimum_size=}, {self.in2.size.maximum_size=}, {self.in2.size.fixed_size=}.') + f'{self.in2.size.minimum_size=}, {self.in2.size.maximum_size=}, {self.in2.size.fixed_size=}.' + ) def create_model(self, model) -> 'TransmissionModel': self._plausibility_checks() @@ -485,6 +494,7 @@ def _do_modeling(self): class StorageModel(ComponentModel): """Submodel of Storage""" + element: Storage def __init__(self, model: FlowSystemModel, element: Storage): @@ -550,7 +560,8 @@ def _do_modeling(self): if self.element.balanced: self.add_constraints( - self.element.charging.submodel._investment.size * 1 == self.element.discharging.submodel._investment.size * 1, + self.element.charging.submodel._investment.size * 1 + == self.element.discharging.submodel._investment.size * 1, short_name='balanced_sizes', ) @@ -562,7 +573,8 @@ def _initial_and_final_charge_state(self): ) else: self.add_constraints( - self.charge_state.isel(time=0) == self.element.initial_charge_state, short_name='initial_charge_state' + self.charge_state.isel(time=0) == self.element.initial_charge_state, + short_name='initial_charge_state', ) if self.element.maximal_final_charge_state is not None: diff --git a/flixopt/core.py b/flixopt/core.py index 99b69b5ed..36d19dea4 100644 --- a/flixopt/core.py +++ b/flixopt/core.py @@ -52,13 +52,13 @@ class TimeSeriesData(xr.DataArray): __slots__ = () # No additional instance attributes - everything goes in attrs def __init__( - self, - *args, - aggregation_group: Optional[str] = None, - aggregation_weight: Optional[float] = None, - agg_group: Optional[str] = None, - agg_weight: Optional[float] = None, - **kwargs + self, + *args, + aggregation_group: Optional[str] = None, + aggregation_weight: Optional[float] = None, + agg_group: Optional[str] = None, + agg_weight: Optional[float] = None, + **kwargs, ): """ Args: @@ -105,7 +105,7 @@ def fit_to_coords( da, aggregation_group=self.aggregation_group, aggregation_weight=self.aggregation_weight, - name=name if name is not None else self.name + name=name if name is not None else self.name, ) @property @@ -117,11 +117,17 @@ def aggregation_weight(self) -> Optional[float]: return self.attrs.get('aggregation_weight') @classmethod - def from_dataarray(cls, da: xr.DataArray, aggregation_group: Optional[str] = None, aggregation_weight: Optional[float] = None): + def from_dataarray( + cls, da: xr.DataArray, aggregation_group: Optional[str] = None, aggregation_weight: Optional[float] = None + ): """Create TimeSeriesData from DataArray, extracting metadata from attrs.""" # Get aggregation metadata from attrs or parameters - final_aggregation_group = aggregation_group if aggregation_group is not None else da.attrs.get('aggregation_group') - final_aggregation_weight = aggregation_weight if aggregation_weight is not None else da.attrs.get('aggregation_weight') + final_aggregation_group = ( + aggregation_group if aggregation_group is not None else da.attrs.get('aggregation_group') + ) + final_aggregation_weight = ( + aggregation_weight if aggregation_weight is not None else da.attrs.get('aggregation_weight') + ) return cls(da, aggregation_group=final_aggregation_group, aggregation_weight=final_aggregation_weight) @@ -185,7 +191,9 @@ def _match_series_to_dimension( """ if len(target_dims) == 0: if len(data) != 1: - raise ConversionError(f'Cannot convert multi-element Series without target dimensions. Got \n{data}\n and \n{coords}') + raise ConversionError( + f'Cannot convert multi-element Series without target dimensions. Got \n{data}\n and \n{coords}' + ) return xr.DataArray(data.iloc[0]) # Try to match Series index to coordinates diff --git a/flixopt/effects.py b/flixopt/effects.py index db72ff8aa..79a44e67a 100644 --- a/flixopt/effects.py +++ b/flixopt/effects.py @@ -117,14 +117,15 @@ def transform_data(self, flow_system: 'FlowSystem'): f'{self.label_full}|maximum_invest', self.maximum_invest, has_time_dim=False ) self.minimum_total = flow_system.fit_to_model_coords( - f'{self.label_full}|minimum_total', self.minimum_total, has_time_dim=False, + f'{self.label_full}|minimum_total', + self.minimum_total, + has_time_dim=False, ) self.maximum_total = flow_system.fit_to_model_coords( f'{self.label_full}|maximum_total', self.maximum_total, has_time_dim=False ) self.specific_share_to_other_effects_invest = flow_system.fit_effects_to_model_coords( - f'{self.label_full}|invest->', self.specific_share_to_other_effects_invest, 'invest', - has_time_dim=False + f'{self.label_full}|invest->', self.specific_share_to_other_effects_invest, 'invest', has_time_dim=False ) def create_model(self, model: FlowSystemModel) -> 'EffectModel': @@ -230,8 +231,7 @@ def add_effects(self, *effects: Effect) -> None: logger.info(f'Registered new Effect: {effect.label}') def create_effect_values_dict( - self, - effect_values_user: Union[NonTemporalEffectsUser, TemporalEffectsUser] + self, effect_values_user: Union[NonTemporalEffectsUser, TemporalEffectsUser] ) -> Optional[Dict[str, Union[Scalar, TemporalDataUser]]]: """ Converts effect values into a dictionary. If a scalar is provided, it is associated with a default effect type. @@ -277,11 +277,11 @@ def _plausibility_checks(self) -> None: invest_cycles = detect_cycles(tuples_to_adjacency_list([key for key in invest])) if operation_cycles: - cycle_str = "\n".join([" -> ".join(cycle) for cycle in operation_cycles]) + cycle_str = '\n'.join([' -> '.join(cycle) for cycle in operation_cycles]) raise ValueError(f'Error: circular operation-shares detected:\n{cycle_str}') if invest_cycles: - cycle_str = "\n".join([" -> ".join(cycle) for cycle in invest_cycles]) + cycle_str = '\n'.join([' -> '.join(cycle) for cycle in invest_cycles]) raise ValueError(f'Error: circular invest-shares detected:\n{cycle_str}') def __getitem__(self, effect: Union[str, Effect]) -> 'Effect': @@ -349,7 +349,9 @@ def objective_effect(self, value: Effect) -> None: raise ValueError(f'An objective-effect already exists! ({self._objective_effect.label=})') self._objective_effect = value - def calculate_effect_share_factors(self) -> Tuple[ + def calculate_effect_share_factors( + self, + ) -> Tuple[ Dict[Tuple[str, str], xr.DataArray], Dict[Tuple[str, str], xr.DataArray], ]: @@ -357,8 +359,7 @@ def calculate_effect_share_factors(self) -> Tuple[ for name, effect in self.effects.items(): if effect.specific_share_to_other_effects_invest: shares_invest[name] = { - target: data - for target, data in effect.specific_share_to_other_effects_invest.items() + target: data for target, data in effect.specific_share_to_other_effects_invest.items() } shares_invest = calculate_all_conversion_paths(shares_invest) @@ -366,8 +367,7 @@ def calculate_effect_share_factors(self) -> Tuple[ for name, effect in self.effects.items(): if effect.specific_share_to_other_effects_operation: shares_operation[name] = { - target: data - for target, data in effect.specific_share_to_other_effects_operation.items() + target: data for target, data in effect.specific_share_to_other_effects_operation.items() } shares_operation = calculate_all_conversion_paths(shares_operation) @@ -423,8 +423,7 @@ def _do_modeling(self): self._add_share_between_effects() self._model.add_objective( - (self.effects.objective_effect.submodel.total * self._model.weights).sum() - + self.penalty.total.sum() + (self.effects.objective_effect.submodel.total * self._model.weights).sum() + self.penalty.total.sum() ) def _add_share_between_effects(self): @@ -446,7 +445,7 @@ def _add_share_between_effects(self): def calculate_all_conversion_paths( - conversion_dict: Dict[str, Dict[str, xr.DataArray]], + conversion_dict: Dict[str, Dict[str, xr.DataArray]], ) -> Dict[Tuple[str, str], xr.DataArray]: """ Calculates all possible direct and indirect conversion factors between units/domains. @@ -511,8 +510,7 @@ def calculate_all_conversion_paths( queue.append((target, indirect_factor, new_path)) # Convert all values to DataArrays - result = {key: value if isinstance(value, xr.DataArray) else xr.DataArray(value) - for key, value in result.items()} + result = {key: value if isinstance(value, xr.DataArray) else xr.DataArray(value) for key, value in result.items()} return result diff --git a/flixopt/elements.py b/flixopt/elements.py index 7acf8b0a9..499ab66db 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -95,7 +95,10 @@ class Bus(Element): """ def __init__( - self, label: str, excess_penalty_per_flow_hour: Optional[TemporalDataUser] = 1e5, meta_data: Optional[Dict] = None + self, + label: str, + excess_penalty_per_flow_hour: Optional[TemporalDataUser] = 1e5, + meta_data: Optional[Dict] = None, ): """ Args: @@ -122,7 +125,9 @@ def transform_data(self, flow_system: 'FlowSystem'): def _plausibility_checks(self) -> None: if self.excess_penalty_per_flow_hour is not None and (self.excess_penalty_per_flow_hour == 0).all(): - logger.warning(f'In Bus {self.label_full}, the excess_penalty_per_flow_hour is 0. Use "None" or a value > 0.') + logger.warning( + f'In Bus {self.label_full}, the excess_penalty_per_flow_hour is 0. Use "None" or a value > 0.' + ) @property def with_excess(self) -> bool: @@ -271,7 +276,7 @@ def _plausibility_checks(self) -> None: raise PlausibilityError(self.label_full + ': Take care, that relative_minimum <= relative_maximum!') if not isinstance(self.size, InvestParameters) and ( - np.any(self.size == CONFIG.modeling.BIG) and self.fixed_relative_profile is not None + np.any(self.size == CONFIG.modeling.BIG) and self.fixed_relative_profile is not None ): # Default Size --> Most likely by accident logger.warning( f'Flow "{self.label_full}" has no size assigned, but a "fixed_relative_profile". ' @@ -293,9 +298,15 @@ def _plausibility_checks(self) -> None: ) if self.previous_flow_rate is not None: - if not any([isinstance(self.previous_flow_rate, np.ndarray) and self.previous_flow_rate.ndim == 1, - isinstance(self.previous_flow_rate, (int, float, list))]): - raise TypeError(f'previous_flow_rate must be None, a scalar, a list of scalars or a 1D-numpy-array. Got {type(self.previous_flow_rate)}') + if not any( + [ + isinstance(self.previous_flow_rate, np.ndarray) and self.previous_flow_rate.ndim == 1, + isinstance(self.previous_flow_rate, (int, float, list)), + ] + ): + raise TypeError( + f'previous_flow_rate must be None, a scalar, a list of scalars or a 1D-numpy-array. Got {type(self.previous_flow_rate)}' + ) @property def label_full(self) -> str: @@ -458,7 +469,7 @@ def _create_shares(self): def _create_bounds_for_load_factor(self): """Create load factor constraints using current approach""" # Get the size (either from element or investment) - size = self.investment.size if self.with_investment else self.element.size + size = self.investment.size if self.with_investment else self.element.size # Maximum load factor constraint if self.element.load_factor_max is not None: @@ -528,15 +539,14 @@ def investment(self) -> Optional[InvestmentModel]: @property def previous_states(self) -> Optional[TemporalData]: """Previous states of the flow rate""" - #TODO: This would be nicer to handle in the Flow itself, and allow DataArrays as well. + # TODO: This would be nicer to handle in the Flow itself, and allow DataArrays as well. previous_flow_rate = self.element.previous_flow_rate if previous_flow_rate is None: return None return ModelingUtilitiesAbstract.to_binary( values=xr.DataArray( - [previous_flow_rate] if np.isscalar(previous_flow_rate) else previous_flow_rate, - dims='time' + [previous_flow_rate] if np.isscalar(previous_flow_rate) else previous_flow_rate, dims='time' ), epsilon=CONFIG.modeling.EPSILON, dims='time', @@ -566,7 +576,9 @@ def _do_modeling(self) -> None: self.excess_input = self.add_variables(lower=0, coords=self._model.get_coords(), short_name='excess_input') - self.excess_output = self.add_variables(lower=0, coords=self._model.get_coords(), short_name='excess_output') + self.excess_output = self.add_variables( + lower=0, coords=self._model.get_coords(), short_name='excess_output' + ) eq_bus_balance.lhs -= -self.excess_input + self.excess_output @@ -580,8 +592,12 @@ def results_structure(self): inputs.append(self.excess_input.name) if self.excess_output is not None: outputs.append(self.excess_output.name) - return {**super().results_structure(), 'inputs': inputs, 'outputs': outputs, - 'flows': [flow.label_full for flow in self.element.inputs + self.element.outputs]} + return { + **super().results_structure(), + 'inputs': inputs, + 'outputs': outputs, + 'flows': [flow.label_full for flow in self.element.inputs + self.element.outputs], + } class ComponentModel(ElementModel): @@ -614,9 +630,11 @@ def _do_modeling(self): self.add_constraints(on == all_flows[0].submodel.on_off.on, short_name='on') else: flow_ons = [flow.submodel.on_off.on for flow in all_flows] - #TODO: Is the EPSILON even necessary? + # TODO: Is the EPSILON even necessary? self.add_constraints(on <= sum(flow_ons) + CONFIG.modeling.EPSILON, short_name='on|ub') - self.add_constraints(on >= sum(flow_ons) / (len(flow_ons) + CONFIG.modeling.EPSILON), short_name='on|lb') + self.add_constraints( + on >= sum(flow_ons) / (len(flow_ons) + CONFIG.modeling.EPSILON), short_name='on|lb' + ) self.on_off = self.add_submodels( OnOffModel( @@ -661,9 +679,7 @@ def previous_states(self) -> Optional[xr.DataArray]: max_len = max(da.sizes['time'] for da in previous_states) padded_previous_states = [ - da.assign_coords( - time=range(-da.sizes['time'], 0) - ).reindex(time=range(-max_len, 0), fill_value=0) + da.assign_coords(time=range(-da.sizes['time'], 0)).reindex(time=range(-max_len, 0), fill_value=0) for da in previous_states ] return xr.concat(padded_previous_states, dim='flow').any(dim='flow').astype(int) diff --git a/flixopt/features.py b/flixopt/features.py index 7b862375c..fc80f0eb3 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -59,7 +59,9 @@ def _create_variables_and_constraints(self): if self.parameters.optional: self.add_variables( - binary=True, coords=self._model.get_coords(['year', 'scenario']), short_name='is_invested', + binary=True, + coords=self._model.get_coords(['year', 'scenario']), + short_name='is_invested', ) BoundingPatterns.bounds_with_state( @@ -167,7 +169,7 @@ def _do_modeling(self): bounds=( self.parameters.on_hours_total_min if self.parameters.on_hours_total_min is not None else 0, self.parameters.on_hours_total_max if self.parameters.on_hours_total_max is not None else np.inf, - ),#TODO: self._model.hours_per_step.sum('time').item() + self._get_previous_on_duration()) + ), # TODO: self._model.hours_per_step.sum('time').item() + self._get_previous_on_duration()) short_name='on_hours_total', coords=self.get_coords(['year', 'scenario']), ) @@ -184,10 +186,15 @@ def _do_modeling(self): switch_off=self.switch_off, name=f'{self.label_of_model}|switch', previous_state=self._previous_states.isel(time=-1) if self._previous_states is not None else 0, - ) + ) if self.parameters.switch_on_total_max is not None: - count = self.add_variables(lower=0, upper=self.parameters.switch_on_total_max, coords=self._model.get_coords(('year', 'scenario')), short_name='switch|count') + count = self.add_variables( + lower=0, + upper=self.parameters.switch_on_total_max, + coords=self._model.get_coords(('year', 'scenario')), + short_name='switch|count', + ) self.add_constraints(count == self.switch_on.sum('time'), short_name='switch|count') # 5. Consecutive on duration using existing pattern @@ -211,7 +218,7 @@ def _do_modeling(self): maximum_duration=self.parameters.consecutive_off_hours_max, previous_duration=self._get_previous_off_duration(), ) - #TODO: + # TODO: self._add_effects() @@ -287,7 +294,7 @@ def _get_previous_off_duration(self): if self._previous_states is None: return hours_per_step else: - return ModelingUtilities.compute_consecutive_hours_in_state(self._previous_states * -1 + 1, hours_per_step) + return ModelingUtilities.compute_consecutive_hours_in_state(self._previous_states * -1 + 1, hours_per_step) class PieceModel(Submodel): @@ -309,7 +316,7 @@ def __init__( def _do_modeling(self): super()._do_modeling() - dims =('time', 'year','scenario') if self._as_time_series else ('year','scenario') + dims = ('time', 'year', 'scenario') if self._as_time_series else ('year', 'scenario') self.inside_piece = self.add_variables( binary=True, short_name='inside_piece', @@ -392,7 +399,7 @@ def _do_modeling(self): ), name=f'{self.label_full}|{var_name}|lambda', short_name=f'{var_name}|lambda', - ) + ) # a) eq: Segment1.onSeg(t) + Segment2.onSeg(t) + ... = 1 Aufenthalt nur in Segmenten erlaubt # b) eq: -On(t) + Segment1.onSeg(t) + Segment2.onSeg(t) + ... = 0 zusätzlich kann alles auch Null sein @@ -508,10 +515,10 @@ def _do_modeling(self): lower=self._total_min, upper=self._total_max, coords=self._model.get_coords([dim for dim in self._dims if dim != 'time']), - short_name='total' + short_name='total', ) # eq: sum = sum(share_i) # skalar - self._eq_total = self.add_constraints(self.total == 0, short_name='total') + self._eq_total = self.add_constraints(self.total == 0, short_name='total') if 'time' in self._dims: self.total_per_timestep = self.add_variables( @@ -521,7 +528,9 @@ def _do_modeling(self): short_name='total_per_timestep', ) - self._eq_total_per_timestep = self.add_constraints(self.total_per_timestep == 0, short_name='total_per_timestep') + self._eq_total_per_timestep = self.add_constraints( + self.total_per_timestep == 0, short_name='total_per_timestep' + ) # Add it to the total self._eq_total.lhs -= self.total_per_timestep.sum(dim='time') diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index 42faadcf6..dd202114e 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -80,7 +80,9 @@ def __init__( self.timesteps = self._validate_timesteps(timesteps) self.timesteps_extra = self._create_timesteps_with_extra(timesteps, hours_of_last_timestep) - self.hours_of_previous_timesteps = self._calculate_hours_of_previous_timesteps(timesteps, hours_of_previous_timesteps) + self.hours_of_previous_timesteps = self._calculate_hours_of_previous_timesteps( + timesteps, hours_of_previous_timesteps + ) self.years = None if years is None else self._validate_years(years) @@ -157,7 +159,7 @@ def _validate_years(years: pd.Index) -> pd.Index: @staticmethod def _create_timesteps_with_extra( - timesteps: pd.DatetimeIndex, hours_of_last_timestep: Optional[float] + timesteps: pd.DatetimeIndex, hours_of_last_timestep: Optional[float] ) -> pd.DatetimeIndex: """Create timesteps with an extra step at the end.""" if hours_of_last_timestep is None: @@ -176,7 +178,7 @@ def calculate_hours_per_timestep(timesteps_extra: pd.DatetimeIndex) -> xr.DataAr @staticmethod def _calculate_hours_of_previous_timesteps( - timesteps: pd.DatetimeIndex, hours_of_previous_timesteps: Optional[Union[float, np.ndarray]] + timesteps: pd.DatetimeIndex, hours_of_previous_timesteps: Optional[Union[float, np.ndarray]] ) -> Union[float, np.ndarray]: """Calculate duration of regular timesteps.""" if hours_of_previous_timesteps is not None: @@ -335,7 +337,9 @@ def to_json(self, path: Union[str, pathlib.Path]): path: The path to the JSON file. """ if not self.connected_and_transformed: - logger.warning('FlowSystem needs to be connected and transformed before saving to JSON. Calling connect_and_transform() now.') + logger.warning( + 'FlowSystem needs to be connected and transformed before saving to JSON. Calling connect_and_transform() now.' + ) self.connect_and_transform() super().to_json(path) @@ -372,13 +376,13 @@ def fit_to_model_coords( return data.fit_to_coords(coords) except ConversionError as e: raise ConversionError( - f'Could not convert time series data "{name}" to DataArray:\n{data}\nOriginal Error: {e}') from e + f'Could not convert time series data "{name}" to DataArray:\n{data}\nOriginal Error: {e}' + ) from e try: return DataConverter.to_dataarray(data, coords=coords).rename(name) except ConversionError as e: - raise ConversionError( - f'Could not convert data "{name}" to DataArray:\n{data}\nOriginal Error: {e}') from e + raise ConversionError(f'Could not convert data "{name}" to DataArray:\n{data}\nOriginal Error: {e}') from e def fit_effects_to_model_coords( self, @@ -397,9 +401,7 @@ def fit_effects_to_model_coords( return { effect: self.fit_to_model_coords( - '|'.join(filter(None, [label_prefix, effect, label_suffix])), - value, - has_time_dim=has_time_dim + '|'.join(filter(None, [label_prefix, effect, label_suffix])), value, has_time_dim=has_time_dim ) for effect, value in effect_values_dict.items() } @@ -410,12 +412,12 @@ def connect_and_transform(self): logger.debug('FlowSystem already connected and transformed') return - self.weights = self.fit_to_model_coords( - 'weights', self.weights, has_time_dim=False - ) + self.weights = self.fit_to_model_coords('weights', self.weights, has_time_dim=False) if self.weights is not None and self.weights.sum() != 1: - logger.warning(f'Scenario weights are not normalized to 1. This is recomended for a better scaled model. ' - f'Sum of weights={self.weights.sum().item()}') + logger.warning( + f'Scenario weights are not normalized to 1. This is recomended for a better scaled model. ' + f'Sum of weights={self.weights.sum().item()}' + ) self._connect_network() for element in list(self.components.values()) + list(self.effects.effects.values()) + list(self.buses.values()): @@ -450,7 +452,9 @@ def add_elements(self, *elements: Element) -> None: def create_model(self) -> FlowSystemModel: if not self.connected_and_transformed: - raise RuntimeError('FlowSystem is not connected_and_transformed. Call FlowSystem.connect_and_transform() first.') + raise RuntimeError( + 'FlowSystem is not connected_and_transformed. Call FlowSystem.connect_and_transform() first.' + ) self.submodel = FlowSystemModel(self) return self.submodel @@ -699,7 +703,7 @@ def isel( self, time: Optional[Union[int, slice, List[int]]] = None, year: Optional[Union[int, slice, List[int]]] = None, - scenario: Optional[Union[int, slice, List[int]]] = None + scenario: Optional[Union[int, slice, List[int]]] = None, ) -> 'FlowSystem': """ Select a subset of the flowsystem by integer indices. @@ -734,7 +738,7 @@ def resample( self, time: str, method: Literal['mean', 'sum', 'max', 'min', 'first', 'last', 'std', 'var', 'median', 'count'] = 'mean', - **kwargs: Any + **kwargs: Any, ) -> 'FlowSystem': """ Create a resampled FlowSystem by resampling data along the time dimension (like xr.Dataset.resample()). diff --git a/flixopt/interface.py b/flixopt/interface.py index 76e74616b..374c3fb44 100644 --- a/flixopt/interface.py +++ b/flixopt/interface.py @@ -277,8 +277,12 @@ def __init__( switch_on_total_max: max nr of switchOn operations force_switch_on: force creation of switch on variable, even if there is no switch_on_total_max """ - self.effects_per_switch_on: 'TemporalEffectsUser' = effects_per_switch_on if effects_per_switch_on is not None else {} - self.effects_per_running_hour: 'TemporalEffectsUser' = effects_per_running_hour if effects_per_running_hour is not None else {} + self.effects_per_switch_on: 'TemporalEffectsUser' = ( + effects_per_switch_on if effects_per_switch_on is not None else {} + ) + self.effects_per_running_hour: 'TemporalEffectsUser' = ( + effects_per_running_hour if effects_per_running_hour is not None else {} + ) self.on_hours_total_min: Scalar = on_hours_total_min self.on_hours_total_max: Scalar = on_hours_total_max self.consecutive_on_hours_min: TemporalDataUser = consecutive_on_hours_min diff --git a/flixopt/modeling.py b/flixopt/modeling.py index 8c03da9f4..fa13aeea8 100644 --- a/flixopt/modeling.py +++ b/flixopt/modeling.py @@ -42,7 +42,7 @@ def to_binary( return xr.DataArray(0) if values.item() < epsilon else xr.DataArray(1) # Convert to binary states - binary_states = (np.abs(values) >= epsilon) + binary_states = np.abs(values) >= epsilon # Optionally collapse dimensions using .any() if dims is not None: @@ -94,13 +94,12 @@ def count_consecutive_states( # Start after last zero start_idx = zero_indices[-1] + 1 - consecutive_values = binary_values.isel({dim:slice(start_idx, None)}) + consecutive_values = binary_values.isel({dim: slice(start_idx, None)}) - return float(consecutive_values.sum().item()) #TODO: Som only over one dim? + return float(consecutive_values.sum().item()) # TODO: Som only over one dim? class ModelingUtilities: - @staticmethod def compute_consecutive_hours_in_state( binary_values: TemporalData, @@ -121,21 +120,25 @@ def compute_consecutive_hours_in_state( if not isinstance(hours_per_timestep, (int, float)): raise TypeError(f'hours_per_timestep must be a scalar, got {type(hours_per_timestep)}') - return ModelingUtilitiesAbstract.count_consecutive_states( - binary_values=binary_values, epsilon=epsilon - ) * hours_per_timestep + return ( + ModelingUtilitiesAbstract.count_consecutive_states(binary_values=binary_values, epsilon=epsilon) + * hours_per_timestep + ) @staticmethod - def compute_previous_states(previous_values: Optional[xr.DataArray], epsilon: Optional[float] = None) -> xr.DataArray: + def compute_previous_states( + previous_values: Optional[xr.DataArray], epsilon: Optional[float] = None + ) -> xr.DataArray: return ModelingUtilitiesAbstract.to_binary(values=previous_values, epsilon=epsilon, dims='time') @staticmethod def compute_previous_on_duration( previous_values: xr.DataArray, hours_per_step: Union[xr.DataArray, float, int] ) -> float: - return ModelingUtilitiesAbstract.count_consecutive_states( - ModelingUtilitiesAbstract.to_binary(previous_values) - ) * hours_per_step + return ( + ModelingUtilitiesAbstract.count_consecutive_states(ModelingUtilitiesAbstract.to_binary(previous_values)) + * hours_per_step + ) @staticmethod def compute_previous_off_duration( @@ -351,9 +354,7 @@ def consecutive_duration_tracking( constraints = {} # Upper bound: duration[t] ≤ state[t] * M - constraints['ub'] = model.add_constraints( - duration <= state_variable * mega, name=f'{duration.name}|ub' - ) + constraints['ub'] = model.add_constraints(duration <= state_variable * mega, name=f'{duration.name}|ub') # Forward constraint: duration[t+1] ≤ duration[t] + hours_per_step[t] constraints['forward'] = model.add_constraints( @@ -373,8 +374,7 @@ def consecutive_duration_tracking( # Initial condition: duration[0] = (hours_per_step[0] + previous_duration) * state[0] constraints['initial'] = model.add_constraints( - duration.isel(time=0) - == (hours_per_step.isel(time=0) + previous_duration) * state_variable.isel(time=0), + duration.isel(time=0) == (hours_per_step.isel(time=0) + previous_duration) * state_variable.isel(time=0), name=f'{duration.name}|initial', ) @@ -399,7 +399,9 @@ def consecutive_duration_tracking( @staticmethod def mutual_exclusivity_constraint( - model: Submodel, binary_variables: List[linopy.Variable], tolerance: float = 1, + model: Submodel, + binary_variables: List[linopy.Variable], + tolerance: float = 1, short_name: str = 'mutual_exclusivity', ) -> linopy.Constraint: """ @@ -514,9 +516,7 @@ def bounds_with_state( name = name or f'{variable.name}' if np.all(lower_bound - upper_bound) < 1e-10: - fix_constraint = model.add_constraints( - variable == variable_state * upper_bound, name=f'{name}|fix' - ) + fix_constraint = model.add_constraints(variable == variable_state * upper_bound, name=f'{name}|fix') return [fix_constraint] epsilon = np.maximum(CONFIG.modeling.EPSILON, lower_bound) @@ -616,9 +616,7 @@ def scaled_bounds_with_state( scaling_lower = model.add_constraints( variable >= (variable_state - 1) * big_m_misc + scaling_variable * rel_lower, name=f'{name}|lb2' ) - scaling_upper = model.add_constraints( - variable <= scaling_variable * rel_upper, name=f'{name}|ub2' - ) + scaling_upper = model.add_constraints(variable <= scaling_variable * rel_upper, name=f'{name}|ub2') big_m_upper = scaling_max * rel_upper big_m_lower = np.maximum(CONFIG.modeling.EPSILON, scaling_min * rel_lower) diff --git a/flixopt/plotting.py b/flixopt/plotting.py index 91fc5e7e7..9543c2c48 100644 --- a/flixopt/plotting.py +++ b/flixopt/plotting.py @@ -251,8 +251,9 @@ def with_plotly( x=data.index, y=data[column], name=column, - marker=dict(color=processed_colors[i], - line=dict(width=0, color='rgba(0,0,0,0)')), #Transparent line with 0 width + marker=dict( + color=processed_colors[i], line=dict(width=0, color='rgba(0,0,0,0)') + ), # Transparent line with 0 width ) ) @@ -263,14 +264,7 @@ def with_plotly( ) if style == 'grouped_bar': for i, column in enumerate(data.columns): - fig.add_trace( - go.Bar( - x=data.index, - y=data[column], - name=column, - marker=dict(color=processed_colors[i]) - ) - ) + fig.add_trace(go.Bar(x=data.index, y=data[column], name=column, marker=dict(color=processed_colors[i]))) fig.update_layout( barmode='group', diff --git a/flixopt/results.py b/flixopt/results.py index 361e7cfde..512af4ad7 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -29,6 +29,7 @@ class _FlowSystemRestorationError(Exception): """Exception raised when a FlowSystem cannot be restored from dataset.""" + pass @@ -175,15 +176,13 @@ def __init__( self.buses = {label: BusResults(self, **infos) for label, infos in self.solution.attrs['Buses'].items()} - self.effects = { - label: EffectResults(self, **infos) for label, infos in self.solution.attrs['Effects'].items() - } + self.effects = {label: EffectResults(self, **infos) for label, infos in self.solution.attrs['Effects'].items()} if 'Flows' not in self.solution.attrs: warnings.warn( 'No Data about flows found in the results. This data is only included since v2.2.0. Some functionality ' 'is not availlable. We recommend to evaluate your results with a version <2.2.0.', - stacklevel=2, + stacklevel=2, ) self.flows = {} else: @@ -247,24 +246,26 @@ def constraints(self) -> linopy.Constraints: def effect_share_factors(self): if self._effect_share_factors is None: effect_share_factors = self.flow_system.effects.calculate_effect_share_factors() - self._effect_share_factors = {'operation': effect_share_factors[0], - 'invest': effect_share_factors[1]} + self._effect_share_factors = {'operation': effect_share_factors[0], 'invest': effect_share_factors[1]} return self._effect_share_factors @property def flow_system(self) -> 'FlowSystem': - """ The restored flow_system that was used to create the calculation. + """The restored flow_system that was used to create the calculation. Contains all input parameters.""" if self._flow_system is None: try: from . import FlowSystem + current_logger_level = logger.getEffectiveLevel() logger.setLevel(logging.CRITICAL) self._flow_system = FlowSystem.from_dataset(self.flow_system_data) self._flow_system._connect_network() logger.setLevel(current_logger_level) except Exception as e: - logger.critical(f'Not able to restore FlowSystem from dataset. Some functionality is not availlable. {e}') + logger.critical( + f'Not able to restore FlowSystem from dataset. Some functionality is not availlable. {e}' + ) raise _FlowSystemRestorationError(f'Not able to restore FlowSystem from dataset. {e}') from e return self._flow_system @@ -351,8 +352,10 @@ def flow_rates( """ if self._flow_rates is None: self._flow_rates = self._assign_flow_coords( - xr.concat([flow.flow_rate.rename(flow.label) for flow in self.flows.values()], - dim=pd.Index(self.flows.keys(), name='flow')) + xr.concat( + [flow.flow_rate.rename(flow.label) for flow in self.flows.values()], + dim=pd.Index(self.flows.keys(), name='flow'), + ) ).rename('flow_rates') filters = {k: v for k, v in {'start': start, 'end': end, 'component': component}.items() if v is not None} return filter_dataarray_by_coord(self._flow_rates, **filters) @@ -393,7 +396,7 @@ def sizes( self, start: Optional[Union[str, List[str]]] = None, end: Optional[Union[str, List[str]]] = None, - component: Optional[Union[str, List[str]]] = None + component: Optional[Union[str, List[str]]] = None, ) -> xr.DataArray: """Returns a dataset with the sizes of the Flows. Args: @@ -410,19 +413,23 @@ def sizes( """ if self._sizes is None: self._sizes = self._assign_flow_coords( - xr.concat([flow.size.rename(flow.label) for flow in self.flows.values()], - dim=pd.Index(self.flows.keys(), name='flow')) + xr.concat( + [flow.size.rename(flow.label) for flow in self.flows.values()], + dim=pd.Index(self.flows.keys(), name='flow'), + ) ).rename('flow_sizes') filters = {k: v for k, v in {'start': start, 'end': end, 'component': component}.items() if v is not None} return filter_dataarray_by_coord(self._sizes, **filters) def _assign_flow_coords(self, da: xr.DataArray): # Add start and end coordinates - da = da.assign_coords({ - 'start': ('flow', [flow.start for flow in self.flows.values()]), - 'end': ('flow', [flow.end for flow in self.flows.values()]), - 'component': ('flow', [flow.component for flow in self.flows.values()]), - }) + da = da.assign_coords( + { + 'start': ('flow', [flow.start for flow in self.flows.values()]), + 'end': ('flow', [flow.end for flow in self.flows.values()]), + 'component': ('flow', [flow.component for flow in self.flows.values()]), + } + ) # Ensure flow is the last dimension if needed existing_dims = [d for d in da.dims if d != 'flow'] @@ -434,7 +441,7 @@ def get_effect_shares( element: str, effect: str, mode: Optional[Literal['operation', 'invest']] = None, - include_flows: bool = False + include_flows: bool = False, ) -> xr.Dataset: """Retrieves individual effect shares for a specific element and effect. Either for operation, investment, or both modes combined. @@ -457,8 +464,14 @@ def get_effect_shares( raise ValueError(f'Effect {effect} is not available.') if mode is None: - return xr.merge([self.get_effect_shares(element=element, effect=effect, mode='operation', include_flows=include_flows), - self.get_effect_shares(element=element, effect=effect, mode='invest', include_flows=include_flows)]) + return xr.merge( + [ + self.get_effect_shares( + element=element, effect=effect, mode='operation', include_flows=include_flows + ), + self.get_effect_shares(element=element, effect=effect, mode='invest', include_flows=include_flows), + ] + ) if mode not in ['operation', 'invest']: raise ValueError(f'Mode {mode} is not available. Choose between "operation" and "invest".') @@ -467,15 +480,20 @@ def get_effect_shares( label = f'{element}->{effect}({mode})' if label in self.solution: - ds = xr.Dataset({label: self.solution[label]}) + ds = xr.Dataset({label: self.solution[label]}) if include_flows: if element not in self.components: raise ValueError(f'Only use Components when retrieving Effects including flows. Got {element}') - flows = [label.split('|')[0] for label in self.components[element].inputs + self.components[element].outputs] + flows = [ + label.split('|')[0] for label in self.components[element].inputs + self.components[element].outputs + ] return xr.merge( - [ds] + [self.get_effect_shares(element=flow, effect=effect, mode=mode, include_flows=False) - for flow in flows] + [ds] + + [ + self.get_effect_shares(element=flow, effect=effect, mode=mode, include_flows=False) + for flow in flows + ] ) return ds @@ -514,8 +532,12 @@ def _compute_effect_total( raise ValueError(f'Effect {effect} is not available.') if mode == 'total': - operation = self._compute_effect_total(element=element, effect=effect, mode='operation', include_flows=include_flows) - invest = self._compute_effect_total(element=element, effect=effect, mode='invest', include_flows=include_flows) + operation = self._compute_effect_total( + element=element, effect=effect, mode='operation', include_flows=include_flows + ) + invest = self._compute_effect_total( + element=element, effect=effect, mode='invest', include_flows=include_flows + ) if invest.isnull().all() and operation.isnull().all(): return xr.DataArray(np.nan) if operation.isnull().all(): @@ -545,8 +567,9 @@ def _compute_effect_total( if include_flows: if element not in self.components: raise ValueError(f'Only use Components when retrieving Effects including flows. Got {element}') - flows = [label.split('|')[0] for label in - self.components[element].inputs + self.components[element].outputs] + flows = [ + label.split('|')[0] for label in self.components[element].inputs + self.components[element].outputs + ] for flow in flows: label = f'{flow}->{target_effect}({mode})' if label in self.solution: @@ -640,16 +663,19 @@ def plot_heatmap( Time filtering (summer months only): - >>> results.plot_heatmap('Boiler(Qth)|flow_rate', indexer={ - ... 'scenario': 'base', - ... 'time': results.solution.time[results.solution.time.dt.month.isin([6, 7, 8])] - ... }) + >>> results.plot_heatmap( + ... 'Boiler(Qth)|flow_rate', + ... indexer={ + ... 'scenario': 'base', + ... 'time': results.solution.time[results.solution.time.dt.month.isin([6, 7, 8])], + ... }, + ... ) Save to specific location: - >>> results.plot_heatmap('Boiler(Qth)|flow_rate', - ... indexer={'scenario': 'base'}, - ... save='path/to/my_heatmap.html') + >>> results.plot_heatmap( + ... 'Boiler(Qth)|flow_rate', indexer={'scenario': 'base'}, save='path/to/my_heatmap.html' + ... ) """ dataarray = self.solution[variable_name] @@ -1079,7 +1105,7 @@ def plot_charge_state( charge_state, suffix_parts = _apply_indexer_to_data(charge_state, indexer, drop=True) suffix = '--' + '-'.join(suffix_parts) if suffix_parts else '' - title=f'Operation Balance of {self.label}{suffix}' + title = f'Operation Balance of {self.label}{suffix}' if engine == 'plotly': fig = plotting.with_plotly( @@ -1097,7 +1123,7 @@ def plot_charge_state( x=charge_state.index, y=charge_state.values.flatten(), mode='lines', name=self._charge_state ) ) - elif engine=='matplotlib': + elif engine == 'matplotlib': fig, ax = plotting.with_matplotlib( ds.to_dataframe(), colors=colors, @@ -1562,10 +1588,7 @@ def filter_dataset( return filtered_ds -def filter_dataarray_by_coord( - da: xr.DataArray, - **kwargs: Optional[Union[str, List[str]]] -) -> xr.DataArray: +def filter_dataarray_by_coord(da: xr.DataArray, **kwargs: Optional[Union[str, List[str]]]) -> xr.DataArray: """Filter flows by node and component attributes. Filters are applied in the order they are specified. All filters must match for an edge to be included. @@ -1585,6 +1608,7 @@ def filter_dataarray_by_coord( AttributeError: If required coordinates are missing. ValueError: If specified nodes don't exist or no matches found. """ + # Helper function to process filters def apply_filter(array, coord_name: str, coord_values: Union[Any, List[Any]]): # Verify coord exists @@ -1598,12 +1622,12 @@ def apply_filter(array, coord_name: str, coord_values: Union[Any, List[Any]]): available = set(array[coord_name].values) missing = [v for v in val_list if v not in available] if missing: - raise ValueError(f"{coord_name.title()} value(s) not found: {missing}") + raise ValueError(f'{coord_name.title()} value(s) not found: {missing}') # Apply filter return array.where( array[coord_name].isin(val_list) if isinstance(coord_values, list) else array[coord_name] == coord_values, - drop=True + drop=True, ) # Apply filters from kwargs @@ -1612,20 +1636,18 @@ def apply_filter(array, coord_name: str, coord_values: Union[Any, List[Any]]): for coord, values in filters.items(): da = apply_filter(da, coord, values) except ValueError as e: - raise ValueError(f"No edges match criteria: {filters}") from e + raise ValueError(f'No edges match criteria: {filters}') from e # Verify results exist if da.size == 0: - raise ValueError(f"No edges match criteria: {filters}") + raise ValueError(f'No edges match criteria: {filters}') return da def _apply_indexer_to_data( - data: Union[xr.DataArray, xr.Dataset], - indexer: Optional[Dict[str, Any]] = None, - drop=False - ) -> Tuple[Union[xr.DataArray, xr.Dataset], List[str]]: + data: Union[xr.DataArray, xr.Dataset], indexer: Optional[Dict[str, Any]] = None, drop=False +) -> Tuple[Union[xr.DataArray, xr.Dataset], List[str]]: """ Apply indexer selection or auto-select first values for non-time dimensions. @@ -1643,7 +1665,7 @@ def _apply_indexer_to_data( if indexer is not None: # User provided indexer data = data.sel(indexer, drop=drop) - selection_string.extend(f"{v}[{k}]" for k, v in indexer.items()) + selection_string.extend(f'{v}[{k}]' for k, v in indexer.items()) else: # Auto-select first value for each dimension except 'time' selection = {} @@ -1651,7 +1673,7 @@ def _apply_indexer_to_data( if dim != 'time' and dim in data.coords: first_value = data.coords[dim].values[0] selection[dim] = first_value - selection_string.append(f"{first_value}[{dim}]") + selection_string.append(f'{first_value}[{dim}]') if selection: data = data.sel(selection, drop=drop) diff --git a/flixopt/structure.py b/flixopt/structure.py index 0e82fa346..da67f9620 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -60,6 +60,7 @@ def register_class_for_io(cls): class SubmodelsMixin: """Mixin that provides submodel functionality for both FlowSystemModel and Submodel.""" + submodels: 'Submodels' @property @@ -198,16 +199,12 @@ def __repr__(self) -> str: # Format sections with headers and underlines formatted_sections = [] for section_header, section_content in sections.items(): - formatted_sections.append( - (f'{section_header}\n' - f'{"-" * len(section_header)}\n' - f'{section_content}') - ) + formatted_sections.append((f'{section_header}\n{"-" * len(section_header)}\n{section_content}')) title = f'FlowSystemModel ({self.type})' - return (f'{title}\n' - f'{"=" * len(title)}\n\n' - f'{"\n".join(formatted_sections)}') + all_sections = '\n'.join(formatted_sections) + + return f'{title}\n{"=" * len(title)}\n\n{all_sections}' class Interface: @@ -523,7 +520,8 @@ def to_dataset(self) -> xr.Dataset: raise ValueError( f'Failed to convert {self.__class__.__name__} to dataset. Its recommended to only call this method on ' f'a fully connected and transformed FlowSystem, or Interfaces inside such a FlowSystem.' - f'Original Error: {e}') from e + f'Original Error: {e}' + ) from e def to_netcdf(self, path: Union[str, pathlib.Path], compression: int = 0): """ @@ -768,9 +766,7 @@ class Submodel(SubmodelsMixin): Can have other Submodels assigned, and can be a Submodel of another Submodel. """ - def __init__( - self, model: FlowSystemModel, label_of_element: str, label_of_model = None - ): + def __init__(self, model: FlowSystemModel, label_of_element: str, label_of_model=None): """ Args: model: The FlowSystemModel that is used to create the model. @@ -895,9 +891,7 @@ def constraints_direct(self) -> linopy.Constraints: def constraints(self) -> linopy.Constraints: """All constraints of the model, including those of all sub-models""" names = list(self.constraints_direct) + [ - constraint_name - for submodel in self.submodels.values() - for constraint_name in submodel.constraints + constraint_name for submodel in self.submodels.values() for constraint_name in submodel.constraints ] return self._model.constraints[names] @@ -906,9 +900,7 @@ def constraints(self) -> linopy.Constraints: def variables(self) -> linopy.Variables: """All variables of the model, including those of all sub-models""" names = list(self.variables_direct) + [ - variable_name - for submodel in self.submodels.values() - for variable_name in submodel.variables + variable_name for submodel in self.submodels.values() for variable_name in submodel.variables ] return self._model.variables[names] @@ -919,25 +911,24 @@ def __repr__(self) -> str: """ # Extract content from existing representations sections = { - f'Variables: [{len(self.variables)}/{len(self._model.variables)}]': self.variables.__repr__().split('\n', 2)[2], - f'Constraints: [{len(self.constraints)}/{len(self._model.constraints)}]': self.constraints.__repr__().split('\n', 2)[2], + f'Variables: [{len(self.variables)}/{len(self._model.variables)}]': self.variables.__repr__().split( + '\n', 2 + )[2], + f'Constraints: [{len(self.constraints)}/{len(self._model.constraints)}]': self.constraints.__repr__().split( + '\n', 2 + )[2], f'Submodels: [{len(self.submodels)}]': self.submodels.__repr__().split('\n', 2)[2], } # Format sections with headers and underlines formatted_sections = [] for section_header, section_content in sections.items(): - formatted_sections.append( - (f'{section_header}\n' - f'{"-" * len(section_header)}\n' - f'{section_content}') - ) + formatted_sections.append((f'{section_header}\n{"-" * len(section_header)}\n{section_content}')) model_string = f'Submodel "{self.label_of_model}":' + all_sections = '\n'.join(formatted_sections) - return (f'{model_string}\n' - f'{"=" * len(model_string)}\n\n' - f'{"\n".join(formatted_sections)}') + return f'{model_string}\n{"=" * len(model_string)}\n\n{all_sections}' @property def hours_per_step(self): @@ -981,7 +972,9 @@ def __repr__(self) -> str: total_vars = sum(len(submodel.variables) for submodel in self.data.values()) total_cons = sum(len(submodel.constraints) for submodel in self.data.values()) - title = f'flixopt.structure.Submodels ({total_vars} vars, {total_cons} constraints, {len(self.data)} submodels):' + title = ( + f'flixopt.structure.Submodels ({total_vars} vars, {total_cons} constraints, {len(self.data)} submodels):' + ) underline = '-' * len(title) if not self.data: diff --git a/mkdocs.yml b/mkdocs.yml index fb009b1fd..6d1a476ff 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -134,4 +134,4 @@ extra_javascript: - https://polyfill.io/v3/polyfill.min.js?features=es6 #Support for older browsers watch: - - flixopt \ No newline at end of file + - flixopt diff --git a/pics/flixopt-icon.svg b/pics/flixopt-icon.svg index 04a6a6851..08fe340f9 100644 --- a/pics/flixopt-icon.svg +++ b/pics/flixopt-icon.svg @@ -1 +1 @@ -flixOpt \ No newline at end of file +flixOpt diff --git a/scripts/extract_release_notes.py b/scripts/extract_release_notes.py index 61ee16425..3532ff2ba 100644 --- a/scripts/extract_release_notes.py +++ b/scripts/extract_release_notes.py @@ -11,10 +11,10 @@ def extract_release_notes(version: str) -> str: """Extract release notes for a specific version from CHANGELOG.md""" - changelog_path = Path("CHANGELOG.md") + changelog_path = Path('CHANGELOG.md') if not changelog_path.exists(): - print("❌ Error: CHANGELOG.md not found", file=sys.stderr) + print('❌ Error: CHANGELOG.md not found', file=sys.stderr) sys.exit(1) content = changelog_path.read_text(encoding='utf-8') @@ -32,8 +32,8 @@ def extract_release_notes(version: str) -> str: def main(): if len(sys.argv) != 2: - print("Usage: python extract_release_notes.py ") - print("Example: python extract_release_notes.py 2.1.2") + print('Usage: python extract_release_notes.py ') + print('Example: python extract_release_notes.py 2.1.2') sys.exit(1) version = sys.argv[1] @@ -41,5 +41,5 @@ def main(): print(release_notes) -if __name__ == "__main__": +if __name__ == '__main__': main() diff --git a/tests/conftest.py b/tests/conftest.py index d6a5df7fa..d12ca9eca 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -193,7 +193,9 @@ def simple_flow_system_scenarios() -> fx.FlowSystem: ) # Create flow system - flow_system = fx.FlowSystem(base_timesteps, scenarios=pd.Index(['A', 'B', 'C']), weights=np.array([0.5, 0.25, 0.25])) + flow_system = fx.FlowSystem( + base_timesteps, scenarios=pd.Index(['A', 'B', 'C']), weights=np.array([0.5, 0.25, 0.25]) + ) flow_system.add_elements(fx.Bus('Strom'), fx.Bus('Fernwärme'), fx.Bus('Gas')) flow_system.add_elements(storage, costs, co2, boiler, heat_load, gas_tariff, electricity_feed_in, chp) @@ -485,7 +487,9 @@ def flow_system_long(): } -def create_calculation_and_solve(flow_system: fx.FlowSystem, solver, name: str, allow_infeasible: bool=False) -> fx.FullCalculation: +def create_calculation_and_solve( + flow_system: fx.FlowSystem, solver, name: str, allow_infeasible: bool = False +) -> fx.FullCalculation: calculation = fx.FullCalculation(name, flow_system) calculation.do_modeling() try: @@ -503,6 +507,7 @@ def create_linopy_model(flow_system: fx.FlowSystem) -> FlowSystemModel: calculation.do_modeling() return calculation.model + @pytest.fixture(params=['h', '3h']) def timesteps_linopy(request): return pd.date_range('2020-01-01', periods=10, freq=request.param, name='time') @@ -527,6 +532,7 @@ def basic_flow_system_linopy(timesteps_linopy) -> fx.FlowSystem: return flow_system + def assert_conequal(actual: linopy.Constraint, desired: linopy.Constraint): """Assert that two constraints are equal with detailed error messages.""" name = actual.name @@ -553,12 +559,16 @@ def assert_var_equal(actual: linopy.Variable, desired: linopy.Variable): try: xr.testing.assert_equal(actual.lower, desired.lower) except AssertionError as e: - raise AssertionError(f"{name} lower bounds don't match:\nActual: {actual.lower}\nExpected: {desired.lower}") from e + raise AssertionError( + f"{name} lower bounds don't match:\nActual: {actual.lower}\nExpected: {desired.lower}" + ) from e try: xr.testing.assert_equal(actual.upper, desired.upper) except AssertionError as e: - raise AssertionError(f"{name} upper bounds don't match:\nActual: {actual.upper}\nExpected: {desired.upper}") from e + raise AssertionError( + f"{name} upper bounds don't match:\nActual: {actual.upper}\nExpected: {desired.upper}" + ) from e if actual.type != desired.type: raise AssertionError(f"{name} types don't match: {actual.type} != {desired.type}") @@ -572,13 +582,15 @@ def assert_var_equal(actual: linopy.Variable, desired: linopy.Variable): try: xr.testing.assert_equal(actual.coords, desired.coords) except AssertionError as e: - raise AssertionError(f"{name} coordinates don't match:\nActual: {actual.coords}\nExpected: {desired.coords}") from e + raise AssertionError( + f"{name} coordinates don't match:\nActual: {actual.coords}\nExpected: {desired.coords}" + ) from e if actual.coord_dims != desired.coord_dims: raise AssertionError(f"{name} coordinate dimensions don't match: {actual.coord_dims} != {desired.coord_dims}") -def assert_sets_equal(set1: Iterable, set2: Iterable, msg=""): +def assert_sets_equal(set1: Iterable, set2: Iterable, msg=''): """Assert two sets are equal with custom error message.""" set1, set2 = set(set1), set(set2) @@ -588,12 +600,12 @@ def assert_sets_equal(set1: Iterable, set2: Iterable, msg=""): if extra or missing: parts = [] if extra: - parts.append(f"Extra: {sorted(extra)}") + parts.append(f'Extra: {sorted(extra)}') if missing: - parts.append(f"Missing: {sorted(missing)}") + parts.append(f'Missing: {sorted(missing)}') - error_msg = ", ".join(parts) + error_msg = ', '.join(parts) if msg: - error_msg = f"{msg}: {error_msg}" + error_msg = f'{msg}: {error_msg}' raise AssertionError(error_msg) diff --git a/tests/test_bus.py b/tests/test_bus.py index fb1cfcda3..c9bf3956c 100644 --- a/tests/test_bus.py +++ b/tests/test_bus.py @@ -15,9 +15,11 @@ def test_bus(self, basic_flow_system_linopy): """Test that flow model constraints are correctly generated.""" flow_system = basic_flow_system_linopy bus = fx.Bus('TestBus', excess_penalty_per_flow_hour=None) - flow_system.add_elements(bus, - fx.Sink('WärmelastTest', sink=fx.Flow('Q_th_Last', 'TestBus')), - fx.Source('GastarifTest', source=fx.Flow('Q_Gas', 'TestBus'))) + flow_system.add_elements( + bus, + fx.Sink('WärmelastTest', sink=fx.Flow('Q_th_Last', 'TestBus')), + fx.Source('GastarifTest', source=fx.Flow('Q_Gas', 'TestBus')), + ) model = create_linopy_model(flow_system) assert set(bus.submodel.variables) == {'WärmelastTest(Q_th_Last)|flow_rate', 'GastarifTest(Q_Gas)|flow_rate'} @@ -25,7 +27,7 @@ def test_bus(self, basic_flow_system_linopy): assert_conequal( model.constraints['TestBus|balance'], - model.variables['GastarifTest(Q_Gas)|flow_rate'] == model.variables['WärmelastTest(Q_th_Last)|flow_rate'] + model.variables['GastarifTest(Q_Gas)|flow_rate'] == model.variables['WärmelastTest(Q_th_Last)|flow_rate'], ) def test_bus_penalty(self, basic_flow_system_linopy): @@ -33,26 +35,36 @@ def test_bus_penalty(self, basic_flow_system_linopy): flow_system = basic_flow_system_linopy timesteps = flow_system.timesteps bus = fx.Bus('TestBus') - flow_system.add_elements(bus, - fx.Sink('WärmelastTest', sink=fx.Flow('Q_th_Last', 'TestBus')), - fx.Source('GastarifTest', source=fx.Flow('Q_Gas', 'TestBus'))) + flow_system.add_elements( + bus, + fx.Sink('WärmelastTest', sink=fx.Flow('Q_th_Last', 'TestBus')), + fx.Source('GastarifTest', source=fx.Flow('Q_Gas', 'TestBus')), + ) model = create_linopy_model(flow_system) - assert set(bus.submodel.variables) == {'TestBus|excess_input', - 'TestBus|excess_output', - 'WärmelastTest(Q_th_Last)|flow_rate', - 'GastarifTest(Q_Gas)|flow_rate'} + assert set(bus.submodel.variables) == { + 'TestBus|excess_input', + 'TestBus|excess_output', + 'WärmelastTest(Q_th_Last)|flow_rate', + 'GastarifTest(Q_Gas)|flow_rate', + } assert set(bus.submodel.constraints) == {'TestBus|balance'} - assert_var_equal(model.variables['TestBus|excess_input'], model.add_variables(lower=0, coords = (timesteps,))) + assert_var_equal(model.variables['TestBus|excess_input'], model.add_variables(lower=0, coords=(timesteps,))) assert_var_equal(model.variables['TestBus|excess_output'], model.add_variables(lower=0, coords=(timesteps,))) assert_conequal( model.constraints['TestBus|balance'], - model.variables['GastarifTest(Q_Gas)|flow_rate'] - model.variables['WärmelastTest(Q_th_Last)|flow_rate'] + model.variables['TestBus|excess_input'] - model.variables['TestBus|excess_output'] == 0 + model.variables['GastarifTest(Q_Gas)|flow_rate'] + - model.variables['WärmelastTest(Q_th_Last)|flow_rate'] + + model.variables['TestBus|excess_input'] + - model.variables['TestBus|excess_output'] + == 0, ) assert_conequal( model.constraints['TestBus->Penalty'], - model.variables['TestBus->Penalty'] == (model.variables['TestBus|excess_input'] * 1e5 * model.hours_per_step).sum() + (model.variables['TestBus|excess_output'] * 1e5 * model.hours_per_step).sum(), + model.variables['TestBus->Penalty'] + == (model.variables['TestBus|excess_input'] * 1e5 * model.hours_per_step).sum() + + (model.variables['TestBus|excess_output'] * 1e5 * model.hours_per_step).sum(), ) diff --git a/tests/test_component.py b/tests/test_component.py index 9b3674cd5..90388ef26 100644 --- a/tests/test_component.py +++ b/tests/test_component.py @@ -17,17 +17,16 @@ class TestComponentModel: - def test_flow_label_check(self, basic_flow_system_linopy): """Test that flow model constraints are correctly generated.""" _ = basic_flow_system_linopy inputs = [ fx.Flow('Q_th_Last', 'Fernwärme', relative_minimum=np.ones(10) * 0.1), - fx.Flow('Q_Gas', 'Fernwärme', relative_minimum=np.ones(10) * 0.1) + fx.Flow('Q_Gas', 'Fernwärme', relative_minimum=np.ones(10) * 0.1), ] outputs = [ fx.Flow('Q_th_Last', 'Gas', relative_minimum=np.ones(10) * 0.01), - fx.Flow('Q_Gas', 'Gas', relative_minimum=np.ones(10) * 0.01) + fx.Flow('Q_Gas', 'Gas', relative_minimum=np.ones(10) * 0.01), ] with pytest.raises(ValueError, match='Flow names must be unique!'): _ = flixopt.elements.Component('TestComponent', inputs=inputs, outputs=outputs) @@ -37,11 +36,11 @@ def test_component(self, basic_flow_system_linopy): flow_system = basic_flow_system_linopy inputs = [ fx.Flow('In1', 'Fernwärme', relative_minimum=np.ones(10) * 0.1), - fx.Flow('In2', 'Fernwärme', relative_minimum=np.ones(10) * 0.1) + fx.Flow('In2', 'Fernwärme', relative_minimum=np.ones(10) * 0.1), ] outputs = [ fx.Flow('Out1', 'Gas', relative_minimum=np.ones(10) * 0.01), - fx.Flow('Out2', 'Gas', relative_minimum=np.ones(10) * 0.01) + fx.Flow('Out2', 'Gas', relative_minimum=np.ones(10) * 0.01), ] comp = flixopt.elements.Component('TestComponent', inputs=inputs, outputs=outputs) flow_system.add_elements(comp) @@ -49,24 +48,28 @@ def test_component(self, basic_flow_system_linopy): assert_sets_equal( set(comp.submodel.variables), - {'TestComponent(In1)|flow_rate', - 'TestComponent(In1)|total_flow_hours', - 'TestComponent(In2)|flow_rate', - 'TestComponent(In2)|total_flow_hours', - 'TestComponent(Out1)|flow_rate', - 'TestComponent(Out1)|total_flow_hours', - 'TestComponent(Out2)|flow_rate', - 'TestComponent(Out2)|total_flow_hours'}, - msg='Incorrect variables' + { + 'TestComponent(In1)|flow_rate', + 'TestComponent(In1)|total_flow_hours', + 'TestComponent(In2)|flow_rate', + 'TestComponent(In2)|total_flow_hours', + 'TestComponent(Out1)|flow_rate', + 'TestComponent(Out1)|total_flow_hours', + 'TestComponent(Out2)|flow_rate', + 'TestComponent(Out2)|total_flow_hours', + }, + msg='Incorrect variables', ) assert_sets_equal( set(comp.submodel.constraints), - {'TestComponent(In1)|total_flow_hours', - 'TestComponent(In2)|total_flow_hours', - 'TestComponent(Out1)|total_flow_hours', - 'TestComponent(Out2)|total_flow_hours'}, - msg='Incorrect constraints' + { + 'TestComponent(In1)|total_flow_hours', + 'TestComponent(In2)|total_flow_hours', + 'TestComponent(Out1)|total_flow_hours', + 'TestComponent(Out2)|total_flow_hours', + }, + msg='Incorrect constraints', ) def test_on_with_multiple_flows(self, basic_flow_system_linopy): @@ -79,11 +82,11 @@ def test_on_with_multiple_flows(self, basic_flow_system_linopy): ] outputs = [ fx.Flow('Out1', 'Gas', relative_minimum=np.ones(10) * 0.2, size=200), - fx.Flow('Out2', 'Gas', relative_minimum=np.ones(10) * 0.3, - relative_maximum = ub_out2, size=300), + fx.Flow('Out2', 'Gas', relative_minimum=np.ones(10) * 0.3, relative_maximum=ub_out2, size=300), ] - comp = flixopt.elements.Component('TestComponent', inputs=inputs, outputs=outputs, - on_off_parameters=fx.OnOffParameters()) + comp = flixopt.elements.Component( + 'TestComponent', inputs=inputs, outputs=outputs, on_off_parameters=fx.OnOffParameters() + ) flow_system.add_elements(comp) model = create_linopy_model(flow_system) @@ -130,13 +133,22 @@ def test_on_with_multiple_flows(self, basic_flow_system_linopy): msg='Incorrect constraints', ) - assert_var_equal(model['TestComponent(Out2)|flow_rate'], - model.add_variables(lower=0, upper=300 * ub_out2, coords=(timesteps,))) - assert_var_equal(model['TestComponent|on'], model.add_variables(binary=True, coords = (timesteps,))) + assert_var_equal( + model['TestComponent(Out2)|flow_rate'], + model.add_variables(lower=0, upper=300 * ub_out2, coords=(timesteps,)), + ) + assert_var_equal(model['TestComponent|on'], model.add_variables(binary=True, coords=(timesteps,))) assert_var_equal(model['TestComponent(Out2)|on'], model.add_variables(binary=True, coords=(timesteps,))) - assert_conequal(model.constraints['TestComponent(Out2)|flow_rate|lb'], model.variables['TestComponent(Out2)|flow_rate'] >= model.variables['TestComponent(Out2)|on'] * 0.3 * 300) - assert_conequal(model.constraints['TestComponent(Out2)|flow_rate|ub'], model.variables['TestComponent(Out2)|flow_rate'] <= model.variables['TestComponent(Out2)|on'] * 300 * ub_out2) + assert_conequal( + model.constraints['TestComponent(Out2)|flow_rate|lb'], + model.variables['TestComponent(Out2)|flow_rate'] >= model.variables['TestComponent(Out2)|on'] * 0.3 * 300, + ) + assert_conequal( + model.constraints['TestComponent(Out2)|flow_rate|ub'], + model.variables['TestComponent(Out2)|flow_rate'] + <= model.variables['TestComponent(Out2)|on'] * 300 * ub_out2, + ) assert_conequal( model.constraints['TestComponent|on|lb'], @@ -159,8 +171,6 @@ def test_on_with_multiple_flows(self, basic_flow_system_linopy): + 1e-5, ) - - def test_on_with_single_flow(self, basic_flow_system_linopy): """Test that flow model constraints are correctly generated.""" flow_system = basic_flow_system_linopy @@ -185,7 +195,7 @@ def test_on_with_single_flow(self, basic_flow_system_linopy): 'TestComponent|on', 'TestComponent|on_hours_total', }, - msg='Incorrect variables' + msg='Incorrect variables', ) assert_sets_equal( @@ -198,7 +208,7 @@ def test_on_with_single_flow(self, basic_flow_system_linopy): 'TestComponent|on', 'TestComponent|on_hours_total', }, - msg='Incorrect constraints' + msg='Incorrect constraints', ) assert_var_equal( @@ -227,15 +237,28 @@ def test_previous_states_with_multiple_flows(self, basic_flow_system_linopy): timesteps = flow_system.timesteps ub_out2 = np.linspace(1, 1.5, 10).round(2) inputs = [ - fx.Flow('In1', 'Fernwärme', relative_minimum=np.ones(10) * 0.1, size=100, previous_flow_rate=np.array([0, 0, 1e-6, 1e-5, 1e-4, 3,4])), + fx.Flow( + 'In1', + 'Fernwärme', + relative_minimum=np.ones(10) * 0.1, + size=100, + previous_flow_rate=np.array([0, 0, 1e-6, 1e-5, 1e-4, 3, 4]), + ), ] outputs = [ - fx.Flow('Out1', 'Gas', relative_minimum=np.ones(10) * 0.2, size=200, previous_flow_rate=[3,4,5]), - fx.Flow('Out2', 'Gas', relative_minimum=np.ones(10) * 0.3, - relative_maximum = ub_out2, size=300, previous_flow_rate=20), + fx.Flow('Out1', 'Gas', relative_minimum=np.ones(10) * 0.2, size=200, previous_flow_rate=[3, 4, 5]), + fx.Flow( + 'Out2', + 'Gas', + relative_minimum=np.ones(10) * 0.3, + relative_maximum=ub_out2, + size=300, + previous_flow_rate=20, + ), ] - comp = flixopt.elements.Component('TestComponent', inputs=inputs, outputs=outputs, - on_off_parameters=fx.OnOffParameters()) + comp = flixopt.elements.Component( + 'TestComponent', inputs=inputs, outputs=outputs, on_off_parameters=fx.OnOffParameters() + ) flow_system.add_elements(comp) model = create_linopy_model(flow_system) @@ -257,7 +280,7 @@ def test_previous_states_with_multiple_flows(self, basic_flow_system_linopy): 'TestComponent|on', 'TestComponent|on_hours_total', }, - msg='Incorrect variables' + msg='Incorrect variables', ) assert_sets_equal( @@ -279,20 +302,35 @@ def test_previous_states_with_multiple_flows(self, basic_flow_system_linopy): 'TestComponent|on|ub', 'TestComponent|on_hours_total', }, - msg='Incorrect constraints' + msg='Incorrect constraints', ) - assert_var_equal(model['TestComponent(Out2)|flow_rate'], - model.add_variables(lower=0, upper=300 * ub_out2, coords=(timesteps,))) - assert_var_equal(model['TestComponent|on'], model.add_variables(binary=True, coords = (timesteps,))) + assert_var_equal( + model['TestComponent(Out2)|flow_rate'], + model.add_variables(lower=0, upper=300 * ub_out2, coords=(timesteps,)), + ) + assert_var_equal(model['TestComponent|on'], model.add_variables(binary=True, coords=(timesteps,))) assert_var_equal(model['TestComponent(Out2)|on'], model.add_variables(binary=True, coords=(timesteps,))) - assert_conequal(model.constraints['TestComponent(Out2)|flow_rate|lb'], model.variables['TestComponent(Out2)|flow_rate'] >= model.variables['TestComponent(Out2)|on'] * 0.3 * 300) - assert_conequal(model.constraints['TestComponent(Out2)|flow_rate|ub'], model.variables['TestComponent(Out2)|flow_rate'] <= model.variables['TestComponent(Out2)|on'] * 300 * ub_out2) + assert_conequal( + model.constraints['TestComponent(Out2)|flow_rate|lb'], + model.variables['TestComponent(Out2)|flow_rate'] >= model.variables['TestComponent(Out2)|on'] * 0.3 * 300, + ) + assert_conequal( + model.constraints['TestComponent(Out2)|flow_rate|ub'], + model.variables['TestComponent(Out2)|flow_rate'] + <= model.variables['TestComponent(Out2)|on'] * 300 * ub_out2, + ) assert_conequal( model.constraints['TestComponent|on|lb'], - model.variables['TestComponent|on'] >= (model.variables['TestComponent(In1)|on'] + model.variables['TestComponent(Out1)|on'] + model.variables['TestComponent(Out2)|on']) / (3 + 1e-5), + model.variables['TestComponent|on'] + >= ( + model.variables['TestComponent(In1)|on'] + + model.variables['TestComponent(Out1)|on'] + + model.variables['TestComponent(Out2)|on'] + ) + / (3 + 1e-5), ) assert_conequal( model.constraints['TestComponent|on|ub'], @@ -301,7 +339,8 @@ def test_previous_states_with_multiple_flows(self, basic_flow_system_linopy): model.variables['TestComponent(In1)|on'] + model.variables['TestComponent(Out1)|on'] + model.variables['TestComponent(Out2)|on'] - ) + 1e-5, + ) + + 1e-5, ) @@ -338,7 +377,7 @@ def test_transmission_basic(self, basic_flow_system, highs_solver): transmission.in1.submodel.flow_rate.solution.values * 0.8 - 20, transmission.out1.submodel.flow_rate.solution.values, 'Losses are not computed correctly', - ) + ) def test_transmission_balanced(self, basic_flow_system, highs_solver): """Test advanced transmission functionality""" @@ -363,7 +402,7 @@ def test_transmission_balanced(self, basic_flow_system, highs_solver): bus='Wärme lokal', size=1, fixed_relative_profile=flow_system.components['Wärmelast'].sink.fixed_relative_profile - * np.array([0, 0, 0, 0, 0, 1, 1, 1, 1, 1]), + * np.array([0, 0, 0, 0, 0, 1, 1, 1, 1, 1]), ), ) @@ -400,7 +439,7 @@ def test_transmission_balanced(self, basic_flow_system, highs_solver): - np.array([20 if val > 0.1 else 0 for val in transmission.in1.submodel.flow_rate.solution.values]), transmission.out1.submodel.flow_rate.solution.values, 'Losses are not computed correctly', - ) + ) assert_almost_equal_numeric( transmission.in1.submodel._investment.size.solution.item(), @@ -431,7 +470,7 @@ def test_transmission_unbalanced(self, basic_flow_system, highs_solver): bus='Wärme lokal', size=1, fixed_relative_profile=flow_system.components['Wärmelast'].sink.fixed_relative_profile - * np.array([0, 0, 0, 0, 0, 1, 1, 1, 1, 1]), + * np.array([0, 0, 0, 0, 0, 1, 1, 1, 1, 1]), ), ) @@ -441,7 +480,9 @@ def test_transmission_unbalanced(self, basic_flow_system, highs_solver): absolute_losses=20, in1=fx.Flow('Rohr1a', bus='Wärme lokal', size=fx.InvestParameters(specific_effects=50, maximum_size=1000)), out1=fx.Flow('Rohr1b', 'Fernwärme', size=1000), - in2=fx.Flow('Rohr2a', 'Fernwärme', size=fx.InvestParameters(specific_effects=100, minimum_size=10, optional=False)), + in2=fx.Flow( + 'Rohr2a', 'Fernwärme', size=fx.InvestParameters(specific_effects=100, minimum_size=10, optional=False) + ), out2=fx.Flow('Rohr2b', bus='Wärme lokal', size=1000), balanced=False, ) @@ -468,7 +509,7 @@ def test_transmission_unbalanced(self, basic_flow_system, highs_solver): - np.array([20 if val > 0.1 else 0 for val in transmission.in1.submodel.flow_rate.solution.values]), transmission.out1.submodel.flow_rate.solution.values, 'Losses are not computed correctly', - ) + ) assert transmission.in1.submodel._investment.size.solution.item() > 11 diff --git a/tests/test_cycle_detection.py b/tests/test_cycle_detection.py index 71c775b99..753a9a3e5 100644 --- a/tests/test_cycle_detection.py +++ b/tests/test_cycle_detection.py @@ -10,164 +10,147 @@ def test_empty_graph(): def test_single_node(): """Test that a graph with a single node and no edges has no cycles.""" - assert detect_cycles({"A": []}) == [] + assert detect_cycles({'A': []}) == [] def test_self_loop(): """Test that a graph with a self-loop has a cycle.""" - cycles = detect_cycles({"A": ["A"]}) + cycles = detect_cycles({'A': ['A']}) assert len(cycles) == 1 - assert cycles[0] == ["A", "A"] + assert cycles[0] == ['A', 'A'] def test_simple_cycle(): """Test that a simple cycle is detected.""" - graph = { - "A": ["B"], - "B": ["C"], - "C": ["A"] - } + graph = {'A': ['B'], 'B': ['C'], 'C': ['A']} cycles = detect_cycles(graph) assert len(cycles) == 1 - assert cycles[0] == ["A", "B", "C", "A"] or cycles[0] == ["B", "C", "A", "B"] or cycles[0] == ["C", "A", "B", "C"] + assert cycles[0] == ['A', 'B', 'C', 'A'] or cycles[0] == ['B', 'C', 'A', 'B'] or cycles[0] == ['C', 'A', 'B', 'C'] def test_no_cycles(): """Test that a directed acyclic graph has no cycles.""" - graph = { - "A": ["B", "C"], - "B": ["D", "E"], - "C": ["F"], - "D": [], - "E": [], - "F": [] - } + graph = {'A': ['B', 'C'], 'B': ['D', 'E'], 'C': ['F'], 'D': [], 'E': [], 'F': []} assert detect_cycles(graph) == [] def test_multiple_cycles(): """Test that a graph with multiple cycles is detected.""" - graph = { - "A": ["B", "D"], - "B": ["C"], - "C": ["A"], - "D": ["E"], - "E": ["D"] - } + graph = {'A': ['B', 'D'], 'B': ['C'], 'C': ['A'], 'D': ['E'], 'E': ['D']} cycles = detect_cycles(graph) assert len(cycles) == 2 # Check that both cycles are detected (order might vary) - cycle_strings = [",".join(cycle) for cycle in cycles] - assert any("A,B,C,A" in s for s in cycle_strings) or any("B,C,A,B" in s for s in cycle_strings) or any( - "C,A,B,C" in s for s in cycle_strings) - assert any("D,E,D" in s for s in cycle_strings) or any("E,D,E" in s for s in cycle_strings) + cycle_strings = [','.join(cycle) for cycle in cycles] + assert ( + any('A,B,C,A' in s for s in cycle_strings) + or any('B,C,A,B' in s for s in cycle_strings) + or any('C,A,B,C' in s for s in cycle_strings) + ) + assert any('D,E,D' in s for s in cycle_strings) or any('E,D,E' in s for s in cycle_strings) def test_hidden_cycle(): """Test that a cycle hidden deep in the graph is detected.""" graph = { - "A": ["B", "C"], - "B": ["D"], - "C": ["E"], - "D": ["F"], - "E": ["G"], - "F": ["H"], - "G": ["I"], - "H": ["J"], - "I": ["K"], - "J": ["L"], - "K": ["M"], - "L": ["N"], - "M": ["N"], - "N": ["O"], - "O": ["P"], - "P": ["Q"], - "Q": ["O"] # Hidden cycle O->P->Q->O + 'A': ['B', 'C'], + 'B': ['D'], + 'C': ['E'], + 'D': ['F'], + 'E': ['G'], + 'F': ['H'], + 'G': ['I'], + 'H': ['J'], + 'I': ['K'], + 'J': ['L'], + 'K': ['M'], + 'L': ['N'], + 'M': ['N'], + 'N': ['O'], + 'O': ['P'], + 'P': ['Q'], + 'Q': ['O'], # Hidden cycle O->P->Q->O } cycles = detect_cycles(graph) assert len(cycles) == 1 # Check that the O-P-Q cycle is detected cycle = cycles[0] - assert "O" in cycle and "P" in cycle and "Q" in cycle + assert 'O' in cycle and 'P' in cycle and 'Q' in cycle # Check that they appear in the correct order - o_index = cycle.index("O") - p_index = cycle.index("P") - q_index = cycle.index("Q") + o_index = cycle.index('O') + p_index = cycle.index('P') + q_index = cycle.index('Q') # Check the cycle order is correct (allowing for different starting points) cycle_len = len(cycle) - assert (p_index == (o_index + 1) % cycle_len and q_index == (p_index + 1) % cycle_len) or \ - (q_index == (o_index + 1) % cycle_len and p_index == (q_index + 1) % cycle_len) or \ - (o_index == (p_index + 1) % cycle_len and q_index == (o_index + 1) % cycle_len) + assert ( + (p_index == (o_index + 1) % cycle_len and q_index == (p_index + 1) % cycle_len) + or (q_index == (o_index + 1) % cycle_len and p_index == (q_index + 1) % cycle_len) + or (o_index == (p_index + 1) % cycle_len and q_index == (o_index + 1) % cycle_len) + ) def test_disconnected_graph(): """Test with a disconnected graph.""" - graph = { - "A": ["B"], - "B": ["C"], - "C": [], - "D": ["E"], - "E": ["F"], - "F": [] - } + graph = {'A': ['B'], 'B': ['C'], 'C': [], 'D': ['E'], 'E': ['F'], 'F': []} assert detect_cycles(graph) == [] def test_disconnected_graph_with_cycle(): """Test with a disconnected graph containing a cycle in one component.""" graph = { - "A": ["B"], - "B": ["C"], - "C": [], - "D": ["E"], - "E": ["F"], - "F": ["D"] # Cycle in D->E->F->D + 'A': ['B'], + 'B': ['C'], + 'C': [], + 'D': ['E'], + 'E': ['F'], + 'F': ['D'], # Cycle in D->E->F->D } cycles = detect_cycles(graph) assert len(cycles) == 1 # Check that the D-E-F cycle is detected cycle = cycles[0] - assert "D" in cycle and "E" in cycle and "F" in cycle + assert 'D' in cycle and 'E' in cycle and 'F' in cycle # Check if they appear in the correct order - d_index = cycle.index("D") - e_index = cycle.index("E") - f_index = cycle.index("F") + d_index = cycle.index('D') + e_index = cycle.index('E') + f_index = cycle.index('F') # Check the cycle order is correct (allowing for different starting points) cycle_len = len(cycle) - assert (e_index == (d_index + 1) % cycle_len and f_index == (e_index + 1) % cycle_len) or \ - (f_index == (d_index + 1) % cycle_len and e_index == (f_index + 1) % cycle_len) or \ - (d_index == (e_index + 1) % cycle_len and f_index == (d_index + 1) % cycle_len) + assert ( + (e_index == (d_index + 1) % cycle_len and f_index == (e_index + 1) % cycle_len) + or (f_index == (d_index + 1) % cycle_len and e_index == (f_index + 1) % cycle_len) + or (d_index == (e_index + 1) % cycle_len and f_index == (d_index + 1) % cycle_len) + ) def test_complex_dag(): """Test with a complex directed acyclic graph.""" graph = { - "A": ["B", "C", "D"], - "B": ["E", "F"], - "C": ["E", "G"], - "D": ["G", "H"], - "E": ["I", "J"], - "F": ["J", "K"], - "G": ["K", "L"], - "H": ["L", "M"], - "I": ["N"], - "J": ["N", "O"], - "K": ["O", "P"], - "L": ["P", "Q"], - "M": ["Q"], - "N": ["R"], - "O": ["R", "S"], - "P": ["S"], - "Q": ["S"], - "R": [], - "S": [] + 'A': ['B', 'C', 'D'], + 'B': ['E', 'F'], + 'C': ['E', 'G'], + 'D': ['G', 'H'], + 'E': ['I', 'J'], + 'F': ['J', 'K'], + 'G': ['K', 'L'], + 'H': ['L', 'M'], + 'I': ['N'], + 'J': ['N', 'O'], + 'K': ['O', 'P'], + 'L': ['P', 'Q'], + 'M': ['Q'], + 'N': ['R'], + 'O': ['R', 'S'], + 'P': ['S'], + 'Q': ['S'], + 'R': [], + 'S': [], } assert detect_cycles(graph) == [] @@ -175,8 +158,8 @@ def test_complex_dag(): def test_missing_node_in_connections(): """Test behavior when a node referenced in edges doesn't have its own key.""" graph = { - "A": ["B", "C"], - "B": ["D"] + 'A': ['B', 'C'], + 'B': ['D'], # C and D don't have their own entries } assert detect_cycles(graph) == [] @@ -184,19 +167,10 @@ def test_missing_node_in_connections(): def test_non_string_keys(): """Test with non-string keys to ensure the algorithm is generic.""" - graph = { - 1: [2, 3], - 2: [4], - 3: [4], - 4: [] - } + graph = {1: [2, 3], 2: [4], 3: [4], 4: []} assert detect_cycles(graph) == [] - graph_with_cycle = { - 1: [2], - 2: [3], - 3: [1] - } + graph_with_cycle = {1: [2], 2: [3], 3: [1]} cycles = detect_cycles(graph_with_cycle) assert len(cycles) == 1 assert cycles[0] == [1, 2, 3, 1] or cycles[0] == [2, 3, 1, 2] or cycles[0] == [3, 1, 2, 3] @@ -222,5 +196,5 @@ def test_complex_network_with_many_nodes(): assert any_cycle_has_both -if __name__ == "__main__": - pytest.main(["-v"]) +if __name__ == '__main__': + pytest.main(['-v']) diff --git a/tests/test_dataconverter.py b/tests/test_dataconverter.py index 08c7d926c..aab04fd15 100644 --- a/tests/test_dataconverter.py +++ b/tests/test_dataconverter.py @@ -31,7 +31,7 @@ def standard_coords(): return { 'time': pd.date_range('2024-01-01', periods=5, freq='D', name='time'), # length 5 'scenario': pd.Index(['A', 'B', 'C'], name='scenario'), # length 3 - 'region': pd.Index(['north', 'south'], name='region') # length 2 + 'region': pd.Index(['north', 'south'], name='region'), # length 2 } @@ -68,10 +68,7 @@ def test_numpy_scalars(self, time_coords): def test_scalar_many_dimensions(self, standard_coords): """Scalar should broadcast to any number of dimensions.""" - coords = { - **standard_coords, - 'technology': pd.Index(['solar', 'wind'], name='technology') - } + coords = {**standard_coords, 'technology': pd.Index(['solar', 'wind'], name='technology')} result = DataConverter.to_dataarray(42, coords=coords) assert result.shape == (5, 3, 2, 2) @@ -134,11 +131,11 @@ def test_1d_array_ambiguous_length(self): # Both dimensions have length 3 coords_3x3 = { 'time': pd.date_range('2024-01-01', periods=3, freq='D', name='time'), - 'scenario': pd.Index(['A', 'B', 'C'], name='scenario') + 'scenario': pd.Index(['A', 'B', 'C'], name='scenario'), } arr = np.array([1, 2, 3]) - with pytest.raises(ConversionError, match="matches multiple dimensions"): + with pytest.raises(ConversionError, match='matches multiple dimensions'): DataConverter.to_dataarray(arr, coords=coords_3x3) def test_1d_array_broadcast_to_many_dimensions(self, standard_coords): @@ -153,10 +150,7 @@ def test_1d_array_broadcast_to_many_dimensions(self, standard_coords): # Check broadcasting - all scenarios and regions should have same time values for scenario in standard_coords['scenario']: for region in standard_coords['region']: - assert np.array_equal( - result.sel(scenario=scenario, region=region).values, - time_arr - ) + assert np.array_equal(result.sel(scenario=scenario, region=region).values, time_arr) class TestSeriesConversion: @@ -227,10 +221,7 @@ def test_series_broadcast_to_many_dimensions(self, standard_coords): # Check that all non-time dimensions have the same time series values for scenario in standard_coords['scenario']: for region in standard_coords['region']: - assert np.array_equal( - result.sel(scenario=scenario, region=region).values, - time_series.values - ) + assert np.array_equal(result.sel(scenario=scenario, region=region).values, time_series.values) class TestDataFrameConversion: @@ -247,11 +238,10 @@ def test_single_column_dataframe(self, time_coords): def test_multi_column_dataframe_accepted(self, time_coords, scenario_coords): """Multi-column DataFrame should now be accepted and converted via numpy array path.""" - df = pd.DataFrame({ - 'value1': [10, 20, 30, 40, 50], - 'value2': [15, 25, 35, 45, 55], - 'value3': [12, 22, 32, 42, 52] - }, index=time_coords) + df = pd.DataFrame( + {'value1': [10, 20, 30, 40, 50], 'value2': [15, 25, 35, 45, 55], 'value3': [12, 22, 32, 42, 52]}, + index=time_coords, + ) # Should work by converting to numpy array (5x3) and matching to time x scenario result = DataConverter.to_dataarray(df, coords={'time': time_coords, 'scenario': scenario_coords}) @@ -264,7 +254,7 @@ def test_empty_dataframe_rejected(self, time_coords): """Empty DataFrame should be rejected.""" df = pd.DataFrame(index=time_coords) # No columns - with pytest.raises(ConversionError, match="DataFrame must have at least one column"): + with pytest.raises(ConversionError, match='DataFrame must have at least one column'): DataConverter.to_dataarray(df, coords={'time': time_coords}) def test_dataframe_broadcast(self, time_coords, scenario_coords): @@ -284,10 +274,9 @@ def test_2d_array_unique_dimensions(self, standard_coords): """2D array with unique dimension lengths should work.""" # 5x3 array should map to time x scenario data_2d = np.random.rand(5, 3) - result = DataConverter.to_dataarray(data_2d, coords={ - 'time': standard_coords['time'], - 'scenario': standard_coords['scenario'] - }) + result = DataConverter.to_dataarray( + data_2d, coords={'time': standard_coords['time'], 'scenario': standard_coords['scenario']} + ) assert result.shape == (5, 3) assert result.dims == ('time', 'scenario') @@ -295,10 +284,9 @@ def test_2d_array_unique_dimensions(self, standard_coords): # 3x5 array should map to scenario x time data_2d_flipped = np.random.rand(3, 5) - result_flipped = DataConverter.to_dataarray(data_2d_flipped, coords={ - 'time': standard_coords['time'], - 'scenario': standard_coords['scenario'] - }) + result_flipped = DataConverter.to_dataarray( + data_2d_flipped, coords={'time': standard_coords['time'], 'scenario': standard_coords['scenario']} + ) assert result_flipped.shape == (5, 3) assert result_flipped.dims == ('time', 'scenario') @@ -343,14 +331,14 @@ def test_4d_array_unique_dimensions(self): 'time': pd.date_range('2024-01-01', periods=2, freq='D', name='time'), # length 2 'scenario': pd.Index(['A', 'B', 'C'], name='scenario'), # length 3 'region': pd.Index(['north', 'south', 'east', 'west'], name='region'), # length 4 - 'technology': pd.Index(['solar', 'wind', 'gas', 'coal', 'hydro'], name='technology') # length 5 + 'technology': pd.Index(['solar', 'wind', 'gas', 'coal', 'hydro'], name='technology'), # length 5 } # 3x5x2x4 array should map to scenario x technology x time x region data_4d = np.random.rand(3, 5, 2, 4) result = DataConverter.to_dataarray(data_4d, coords=coords) - assert result.shape == (2,3,4,5) + assert result.shape == (2, 3, 4, 5) assert result.dims == ('time', 'scenario', 'region', 'technology') assert np.array_equal(result.transpose('scenario', 'technology', 'time', 'region').values, data_4d) @@ -359,18 +347,18 @@ def test_2d_array_ambiguous_dimensions_error(self): # Both dimensions have length 3 coords_ambiguous = { 'scenario': pd.Index(['A', 'B', 'C'], name='scenario'), # length 3 - 'region': pd.Index(['north', 'south', 'east'], name='region') # length 3 + 'region': pd.Index(['north', 'south', 'east'], name='region'), # length 3 } data_2d = np.random.rand(3, 3) - with pytest.raises(ConversionError, match="matches multiple dimension orders"): + with pytest.raises(ConversionError, match='matches multiple dimension orders'): DataConverter.to_dataarray(data_2d, coords=coords_ambiguous) def test_multid_array_no_coords(self): """Multi-D arrays without coords should fail unless scalar.""" # Multi-element fails data_2d = np.random.rand(2, 3) - with pytest.raises(ConversionError, match="Cannot convert multi-element array without target dimensions"): + with pytest.raises(ConversionError, match='Cannot convert multi-element array without target dimensions'): DataConverter.to_dataarray(data_2d) # Single element succeeds @@ -385,25 +373,22 @@ def test_array_no_matching_dimensions_error(self, standard_coords): data_2d = np.random.rand(7, 8) coords_2d = { 'time': standard_coords['time'], # length 5 - 'scenario': standard_coords['scenario'] # length 3 + 'scenario': standard_coords['scenario'], # length 3 } - with pytest.raises(ConversionError, match="Array dimensions do not match any coordinate lengths"): + with pytest.raises(ConversionError, match='Array dimensions do not match any coordinate lengths'): DataConverter.to_dataarray(data_2d, coords=coords_2d) def test_multid_array_special_values(self, standard_coords): """Multi-D arrays should preserve special values.""" # Create 2D array with special values - data_2d = np.array([[1.0, np.nan, 3.0], - [np.inf, 5.0, -np.inf], - [7.0, 8.0, 9.0], - [10.0, np.nan, 12.0], - [13.0, 14.0, np.inf]]) + data_2d = np.array( + [[1.0, np.nan, 3.0], [np.inf, 5.0, -np.inf], [7.0, 8.0, 9.0], [10.0, np.nan, 12.0], [13.0, 14.0, np.inf]] + ) - result = DataConverter.to_dataarray(data_2d, coords={ - 'time': standard_coords['time'], - 'scenario': standard_coords['scenario'] - }) + result = DataConverter.to_dataarray( + data_2d, coords={'time': standard_coords['time'], 'scenario': standard_coords['scenario']} + ) assert result.shape == (5, 3) assert np.array_equal(np.isnan(result.values), np.isnan(data_2d)) @@ -412,31 +397,23 @@ def test_multid_array_special_values(self, standard_coords): def test_multid_array_dtype_preservation(self, standard_coords): """Multi-D arrays should preserve data types.""" # Integer array - int_data = np.array([[1, 2, 3], - [4, 5, 6], - [7, 8, 9], - [10, 11, 12], - [13, 14, 15]], dtype=np.int32) + int_data = np.array([[1, 2, 3], [4, 5, 6], [7, 8, 9], [10, 11, 12], [13, 14, 15]], dtype=np.int32) - result_int = DataConverter.to_dataarray(int_data, coords={ - 'time': standard_coords['time'], - 'scenario': standard_coords['scenario'] - }) + result_int = DataConverter.to_dataarray( + int_data, coords={'time': standard_coords['time'], 'scenario': standard_coords['scenario']} + ) assert result_int.dtype == np.int32 assert np.array_equal(result_int.values, int_data) # Boolean array - bool_data = np.array([[True, False, True], - [False, True, False], - [True, True, False], - [False, False, True], - [True, False, True]]) + bool_data = np.array( + [[True, False, True], [False, True, False], [True, True, False], [False, False, True], [True, False, True]] + ) - result_bool = DataConverter.to_dataarray(bool_data, coords={ - 'time': standard_coords['time'], - 'scenario': standard_coords['scenario'] - }) + result_bool = DataConverter.to_dataarray( + bool_data, coords={'time': standard_coords['time'], 'scenario': standard_coords['scenario']} + ) assert result_bool.dtype == bool assert np.array_equal(result_bool.values, bool_data) @@ -499,7 +476,7 @@ def test_2d_dataarray_broadcast_to_more_dimensions(self, standard_coords): original = xr.DataArray( [[10, 20, 30], [40, 50, 60], [70, 80, 90], [100, 110, 120], [130, 140, 150]], coords={'time': standard_coords['time'], 'scenario': standard_coords['scenario']}, - dims=('time', 'scenario') + dims=('time', 'scenario'), ) # Broadcast to 3D @@ -510,10 +487,7 @@ def test_2d_dataarray_broadcast_to_more_dimensions(self, standard_coords): # Check that all regions have the same time+scenario values for region in standard_coords['region']: - assert np.array_equal( - result.sel(region=region).values, - original.values - ) + assert np.array_equal(result.sel(region=region).values, original.values) class TestTimeSeriesDataConversion: @@ -585,7 +559,7 @@ def test_custom_dimensions_complex(self): coords = { 'product': pd.Index(['A', 'B'], name='product'), 'factory': pd.Index(['F1', 'F2', 'F3'], name='factory'), - 'quarter': pd.Index(['Q1', 'Q2', 'Q3', 'Q4'], name='quarter') + 'quarter': pd.Index(['Q1', 'Q2', 'Q3', 'Q4'], name='quarter'), } # Array matching factory dimension @@ -626,7 +600,7 @@ def test_time_coord_validation(self): """Time coordinates must be DatetimeIndex.""" # Non-datetime index with name 'time' should fail wrong_time = pd.Index([1, 2, 3], name='time') - with pytest.raises(ConversionError, match="time coordinates must be a DatetimeIndex"): + with pytest.raises(ConversionError, match='time coordinates must be a DatetimeIndex'): DataConverter.to_dataarray(42, coords={'time': wrong_time}) def test_coord_naming(self, time_coords): @@ -642,13 +616,7 @@ class TestErrorHandling: def test_unsupported_data_types(self, time_coords): """Unsupported data types should fail with clear messages.""" - unsupported = [ - 'string', - object(), - None, - {'dict': 'value'}, - [1, 2, 3] - ] + unsupported = ['string', object(), None, {'dict': 'value'}, [1, 2, 3]] for data in unsupported: with pytest.raises(ConversionError): @@ -658,14 +626,14 @@ def test_dimension_mismatch_messages(self, time_coords, scenario_coords): """Error messages should be informative.""" # Array with wrong length wrong_arr = np.array([1, 2]) # Length 2, but no dimension has length 2 - with pytest.raises(ConversionError, match="matches none of the target dimensions"): + with pytest.raises(ConversionError, match='matches none of the target dimensions'): DataConverter.to_dataarray(wrong_arr, coords={'time': time_coords, 'scenario': scenario_coords}) def test_multidimensional_array_dimension_count_mismatch(self, standard_coords): """Array with wrong number of dimensions should fail with clear error.""" # 4D array with 3D coordinates data_4d = np.random.rand(5, 3, 2, 4) - with pytest.raises(ConversionError, match="matches multiple dimension orders|Array dimensions do not match"): + with pytest.raises(ConversionError, match='matches multiple dimension orders|Array dimensions do not match'): DataConverter.to_dataarray(data_4d, coords=standard_coords) def test_error_message_quality(self, standard_coords): @@ -674,7 +642,7 @@ def test_error_message_quality(self, standard_coords): data_2d = np.random.rand(7, 8) coords_2d = { 'time': standard_coords['time'], # length 5 - 'scenario': standard_coords['scenario'] # length 3 + 'scenario': standard_coords['scenario'], # length 3 } try: @@ -682,8 +650,8 @@ def test_error_message_quality(self, standard_coords): raise AssertionError('Should have raised ConversionError') except ConversionError as e: error_msg = str(e) - assert "Array shape: (7, 8)" in error_msg - assert "Coordinate lengths:" in error_msg + assert 'Array shape: (7, 8)' in error_msg + assert 'Coordinate lengths:' in error_msg class TestDataIntegrity: @@ -725,10 +693,9 @@ def test_dataframe_copy_independence(self, time_coords): def test_multid_array_copy_independence(self, standard_coords): """Multi-D arrays should be independent copies.""" original_data = np.random.rand(5, 3) - result = DataConverter.to_dataarray(original_data, coords={ - 'time': standard_coords['time'], - 'scenario': standard_coords['scenario'] - }) + result = DataConverter.to_dataarray( + original_data, coords={'time': standard_coords['time'], 'scenario': standard_coords['scenario']} + ) # Modify result result[0, 0] = 999 @@ -810,7 +777,7 @@ def test_complex_multid_scenario(self): coords = { 'time': pd.date_range('2024-01-01', periods=24, freq='H', name='time'), # 24 hours 'technology': pd.Index(['solar', 'wind', 'gas', 'coal'], name='technology'), # 4 technologies - 'region': pd.Index(['north', 'south', 'east'], name='region') # 3 regions + 'region': pd.Index(['north', 'south', 'east'], name='region'), # 3 regions } # Capacity factors: 24 x 4 (will broadcast to 24 x 4 x 3) @@ -832,22 +799,22 @@ def test_ambiguous_length_handling(self): coords_3x3x3 = { 'time': pd.date_range('2024-01-01', periods=3, freq='D', name='time'), 'scenario': pd.Index(['A', 'B', 'C'], name='scenario'), - 'region': pd.Index(['X', 'Y', 'Z'], name='region') + 'region': pd.Index(['X', 'Y', 'Z'], name='region'), } # 1D array - should fail arr_1d = np.array([1, 2, 3]) - with pytest.raises(ConversionError, match="matches multiple dimensions"): + with pytest.raises(ConversionError, match='matches multiple dimensions'): DataConverter.to_dataarray(arr_1d, coords=coords_3x3x3) # 2D array - should fail arr_2d = np.random.rand(3, 3) - with pytest.raises(ConversionError, match="matches multiple dimension orders"): + with pytest.raises(ConversionError, match='matches multiple dimension orders'): DataConverter.to_dataarray(arr_2d, coords=coords_3x3x3) # 3D array - should fail arr_3d = np.random.rand(3, 3, 3) - with pytest.raises(ConversionError, match="matches multiple dimension orders"): + with pytest.raises(ConversionError, match='matches multiple dimension orders'): DataConverter.to_dataarray(arr_3d, coords=coords_3x3x3) def test_mixed_broadcasting_scenarios(self): @@ -856,7 +823,7 @@ def test_mixed_broadcasting_scenarios(self): 'time': pd.date_range('2024-01-01', periods=4, freq='D', name='time'), # length 4 'scenario': pd.Index(['A', 'B'], name='scenario'), # length 2 'region': pd.Index(['north', 'south', 'east'], name='region'), # length 3 - 'product': pd.Index(['X', 'Y', 'Z', 'W', 'V'], name='product') # length 5 + 'product': pd.Index(['X', 'Y', 'Z', 'W', 'V'], name='product'), # length 5 } # Scalar to 4D @@ -873,8 +840,7 @@ def test_mixed_broadcasting_scenarios(self): for region in coords['region']: for product in coords['product']: assert np.array_equal( - arr_result.sel(scenario=scenario, region=region, product=product).values, - arr_1d + arr_result.sel(scenario=scenario, region=region, product=product).values, arr_1d ) # 2D array (4x2, matches time×scenario) to 4D @@ -884,10 +850,8 @@ def test_mixed_broadcasting_scenarios(self): # Verify broadcasting for region in coords['region']: for product in coords['product']: - assert np.array_equal( - arr_2d_result.sel(region=region, product=product).values, - arr_2d - ) + assert np.array_equal(arr_2d_result.sel(region=region, product=product).values, arr_2d) + class TestAmbiguousDimensionLengthHandling: """Test that DataConverter correctly raises errors when multiple dimensions have the same length.""" diff --git a/tests/test_effect.py b/tests/test_effect.py index dfcc2ea66..e2807cc89 100644 --- a/tests/test_effect.py +++ b/tests/test_effect.py @@ -27,98 +27,126 @@ def test_minimal(self, basic_flow_system_linopy): assert_sets_equal( set(effect.submodel.variables), - {'Effect1(invest)|total', - 'Effect1(operation)|total', - 'Effect1(operation)|total_per_timestep', - 'Effect1|total'}, - msg='Incorrect variables' + { + 'Effect1(invest)|total', + 'Effect1(operation)|total', + 'Effect1(operation)|total_per_timestep', + 'Effect1|total', + }, + msg='Incorrect variables', ) assert_sets_equal( set(effect.submodel.constraints), - {'Effect1(invest)|total', - 'Effect1(operation)|total', - 'Effect1(operation)|total_per_timestep', - 'Effect1|total'}, - msg='Incorrect constraints' + { + 'Effect1(invest)|total', + 'Effect1(operation)|total', + 'Effect1(operation)|total_per_timestep', + 'Effect1|total', + }, + msg='Incorrect constraints', ) assert_var_equal(model.variables['Effect1|total'], model.add_variables()) assert_var_equal(model.variables['Effect1(invest)|total'], model.add_variables()) assert_var_equal(model.variables['Effect1(operation)|total'], model.add_variables()) - assert_var_equal(model.variables['Effect1(operation)|total_per_timestep'], model.add_variables(coords=(timesteps,))) + assert_var_equal( + model.variables['Effect1(operation)|total_per_timestep'], model.add_variables(coords=(timesteps,)) + ) - assert_conequal(model.constraints['Effect1|total'], - model.variables['Effect1|total'] == model.variables['Effect1(operation)|total'] + model.variables['Effect1(invest)|total']) + assert_conequal( + model.constraints['Effect1|total'], + model.variables['Effect1|total'] + == model.variables['Effect1(operation)|total'] + model.variables['Effect1(invest)|total'], + ) assert_conequal(model.constraints['Effect1(invest)|total'], model.variables['Effect1(invest)|total'] == 0) - assert_conequal(model.constraints['Effect1(operation)|total'], - model.variables['Effect1(operation)|total'] == model.variables['Effect1(operation)|total_per_timestep'].sum()) - assert_conequal(model.constraints['Effect1(operation)|total_per_timestep'], - model.variables['Effect1(operation)|total_per_timestep'] ==0) + assert_conequal( + model.constraints['Effect1(operation)|total'], + model.variables['Effect1(operation)|total'] + == model.variables['Effect1(operation)|total_per_timestep'].sum(), + ) + assert_conequal( + model.constraints['Effect1(operation)|total_per_timestep'], + model.variables['Effect1(operation)|total_per_timestep'] == 0, + ) def test_bounds(self, basic_flow_system_linopy): flow_system = basic_flow_system_linopy timesteps = flow_system.timesteps - effect = fx.Effect('Effect1', '€', 'Testing Effect', - minimum_operation=1.0, - maximum_operation=1.1, - minimum_invest=2.0, - maximum_invest=2.1, - minimum_total=3.0, - maximum_total=3.1, - minimum_operation_per_hour=4.0, - maximum_operation_per_hour=4.1 - ) + effect = fx.Effect( + 'Effect1', + '€', + 'Testing Effect', + minimum_operation=1.0, + maximum_operation=1.1, + minimum_invest=2.0, + maximum_invest=2.1, + minimum_total=3.0, + maximum_total=3.1, + minimum_operation_per_hour=4.0, + maximum_operation_per_hour=4.1, + ) flow_system.add_elements(effect) model = create_linopy_model(flow_system) assert_sets_equal( set(effect.submodel.variables), - {'Effect1(invest)|total', - 'Effect1(operation)|total', - 'Effect1(operation)|total_per_timestep', - 'Effect1|total'}, - msg='Incorrect variables' + { + 'Effect1(invest)|total', + 'Effect1(operation)|total', + 'Effect1(operation)|total_per_timestep', + 'Effect1|total', + }, + msg='Incorrect variables', ) assert_sets_equal( set(effect.submodel.constraints), - {'Effect1(invest)|total', - 'Effect1(operation)|total', - 'Effect1(operation)|total_per_timestep', - 'Effect1|total'}, - msg='Incorrect constraints' + { + 'Effect1(invest)|total', + 'Effect1(operation)|total', + 'Effect1(operation)|total_per_timestep', + 'Effect1|total', + }, + msg='Incorrect constraints', ) assert_var_equal(model.variables['Effect1|total'], model.add_variables(lower=3.0, upper=3.1)) assert_var_equal(model.variables['Effect1(invest)|total'], model.add_variables(lower=2.0, upper=2.1)) assert_var_equal(model.variables['Effect1(operation)|total'], model.add_variables(lower=1.0, upper=1.1)) assert_var_equal( - model.variables['Effect1(operation)|total_per_timestep'], model.add_variables( - lower=4.0 * model.hours_per_step, upper=4.1* model.hours_per_step, coords=(timesteps,)) + model.variables['Effect1(operation)|total_per_timestep'], + model.add_variables( + lower=4.0 * model.hours_per_step, upper=4.1 * model.hours_per_step, coords=(timesteps,) + ), ) - assert_conequal(model.constraints['Effect1|total'], - model.variables['Effect1|total'] == model.variables['Effect1(operation)|total'] + model.variables['Effect1(invest)|total']) + assert_conequal( + model.constraints['Effect1|total'], + model.variables['Effect1|total'] + == model.variables['Effect1(operation)|total'] + model.variables['Effect1(invest)|total'], + ) assert_conequal(model.constraints['Effect1(invest)|total'], model.variables['Effect1(invest)|total'] == 0) - assert_conequal(model.constraints['Effect1(operation)|total'], - model.variables['Effect1(operation)|total'] == model.variables['Effect1(operation)|total_per_timestep'].sum()) - assert_conequal(model.constraints['Effect1(operation)|total_per_timestep'], - model.variables['Effect1(operation)|total_per_timestep'] ==0) + assert_conequal( + model.constraints['Effect1(operation)|total'], + model.variables['Effect1(operation)|total'] + == model.variables['Effect1(operation)|total_per_timestep'].sum(), + ) + assert_conequal( + model.constraints['Effect1(operation)|total_per_timestep'], + model.variables['Effect1(operation)|total_per_timestep'] == 0, + ) def test_shares(self, basic_flow_system_linopy): flow_system = basic_flow_system_linopy - effect1 = fx.Effect('Effect1', '€', 'Testing Effect', - specific_share_to_other_effects_operation={ - 'Effect2': 1.1, - 'Effect3': 1.2 - }, - specific_share_to_other_effects_invest={ - 'Effect2': 2.1, - 'Effect3': 2.2 - } - ) + effect1 = fx.Effect( + 'Effect1', + '€', + 'Testing Effect', + specific_share_to_other_effects_operation={'Effect2': 1.1, 'Effect3': 1.2}, + specific_share_to_other_effects_invest={'Effect2': 2.1, 'Effect3': 2.2}, + ) effect2 = fx.Effect('Effect2', '€', 'Testing Effect') effect3 = fx.Effect('Effect3', '€', 'Testing Effect') flow_system.add_elements(effect1, effect2, effect3) @@ -134,7 +162,7 @@ def test_shares(self, basic_flow_system_linopy): 'Effect1(invest)->Effect2(invest)', 'Effect1(operation)->Effect2(operation)', }, - msg='Incorrect variables for effect2' + msg='Incorrect variables for effect2', ) assert_sets_equal( @@ -147,7 +175,7 @@ def test_shares(self, basic_flow_system_linopy): 'Effect1(invest)->Effect2(invest)', 'Effect1(operation)->Effect2(operation)', }, - msg='Incorrect constraints for effect2' + msg='Incorrect constraints for effect2', ) assert_conequal( @@ -157,19 +185,19 @@ def test_shares(self, basic_flow_system_linopy): assert_conequal( model.constraints['Effect2(operation)|total_per_timestep'], - model.variables['Effect2(operation)|total_per_timestep'] == model.variables['Effect1(operation)->Effect2(operation)'], + model.variables['Effect2(operation)|total_per_timestep'] + == model.variables['Effect1(operation)->Effect2(operation)'], ) assert_conequal( model.constraints['Effect1(operation)->Effect2(operation)'], model.variables['Effect1(operation)->Effect2(operation)'] - == model.variables['Effect1(operation)|total_per_timestep'] * 1.1 + == model.variables['Effect1(operation)|total_per_timestep'] * 1.1, ) assert_conequal( model.constraints['Effect1(invest)->Effect2(invest)'], - model.variables['Effect1(invest)->Effect2(invest)'] - == model.variables['Effect1(invest)|total'] * 2.1, + model.variables['Effect1(invest)->Effect2(invest)'] == model.variables['Effect1(invest)|total'] * 2.1, ) @@ -178,21 +206,24 @@ def test_shares(self, basic_flow_system_linopy): flow_system = basic_flow_system_linopy flow_system.effects['Costs'].specific_share_to_other_effects_operation['Effect1'] = 0.5 flow_system.add_elements( - fx.Effect('Effect1', '€', 'Testing Effect', - specific_share_to_other_effects_operation={ - 'Effect2': 1.1, - 'Effect3': 1.2 - }, - specific_share_to_other_effects_invest={ - 'Effect2': 2.1, - 'Effect3': 2.2 - } - ), + fx.Effect( + 'Effect1', + '€', + 'Testing Effect', + specific_share_to_other_effects_operation={'Effect2': 1.1, 'Effect3': 1.2}, + specific_share_to_other_effects_invest={'Effect2': 2.1, 'Effect3': 2.2}, + ), fx.Effect('Effect2', '€', 'Testing Effect', specific_share_to_other_effects_operation={'Effect3': 5}), fx.Effect('Effect3', '€', 'Testing Effect'), fx.linear_converters.Boiler( - 'Boiler', eta=0.5, Q_th=fx.Flow('Q_th', bus='Fernwärme', ),Q_fu=fx.Flow('Q_fu', bus='Gas'), - ) + 'Boiler', + eta=0.5, + Q_th=fx.Flow( + 'Q_th', + bus='Fernwärme', + ), + Q_fu=fx.Flow('Q_fu', bus='Gas'), + ), ) results = create_calculation_and_solve(flow_system, fx.solvers.HighsSolver(0.01, 60), 'Sim1').results @@ -201,7 +232,7 @@ def test_shares(self, basic_flow_system_linopy): 'operation': { ('Costs', 'Effect1'): 0.5, ('Costs', 'Effect2'): 0.5 * 1.1, - ('Costs', 'Effect3'): 0.5 * 1.1 * 5 + 0.5 * 1.2, #This is where the issue lies + ('Costs', 'Effect3'): 0.5 * 1.1 * 5 + 0.5 * 1.2, # This is where the issue lies ('Effect1', 'Effect2'): 1.1, ('Effect1', 'Effect3'): 1.2 + 1.1 * 5, ('Effect2', 'Effect3'): 5, @@ -209,7 +240,7 @@ def test_shares(self, basic_flow_system_linopy): 'invest': { ('Effect1', 'Effect2'): 2.1, ('Effect1', 'Effect3'): 2.2, - } + }, } for key, value in effect_share_factors['operation'].items(): np.testing.assert_allclose(results.effect_share_factors['operation'][key].values, value) @@ -217,40 +248,59 @@ def test_shares(self, basic_flow_system_linopy): for key, value in effect_share_factors['invest'].items(): np.testing.assert_allclose(results.effect_share_factors['invest'][key].values, value) - xr.testing.assert_allclose(results.effects_per_component('operation').sum('component')['Costs'], - results.solution['Costs(operation)|total_per_timestep'].fillna(0)) + xr.testing.assert_allclose( + results.effects_per_component('operation').sum('component')['Costs'], + results.solution['Costs(operation)|total_per_timestep'].fillna(0), + ) - xr.testing.assert_allclose(results.effects_per_component('operation').sum('component')['Effect1'], - results.solution['Effect1(operation)|total_per_timestep'].fillna(0)) + xr.testing.assert_allclose( + results.effects_per_component('operation').sum('component')['Effect1'], + results.solution['Effect1(operation)|total_per_timestep'].fillna(0), + ) - xr.testing.assert_allclose(results.effects_per_component('operation').sum('component')['Effect2'], - results.solution['Effect2(operation)|total_per_timestep'].fillna(0)) + xr.testing.assert_allclose( + results.effects_per_component('operation').sum('component')['Effect2'], + results.solution['Effect2(operation)|total_per_timestep'].fillna(0), + ) - xr.testing.assert_allclose(results.effects_per_component('operation').sum('component')['Effect3'], - results.solution['Effect3(operation)|total_per_timestep'].fillna(0)) + xr.testing.assert_allclose( + results.effects_per_component('operation').sum('component')['Effect3'], + results.solution['Effect3(operation)|total_per_timestep'].fillna(0), + ) # Invest mode checks - xr.testing.assert_allclose(results.effects_per_component('invest').sum('component')['Costs'], - results.solution['Costs(invest)|total']) + xr.testing.assert_allclose( + results.effects_per_component('invest').sum('component')['Costs'], results.solution['Costs(invest)|total'] + ) - xr.testing.assert_allclose(results.effects_per_component('invest').sum('component')['Effect1'], - results.solution['Effect1(invest)|total']) + xr.testing.assert_allclose( + results.effects_per_component('invest').sum('component')['Effect1'], + results.solution['Effect1(invest)|total'], + ) - xr.testing.assert_allclose(results.effects_per_component('invest').sum('component')['Effect2'], - results.solution['Effect2(invest)|total']) + xr.testing.assert_allclose( + results.effects_per_component('invest').sum('component')['Effect2'], + results.solution['Effect2(invest)|total'], + ) - xr.testing.assert_allclose(results.effects_per_component('invest').sum('component')['Effect3'], - results.solution['Effect3(invest)|total']) + xr.testing.assert_allclose( + results.effects_per_component('invest').sum('component')['Effect3'], + results.solution['Effect3(invest)|total'], + ) # Total mode checks - xr.testing.assert_allclose(results.effects_per_component('total').sum('component')['Costs'], - results.solution['Costs|total']) + xr.testing.assert_allclose( + results.effects_per_component('total').sum('component')['Costs'], results.solution['Costs|total'] + ) - xr.testing.assert_allclose(results.effects_per_component('total').sum('component')['Effect1'], - results.solution['Effect1|total']) + xr.testing.assert_allclose( + results.effects_per_component('total').sum('component')['Effect1'], results.solution['Effect1|total'] + ) - xr.testing.assert_allclose(results.effects_per_component('total').sum('component')['Effect2'], - results.solution['Effect2|total']) + xr.testing.assert_allclose( + results.effects_per_component('total').sum('component')['Effect2'], results.solution['Effect2|total'] + ) - xr.testing.assert_allclose(results.effects_per_component('total').sum('component')['Effect3'], - results.solution['Effect3|total']) + xr.testing.assert_allclose( + results.effects_per_component('total').sum('component')['Effect3'], results.solution['Effect3|total'] + ) diff --git a/tests/test_effects_shares_summation.py b/tests/test_effects_shares_summation.py index b1ff5c3a3..d4d22d6df 100644 --- a/tests/test_effects_shares_summation.py +++ b/tests/test_effects_shares_summation.py @@ -9,10 +9,7 @@ def test_direct_conversions(): """Test direct conversions with simple scalar values.""" - conversion_dict = { - 'A': {'B': xr.DataArray(2.0)}, - 'B': {'C': xr.DataArray(3.0)} - } + conversion_dict = {'A': {'B': xr.DataArray(2.0)}, 'B': {'C': xr.DataArray(3.0)}} result = calculate_all_conversion_paths(conversion_dict) @@ -32,7 +29,7 @@ def test_multiple_paths(): conversion_dict = { 'A': {'B': xr.DataArray(2.0), 'C': xr.DataArray(3.0)}, 'B': {'D': xr.DataArray(4.0)}, - 'C': {'D': xr.DataArray(5.0)} + 'C': {'D': xr.DataArray(5.0)}, } result = calculate_all_conversion_paths(conversion_dict) @@ -49,10 +46,7 @@ def test_xarray_conversions(): a_to_b = xr.DataArray([2.0, 2.1, 2.2], dims='time', coords={'time': time_points}) b_to_c = xr.DataArray([3.0, 3.1, 3.2], dims='time', coords={'time': time_points}) - conversion_dict = { - 'A': {'B': a_to_b}, - 'B': {'C': b_to_c} - } + conversion_dict = {'A': {'B': a_to_b}, 'B': {'C': b_to_c}} result = calculate_all_conversion_paths(conversion_dict) @@ -72,7 +66,7 @@ def test_long_paths(): 'A': {'B': xr.DataArray(2.0)}, 'B': {'C': xr.DataArray(3.0)}, 'C': {'D': xr.DataArray(4.0)}, - 'D': {'E': xr.DataArray(5.0)} + 'D': {'E': xr.DataArray(5.0)}, } result = calculate_all_conversion_paths(conversion_dict) @@ -89,7 +83,7 @@ def test_diamond_paths(): 'A': {'B': xr.DataArray(2.0), 'C': xr.DataArray(3.0)}, 'B': {'D': xr.DataArray(4.0)}, 'C': {'D': xr.DataArray(5.0)}, - 'D': {'E': xr.DataArray(6.0)} + 'D': {'E': xr.DataArray(6.0)}, } result = calculate_all_conversion_paths(conversion_dict) @@ -107,7 +101,7 @@ def test_effect_shares_example(): conversion_dict = { 'Costs': {'Effect1': xr.DataArray(0.5)}, 'Effect1': {'Effect2': xr.DataArray(1.1), 'Effect3': xr.DataArray(1.2)}, - 'Effect2': {'Effect3': xr.DataArray(5.0)} + 'Effect2': {'Effect3': xr.DataArray(5.0)}, } result = calculate_all_conversion_paths(conversion_dict) @@ -142,10 +136,7 @@ def test_empty_conversion_dict(): def test_no_indirect_paths(): """Test with a dictionary that has no indirect paths.""" - conversion_dict = { - 'A': {'B': xr.DataArray(2.0)}, - 'C': {'D': xr.DataArray(3.0)} - } + conversion_dict = {'A': {'B': xr.DataArray(2.0)}, 'C': {'D': xr.DataArray(3.0)}} result = calculate_all_conversion_paths(conversion_dict) @@ -178,7 +169,7 @@ def test_complex_network(): 'N': {'R': xr.DataArray(1.7)}, 'O': {'R': xr.DataArray(2.9), 'S': xr.DataArray(1.0)}, 'P': {'S': xr.DataArray(2.4)}, - 'Q': {'S': xr.DataArray(1.5)} + 'Q': {'S': xr.DataArray(1.5)}, } result = calculate_all_conversion_paths(conversion_dict) @@ -232,5 +223,6 @@ def test_complex_network(): # Just verify we have a reasonable number assert len(result) > 50 + if __name__ == '__main__': pytest.main() diff --git a/tests/test_flow.py b/tests/test_flow.py index 2cc26e52a..93658bf2e 100644 --- a/tests/test_flow.py +++ b/tests/test_flow.py @@ -23,22 +23,18 @@ def test_flow_minimal(self, basic_flow_system_linopy): assert_conequal( model.constraints['Sink(Wärme)|total_flow_hours'], - flow.submodel.variables['Sink(Wärme)|total_flow_hours'] == (flow.submodel.variables['Sink(Wärme)|flow_rate'] * model.hours_per_step).sum() + flow.submodel.variables['Sink(Wärme)|total_flow_hours'] + == (flow.submodel.variables['Sink(Wärme)|flow_rate'] * model.hours_per_step).sum(), ) - assert_var_equal(flow.submodel.flow_rate, - model.add_variables(lower=0, upper=100, coords=(timesteps,))) + assert_var_equal(flow.submodel.flow_rate, model.add_variables(lower=0, upper=100, coords=(timesteps,))) assert_var_equal(flow.submodel.total_flow_hours, model.add_variables(lower=0)) assert_sets_equal( set(flow.submodel.variables), {'Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|flow_rate'}, - msg='Incorrect variables' - ) - assert_sets_equal( - set(flow.submodel.constraints), - {'Sink(Wärme)|total_flow_hours'}, - msg='Incorrect constraints' + msg='Incorrect variables', ) + assert_sets_equal(set(flow.submodel.constraints), {'Sink(Wärme)|total_flow_hours'}, msg='Incorrect constraints') def test_flow(self, basic_flow_system_linopy): flow_system = basic_flow_system_linopy @@ -65,52 +61,47 @@ def test_flow(self, basic_flow_system_linopy): == (flow.submodel.variables['Sink(Wärme)|flow_rate'] * model.hours_per_step).sum(), ) - assert_var_equal( - flow.submodel.total_flow_hours, - model.add_variables(lower=10, upper=1000) - ) + assert_var_equal(flow.submodel.total_flow_hours, model.add_variables(lower=10, upper=1000)) assert_var_equal( flow.submodel.flow_rate, - model.add_variables(lower=np.linspace(0, 0.5, timesteps.size) * 100, - upper=np.linspace(0.5, 1, timesteps.size) * 100, - coords=(timesteps,)) + model.add_variables( + lower=np.linspace(0, 0.5, timesteps.size) * 100, + upper=np.linspace(0.5, 1, timesteps.size) * 100, + coords=(timesteps,), + ), ) assert_conequal( model.constraints['Sink(Wärme)|load_factor_min'], - flow.submodel.variables['Sink(Wärme)|total_flow_hours'] - >= model.hours_per_step.sum('time') * 0.1 * 100, + flow.submodel.variables['Sink(Wärme)|total_flow_hours'] >= model.hours_per_step.sum('time') * 0.1 * 100, ) assert_conequal( model.constraints['Sink(Wärme)|load_factor_max'], - flow.submodel.variables['Sink(Wärme)|total_flow_hours'] - <= model.hours_per_step.sum('time') * 0.9 * 100, + flow.submodel.variables['Sink(Wärme)|total_flow_hours'] <= model.hours_per_step.sum('time') * 0.9 * 100, ) assert_sets_equal( set(flow.submodel.variables), {'Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|flow_rate'}, - msg='Incorrect variables' + msg='Incorrect variables', ) assert_sets_equal( set(flow.submodel.constraints), {'Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|load_factor_max', 'Sink(Wärme)|load_factor_min'}, - msg='Incorrect constraints' + msg='Incorrect constraints', ) def test_effects_per_flow_hour(self, basic_flow_system_linopy): flow_system = basic_flow_system_linopy timesteps = flow_system.timesteps - costs_per_flow_hour = xr.DataArray(np.linspace(1,2,timesteps.size), coords=(timesteps,)) + costs_per_flow_hour = xr.DataArray(np.linspace(1, 2, timesteps.size), coords=(timesteps,)) co2_per_flow_hour = xr.DataArray(np.linspace(4, 5, timesteps.size), coords=(timesteps,)) flow = fx.Flow( - 'Wärme', - bus='Fernwärme', - effects_per_flow_hour={'Costs': costs_per_flow_hour, 'CO2': co2_per_flow_hour} + 'Wärme', bus='Fernwärme', effects_per_flow_hour={'Costs': costs_per_flow_hour, 'CO2': co2_per_flow_hour} ) flow_system.add_elements(fx.Sink('Sink', sink=flow), fx.Effect('CO2', 't', '')) model = create_linopy_model(flow_system) @@ -119,24 +110,24 @@ def test_effects_per_flow_hour(self, basic_flow_system_linopy): assert_sets_equal( set(flow.submodel.variables), {'Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|flow_rate'}, - msg='Incorrect variables' - ) - assert_sets_equal( - set(flow.submodel.constraints), - {'Sink(Wärme)|total_flow_hours'}, - msg='Incorrect constraints' + msg='Incorrect variables', ) + assert_sets_equal(set(flow.submodel.constraints), {'Sink(Wärme)|total_flow_hours'}, msg='Incorrect constraints') assert 'Sink(Wärme)->Costs(operation)' in set(costs.submodel.constraints) assert 'Sink(Wärme)->CO2(operation)' in set(co2.submodel.constraints) assert_conequal( model.constraints['Sink(Wärme)->Costs(operation)'], - model.variables['Sink(Wärme)->Costs(operation)'] == flow.submodel.variables['Sink(Wärme)|flow_rate'] * model.hours_per_step * costs_per_flow_hour) + model.variables['Sink(Wärme)->Costs(operation)'] + == flow.submodel.variables['Sink(Wärme)|flow_rate'] * model.hours_per_step * costs_per_flow_hour, + ) assert_conequal( model.constraints['Sink(Wärme)->CO2(operation)'], - model.variables['Sink(Wärme)->CO2(operation)'] == flow.submodel.variables['Sink(Wärme)|flow_rate'] * model.hours_per_step * co2_per_flow_hour) + model.variables['Sink(Wärme)->CO2(operation)'] + == flow.submodel.variables['Sink(Wärme)|flow_rate'] * model.hours_per_step * co2_per_flow_hour, + ) class TestFlowInvestModel: @@ -164,7 +155,7 @@ def test_flow_invest(self, basic_flow_system_linopy): 'Sink(Wärme)|flow_rate', 'Sink(Wärme)|size', }, - msg='Incorrect variables' + msg='Incorrect variables', ) assert_sets_equal( set(flow.submodel.constraints), @@ -173,7 +164,7 @@ def test_flow_invest(self, basic_flow_system_linopy): 'Sink(Wärme)|flow_rate|ub', 'Sink(Wärme)|flow_rate|lb', }, - msg='Incorrect constraints' + msg='Incorrect constraints', ) # size @@ -219,7 +210,7 @@ def test_flow_invest_optional(self, basic_flow_system_linopy): assert_sets_equal( set(flow.submodel.variables), {'Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|flow_rate', 'Sink(Wärme)|size', 'Sink(Wärme)|is_invested'}, - msg='Incorrect variables' + msg='Incorrect variables', ) assert_sets_equal( set(flow.submodel.constraints), @@ -230,7 +221,7 @@ def test_flow_invest_optional(self, basic_flow_system_linopy): 'Sink(Wärme)|flow_rate|lb', 'Sink(Wärme)|flow_rate|ub', }, - msg='Incorrect constraints' + msg='Incorrect constraints', ) assert_var_equal(model['Sink(Wärme)|size'], model.add_variables(lower=0, upper=100)) @@ -287,7 +278,7 @@ def test_flow_invest_optional_wo_min_size(self, basic_flow_system_linopy): assert_sets_equal( set(flow.submodel.variables), {'Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|flow_rate', 'Sink(Wärme)|size', 'Sink(Wärme)|is_invested'}, - msg='Incorrect variables' + msg='Incorrect variables', ) assert_sets_equal( set(flow.submodel.constraints), @@ -298,7 +289,7 @@ def test_flow_invest_optional_wo_min_size(self, basic_flow_system_linopy): 'Sink(Wärme)|flow_rate|lb', 'Sink(Wärme)|flow_rate|ub', }, - msg='Incorrect constraints' + msg='Incorrect constraints', ) assert_var_equal(model['Sink(Wärme)|size'], model.add_variables(lower=0, upper=100)) @@ -355,7 +346,7 @@ def test_flow_invest_wo_min_size_non_optional(self, basic_flow_system_linopy): assert_sets_equal( set(flow.submodel.variables), {'Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|flow_rate', 'Sink(Wärme)|size'}, - msg='Incorrect variables' + msg='Incorrect variables', ) assert_sets_equal( set(flow.submodel.constraints), @@ -364,7 +355,7 @@ def test_flow_invest_wo_min_size_non_optional(self, basic_flow_system_linopy): 'Sink(Wärme)|flow_rate|lb', 'Sink(Wärme)|flow_rate|ub', }, - msg='Incorrect constraints' + msg='Incorrect constraints', ) assert_var_equal(model['Sink(Wärme)|size'], model.add_variables(lower=1e-5, upper=100)) @@ -410,14 +401,16 @@ def test_flow_invest_fixed_size(self, basic_flow_system_linopy): assert_sets_equal( set(flow.submodel.variables), {'Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|flow_rate', 'Sink(Wärme)|size'}, - msg='Incorrect variables' + msg='Incorrect variables', ) # Check that size is fixed to 75 assert_var_equal(flow.submodel.variables['Sink(Wärme)|size'], model.add_variables(lower=75, upper=75)) # Check flow rate bounds - assert_var_equal(flow.submodel.flow_rate, model.add_variables(lower=0.2 * 75, upper=0.9 * 75, coords=(timesteps,))) + assert_var_equal( + flow.submodel.flow_rate, model.add_variables(lower=0.2 * 75, upper=0.9 * 75, coords=(timesteps,)) + ) def test_flow_invest_with_effects(self, basic_flow_system_linopy): """Test flow with investment effects.""" @@ -438,7 +431,7 @@ def test_flow_invest_with_effects(self, basic_flow_system_linopy): ), ) - flow_system.add_elements( fx.Sink('Sink', sink=flow), co2) + flow_system.add_elements(fx.Sink('Sink', sink=flow), co2) model = create_linopy_model(flow_system) # Check investment effects @@ -449,13 +442,15 @@ def test_flow_invest_with_effects(self, basic_flow_system_linopy): assert_conequal( model.constraints['Sink(Wärme)->Costs(invest)'], model.variables['Sink(Wärme)->Costs(invest)'] - == flow.submodel.variables['Sink(Wärme)|is_invested'] * 1000 + flow.submodel.variables['Sink(Wärme)|size'] * 500, + == flow.submodel.variables['Sink(Wärme)|is_invested'] * 1000 + + flow.submodel.variables['Sink(Wärme)|size'] * 500, ) assert_conequal( model.constraints['Sink(Wärme)->CO2(invest)'], model.variables['Sink(Wärme)->CO2(invest)'] - == flow.submodel.variables['Sink(Wärme)|is_invested'] * 5 + flow.submodel.variables['Sink(Wärme)|size'] * 0.1, + == flow.submodel.variables['Sink(Wärme)|is_invested'] * 5 + + flow.submodel.variables['Sink(Wärme)|size'] * 0.1, ) def test_flow_invest_divest_effects(self, basic_flow_system_linopy): @@ -481,7 +476,7 @@ def test_flow_invest_divest_effects(self, basic_flow_system_linopy): assert_conequal( model.constraints['Sink(Wärme)->Costs(invest)'], - model.variables['Sink(Wärme)->Costs(invest)'] + (model.variables['Sink(Wärme)|is_invested'] -1) * 500 == 0 + model.variables['Sink(Wärme)->Costs(invest)'] + (model.variables['Sink(Wärme)|is_invested'] - 1) * 500 == 0, ) @@ -505,7 +500,7 @@ def test_flow_on(self, basic_flow_system_linopy): assert_sets_equal( set(flow.submodel.variables), {'Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|flow_rate', 'Sink(Wärme)|on', 'Sink(Wärme)|on_hours_total'}, - msg='Incorrect variables' + msg='Incorrect variables', ) assert_sets_equal( @@ -516,7 +511,7 @@ def test_flow_on(self, basic_flow_system_linopy): 'Sink(Wärme)|flow_rate|lb', 'Sink(Wärme)|flow_rate|ub', }, - msg='Incorrect constraints' + msg='Incorrect constraints', ) # flow_rate assert_var_equal( @@ -543,7 +538,7 @@ def test_flow_on(self, basic_flow_system_linopy): ) assert_conequal( model.constraints['Sink(Wärme)|flow_rate|ub'], - flow.submodel.variables['Sink(Wärme)|flow_rate'] <= flow.submodel.variables['Sink(Wärme)|on'] * 0.8 * 100, + flow.submodel.variables['Sink(Wärme)|flow_rate'] <= flow.submodel.variables['Sink(Wärme)|on'] * 0.8 * 100, ) assert_conequal( @@ -578,7 +573,7 @@ def test_effects_per_running_hour(self, basic_flow_system_linopy): 'Sink(Wärme)|on', 'Sink(Wärme)|on_hours_total', }, - msg='Incorrect variables' + msg='Incorrect variables', ) assert_sets_equal( set(flow.submodel.constraints), @@ -588,7 +583,7 @@ def test_effects_per_running_hour(self, basic_flow_system_linopy): 'Sink(Wärme)|flow_rate|ub', 'Sink(Wärme)|on_hours_total', }, - msg='Incorrect constraints' + msg='Incorrect constraints', ) assert 'Sink(Wärme)->Costs(operation)' in set(costs.submodel.constraints) @@ -621,41 +616,47 @@ def test_consecutive_on_hours(self, basic_flow_system_linopy): ), ) - flow_system.add_elements( fx.Sink('Sink', sink=flow)) + flow_system.add_elements(fx.Sink('Sink', sink=flow)) model = create_linopy_model(flow_system) assert {'Sink(Wärme)|consecutive_on_hours', 'Sink(Wärme)|on'}.issubset(set(flow.submodel.variables)) assert_sets_equal( - {'Sink(Wärme)|consecutive_on_hours|ub', - 'Sink(Wärme)|consecutive_on_hours|forward', - 'Sink(Wärme)|consecutive_on_hours|backward', - 'Sink(Wärme)|consecutive_on_hours|initial', - 'Sink(Wärme)|consecutive_on_hours|lb'} & set(flow.submodel.constraints), - {'Sink(Wärme)|consecutive_on_hours|ub', - 'Sink(Wärme)|consecutive_on_hours|forward', - 'Sink(Wärme)|consecutive_on_hours|backward', - 'Sink(Wärme)|consecutive_on_hours|initial', - 'Sink(Wärme)|consecutive_on_hours|lb'}, - msg='Missing consecutive on hours constraints' + { + 'Sink(Wärme)|consecutive_on_hours|ub', + 'Sink(Wärme)|consecutive_on_hours|forward', + 'Sink(Wärme)|consecutive_on_hours|backward', + 'Sink(Wärme)|consecutive_on_hours|initial', + 'Sink(Wärme)|consecutive_on_hours|lb', + } + & set(flow.submodel.constraints), + { + 'Sink(Wärme)|consecutive_on_hours|ub', + 'Sink(Wärme)|consecutive_on_hours|forward', + 'Sink(Wärme)|consecutive_on_hours|backward', + 'Sink(Wärme)|consecutive_on_hours|initial', + 'Sink(Wärme)|consecutive_on_hours|lb', + }, + msg='Missing consecutive on hours constraints', ) assert_var_equal( model.variables['Sink(Wärme)|consecutive_on_hours'], - model.add_variables(lower=0, upper=8, coords=(timesteps,)) + model.add_variables(lower=0, upper=8, coords=(timesteps,)), ) mega = model.hours_per_step.sum('time') assert_conequal( model.constraints['Sink(Wärme)|consecutive_on_hours|ub'], - model.variables['Sink(Wärme)|consecutive_on_hours'] <= model.variables['Sink(Wärme)|on'] * mega + model.variables['Sink(Wärme)|consecutive_on_hours'] <= model.variables['Sink(Wärme)|on'] * mega, ) assert_conequal( model.constraints['Sink(Wärme)|consecutive_on_hours|forward'], model.variables['Sink(Wärme)|consecutive_on_hours'].isel(time=slice(1, None)) - <= model.variables['Sink(Wärme)|consecutive_on_hours'].isel(time=slice(None, -1)) + model.hours_per_step.isel(time=slice(None, -1)) + <= model.variables['Sink(Wärme)|consecutive_on_hours'].isel(time=slice(None, -1)) + + model.hours_per_step.isel(time=slice(None, -1)), ) # eq: duration(t) >= duration(t - 1) + dt(t) + (On(t) - 1) * BIG @@ -664,7 +665,7 @@ def test_consecutive_on_hours(self, basic_flow_system_linopy): model.variables['Sink(Wärme)|consecutive_on_hours'].isel(time=slice(1, None)) >= model.variables['Sink(Wärme)|consecutive_on_hours'].isel(time=slice(None, -1)) + model.hours_per_step.isel(time=slice(None, -1)) - + (model.variables['Sink(Wärme)|on'].isel(time=slice(1, None)) - 1) * mega + + (model.variables['Sink(Wärme)|on'].isel(time=slice(1, None)) - 1) * mega, ) assert_conequal( @@ -676,7 +677,11 @@ def test_consecutive_on_hours(self, basic_flow_system_linopy): assert_conequal( model.constraints['Sink(Wärme)|consecutive_on_hours|lb'], model.variables['Sink(Wärme)|consecutive_on_hours'] - >= (model.variables['Sink(Wärme)|on'].isel(time=slice(None, -1)) - model.variables['Sink(Wärme)|on'].isel(time=slice(1, None))) * 2 + >= ( + model.variables['Sink(Wärme)|on'].isel(time=slice(None, -1)) + - model.variables['Sink(Wärme)|on'].isel(time=slice(1, None)) + ) + * 2, ) def test_consecutive_on_hours_previous(self, basic_flow_system_linopy): @@ -692,42 +697,48 @@ def test_consecutive_on_hours_previous(self, basic_flow_system_linopy): consecutive_on_hours_min=2, # Must run for at least 2 hours when turned on consecutive_on_hours_max=8, # Can't run more than 8 consecutive hours ), - previous_flow_rate=np.array([10, 20, 30, 0, 20, 20, 30]) # Previously on for 3 steps + previous_flow_rate=np.array([10, 20, 30, 0, 20, 20, 30]), # Previously on for 3 steps ) - flow_system.add_elements( fx.Sink('Sink', sink=flow)) + flow_system.add_elements(fx.Sink('Sink', sink=flow)) model = create_linopy_model(flow_system) assert {'Sink(Wärme)|consecutive_on_hours', 'Sink(Wärme)|on'}.issubset(set(flow.submodel.variables)) assert_sets_equal( - {'Sink(Wärme)|consecutive_on_hours|lb', - 'Sink(Wärme)|consecutive_on_hours|forward', - 'Sink(Wärme)|consecutive_on_hours|backward', - 'Sink(Wärme)|consecutive_on_hours|initial'} & set(flow.submodel.constraints), - {'Sink(Wärme)|consecutive_on_hours|lb', - 'Sink(Wärme)|consecutive_on_hours|forward', - 'Sink(Wärme)|consecutive_on_hours|backward', - 'Sink(Wärme)|consecutive_on_hours|initial'}, - msg='Missing consecutive on hours constraints for previous states' + { + 'Sink(Wärme)|consecutive_on_hours|lb', + 'Sink(Wärme)|consecutive_on_hours|forward', + 'Sink(Wärme)|consecutive_on_hours|backward', + 'Sink(Wärme)|consecutive_on_hours|initial', + } + & set(flow.submodel.constraints), + { + 'Sink(Wärme)|consecutive_on_hours|lb', + 'Sink(Wärme)|consecutive_on_hours|forward', + 'Sink(Wärme)|consecutive_on_hours|backward', + 'Sink(Wärme)|consecutive_on_hours|initial', + }, + msg='Missing consecutive on hours constraints for previous states', ) assert_var_equal( model.variables['Sink(Wärme)|consecutive_on_hours'], - model.add_variables(lower=0, upper=8, coords=(timesteps,)) + model.add_variables(lower=0, upper=8, coords=(timesteps,)), ) mega = model.hours_per_step.sum('time') + model.hours_per_step.isel(time=0) * 3 assert_conequal( model.constraints['Sink(Wärme)|consecutive_on_hours|ub'], - model.variables['Sink(Wärme)|consecutive_on_hours'] <= model.variables['Sink(Wärme)|on'] * mega + model.variables['Sink(Wärme)|consecutive_on_hours'] <= model.variables['Sink(Wärme)|on'] * mega, ) assert_conequal( model.constraints['Sink(Wärme)|consecutive_on_hours|forward'], model.variables['Sink(Wärme)|consecutive_on_hours'].isel(time=slice(1, None)) - <= model.variables['Sink(Wärme)|consecutive_on_hours'].isel(time=slice(None, -1)) + model.hours_per_step.isel(time=slice(None, -1)) + <= model.variables['Sink(Wärme)|consecutive_on_hours'].isel(time=slice(None, -1)) + + model.hours_per_step.isel(time=slice(None, -1)), ) # eq: duration(t) >= duration(t - 1) + dt(t) + (On(t) - 1) * BIG @@ -736,7 +747,7 @@ def test_consecutive_on_hours_previous(self, basic_flow_system_linopy): model.variables['Sink(Wärme)|consecutive_on_hours'].isel(time=slice(1, None)) >= model.variables['Sink(Wärme)|consecutive_on_hours'].isel(time=slice(None, -1)) + model.hours_per_step.isel(time=slice(None, -1)) - + (model.variables['Sink(Wärme)|on'].isel(time=slice(1, None)) - 1) * mega + + (model.variables['Sink(Wärme)|on'].isel(time=slice(1, None)) - 1) * mega, ) assert_conequal( @@ -748,7 +759,11 @@ def test_consecutive_on_hours_previous(self, basic_flow_system_linopy): assert_conequal( model.constraints['Sink(Wärme)|consecutive_on_hours|lb'], model.variables['Sink(Wärme)|consecutive_on_hours'] - >= (model.variables['Sink(Wärme)|on'].isel(time=slice(None, -1)) - model.variables['Sink(Wärme)|on'].isel(time=slice(1, None))) * 2 + >= ( + model.variables['Sink(Wärme)|on'].isel(time=slice(None, -1)) + - model.variables['Sink(Wärme)|on'].isel(time=slice(1, None)) + ) + * 2, ) def test_consecutive_off_hours(self, basic_flow_system_linopy): @@ -766,7 +781,7 @@ def test_consecutive_off_hours(self, basic_flow_system_linopy): ), ) - flow_system.add_elements( fx.Sink('Sink', sink=flow)) + flow_system.add_elements(fx.Sink('Sink', sink=flow)) model = create_linopy_model(flow_system) assert {'Sink(Wärme)|consecutive_off_hours', 'Sink(Wärme)|off'}.issubset(set(flow.submodel.variables)) @@ -777,34 +792,36 @@ def test_consecutive_off_hours(self, basic_flow_system_linopy): 'Sink(Wärme)|consecutive_off_hours|forward', 'Sink(Wärme)|consecutive_off_hours|backward', 'Sink(Wärme)|consecutive_off_hours|initial', - 'Sink(Wärme)|consecutive_off_hours|lb' - } & set(flow.submodel.constraints), + 'Sink(Wärme)|consecutive_off_hours|lb', + } + & set(flow.submodel.constraints), { 'Sink(Wärme)|consecutive_off_hours|ub', 'Sink(Wärme)|consecutive_off_hours|forward', 'Sink(Wärme)|consecutive_off_hours|backward', 'Sink(Wärme)|consecutive_off_hours|initial', - 'Sink(Wärme)|consecutive_off_hours|lb' + 'Sink(Wärme)|consecutive_off_hours|lb', }, - msg='Missing consecutive off hours constraints' + msg='Missing consecutive off hours constraints', ) assert_var_equal( model.variables['Sink(Wärme)|consecutive_off_hours'], - model.add_variables(lower=0, upper=12, coords=(timesteps,)) + model.add_variables(lower=0, upper=12, coords=(timesteps,)), ) mega = model.hours_per_step.sum('time') + model.hours_per_step.isel(time=0) * 1 # previously off for 1h assert_conequal( model.constraints['Sink(Wärme)|consecutive_off_hours|ub'], - model.variables['Sink(Wärme)|consecutive_off_hours'] <= model.variables['Sink(Wärme)|off'] * mega + model.variables['Sink(Wärme)|consecutive_off_hours'] <= model.variables['Sink(Wärme)|off'] * mega, ) assert_conequal( model.constraints['Sink(Wärme)|consecutive_off_hours|forward'], model.variables['Sink(Wärme)|consecutive_off_hours'].isel(time=slice(1, None)) - <= model.variables['Sink(Wärme)|consecutive_off_hours'].isel(time=slice(None, -1)) + model.hours_per_step.isel(time=slice(None, -1)) + <= model.variables['Sink(Wärme)|consecutive_off_hours'].isel(time=slice(None, -1)) + + model.hours_per_step.isel(time=slice(None, -1)), ) # eq: duration(t) >= duration(t - 1) + dt(t) + (On(t) - 1) * BIG @@ -813,7 +830,7 @@ def test_consecutive_off_hours(self, basic_flow_system_linopy): model.variables['Sink(Wärme)|consecutive_off_hours'].isel(time=slice(1, None)) >= model.variables['Sink(Wärme)|consecutive_off_hours'].isel(time=slice(None, -1)) + model.hours_per_step.isel(time=slice(None, -1)) - + (model.variables['Sink(Wärme)|off'].isel(time=slice(1, None)) - 1) * mega + + (model.variables['Sink(Wärme)|off'].isel(time=slice(1, None)) - 1) * mega, ) assert_conequal( @@ -825,7 +842,11 @@ def test_consecutive_off_hours(self, basic_flow_system_linopy): assert_conequal( model.constraints['Sink(Wärme)|consecutive_off_hours|lb'], model.variables['Sink(Wärme)|consecutive_off_hours'] - >= (model.variables['Sink(Wärme)|off'].isel(time=slice(None, -1)) - model.variables['Sink(Wärme)|off'].isel(time=slice(1, None))) * 4 + >= ( + model.variables['Sink(Wärme)|off'].isel(time=slice(None, -1)) + - model.variables['Sink(Wärme)|off'].isel(time=slice(1, None)) + ) + * 4, ) def test_consecutive_off_hours_previous(self, basic_flow_system_linopy): @@ -841,10 +862,10 @@ def test_consecutive_off_hours_previous(self, basic_flow_system_linopy): consecutive_off_hours_min=4, # Must stay off for at least 4 hours when shut down consecutive_off_hours_max=12, # Can't be off for more than 12 consecutive hours ), - previous_flow_rate=np.array([10, 20, 30, 0, 20, 0, 0]) # Previously off for 2 steps + previous_flow_rate=np.array([10, 20, 30, 0, 20, 0, 0]), # Previously off for 2 steps ) - flow_system.add_elements( fx.Sink('Sink', sink=flow)) + flow_system.add_elements(fx.Sink('Sink', sink=flow)) model = create_linopy_model(flow_system) assert {'Sink(Wärme)|consecutive_off_hours', 'Sink(Wärme)|off'}.issubset(set(flow.submodel.variables)) @@ -855,34 +876,36 @@ def test_consecutive_off_hours_previous(self, basic_flow_system_linopy): 'Sink(Wärme)|consecutive_off_hours|forward', 'Sink(Wärme)|consecutive_off_hours|backward', 'Sink(Wärme)|consecutive_off_hours|initial', - 'Sink(Wärme)|consecutive_off_hours|lb' - } & set(flow.submodel.constraints), + 'Sink(Wärme)|consecutive_off_hours|lb', + } + & set(flow.submodel.constraints), { 'Sink(Wärme)|consecutive_off_hours|ub', 'Sink(Wärme)|consecutive_off_hours|forward', 'Sink(Wärme)|consecutive_off_hours|backward', 'Sink(Wärme)|consecutive_off_hours|initial', - 'Sink(Wärme)|consecutive_off_hours|lb' + 'Sink(Wärme)|consecutive_off_hours|lb', }, - msg='Missing consecutive off hours constraints for previous states' + msg='Missing consecutive off hours constraints for previous states', ) assert_var_equal( model.variables['Sink(Wärme)|consecutive_off_hours'], - model.add_variables(lower=0, upper=12, coords=(timesteps,)) + model.add_variables(lower=0, upper=12, coords=(timesteps,)), ) mega = model.hours_per_step.sum('time') + model.hours_per_step.isel(time=0) * 2 assert_conequal( model.constraints['Sink(Wärme)|consecutive_off_hours|ub'], - model.variables['Sink(Wärme)|consecutive_off_hours'] <= model.variables['Sink(Wärme)|off'] * mega + model.variables['Sink(Wärme)|consecutive_off_hours'] <= model.variables['Sink(Wärme)|off'] * mega, ) assert_conequal( model.constraints['Sink(Wärme)|consecutive_off_hours|forward'], model.variables['Sink(Wärme)|consecutive_off_hours'].isel(time=slice(1, None)) - <= model.variables['Sink(Wärme)|consecutive_off_hours'].isel(time=slice(None, -1)) + model.hours_per_step.isel(time=slice(None, -1)) + <= model.variables['Sink(Wärme)|consecutive_off_hours'].isel(time=slice(None, -1)) + + model.hours_per_step.isel(time=slice(None, -1)), ) # eq: duration(t) >= duration(t - 1) + dt(t) + (On(t) - 1) * BIG @@ -891,19 +914,23 @@ def test_consecutive_off_hours_previous(self, basic_flow_system_linopy): model.variables['Sink(Wärme)|consecutive_off_hours'].isel(time=slice(1, None)) >= model.variables['Sink(Wärme)|consecutive_off_hours'].isel(time=slice(None, -1)) + model.hours_per_step.isel(time=slice(None, -1)) - + (model.variables['Sink(Wärme)|off'].isel(time=slice(1, None)) - 1) * mega + + (model.variables['Sink(Wärme)|off'].isel(time=slice(1, None)) - 1) * mega, ) assert_conequal( model.constraints['Sink(Wärme)|consecutive_off_hours|initial'], model.variables['Sink(Wärme)|consecutive_off_hours'].isel(time=0) - == model.variables['Sink(Wärme)|off'].isel(time=0) * (model.hours_per_step.isel(time=0) * (1+2)), + == model.variables['Sink(Wärme)|off'].isel(time=0) * (model.hours_per_step.isel(time=0) * (1 + 2)), ) assert_conequal( model.constraints['Sink(Wärme)|consecutive_off_hours|lb'], model.variables['Sink(Wärme)|consecutive_off_hours'] - >= (model.variables['Sink(Wärme)|off'].isel(time=slice(None, -1)) - model.variables['Sink(Wärme)|off'].isel(time=slice(1, None))) * 4 + >= ( + model.variables['Sink(Wärme)|off'].isel(time=slice(None, -1)) + - model.variables['Sink(Wärme)|off'].isel(time=slice(1, None)) + ) + * 4, ) def test_switch_on_constraints(self, basic_flow_system_linopy): @@ -935,14 +962,15 @@ def test_switch_on_constraints(self, basic_flow_system_linopy): 'Sink(Wärme)|switch|initial', 'Sink(Wärme)|switch|mutex', 'Sink(Wärme)|switch|count', - } & set(flow.submodel.constraints), + } + & set(flow.submodel.constraints), { 'Sink(Wärme)|switch|transition', 'Sink(Wärme)|switch|initial', 'Sink(Wärme)|switch|mutex', 'Sink(Wärme)|switch|count', }, - msg='Missing switch constraints' + msg='Missing switch constraints', ) # Check switch_on_nr variable bounds @@ -988,7 +1016,9 @@ def test_on_hours_limits(self, basic_flow_system_linopy): assert 'Sink(Wärme)|on_hours_total' in model.constraints # Check on_hours_total variable bounds - assert_var_equal(flow.submodel.variables['Sink(Wärme)|on_hours_total'], model.add_variables(lower=20, upper=100)) + assert_var_equal( + flow.submodel.variables['Sink(Wärme)|on_hours_total'], model.add_variables(lower=20, upper=100) + ) # Check on_hours_total constraint assert_conequal( @@ -1025,7 +1055,7 @@ def test_flow_on_invest_optional(self, basic_flow_system_linopy): 'Sink(Wärme)|on', 'Sink(Wärme)|on_hours_total', }, - msg='Incorrect variables' + msg='Incorrect variables', ) assert_sets_equal( @@ -1040,7 +1070,7 @@ def test_flow_on_invest_optional(self, basic_flow_system_linopy): 'Sink(Wärme)|flow_rate|lb2', 'Sink(Wärme)|flow_rate|ub2', }, - msg='Incorrect constraints' + msg='Incorrect constraints', ) # flow_rate @@ -1064,11 +1094,11 @@ def test_flow_on_invest_optional(self, basic_flow_system_linopy): ) assert_conequal( model.constraints['Sink(Wärme)|size|lb'], - flow.submodel.variables['Sink(Wärme)|size'] >= flow.submodel.variables['Sink(Wärme)|is_invested'] * 20, + flow.submodel.variables['Sink(Wärme)|size'] >= flow.submodel.variables['Sink(Wärme)|is_invested'] * 20, ) assert_conequal( model.constraints['Sink(Wärme)|size|ub'], - flow.submodel.variables['Sink(Wärme)|size']<= flow.submodel.variables['Sink(Wärme)|is_invested'] * 200, + flow.submodel.variables['Sink(Wärme)|size'] <= flow.submodel.variables['Sink(Wärme)|is_invested'] * 200, ) assert_conequal( model.constraints['Sink(Wärme)|flow_rate|lb1'], @@ -1091,7 +1121,9 @@ def test_flow_on_invest_optional(self, basic_flow_system_linopy): assert_conequal( model.constraints['Sink(Wärme)|flow_rate|lb2'], flow.submodel.variables['Sink(Wärme)|flow_rate'] - >= flow.submodel.variables['Sink(Wärme)|on'] * mega + flow.submodel.variables['Sink(Wärme)|size'] * 0.2 - mega, + >= flow.submodel.variables['Sink(Wärme)|on'] * mega + + flow.submodel.variables['Sink(Wärme)|size'] * 0.2 + - mega, ) assert_conequal( model.constraints['Sink(Wärme)|flow_rate|ub2'], @@ -1121,7 +1153,7 @@ def test_flow_on_invest_non_optional(self, basic_flow_system_linopy): 'Sink(Wärme)|on', 'Sink(Wärme)|on_hours_total', }, - msg='Incorrect variables' + msg='Incorrect variables', ) assert_sets_equal( @@ -1134,7 +1166,7 @@ def test_flow_on_invest_non_optional(self, basic_flow_system_linopy): 'Sink(Wärme)|flow_rate|lb2', 'Sink(Wärme)|flow_rate|ub2', }, - msg='Incorrect constraints' + msg='Incorrect constraints', ) # flow_rate @@ -1177,7 +1209,9 @@ def test_flow_on_invest_non_optional(self, basic_flow_system_linopy): assert_conequal( model.constraints['Sink(Wärme)|flow_rate|lb2'], flow.submodel.variables['Sink(Wärme)|flow_rate'] - >= flow.submodel.variables['Sink(Wärme)|on'] * mega + flow.submodel.variables['Sink(Wärme)|size'] * 0.2 - mega, + >= flow.submodel.variables['Sink(Wärme)|on'] * mega + + flow.submodel.variables['Sink(Wärme)|size'] * 0.2 + - mega, ) assert_conequal( model.constraints['Sink(Wärme)|flow_rate|ub2'], @@ -1203,12 +1237,10 @@ def test_fixed_relative_profile(self, basic_flow_system_linopy): flow_system.add_elements(fx.Sink('Sink', sink=flow)) model = create_linopy_model(flow_system) - assert_var_equal(flow.submodel.variables['Sink(Wärme)|flow_rate'], - model.add_variables(lower=profile * 100, - upper=profile * 100, - coords=(timesteps,)) - ) - + assert_var_equal( + flow.submodel.variables['Sink(Wärme)|flow_rate'], + model.add_variables(lower=profile * 100, upper=profile * 100, coords=(timesteps,)), + ) def test_fixed_profile_with_investment(self, basic_flow_system_linopy): """Test flow with fixed profile and investment.""" @@ -1225,7 +1257,7 @@ def test_fixed_profile_with_investment(self, basic_flow_system_linopy): fixed_relative_profile=xr.DataArray(profile, coords=(timesteps,)), ) - flow_system.add_elements( fx.Sink('Sink', sink=flow)) + flow_system.add_elements(fx.Sink('Sink', sink=flow)) model = create_linopy_model(flow_system) assert_var_equal( diff --git a/tests/test_io.py b/tests/test_io.py index 8e56f36eb..6e18ef3d3 100644 --- a/tests/test_io.py +++ b/tests/test_io.py @@ -15,7 +15,15 @@ ) -@pytest.fixture(params=[flow_system_base, simple_flow_system_scenarios, flow_system_segments_of_flows_2, simple_flow_system, flow_system_long]) +@pytest.fixture( + params=[ + flow_system_base, + simple_flow_system_scenarios, + flow_system_segments_of_flows_2, + simple_flow_system, + flow_system_long, + ] +) def flow_system(request): fs = request.getfixturevalue(request.param.__name__) if isinstance(fs, fx.FlowSystem): @@ -23,6 +31,7 @@ def flow_system(request): else: return fs[0] + @pytest.mark.slow def test_flow_system_file_io(flow_system, highs_solver): calculation_0 = fx.FullCalculation('IO', flow_system=flow_system) diff --git a/tests/test_linear_converter.py b/tests/test_linear_converter.py index 7f65d8fc2..42dc80077 100644 --- a/tests/test_linear_converter.py +++ b/tests/test_linear_converter.py @@ -25,15 +25,11 @@ def test_basic_linear_converter(self, basic_flow_system_linopy): label='Converter', inputs=[input_flow], outputs=[output_flow], - conversion_factors=[{input_flow.label: 0.8, output_flow.label: 1.0}] + conversion_factors=[{input_flow.label: 0.8, output_flow.label: 1.0}], ) # Add to flow system - flow_system.add_elements( - fx.Bus('input_bus'), - fx.Bus('output_bus'), - converter - ) + flow_system.add_elements(fx.Bus('input_bus'), fx.Bus('output_bus'), converter) # Create model model = create_linopy_model(flow_system) @@ -46,7 +42,7 @@ def test_basic_linear_converter(self, basic_flow_system_linopy): # Check conversion constraint (input * 0.8 == output * 1.0) assert_conequal( model.constraints['Converter|conversion_0'], - input_flow.submodel.flow_rate * 0.8 == output_flow.submodel.flow_rate * 1.0 + input_flow.submodel.flow_rate * 0.8 == output_flow.submodel.flow_rate * 1.0, ) def test_linear_converter_time_varying(self, basic_flow_system_linopy): @@ -67,15 +63,11 @@ def test_linear_converter_time_varying(self, basic_flow_system_linopy): label='Converter', inputs=[input_flow], outputs=[output_flow], - conversion_factors=[{input_flow.label: efficiency_series, output_flow.label: 1.0}] + conversion_factors=[{input_flow.label: efficiency_series, output_flow.label: 1.0}], ) # Add to flow system - flow_system.add_elements( - fx.Bus('input_bus'), - fx.Bus('output_bus'), - converter - ) + flow_system.add_elements(fx.Bus('input_bus'), fx.Bus('output_bus'), converter) # Create model model = create_linopy_model(flow_system) @@ -88,7 +80,7 @@ def test_linear_converter_time_varying(self, basic_flow_system_linopy): # Check conversion constraint (input * efficiency_series == output * 1.0) assert_conequal( model.constraints['Converter|conversion_0'], - input_flow.submodel.flow_rate * efficiency_series == output_flow.submodel.flow_rate * 1.0 + input_flow.submodel.flow_rate * efficiency_series == output_flow.submodel.flow_rate * 1.0, ) def test_linear_converter_multiple_factors(self, basic_flow_system_linopy): @@ -109,17 +101,13 @@ def test_linear_converter_multiple_factors(self, basic_flow_system_linopy): conversion_factors=[ {input_flow1.label: 0.8, output_flow1.label: 1.0}, # input1 -> output1 {input_flow2.label: 0.5, output_flow2.label: 1.0}, # input2 -> output2 - {input_flow1.label: 0.2, output_flow2.label: 0.3} # input1 contributes to output2 - ] + {input_flow1.label: 0.2, output_flow2.label: 0.3}, # input1 contributes to output2 + ], ) # Add to flow system flow_system.add_elements( - fx.Bus('input_bus1'), - fx.Bus('input_bus2'), - fx.Bus('output_bus1'), - fx.Bus('output_bus2'), - converter + fx.Bus('input_bus1'), fx.Bus('input_bus2'), fx.Bus('output_bus1'), fx.Bus('output_bus2'), converter ) # Create model @@ -133,19 +121,19 @@ def test_linear_converter_multiple_factors(self, basic_flow_system_linopy): # Check conversion constraint 1 (input1 * 0.8 == output1 * 1.0) assert_conequal( model.constraints['Converter|conversion_0'], - input_flow1.submodel.flow_rate * 0.8 == output_flow1.submodel.flow_rate * 1.0 + input_flow1.submodel.flow_rate * 0.8 == output_flow1.submodel.flow_rate * 1.0, ) # Check conversion constraint 2 (input2 * 0.5 == output2 * 1.0) assert_conequal( model.constraints['Converter|conversion_1'], - input_flow2.submodel.flow_rate * 0.5 == output_flow2.submodel.flow_rate * 1.0 + input_flow2.submodel.flow_rate * 0.5 == output_flow2.submodel.flow_rate * 1.0, ) # Check conversion constraint 3 (input1 * 0.2 == output2 * 0.3) assert_conequal( model.constraints['Converter|conversion_2'], - input_flow1.submodel.flow_rate * 0.2 == output_flow2.submodel.flow_rate * 0.3 + input_flow1.submodel.flow_rate * 0.2 == output_flow2.submodel.flow_rate * 0.3, ) def test_linear_converter_with_on_off(self, basic_flow_system_linopy): @@ -158,9 +146,7 @@ def test_linear_converter_with_on_off(self, basic_flow_system_linopy): # Create OnOffParameters on_off_params = fx.OnOffParameters( - on_hours_total_min=10, - on_hours_total_max=40, - effects_per_running_hour={'Costs': 5} + on_hours_total_min=10, on_hours_total_max=40, effects_per_running_hour={'Costs': 5} ) # Create a linear converter with OnOffParameters @@ -169,7 +155,7 @@ def test_linear_converter_with_on_off(self, basic_flow_system_linopy): inputs=[input_flow], outputs=[output_flow], conversion_factors=[{input_flow.label: 0.8, output_flow.label: 1.0}], - on_off_parameters=on_off_params + on_off_parameters=on_off_params, ) # Add to flow system @@ -189,22 +175,22 @@ def test_linear_converter_with_on_off(self, basic_flow_system_linopy): # Check on_hours_total constraint assert_conequal( model.constraints['Converter|on_hours_total'], - model.variables['Converter|on_hours_total'] == - (model.variables['Converter|on'] * model.hours_per_step).sum() + model.variables['Converter|on_hours_total'] + == (model.variables['Converter|on'] * model.hours_per_step).sum(), ) # Check conversion constraint assert_conequal( model.constraints['Converter|conversion_0'], - input_flow.submodel.flow_rate * 0.8 == output_flow.submodel.flow_rate * 1.0 + input_flow.submodel.flow_rate * 0.8 == output_flow.submodel.flow_rate * 1.0, ) # Check on_off effects assert 'Converter->Costs(operation)' in model.constraints assert_conequal( model.constraints['Converter->Costs(operation)'], - model.variables['Converter->Costs(operation)'] == - model.variables['Converter|on'] * model.hours_per_step * 5 + model.variables['Converter->Costs(operation)'] + == model.variables['Converter|on'] * model.hours_per_step * 5, ) def test_linear_converter_multidimensional(self, basic_flow_system_linopy): @@ -228,17 +214,13 @@ def test_linear_converter_multidimensional(self, basic_flow_system_linopy): # Electricity to cooling {input_flow2.label: 0.3, output_flow2.label: 1.0}, # Fuel also contributes to cooling - {input_flow1.label: 0.1, output_flow2.label: 0.5} - ] + {input_flow1.label: 0.1, output_flow2.label: 0.5}, + ], ) # Add to flow system flow_system.add_elements( - fx.Bus('fuel_bus'), - fx.Bus('electricity_bus'), - fx.Bus('heat_bus'), - fx.Bus('cooling_bus'), - converter + fx.Bus('fuel_bus'), fx.Bus('electricity_bus'), fx.Bus('heat_bus'), fx.Bus('cooling_bus'), converter ) # Create model @@ -252,17 +234,17 @@ def test_linear_converter_multidimensional(self, basic_flow_system_linopy): # Check the conversion equations assert_conequal( model.constraints['MultiConverter|conversion_0'], - input_flow1.submodel.flow_rate * 0.7 == output_flow1.submodel.flow_rate * 1.0 + input_flow1.submodel.flow_rate * 0.7 == output_flow1.submodel.flow_rate * 1.0, ) assert_conequal( model.constraints['MultiConverter|conversion_1'], - input_flow2.submodel.flow_rate * 0.3 == output_flow2.submodel.flow_rate * 1.0 + input_flow2.submodel.flow_rate * 0.3 == output_flow2.submodel.flow_rate * 1.0, ) assert_conequal( model.constraints['MultiConverter|conversion_2'], - input_flow1.submodel.flow_rate * 0.1 == output_flow2.submodel.flow_rate * 0.5 + input_flow1.submodel.flow_rate * 0.1 == output_flow2.submodel.flow_rate * 0.5, ) def test_edge_case_time_varying_conversion(self, basic_flow_system_linopy): @@ -272,35 +254,27 @@ def test_edge_case_time_varying_conversion(self, basic_flow_system_linopy): # Create fluctuating conversion efficiency (e.g., for a heat pump) # Values range from very low (0.1) to very high (5.0) - fluctuating_cop = np.concatenate([ - np.linspace(0.1, 1.0, len(timesteps)//3), - np.linspace(1.0, 5.0, len(timesteps)//3), - np.linspace(5.0, 0.1, len(timesteps)//3 + len(timesteps)%3) - ]) + fluctuating_cop = np.concatenate( + [ + np.linspace(0.1, 1.0, len(timesteps) // 3), + np.linspace(1.0, 5.0, len(timesteps) // 3), + np.linspace(5.0, 0.1, len(timesteps) // 3 + len(timesteps) % 3), + ] + ) # Create input and output flows input_flow = fx.Flow('electricity', bus='electricity_bus', size=100) output_flow = fx.Flow('heat', bus='heat_bus', size=500) # Higher maximum to allow for COP of 5 - conversion_factors = [{ - input_flow.label: fluctuating_cop, - output_flow.label: np.ones(len(timesteps)) - }] + conversion_factors = [{input_flow.label: fluctuating_cop, output_flow.label: np.ones(len(timesteps))}] # Create the converter converter = fx.LinearConverter( - label='VariableConverter', - inputs=[input_flow], - outputs=[output_flow], - conversion_factors=conversion_factors + label='VariableConverter', inputs=[input_flow], outputs=[output_flow], conversion_factors=conversion_factors ) # Add to flow system - flow_system.add_elements( - fx.Bus('electricity_bus'), - fx.Bus('heat_bus'), - converter - ) + flow_system.add_elements(fx.Bus('electricity_bus'), fx.Bus('heat_bus'), converter) # Create model model = create_linopy_model(flow_system) @@ -311,7 +285,7 @@ def test_edge_case_time_varying_conversion(self, basic_flow_system_linopy): # Verify the constraint has the time-varying coefficient assert_conequal( model.constraints['VariableConverter|conversion_0'], - input_flow.submodel.flow_rate * fluctuating_cop == output_flow.submodel.flow_rate * 1.0 + input_flow.submodel.flow_rate * fluctuating_cop == output_flow.submodel.flow_rate * 1.0, ) def test_piecewise_conversion(self, basic_flow_system_linopy): @@ -325,37 +299,23 @@ def test_piecewise_conversion(self, basic_flow_system_linopy): # Create pieces for piecewise conversion # For input flow: two pieces from 0-50 and 50-100 - input_pieces = [ - fx.Piece(start=0, end=50), - fx.Piece(start=50, end=100) - ] + input_pieces = [fx.Piece(start=0, end=50), fx.Piece(start=50, end=100)] # For output flow: two pieces from 0-30 and 30-90 - output_pieces = [ - fx.Piece(start=0, end=30), - fx.Piece(start=30, end=90) - ] + output_pieces = [fx.Piece(start=0, end=30), fx.Piece(start=30, end=90)] # Create piecewise conversion - piecewise_conversion = fx.PiecewiseConversion({ - input_flow.label: fx.Piecewise(input_pieces), - output_flow.label: fx.Piecewise(output_pieces) - }) + piecewise_conversion = fx.PiecewiseConversion( + {input_flow.label: fx.Piecewise(input_pieces), output_flow.label: fx.Piecewise(output_pieces)} + ) # Create a linear converter with piecewise conversion converter = fx.LinearConverter( - label='Converter', - inputs=[input_flow], - outputs=[output_flow], - piecewise_conversion=piecewise_conversion + label='Converter', inputs=[input_flow], outputs=[output_flow], piecewise_conversion=piecewise_conversion ) # Add to flow system - flow_system.add_elements( - fx.Bus('input_bus'), - fx.Bus('output_bus'), - converter - ) + flow_system.add_elements(fx.Bus('input_bus'), fx.Bus('output_bus'), converter) # Create model with the piecewise conversion model = create_linopy_model(flow_system) @@ -391,8 +351,7 @@ def test_piecewise_conversion(self, basic_flow_system_linopy): assert_conequal( model.constraints['Converter|Converter(input)|flow_rate|lambda'], model.variables['Converter(input)|flow_rate'] - == - model.variables['Converter|Piece_0|lambda0'] * 0 + == model.variables['Converter|Piece_0|lambda0'] * 0 + model.variables['Converter|Piece_0|lambda1'] * 50 + model.variables['Converter|Piece_1|lambda0'] * 50 + model.variables['Converter|Piece_1|lambda1'] * 100, @@ -401,8 +360,7 @@ def test_piecewise_conversion(self, basic_flow_system_linopy): assert_conequal( model.constraints['Converter|Converter(output)|flow_rate|lambda'], model.variables['Converter(output)|flow_rate'] - == - model.variables['Converter|Piece_0|lambda0'] * 0 + == model.variables['Converter|Piece_0|lambda0'] * 0 + model.variables['Converter|Piece_0|lambda1'] * 30 + model.variables['Converter|Piece_1|lambda0'] * 30 + model.variables['Converter|Piece_1|lambda1'] * 90, @@ -415,11 +373,10 @@ def test_piecewise_conversion(self, basic_flow_system_linopy): # If there's no on_off parameter, the right-hand side should be 1 assert_conequal( model.constraints['Converter|Converter(input)|flow_rate|single_segment'], - sum([model.variables[f'Converter|Piece_{i}|inside_piece'] - for i in range(len(piecewise_model.pieces))]) <= 1 + sum([model.variables[f'Converter|Piece_{i}|inside_piece'] for i in range(len(piecewise_model.pieces))]) + <= 1, ) - def test_piecewise_conversion_with_onoff(self, basic_flow_system_linopy): """Test a LinearConverter with PiecewiseConversion and OnOffParameters.""" flow_system = basic_flow_system_linopy @@ -430,27 +387,18 @@ def test_piecewise_conversion_with_onoff(self, basic_flow_system_linopy): output_flow = fx.Flow('output', bus='output_bus', size=100) # Create pieces for piecewise conversion - input_pieces = [ - fx.Piece(start=0, end=50), - fx.Piece(start=50, end=100) - ] + input_pieces = [fx.Piece(start=0, end=50), fx.Piece(start=50, end=100)] - output_pieces = [ - fx.Piece(start=0, end=30), - fx.Piece(start=30, end=90) - ] + output_pieces = [fx.Piece(start=0, end=30), fx.Piece(start=30, end=90)] # Create piecewise conversion - piecewise_conversion = fx.PiecewiseConversion({ - input_flow.label: fx.Piecewise(input_pieces), - output_flow.label: fx.Piecewise(output_pieces) - }) + piecewise_conversion = fx.PiecewiseConversion( + {input_flow.label: fx.Piecewise(input_pieces), output_flow.label: fx.Piecewise(output_pieces)} + ) # Create OnOffParameters on_off_params = fx.OnOffParameters( - on_hours_total_min=10, - on_hours_total_max=40, - effects_per_running_hour={'Costs': 5} + on_hours_total_min=10, on_hours_total_max=40, effects_per_running_hour={'Costs': 5} ) # Create a linear converter with piecewise conversion and on/off parameters @@ -459,7 +407,7 @@ def test_piecewise_conversion_with_onoff(self, basic_flow_system_linopy): inputs=[input_flow], outputs=[output_flow], piecewise_conversion=piecewise_conversion, - on_off_parameters=on_off_params + on_off_parameters=on_off_params, ) # Add to flow system @@ -508,8 +456,7 @@ def test_piecewise_conversion_with_onoff(self, basic_flow_system_linopy): assert_conequal( model.constraints['Converter|Converter(input)|flow_rate|lambda'], model.variables['Converter(input)|flow_rate'] - == - model.variables['Converter|Piece_0|lambda0'] * 0 + == model.variables['Converter|Piece_0|lambda0'] * 0 + model.variables['Converter|Piece_0|lambda1'] * 50 + model.variables['Converter|Piece_1|lambda0'] * 50 + model.variables['Converter|Piece_1|lambda1'] * 100, @@ -518,8 +465,7 @@ def test_piecewise_conversion_with_onoff(self, basic_flow_system_linopy): assert_conequal( model.constraints['Converter|Converter(output)|flow_rate|lambda'], model.variables['Converter(output)|flow_rate'] - == - model.variables['Converter|Piece_0|lambda0'] * 0 + == model.variables['Converter|Piece_0|lambda0'] * 0 + model.variables['Converter|Piece_0|lambda1'] * 30 + model.variables['Converter|Piece_1|lambda0'] * 30 + model.variables['Converter|Piece_1|lambda1'] * 90, @@ -531,24 +477,23 @@ def test_piecewise_conversion_with_onoff(self, basic_flow_system_linopy): # The constraint should enforce that the sum of inside_piece variables is limited assert_conequal( model.constraints['Converter|Converter(input)|flow_rate|single_segment'], - sum([model.variables[f'Converter|Piece_{i}|inside_piece'] - for i in range(len(piecewise_model.pieces))]) <= model.variables['Converter|on'] + sum([model.variables[f'Converter|Piece_{i}|inside_piece'] for i in range(len(piecewise_model.pieces))]) + <= model.variables['Converter|on'], ) # Also check that the OnOff model is working correctly assert 'Converter|on_hours_total' in model.constraints assert_conequal( model.constraints['Converter|on_hours_total'], - model['Converter|on_hours_total'] == - (model['Converter|on'] * model.hours_per_step).sum() + model['Converter|on_hours_total'] == (model['Converter|on'] * model.hours_per_step).sum(), ) # Verify that the costs effect is applied assert 'Converter->Costs(operation)' in model.constraints assert_conequal( model.constraints['Converter->Costs(operation)'], - model.variables['Converter->Costs(operation)'] == - model.variables['Converter|on'] * model.hours_per_step * 5 + model.variables['Converter->Costs(operation)'] + == model.variables['Converter|on'] * model.hours_per_step * 5, ) diff --git a/tests/test_results_plots.py b/tests/test_results_plots.py index fe8d27c3b..35a219e31 100644 --- a/tests/test_results_plots.py +++ b/tests/test_results_plots.py @@ -40,6 +40,7 @@ def plotting_engine(request): def color_spec(request): return request.param + @pytest.mark.slow def test_results_plots(flow_system, plotting_engine, show, save, color_spec): calculation = create_calculation_and_solve(flow_system, fx.solvers.HighsSolver(0.01, 30), 'test_results_plots') @@ -62,6 +63,7 @@ def test_results_plots(flow_system, plotting_engine, show, save, color_spec): plt.close('all') + @pytest.mark.slow def test_color_handling_edge_cases(flow_system, plotting_engine, show, save): """Test edge cases for color handling""" diff --git a/tests/test_scenarios.py b/tests/test_scenarios.py index 62f206e68..0ccc1a5dd 100644 --- a/tests/test_scenarios.py +++ b/tests/test_scenarios.py @@ -17,12 +17,10 @@ def test_system(): """Create a basic test system with scenarios.""" # Create a two-day time index with hourly resolution - timesteps = pd.date_range( - "2023-01-01", periods=48, freq="h", name="time" - ) + timesteps = pd.date_range('2023-01-01', periods=48, freq='h', name='time') # Create two scenarios - scenarios = pd.Index(["Scenario A", "Scenario B"], name="scenario") + scenarios = pd.Index(['Scenario A', 'Scenario B'], name='scenario') # Create scenario weights weights = np.array([0.7, 0.3]) @@ -31,109 +29,90 @@ def test_system(): flow_system = FlowSystem( timesteps=timesteps, scenarios=scenarios, - weights=weights # Use TimeSeriesData for weights + weights=weights, # Use TimeSeriesData for weights ) # Create demand profiles that differ between scenarios # Scenario A: Higher demand in first day, lower in second day # Scenario B: Lower demand in first day, higher in second day - demand_profile_a = np.concatenate([ - np.sin(np.linspace(0, 2*np.pi, 24)) * 5 + 10, # Day 1, max ~15 - np.sin(np.linspace(0, 2*np.pi, 24)) * 2 + 5 # Day 2, max ~7 - ]) + demand_profile_a = np.concatenate( + [ + np.sin(np.linspace(0, 2 * np.pi, 24)) * 5 + 10, # Day 1, max ~15 + np.sin(np.linspace(0, 2 * np.pi, 24)) * 2 + 5, # Day 2, max ~7 + ] + ) - demand_profile_b = np.concatenate([ - np.sin(np.linspace(0, 2*np.pi, 24)) * 2 + 5, # Day 1, max ~7 - np.sin(np.linspace(0, 2*np.pi, 24)) * 5 + 10 # Day 2, max ~15 - ]) + demand_profile_b = np.concatenate( + [ + np.sin(np.linspace(0, 2 * np.pi, 24)) * 2 + 5, # Day 1, max ~7 + np.sin(np.linspace(0, 2 * np.pi, 24)) * 5 + 10, # Day 2, max ~15 + ] + ) # Stack the profiles into a 2D array (time, scenario) demand_profiles = np.column_stack([demand_profile_a, demand_profile_b]) # Create the necessary model elements # Create buses - electricity_bus = Bus("Electricity") + electricity_bus = Bus('Electricity') # Create a demand sink with scenario-dependent profiles - demand = Flow( - label="Demand", - bus=electricity_bus.label_full, - fixed_relative_profile=demand_profiles - ) - demand_sink = Sink("Demand", sink=demand) + demand = Flow(label='Demand', bus=electricity_bus.label_full, fixed_relative_profile=demand_profiles) + demand_sink = Sink('Demand', sink=demand) # Create a power source with investment option power_gen = Flow( - label="Generation", + label='Generation', bus=electricity_bus.label_full, size=InvestParameters( minimum_size=0, maximum_size=20, - specific_effects={"Costs": 100} # €/kW + specific_effects={'Costs': 100}, # €/kW ), - effects_per_flow_hour={"Costs": 20} # €/MWh + effects_per_flow_hour={'Costs': 20}, # €/MWh ) - generator = Source("Generator", source=power_gen) + generator = Source('Generator', source=power_gen) # Create a storage for electricity - storage_charge = Flow( - label="Charge", - bus=electricity_bus.label_full, - size=10 - ) - storage_discharge = Flow( - label="Discharge", - bus=electricity_bus.label_full, - size=10 - ) + storage_charge = Flow(label='Charge', bus=electricity_bus.label_full, size=10) + storage_discharge = Flow(label='Discharge', bus=electricity_bus.label_full, size=10) storage = Storage( - label="Battery", + label='Battery', charging=storage_charge, discharging=storage_discharge, capacity_in_flow_hours=InvestParameters( minimum_size=0, maximum_size=50, - specific_effects={"Costs": 50} # €/kWh + specific_effects={'Costs': 50}, # €/kWh ), eta_charge=0.95, eta_discharge=0.95, - initial_charge_state="lastValueOfSim" + initial_charge_state='lastValueOfSim', ) # Create effects and objective - cost_effect = Effect( - label="Costs", - unit="€", - description="Total costs", - is_standard=True, - is_objective=True - ) + cost_effect = Effect(label='Costs', unit='€', description='Total costs', is_standard=True, is_objective=True) # Add all elements to the flow system - flow_system.add_elements( - electricity_bus, - generator, - demand_sink, - storage, - cost_effect - ) + flow_system.add_elements(electricity_bus, generator, demand_sink, storage, cost_effect) # Return the created system and its components return { - "flow_system": flow_system, - "timesteps": timesteps, - "scenarios": scenarios, - "electricity_bus": electricity_bus, - "demand": demand, - "demand_sink": demand_sink, - "generator": generator, - "power_gen": power_gen, - "storage": storage, - "storage_charge": storage_charge, - "storage_discharge": storage_discharge, - "cost_effect": cost_effect + 'flow_system': flow_system, + 'timesteps': timesteps, + 'scenarios': scenarios, + 'electricity_bus': electricity_bus, + 'demand': demand, + 'demand_sink': demand_sink, + 'generator': generator, + 'power_gen': power_gen, + 'storage': storage, + 'storage_charge': storage_charge, + 'storage_discharge': storage_discharge, + 'cost_effect': cost_effect, } + @pytest.fixture def flow_system_complex_scenarios() -> fx.FlowSystem: """ @@ -141,8 +120,10 @@ def flow_system_complex_scenarios() -> fx.FlowSystem: """ thermal_load = np.array([30, 0, 90, 110, 110, 20, 20, 20, 20]) electrical_load = np.array([40, 40, 40, 40, 40, 40, 40, 40, 40]) - flow_system = fx.FlowSystem(pd.date_range('2020-01-01', periods=9, freq='h', name='time'), - scenarios=pd.Index(['A', 'B', 'C'], name='scenario')) + flow_system = fx.FlowSystem( + pd.date_range('2020-01-01', periods=9, freq='h', name='time'), + scenarios=pd.Index(['A', 'B', 'C'], name='scenario'), + ) # Define the components and flow_system flow_system.add_elements( fx.Effect('costs', '€', 'Kosten', is_standard=True, is_objective=True), @@ -260,10 +241,12 @@ def test_weights(flow_system_piecewise_conversion_scenarios): flow_system_piecewise_conversion_scenarios.weights = weights model = create_linopy_model(flow_system_piecewise_conversion_scenarios) np.testing.assert_allclose(model.weights.values, weights) - assert_linequal(model.objective.expression, - (model.variables['costs|total'] * weights).sum() + model.variables['Penalty|total']) + assert_linequal( + model.objective.expression, (model.variables['costs|total'] * weights).sum() + model.variables['Penalty|total'] + ) assert np.isclose(model.weights.sum().item(), 2.25) + def test_weights_io(flow_system_piecewise_conversion_scenarios): """Test that scenario weights are correctly used in the model.""" scenarios = flow_system_piecewise_conversion_scenarios.scenarios @@ -271,24 +254,29 @@ def test_weights_io(flow_system_piecewise_conversion_scenarios): flow_system_piecewise_conversion_scenarios.weights = weights model = create_linopy_model(flow_system_piecewise_conversion_scenarios) np.testing.assert_allclose(model.weights.values, weights) - assert_linequal(model.objective.expression, - (model.variables['costs|total'] * weights).sum() + model.variables['Penalty|total']) + assert_linequal( + model.objective.expression, (model.variables['costs|total'] * weights).sum() + model.variables['Penalty|total'] + ) assert np.isclose(model.weights.sum().item(), 1.0) + def test_scenario_dimensions_in_variables(flow_system_piecewise_conversion_scenarios): """Test that all time variables are correctly broadcasted to scenario dimensions.""" model = create_linopy_model(flow_system_piecewise_conversion_scenarios) for var in model.variables: - assert model.variables[var].dims in [('time', 'scenario'), ('scenario',), ()] + assert model.variables[var].dims in [('time', 'scenario'), ('scenario',), ()] + def test_full_scenario_optimization(flow_system_piecewise_conversion_scenarios): """Test a full optimization with scenarios and verify results.""" scenarios = flow_system_piecewise_conversion_scenarios.scenarios weights = np.linspace(0.5, 1, len(scenarios)) / np.sum(np.linspace(0.5, 1, len(scenarios))) flow_system_piecewise_conversion_scenarios.weights = weights - calc = create_calculation_and_solve(flow_system_piecewise_conversion_scenarios, - solver=fx.solvers.GurobiSolver(mip_gap=0.01, time_limit_seconds=60), - name='test_full_scenario') + calc = create_calculation_and_solve( + flow_system_piecewise_conversion_scenarios, + solver=fx.solvers.GurobiSolver(mip_gap=0.01, time_limit_seconds=60), + name='test_full_scenario', + ) calc.results.to_file() res = fx.results.CalculationResults.from_file('results', 'test_full_scenario') @@ -299,15 +287,18 @@ def test_full_scenario_optimization(flow_system_piecewise_conversion_scenarios): name='test_full_scenario', ) -@pytest.mark.skip(reason="This test is taking too long with highs and is too big for gurobipy free") + +@pytest.mark.skip(reason='This test is taking too long with highs and is too big for gurobipy free') def test_io_persistance(flow_system_piecewise_conversion_scenarios): """Test a full optimization with scenarios and verify results.""" scenarios = flow_system_piecewise_conversion_scenarios.scenarios weights = np.linspace(0.5, 1, len(scenarios)) / np.sum(np.linspace(0.5, 1, len(scenarios))) flow_system_piecewise_conversion_scenarios.weights = weights - calc = create_calculation_and_solve(flow_system_piecewise_conversion_scenarios, - solver=fx.solvers.HighsSolver(mip_gap=0.001, time_limit_seconds=60), - name='test_full_scenario') + calc = create_calculation_and_solve( + flow_system_piecewise_conversion_scenarios, + solver=fx.solvers.HighsSolver(mip_gap=0.001, time_limit_seconds=60), + name='test_full_scenario', + ) calc.results.to_file() res = fx.results.CalculationResults.from_file('results', 'test_full_scenario') @@ -332,13 +323,17 @@ def test_scenarios_selection(flow_system_piecewise_conversion_scenarios): np.testing.assert_allclose(flow_system.weights.values, flow_system_full.weights[0:2]) - calc = fx.FullCalculation(flow_system=flow_system, name='test_full_scenario') calc.do_modeling() calc.solve(fx.solvers.GurobiSolver(mip_gap=0.01, time_limit_seconds=60)) calc.results.to_file() - np.testing.assert_allclose(calc.results.objective, ((calc.results.solution['costs|total'] * flow_system.weights).sum() + calc.results.solution['Penalty|total']).item()) ## Acount for rounding errors + np.testing.assert_allclose( + calc.results.objective, + ( + (calc.results.solution['costs|total'] * flow_system.weights).sum() + calc.results.solution['Penalty|total'] + ).item(), + ) ## Acount for rounding errors assert calc.results.solution.indexes['scenario'].equals(flow_system_full.scenarios[0:2]) diff --git a/tests/test_storage.py b/tests/test_storage.py index 3a6b2a06c..f6b6f2079 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -40,7 +40,7 @@ def test_basic_storage(self, basic_flow_system_linopy): 'TestStorage|netto_discharge', } for var_name in expected_variables: - assert var_name in model.variables, f"Missing variable: {var_name}" + assert var_name in model.variables, f'Missing variable: {var_name}' # Check that all expected constraints exist - linopy model constraints are accessed by indexing expected_constraints = { @@ -51,27 +51,24 @@ def test_basic_storage(self, basic_flow_system_linopy): 'TestStorage|initial_charge_state', } for con_name in expected_constraints: - assert con_name in model.constraints, f"Missing constraint: {con_name}" + assert con_name in model.constraints, f'Missing constraint: {con_name}' # Check variable properties assert_var_equal( - model['TestStorage(Q_th_in)|flow_rate'], - model.add_variables(lower=0, upper=20, coords=(timesteps,)) + model['TestStorage(Q_th_in)|flow_rate'], model.add_variables(lower=0, upper=20, coords=(timesteps,)) ) assert_var_equal( - model['TestStorage(Q_th_out)|flow_rate'], - model.add_variables(lower=0, upper=20, coords=(timesteps,)) + model['TestStorage(Q_th_out)|flow_rate'], model.add_variables(lower=0, upper=20, coords=(timesteps,)) ) assert_var_equal( - model['TestStorage|charge_state'], - model.add_variables(lower=0, upper=30, coords=(timesteps_extra,)) + model['TestStorage|charge_state'], model.add_variables(lower=0, upper=30, coords=(timesteps_extra,)) ) # Check constraint formulations assert_conequal( model.constraints['TestStorage|netto_discharge'], - model.variables['TestStorage|netto_discharge'] == - model.variables['TestStorage(Q_th_out)|flow_rate'] - model.variables['TestStorage(Q_th_in)|flow_rate'] + model.variables['TestStorage|netto_discharge'] + == model.variables['TestStorage(Q_th_out)|flow_rate'] - model.variables['TestStorage(Q_th_in)|flow_rate'], ) charge_state = model.variables['TestStorage|charge_state'] @@ -80,12 +77,12 @@ def test_basic_storage(self, basic_flow_system_linopy): charge_state.isel(time=slice(1, None)) == charge_state.isel(time=slice(None, -1)) + model.variables['TestStorage(Q_th_in)|flow_rate'] * model.hours_per_step - - model.variables['TestStorage(Q_th_out)|flow_rate'] * model.hours_per_step, + - model.variables['TestStorage(Q_th_out)|flow_rate'] * model.hours_per_step, ) # Check initial charge state constraint assert_conequal( model.constraints['TestStorage|initial_charge_state'], - model.variables['TestStorage|charge_state'].isel(time=0) == 0 + model.variables['TestStorage|charge_state'].isel(time=0) == 0, ) def test_lossy_storage(self, basic_flow_system_linopy): @@ -120,7 +117,7 @@ def test_lossy_storage(self, basic_flow_system_linopy): 'TestStorage|netto_discharge', } for var_name in expected_variables: - assert var_name in model.variables, f"Missing variable: {var_name}" + assert var_name in model.variables, f'Missing variable: {var_name}' # Check that all expected constraints exist - linopy model constraints are accessed by indexing expected_constraints = { @@ -131,27 +128,24 @@ def test_lossy_storage(self, basic_flow_system_linopy): 'TestStorage|initial_charge_state', } for con_name in expected_constraints: - assert con_name in model.constraints, f"Missing constraint: {con_name}" + assert con_name in model.constraints, f'Missing constraint: {con_name}' # Check variable properties assert_var_equal( - model['TestStorage(Q_th_in)|flow_rate'], - model.add_variables(lower=0, upper=20, coords=(timesteps,)) + model['TestStorage(Q_th_in)|flow_rate'], model.add_variables(lower=0, upper=20, coords=(timesteps,)) ) assert_var_equal( - model['TestStorage(Q_th_out)|flow_rate'], - model.add_variables(lower=0, upper=20, coords=(timesteps,)) + model['TestStorage(Q_th_out)|flow_rate'], model.add_variables(lower=0, upper=20, coords=(timesteps,)) ) assert_var_equal( - model['TestStorage|charge_state'], - model.add_variables(lower=0, upper=30, coords=(timesteps_extra,)) + model['TestStorage|charge_state'], model.add_variables(lower=0, upper=30, coords=(timesteps_extra,)) ) # Check constraint formulations assert_conequal( model.constraints['TestStorage|netto_discharge'], - model.variables['TestStorage|netto_discharge'] == - model.variables['TestStorage(Q_th_out)|flow_rate'] - model.variables['TestStorage(Q_th_in)|flow_rate'] + model.variables['TestStorage|netto_discharge'] + == model.variables['TestStorage(Q_th_out)|flow_rate'] - model.variables['TestStorage(Q_th_in)|flow_rate'], ) charge_state = model.variables['TestStorage|charge_state'] @@ -166,13 +160,14 @@ def test_lossy_storage(self, basic_flow_system_linopy): model.constraints['TestStorage|charge_state'], charge_state.isel(time=slice(1, None)) == charge_state.isel(time=slice(None, -1)) * (1 - rel_loss) ** hours_per_step - + charge_rate * eff_charge * hours_per_step - discharge_rate * eff_discharge * hours_per_step, + + charge_rate * eff_charge * hours_per_step + - discharge_rate * eff_discharge * hours_per_step, ) # Check initial charge state constraint assert_conequal( model.constraints['TestStorage|initial_charge_state'], - model.variables['TestStorage|charge_state'].isel(time=0) == 0 + model.variables['TestStorage|charge_state'].isel(time=0) == 0, ) def test_charge_state_bounds(self, basic_flow_system_linopy): @@ -189,7 +184,7 @@ def test_charge_state_bounds(self, basic_flow_system_linopy): capacity_in_flow_hours=30, # 30 kWh storage capacity initial_charge_state=3, prevent_simultaneous_charge_and_discharge=True, - relative_maximum_charge_state=np.array([0.14, 0.22, 0.3 , 0.38, 0.46, 0.54, 0.62, 0.7 , 0.78, 0.86]), + relative_maximum_charge_state=np.array([0.14, 0.22, 0.3, 0.38, 0.46, 0.54, 0.62, 0.7, 0.78, 0.86]), relative_minimum_charge_state=np.array([0.07, 0.11, 0.15, 0.19, 0.23, 0.27, 0.31, 0.35, 0.39, 0.43]), ) @@ -206,7 +201,7 @@ def test_charge_state_bounds(self, basic_flow_system_linopy): 'TestStorage|netto_discharge', } for var_name in expected_variables: - assert var_name in model.variables, f"Missing variable: {var_name}" + assert var_name in model.variables, f'Missing variable: {var_name}' # Check that all expected constraints exist - linopy model constraints are accessed by indexing expected_constraints = { @@ -217,29 +212,29 @@ def test_charge_state_bounds(self, basic_flow_system_linopy): 'TestStorage|initial_charge_state', } for con_name in expected_constraints: - assert con_name in model.constraints, f"Missing constraint: {con_name}" + assert con_name in model.constraints, f'Missing constraint: {con_name}' # Check variable properties assert_var_equal( - model['TestStorage(Q_th_in)|flow_rate'], - model.add_variables(lower=0, upper=20, coords=(timesteps,)) + model['TestStorage(Q_th_in)|flow_rate'], model.add_variables(lower=0, upper=20, coords=(timesteps,)) ) assert_var_equal( - model['TestStorage(Q_th_out)|flow_rate'], - model.add_variables(lower=0, upper=20, coords=(timesteps,)) + model['TestStorage(Q_th_out)|flow_rate'], model.add_variables(lower=0, upper=20, coords=(timesteps,)) ) assert_var_equal( model['TestStorage|charge_state'], - model.add_variables(lower=np.array([0.07, 0.11, 0.15, 0.19, 0.23, 0.27, 0.31, 0.35, 0.39, 0.43, 0.43]) * 30, - upper=np.array([0.14, 0.22, 0.3 , 0.38, 0.46, 0.54, 0.62, 0.7 , 0.78, 0.86, 0.86]) * 30, - coords=(timesteps_extra,)) + model.add_variables( + lower=np.array([0.07, 0.11, 0.15, 0.19, 0.23, 0.27, 0.31, 0.35, 0.39, 0.43, 0.43]) * 30, + upper=np.array([0.14, 0.22, 0.3, 0.38, 0.46, 0.54, 0.62, 0.7, 0.78, 0.86, 0.86]) * 30, + coords=(timesteps_extra,), + ), ) # Check constraint formulations assert_conequal( model.constraints['TestStorage|netto_discharge'], - model.variables['TestStorage|netto_discharge'] == - model.variables['TestStorage(Q_th_out)|flow_rate'] - model.variables['TestStorage(Q_th_in)|flow_rate'] + model.variables['TestStorage|netto_discharge'] + == model.variables['TestStorage(Q_th_out)|flow_rate'] - model.variables['TestStorage(Q_th_in)|flow_rate'], ) charge_state = model.variables['TestStorage|charge_state'] @@ -248,12 +243,12 @@ def test_charge_state_bounds(self, basic_flow_system_linopy): charge_state.isel(time=slice(1, None)) == charge_state.isel(time=slice(None, -1)) + model.variables['TestStorage(Q_th_in)|flow_rate'] * model.hours_per_step - - model.variables['TestStorage(Q_th_out)|flow_rate'] * model.hours_per_step, + - model.variables['TestStorage(Q_th_out)|flow_rate'] * model.hours_per_step, ) # Check initial charge state constraint assert_conequal( model.constraints['TestStorage|initial_charge_state'], - model.variables['TestStorage|charge_state'].isel(time=0) == 3 + model.variables['TestStorage|charge_state'].isel(time=0) == 3, ) def test_storage_with_investment(self, basic_flow_system_linopy): @@ -266,11 +261,7 @@ def test_storage_with_investment(self, basic_flow_system_linopy): charging=fx.Flow('Q_th_in', bus='Fernwärme', size=20), discharging=fx.Flow('Q_th_out', bus='Fernwärme', size=20), capacity_in_flow_hours=fx.InvestParameters( - fix_effects=100, - specific_effects=10, - minimum_size=20, - maximum_size=100, - optional=True + fix_effects=100, specific_effects=10, minimum_size=20, maximum_size=100, optional=True ), initial_charge_state=0, eta_charge=0.9, @@ -288,25 +279,23 @@ def test_storage_with_investment(self, basic_flow_system_linopy): 'InvestStorage|size', 'InvestStorage|is_invested', }: - assert var_name in model.variables, f"Missing investment variable: {var_name}" + assert var_name in model.variables, f'Missing investment variable: {var_name}' # Check investment constraints exist for con_name in {'InvestStorage|size|ub', 'InvestStorage|size|lb'}: - assert con_name in model.constraints, f"Missing investment constraint: {con_name}" + assert con_name in model.constraints, f'Missing investment constraint: {con_name}' # Check variable properties - assert_var_equal( - model['InvestStorage|size'], - model.add_variables(lower=0, upper=100) + assert_var_equal(model['InvestStorage|size'], model.add_variables(lower=0, upper=100)) + assert_var_equal(model['InvestStorage|is_invested'], model.add_variables(binary=True)) + assert_conequal( + model.constraints['InvestStorage|size|ub'], + model.variables['InvestStorage|size'] <= model.variables['InvestStorage|is_invested'] * 100, ) - assert_var_equal( - model['InvestStorage|is_invested'], - model.add_variables(binary=True) + assert_conequal( + model.constraints['InvestStorage|size|lb'], + model.variables['InvestStorage|size'] >= model.variables['InvestStorage|is_invested'] * 20, ) - assert_conequal(model.constraints['InvestStorage|size|ub'], - model.variables['InvestStorage|size'] <= model.variables['InvestStorage|is_invested'] * 100) - assert_conequal(model.constraints['InvestStorage|size|lb'], - model.variables['InvestStorage|size'] >= model.variables['InvestStorage|is_invested'] * 20) def test_storage_with_final_state_constraints(self, basic_flow_system_linopy): """Test storage with final state constraints.""" @@ -336,7 +325,7 @@ def test_storage_with_final_state_constraints(self, basic_flow_system_linopy): } for con_name in expected_constraints: - assert con_name in model.constraints, f"Missing final state constraint: {con_name}" + assert con_name in model.constraints, f'Missing final state constraint: {con_name}' assert_conequal( model.constraints['FinalStateStorage|initial_charge_state'], @@ -346,11 +335,11 @@ def test_storage_with_final_state_constraints(self, basic_flow_system_linopy): # Check final state constraint formulations assert_conequal( model.constraints['FinalStateStorage|final_charge_min'], - model.variables['FinalStateStorage|charge_state'].isel(time=-1) >= 15 + model.variables['FinalStateStorage|charge_state'].isel(time=-1) >= 15, ) assert_conequal( model.constraints['FinalStateStorage|final_charge_max'], - model.variables['FinalStateStorage|charge_state'].isel(time=-1) <= 25 + model.variables['FinalStateStorage|charge_state'].isel(time=-1) <= 25, ) def test_storage_cyclic_initialization(self, basic_flow_system_linopy): @@ -373,14 +362,13 @@ def test_storage_cyclic_initialization(self, basic_flow_system_linopy): model = create_linopy_model(flow_system) # Check cyclic constraint exists - assert 'CyclicStorage|initial_charge_state' in model.constraints, \ - "Missing cyclic initialization constraint" + assert 'CyclicStorage|initial_charge_state' in model.constraints, 'Missing cyclic initialization constraint' # Check cyclic constraint formulation assert_conequal( model.constraints['CyclicStorage|initial_charge_state'], - model.variables['CyclicStorage|charge_state'].isel(time=0) == - model.variables['CyclicStorage|charge_state'].isel(time=-1) + model.variables['CyclicStorage|charge_state'].isel(time=0) + == model.variables['CyclicStorage|charge_state'].isel(time=-1), ) @pytest.mark.parametrize( @@ -420,8 +408,11 @@ def test_simultaneous_charge_discharge(self, basic_flow_system_linopy, prevent_s constraint_name = 'SimultaneousStorage|prevent_simultaneous_use' assert constraint_name in model.constraints, 'Missing constraint to prevent simultaneous operation' - assert_conequal(model.constraints['SimultaneousStorage|prevent_simultaneous_use'], - model.variables['SimultaneousStorage(Q_th_in)|on'] + model.variables['SimultaneousStorage(Q_th_out)|on'] <= 1) + assert_conequal( + model.constraints['SimultaneousStorage|prevent_simultaneous_use'], + model.variables['SimultaneousStorage(Q_th_in)|on'] + model.variables['SimultaneousStorage(Q_th_out)|on'] + <= 1, + ) @pytest.mark.parametrize( 'optional,minimum_size,expected_vars,expected_constraints', @@ -432,7 +423,9 @@ def test_simultaneous_charge_discharge(self, basic_flow_system_linopy, prevent_s (False, 20, set(), set()), ], ) - def test_investment_parameters(self, basic_flow_system_linopy, optional, minimum_size, expected_vars, expected_constraints): + def test_investment_parameters( + self, basic_flow_system_linopy, optional, minimum_size, expected_vars, expected_constraints + ): """Test different investment parameter combinations.""" flow_system = basic_flow_system_linopy @@ -463,12 +456,12 @@ def test_investment_parameters(self, basic_flow_system_linopy, optional, minimum # Check that expected variables exist for var_name in expected_vars: if optional: - assert var_name in model.variables, f"Expected variable {var_name} not found" + assert var_name in model.variables, f'Expected variable {var_name} not found' # Check that expected constraints exist for constraint_name in expected_constraints: if optional: - assert constraint_name in model.constraints, f"Expected constraint {constraint_name} not found" + assert constraint_name in model.constraints, f'Expected constraint {constraint_name} not found' # If optional is False, is_invested should be fixed to 1 if not optional: @@ -476,5 +469,6 @@ def test_investment_parameters(self, basic_flow_system_linopy, optional, minimum if 'InvestStorage|is_invested' in model.variables: var = model.variables['InvestStorage|is_invested'] # Check if the lower and upper bounds are both 1 - assert var.upper == 1 and var.lower == 1, \ - "is_invested variable should be fixed to 1 when optional=False" + assert var.upper == 1 and var.lower == 1, ( + 'is_invested variable should be fixed to 1 when optional=False' + ) diff --git a/tests/todos.txt b/tests/todos.txt index b82e77f34..d4628c259 100644 --- a/tests/todos.txt +++ b/tests/todos.txt @@ -1,5 +1,5 @@ # testing of # abschnittsweise linear testen - # Komponenten mit offenen Flows + # Komponenten mit offenen Flows # Binärvariablen ohne max-Wert-Vorgabe des Flows (Binärungenauigkeitsproblem) - # Medien-zulässigkeit \ No newline at end of file + # Medien-zulässigkeit From 1716607c88323445fe5c56053a3377d28288caf4 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 23 Jul 2025 16:08:24 +0200 Subject: [PATCH 261/336] Fix link in README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 68966910b..8406f971f 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ For detailed licensing and installation instructions, refer to the respective so --- ## 🛠 Development Setup -Look into our docs for [development setup](https://flixopt.github.io/flixopt/latest/contribute/#development-setup) +Look into our docs for [development setup](https://flixopt.github.io/flixopt/latest/faq/contribute/) --- From b4a92361ca5777e126c638b1d6c17e752e1a3889 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 23 Jul 2025 16:48:13 +0200 Subject: [PATCH 262/336] Update Effect name in tests to be 'costs' instead of 'Costs' Everywhere Simplify testing by creating a Element Library --- tests/conftest.py | 742 +++++++++++++++---------- tests/test_effect.py | 16 +- tests/test_effects_shares_summation.py | 16 +- tests/test_flow.py | 46 +- tests/test_integration.py | 6 +- tests/test_linear_converter.py | 16 +- tests/test_scenarios.py | 8 +- 7 files changed, 487 insertions(+), 363 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index d12ca9eca..3d6623eb7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,7 +5,7 @@ """ import os -from typing import Iterable +from typing import Dict, Iterable import linopy.testing import numpy as np @@ -16,6 +16,10 @@ import flixopt as fx from flixopt.structure import FlowSystemModel +# ============================================================================ +# SOLVER FIXTURES +# ============================================================================ + @pytest.fixture() def highs_solver(): @@ -32,20 +36,320 @@ def solver_fixture(request): return request.getfixturevalue(request.param.__name__) -# Custom assertion function -def assert_almost_equal_numeric( - actual, desired, err_msg, relative_error_range_in_percent=0.011, absolute_tolerance=1e-7 -): - """ - Custom assertion function for comparing numeric values with relative and absolute tolerances - """ - relative_tol = relative_error_range_in_percent / 100 +# ============================================================================ +# HIERARCHICAL ELEMENT LIBRARY +# ============================================================================ - if isinstance(desired, (int, float)): - delta = abs(relative_tol * desired) if desired != 0 else absolute_tolerance - assert np.isclose(actual, desired, atol=delta), err_msg - else: - np.testing.assert_allclose(actual, desired, rtol=relative_tol, atol=absolute_tolerance, err_msg=err_msg) + +class Buses: + """Standard buses used across flow systems""" + + @staticmethod + def electricity(): + return fx.Bus('Strom') + + @staticmethod + def heat(): + return fx.Bus('Fernwärme') + + @staticmethod + def gas(): + return fx.Bus('Gas') + + @staticmethod + def coal(): + return fx.Bus('Kohle') + + @staticmethod + def defaults(): + """Get all standard buses at once""" + return [Buses.electricity(), Buses.heat(), Buses.gas()] + + +class Effects: + """Standard effects used across flow systems""" + + @staticmethod + def costs(): + return fx.Effect('costs', '€', 'Kosten', is_standard=True, is_objective=True) + + @staticmethod + def co2(): + return fx.Effect('CO2', 'kg', 'CO2_e-Emissionen') + + @staticmethod + def co2_with_costs_share(): + return fx.Effect( + 'CO2', + 'kg', + 'CO2_e-Emissionen', + specific_share_to_other_effects_operation={'costs': 0.2}, + ) + + @staticmethod + def primary_energy(): + return fx.Effect('PE', 'kWh_PE', 'Primärenergie') + + +class Converters: + """Energy conversion components""" + + class Boilers: + @staticmethod + def simple(): + """Simple boiler from simple_flow_system""" + return fx.linear_converters.Boiler( + 'Boiler', + eta=0.5, + Q_th=fx.Flow( + 'Q_th', + bus='Fernwärme', + size=50, + relative_minimum=5 / 50, + relative_maximum=1, + on_off_parameters=fx.OnOffParameters(), + ), + Q_fu=fx.Flow('Q_fu', bus='Gas'), + ) + + @staticmethod + def complex(): + """Complex boiler with investment parameters from flow_system_complex""" + return fx.linear_converters.Boiler( + 'Kessel', + eta=0.5, + on_off_parameters=fx.OnOffParameters(effects_per_running_hour={'costs': 0, 'CO2': 1000}), + Q_th=fx.Flow( + 'Q_th', + bus='Fernwärme', + load_factor_max=1.0, + load_factor_min=0.1, + relative_minimum=5 / 50, + relative_maximum=1, + previous_flow_rate=50, + size=fx.InvestParameters( + fix_effects=1000, fixed_size=50, optional=False, specific_effects={'costs': 10, 'PE': 2} + ), + on_off_parameters=fx.OnOffParameters( + on_hours_total_min=0, + on_hours_total_max=1000, + consecutive_on_hours_max=10, + consecutive_on_hours_min=1, + consecutive_off_hours_max=10, + effects_per_switch_on=0.01, + switch_on_total_max=1000, + ), + flow_hours_total_max=1e6, + ), + Q_fu=fx.Flow('Q_fu', bus='Gas', size=200, relative_minimum=0, relative_maximum=1), + ) + + class CHPs: + @staticmethod + def simple(): + """Simple CHP from simple_flow_system""" + return fx.linear_converters.CHP( + 'CHP_unit', + eta_th=0.5, + eta_el=0.4, + P_el=fx.Flow( + 'P_el', bus='Strom', size=60, relative_minimum=5 / 60, on_off_parameters=fx.OnOffParameters() + ), + Q_th=fx.Flow('Q_th', bus='Fernwärme'), + Q_fu=fx.Flow('Q_fu', bus='Gas'), + ) + + @staticmethod + def base(): + """CHP from flow_system_base""" + return fx.linear_converters.CHP( + 'KWK', + eta_th=0.5, + eta_el=0.4, + on_off_parameters=fx.OnOffParameters(effects_per_switch_on=0.01), + P_el=fx.Flow('P_el', bus='Strom', size=60, relative_minimum=5 / 60, previous_flow_rate=10), + Q_th=fx.Flow('Q_th', bus='Fernwärme', size=1e3), + Q_fu=fx.Flow('Q_fu', bus='Gas', size=1e3), + ) + + class LinearConverters: + @staticmethod + def piecewise(): + """Piecewise converter from flow_system_piecewise_conversion""" + return fx.LinearConverter( + 'KWK', + inputs=[fx.Flow('Q_fu', bus='Gas')], + outputs=[ + fx.Flow('P_el', bus='Strom', size=60, relative_maximum=55, previous_flow_rate=10), + fx.Flow('Q_th', bus='Fernwärme'), + ], + piecewise_conversion=fx.PiecewiseConversion( + { + 'P_el': fx.Piecewise([fx.Piece(5, 30), fx.Piece(40, 60)]), + 'Q_th': fx.Piecewise([fx.Piece(6, 35), fx.Piece(45, 100)]), + 'Q_fu': fx.Piecewise([fx.Piece(12, 70), fx.Piece(90, 200)]), + } + ), + on_off_parameters=fx.OnOffParameters(effects_per_switch_on=0.01), + ) + + @staticmethod + def segments(timesteps_length): + """Segments converter with time-varying piecewise conversion""" + return fx.LinearConverter( + 'KWK', + inputs=[fx.Flow('Q_fu', bus='Gas')], + outputs=[ + fx.Flow('P_el', bus='Strom', size=60, relative_maximum=55, previous_flow_rate=10), + fx.Flow('Q_th', bus='Fernwärme'), + ], + piecewise_conversion=fx.PiecewiseConversion( + { + 'P_el': fx.Piecewise( + [ + fx.Piece(np.linspace(5, 6, timesteps_length), 30), + fx.Piece(40, np.linspace(60, 70, timesteps_length)), + ] + ), + 'Q_th': fx.Piecewise([fx.Piece(6, 35), fx.Piece(45, 100)]), + 'Q_fu': fx.Piecewise([fx.Piece(12, 70), fx.Piece(90, 200)]), + } + ), + on_off_parameters=fx.OnOffParameters(effects_per_switch_on=0.01), + ) + + +class Storage: + """Energy storage components""" + + @staticmethod + def simple(): + """Simple storage from simple_flow_system""" + return fx.Storage( + 'Speicher', + charging=fx.Flow('Q_th_load', bus='Fernwärme', size=1e4), + discharging=fx.Flow('Q_th_unload', bus='Fernwärme', size=1e4), + capacity_in_flow_hours=fx.InvestParameters(fix_effects=20, fixed_size=30, optional=False), + initial_charge_state=0, + relative_maximum_charge_state=1 / 100 * np.array([80.0, 70.0, 80.0, 80, 80, 80, 80, 80, 80]), + relative_maximum_final_charge_state=0.8, + eta_charge=0.9, + eta_discharge=1, + relative_loss_per_hour=0.08, + prevent_simultaneous_charge_and_discharge=True, + ) + + @staticmethod + def complex(): + """Complex storage with piecewise investment from flow_system_complex""" + invest_speicher = fx.InvestParameters( + fix_effects=0, + piecewise_effects=fx.PiecewiseEffects( + piecewise_origin=fx.Piecewise([fx.Piece(5, 25), fx.Piece(25, 100)]), + piecewise_shares={ + 'costs': fx.Piecewise([fx.Piece(50, 250), fx.Piece(250, 800)]), + 'PE': fx.Piecewise([fx.Piece(5, 25), fx.Piece(25, 100)]), + }, + ), + optional=False, + specific_effects={'costs': 0.01, 'CO2': 0.01}, + minimum_size=0, + maximum_size=1000, + ) + return fx.Storage( + 'Speicher', + charging=fx.Flow('Q_th_load', bus='Fernwärme', size=1e4), + discharging=fx.Flow('Q_th_unload', bus='Fernwärme', size=1e4), + capacity_in_flow_hours=invest_speicher, + initial_charge_state=0, + maximal_final_charge_state=10, + eta_charge=0.9, + eta_discharge=1, + relative_loss_per_hour=0.08, + prevent_simultaneous_charge_and_discharge=True, + ) + + +class LoadProfiles: + """Standard load and price profiles""" + + @staticmethod + def thermal_simple(): + return np.array([30.0, 0.0, 90.0, 110, 110, 20, 20, 20, 20]) + + @staticmethod + def thermal_complex(): + return np.array([30, 0, 90, 110, 110, 20, 20, 20, 20]) + + @staticmethod + def electrical_simple(): + return 1 / 1000 * np.array([80.0, 80.0, 80.0, 80, 80, 80, 80, 80, 80]) + + @staticmethod + def electrical_scenario(): + return np.array([0.08, 0.1, 0.15]) + + @staticmethod + def electrical_complex(): + return np.array([40, 40, 40, 40, 40, 40, 40, 40, 40]) + + @staticmethod + def random_thermal(length=10, seed=42): + np.random.seed(seed) + return np.array([np.random.random() for _ in range(length)]) * 180 + + @staticmethod + def random_electrical(length=10, seed=42): + np.random.seed(seed) + return (np.array([np.random.random() for _ in range(length)]) + 0.5) / 1.5 * 50 + + +class Sinks: + """Energy sinks (loads)""" + + @staticmethod + def heat_load(thermal_profile): + """Create thermal heat load sink""" + return fx.Sink( + 'Wärmelast', sink=fx.Flow('Q_th_Last', bus='Fernwärme', size=1, fixed_relative_profile=thermal_profile) + ) + + @staticmethod + def electricity_feed_in(electrical_price_profile): + """Create electricity feed-in sink""" + return fx.Sink( + 'Einspeisung', sink=fx.Flow('P_el', bus='Strom', effects_per_flow_hour=-1 * electrical_price_profile) + ) + + @staticmethod + def electricity_load(electrical_profile): + """Create electrical load sink (for flow_system_long)""" + return fx.Sink( + 'Stromlast', sink=fx.Flow('P_el_Last', bus='Strom', size=1, fixed_relative_profile=electrical_profile) + ) + + +class Sources: + """Energy sources""" + + @staticmethod + def gas_with_costs_and_co2(): + """Standard gas tariff with CO2 emissions""" + source = Sources.gas_with_costs() + source.source.effects_per_flow_hour = {'costs': 0.04, 'CO2': 0.3} + return source + + @staticmethod + def gas_with_costs(): + """Simple gas tariff without CO2""" + return fx.Source( + 'Gastarif', source=fx.Flow(label='Q_Gas', bus='Gas', size=1000, effects_per_flow_hour={'costs': 0.04}) + ) + + +# ============================================================================ +# RECREATED FIXTURES USING HIERARCHICAL LIBRARY +# ============================================================================ @pytest.fixture @@ -53,72 +357,26 @@ def simple_flow_system() -> fx.FlowSystem: """ Create a simple energy system for testing """ - base_thermal_load = np.array([30.0, 0.0, 90.0, 110, 110, 20, 20, 20, 20]) - base_electrical_price = 1 / 1000 * np.array([80.0, 80.0, 80.0, 80, 80, 80, 80, 80, 80]) + base_thermal_load = LoadProfiles.thermal_simple() + base_electrical_price = LoadProfiles.electrical_simple() base_timesteps = pd.date_range('2020-01-01', periods=9, freq='h', name='time') + # Define effects - costs = fx.Effect('costs', '€', 'Kosten', is_standard=True, is_objective=True) - co2 = fx.Effect( - 'CO2', - 'kg', - 'CO2_e-Emissionen', - specific_share_to_other_effects_operation={costs.label: 0.2}, - maximum_operation_per_hour=1000, - ) + costs = Effects.costs() + co2 = Effects.co2_with_costs_share() + co2.maximum_operation_per_hour = 1000 # Create components - boiler = fx.linear_converters.Boiler( - 'Boiler', - eta=0.5, - Q_th=fx.Flow( - 'Q_th', - bus='Fernwärme', - size=50, - relative_minimum=5 / 50, - relative_maximum=1, - on_off_parameters=fx.OnOffParameters(), - ), - Q_fu=fx.Flow('Q_fu', bus='Gas'), - ) - - chp = fx.linear_converters.CHP( - 'CHP_unit', - eta_th=0.5, - eta_el=0.4, - P_el=fx.Flow('P_el', bus='Strom', size=60, relative_minimum=5 / 60, on_off_parameters=fx.OnOffParameters()), - Q_th=fx.Flow('Q_th', bus='Fernwärme'), - Q_fu=fx.Flow('Q_fu', bus='Gas'), - ) - - storage = fx.Storage( - 'Speicher', - charging=fx.Flow('Q_th_load', bus='Fernwärme', size=1e4), - discharging=fx.Flow('Q_th_unload', bus='Fernwärme', size=1e4), - capacity_in_flow_hours=fx.InvestParameters(fix_effects=20, fixed_size=30, optional=False), - initial_charge_state=0, - relative_maximum_charge_state=1 / 100 * np.array([80.0, 70.0, 80.0, 80, 80, 80, 80, 80, 80]), - relative_maximum_final_charge_state=0.8, - eta_charge=0.9, - eta_discharge=1, - relative_loss_per_hour=0.08, - prevent_simultaneous_charge_and_discharge=True, - ) - - heat_load = fx.Sink( - 'Wärmelast', sink=fx.Flow('Q_th_Last', bus='Fernwärme', size=1, fixed_relative_profile=base_thermal_load) - ) - - gas_tariff = fx.Source( - 'Gastarif', source=fx.Flow('Q_Gas', bus='Gas', size=1000, effects_per_flow_hour={'costs': 0.04, 'CO2': 0.3}) - ) - - electricity_feed_in = fx.Sink( - 'Einspeisung', sink=fx.Flow('P_el', bus='Strom', effects_per_flow_hour=-1 * base_electrical_price) - ) + boiler = Converters.Boilers.simple() + chp = Converters.CHPs.simple() + storage = Storage.simple() + heat_load = Sinks.heat_load(base_thermal_load) + gas_tariff = Sources.gas_with_costs_and_co2() + electricity_feed_in = Sinks.electricity_feed_in(base_electrical_price) # Create flow system flow_system = fx.FlowSystem(base_timesteps) - flow_system.add_elements(fx.Bus('Strom'), fx.Bus('Fernwärme'), fx.Bus('Gas')) + flow_system.add_elements(*Buses.defaults()) flow_system.add_elements(storage, costs, co2, boiler, heat_load, gas_tariff, electricity_feed_in, chp) return flow_system @@ -129,74 +387,28 @@ def simple_flow_system_scenarios() -> fx.FlowSystem: """ Create a simple energy system for testing """ - base_thermal_load = np.array([30.0, 0.0, 90.0, 110, 110, 20, 20, 20, 20]) - base_electrical_price = np.array([0.08, 0.1, 0.15]) + base_thermal_load = LoadProfiles.thermal_simple() + base_electrical_price = LoadProfiles.electrical_scenario() base_timesteps = pd.date_range('2020-01-01', periods=9, freq='h', name='time') + # Define effects - costs = fx.Effect('costs', '€', 'Kosten', is_standard=True, is_objective=True) - co2 = fx.Effect( - 'CO2', - 'kg', - 'CO2_e-Emissionen', - specific_share_to_other_effects_operation={costs.label: 0.2}, - maximum_operation_per_hour=1000, - ) + costs = Effects.costs() + co2 = Effects.co2_with_costs_share() + co2.maximum_operation_per_hour = 1000 # Create components - boiler = fx.linear_converters.Boiler( - 'Boiler', - eta=0.5, - Q_th=fx.Flow( - 'Q_th', - bus='Fernwärme', - size=50, - relative_minimum=5 / 50, - relative_maximum=1, - on_off_parameters=fx.OnOffParameters(), - ), - Q_fu=fx.Flow('Q_fu', bus='Gas'), - ) - - chp = fx.linear_converters.CHP( - 'CHP_unit', - eta_th=0.5, - eta_el=0.4, - P_el=fx.Flow('P_el', bus='Strom', size=60, relative_minimum=5 / 60, on_off_parameters=fx.OnOffParameters()), - Q_th=fx.Flow('Q_th', bus='Fernwärme'), - Q_fu=fx.Flow('Q_fu', bus='Gas'), - ) - - storage = fx.Storage( - 'Speicher', - charging=fx.Flow('Q_th_load', bus='Fernwärme', size=1e4), - discharging=fx.Flow('Q_th_unload', bus='Fernwärme', size=1e4), - capacity_in_flow_hours=fx.InvestParameters(fix_effects=20, fixed_size=30, optional=False), - initial_charge_state=0, - relative_maximum_charge_state=1 / 100 * np.array([80.0, 70.0, 80.0, 80, 80, 80, 80, 80, 80]), - relative_maximum_final_charge_state=0.8, - eta_charge=0.9, - eta_discharge=1, - relative_loss_per_hour=0.08, - prevent_simultaneous_charge_and_discharge=True, - ) - - heat_load = fx.Sink( - 'Wärmelast', sink=fx.Flow('Q_th_Last', bus='Fernwärme', size=1, fixed_relative_profile=base_thermal_load) - ) - - gas_tariff = fx.Source( - 'Gastarif', source=fx.Flow('Q_Gas', bus='Gas', size=1000, effects_per_flow_hour={'costs': 0.04, 'CO2': 0.3}) - ) - - electricity_feed_in = fx.Sink( - 'Einspeisung', sink=fx.Flow('P_el', bus='Strom', effects_per_flow_hour=-1 * base_electrical_price) - ) + boiler = Converters.Boilers.simple() + chp = Converters.CHPs.simple() + storage = Storage.simple() + heat_load = Sinks.heat_load(base_thermal_load) + gas_tariff = Sources.gas_with_costs_and_co2() + electricity_feed_in = Sinks.electricity_feed_in(base_electrical_price) # Create flow system flow_system = fx.FlowSystem( base_timesteps, scenarios=pd.Index(['A', 'B', 'C']), weights=np.array([0.5, 0.25, 0.25]) ) - flow_system.add_elements(fx.Bus('Strom'), fx.Bus('Fernwärme'), fx.Bus('Gas')) + flow_system.add_elements(*Buses.defaults()) flow_system.add_elements(storage, costs, co2, boiler, heat_load, gas_tariff, electricity_feed_in, chp) return flow_system @@ -206,18 +418,17 @@ def simple_flow_system_scenarios() -> fx.FlowSystem: def basic_flow_system() -> fx.FlowSystem: """Create basic elements for component testing""" flow_system = fx.FlowSystem(pd.date_range('2020-01-01', periods=10, freq='h', name='time')) - thermal_load = np.array([np.random.random() for _ in range(10)]) * 180 - p_el = (np.array([np.random.random() for _ in range(10)]) + 0.5) / 1.5 * 50 - flow_system.add_elements( - fx.Bus('Strom'), - fx.Bus('Fernwärme'), - fx.Bus('Gas'), - fx.Effect('Costs', '€', 'Kosten', is_standard=True, is_objective=True), - fx.Sink('Wärmelast', sink=fx.Flow('Q_th_Last', 'Fernwärme', size=1, fixed_relative_profile=thermal_load)), - fx.Source('Gastarif', source=fx.Flow('Q_Gas', 'Gas', size=1000, effects_per_flow_hour=0.04)), - fx.Sink('Einspeisung', sink=fx.Flow('P_el', 'Strom', effects_per_flow_hour=-1 * p_el)), - ) + thermal_load = LoadProfiles.random_thermal(10) + p_el = LoadProfiles.random_electrical(10) + + costs = Effects.costs() + heat_load = Sinks.heat_load(thermal_load) + gas_source = Sources.gas_with_costs() + electricity_sink = Sinks.electricity_feed_in(p_el) + + flow_system.add_elements(*Buses.defaults()) + flow_system.add_elements(costs, heat_load, gas_source, electricity_sink) return flow_system @@ -227,79 +438,26 @@ def flow_system_complex() -> fx.FlowSystem: """ Helper method to create a base model with configurable parameters """ - thermal_load = np.array([30, 0, 90, 110, 110, 20, 20, 20, 20]) - electrical_load = np.array([40, 40, 40, 40, 40, 40, 40, 40, 40]) + thermal_load = LoadProfiles.thermal_complex() + electrical_load = LoadProfiles.electrical_complex() flow_system = fx.FlowSystem(pd.date_range('2020-01-01', periods=9, freq='h', name='time')) + # Define the components and flow_system - flow_system.add_elements( - fx.Effect('costs', '€', 'Kosten', is_standard=True, is_objective=True), - fx.Effect('CO2', 'kg', 'CO2_e-Emissionen', specific_share_to_other_effects_operation={'costs': 0.2}), - fx.Effect('PE', 'kWh_PE', 'Primärenergie', maximum_total=3.5e3), - fx.Bus('Strom'), - fx.Bus('Fernwärme'), - fx.Bus('Gas'), - fx.Sink('Wärmelast', sink=fx.Flow('Q_th_Last', 'Fernwärme', size=1, fixed_relative_profile=thermal_load)), - fx.Source( - 'Gastarif', source=fx.Flow('Q_Gas', 'Gas', size=1000, effects_per_flow_hour={'costs': 0.04, 'CO2': 0.3}) - ), - fx.Sink('Einspeisung', sink=fx.Flow('P_el', 'Strom', effects_per_flow_hour=-1 * electrical_load)), - ) + costs = Effects.costs() + co2 = Effects.co2() + co2.specific_share_to_other_effects_operation = {'costs': 0.2} + pe = Effects.primary_energy() + pe.maximum_total = 3.5e3 - boiler = fx.linear_converters.Boiler( - 'Kessel', - eta=0.5, - on_off_parameters=fx.OnOffParameters(effects_per_running_hour={'costs': 0, 'CO2': 1000}), - Q_th=fx.Flow( - 'Q_th', - bus='Fernwärme', - load_factor_max=1.0, - load_factor_min=0.1, - relative_minimum=5 / 50, - relative_maximum=1, - previous_flow_rate=50, - size=fx.InvestParameters( - fix_effects=1000, fixed_size=50, optional=False, specific_effects={'costs': 10, 'PE': 2} - ), - on_off_parameters=fx.OnOffParameters( - on_hours_total_min=0, - on_hours_total_max=1000, - consecutive_on_hours_max=10, - consecutive_on_hours_min=1, - consecutive_off_hours_max=10, - effects_per_switch_on=0.01, - switch_on_total_max=1000, - ), - flow_hours_total_max=1e6, - ), - Q_fu=fx.Flow('Q_fu', bus='Gas', size=200, relative_minimum=0, relative_maximum=1), - ) + heat_load = Sinks.heat_load(thermal_load) + gas_tariff = Sources.gas_with_costs_and_co2() + electricity_feed_in = Sinks.electricity_feed_in(electrical_load) - invest_speicher = fx.InvestParameters( - fix_effects=0, - piecewise_effects=fx.PiecewiseEffects( - piecewise_origin=fx.Piecewise([fx.Piece(5, 25), fx.Piece(25, 100)]), - piecewise_shares={ - 'costs': fx.Piecewise([fx.Piece(50, 250), fx.Piece(250, 800)]), - 'PE': fx.Piecewise([fx.Piece(5, 25), fx.Piece(25, 100)]), - }, - ), - optional=False, - specific_effects={'costs': 0.01, 'CO2': 0.01}, - minimum_size=0, - maximum_size=1000, - ) - speicher = fx.Storage( - 'Speicher', - charging=fx.Flow('Q_th_load', bus='Fernwärme', size=1e4), - discharging=fx.Flow('Q_th_unload', bus='Fernwärme', size=1e4), - capacity_in_flow_hours=invest_speicher, - initial_charge_state=0, - maximal_final_charge_state=10, - eta_charge=0.9, - eta_discharge=1, - relative_loss_per_hour=0.08, - prevent_simultaneous_charge_and_discharge=True, - ) + flow_system.add_elements(*Buses.defaults()) + flow_system.add_elements(costs, co2, pe, heat_load, gas_tariff, electricity_feed_in) + + boiler = Converters.Boilers.complex() + speicher = Storage.complex() flow_system.add_elements(boiler, speicher) @@ -312,45 +470,16 @@ def flow_system_base(flow_system_complex) -> fx.FlowSystem: Helper method to create a base model with configurable parameters """ flow_system = flow_system_complex - - flow_system.add_elements( - fx.linear_converters.CHP( - 'KWK', - eta_th=0.5, - eta_el=0.4, - on_off_parameters=fx.OnOffParameters(effects_per_switch_on=0.01), - P_el=fx.Flow('P_el', bus='Strom', size=60, relative_minimum=5 / 60, previous_flow_rate=10), - Q_th=fx.Flow('Q_th', bus='Fernwärme', size=1e3), - Q_fu=fx.Flow('Q_fu', bus='Gas', size=1e3), - ) - ) - + chp = Converters.CHPs.base() + flow_system.add_elements(chp) return flow_system @pytest.fixture def flow_system_piecewise_conversion(flow_system_complex) -> fx.FlowSystem: flow_system = flow_system_complex - - flow_system.add_elements( - fx.LinearConverter( - 'KWK', - inputs=[fx.Flow('Q_fu', bus='Gas')], - outputs=[ - fx.Flow('P_el', bus='Strom', size=60, relative_maximum=55, previous_flow_rate=10), - fx.Flow('Q_th', bus='Fernwärme'), - ], - piecewise_conversion=fx.PiecewiseConversion( - { - 'P_el': fx.Piecewise([fx.Piece(5, 30), fx.Piece(40, 60)]), - 'Q_th': fx.Piecewise([fx.Piece(6, 35), fx.Piece(45, 100)]), - 'Q_fu': fx.Piecewise([fx.Piece(12, 70), fx.Piece(90, 200)]), - } - ), - on_off_parameters=fx.OnOffParameters(effects_per_switch_on=0.01), - ) - ) - + converter = Converters.LinearConverters.piecewise() + flow_system.add_elements(converter) return flow_system @@ -360,38 +489,16 @@ def flow_system_segments_of_flows_2(flow_system_complex) -> fx.FlowSystem: Use segments/Piecewise with numeric data """ flow_system = flow_system_complex - - flow_system.add_elements( - fx.LinearConverter( - 'KWK', - inputs=[fx.Flow('Q_fu', bus='Gas')], - outputs=[ - fx.Flow('P_el', bus='Strom', size=60, relative_maximum=55, previous_flow_rate=10), - fx.Flow('Q_th', bus='Fernwärme'), - ], - piecewise_conversion=fx.PiecewiseConversion( - { - 'P_el': fx.Piecewise( - [ - fx.Piece(np.linspace(5, 6, len(flow_system.timesteps)), 30), - fx.Piece(40, np.linspace(60, 70, len(flow_system.timesteps))), - ] - ), - 'Q_th': fx.Piecewise([fx.Piece(6, 35), fx.Piece(45, 100)]), - 'Q_fu': fx.Piecewise([fx.Piece(12, 70), fx.Piece(90, 200)]), - } - ), - on_off_parameters=fx.OnOffParameters(effects_per_switch_on=0.01), - ) - ) - + converter = Converters.LinearConverters.segments(len(flow_system.timesteps)) + flow_system.add_elements(converter) return flow_system @pytest.fixture def flow_system_long(): """ - Fixture to create and return the flow system with loaded data + Special fixture with CSV data loading - kept separate for backward compatibility + Uses library components where possible, but has special elements inline """ # Load data filename = os.path.join(os.path.dirname(__file__), 'ressources', 'Zeitreihen2020.csv') @@ -404,25 +511,22 @@ def flow_system_long(): p_el = data['Strompr.€/MWh'].values gas_price = data['Gaspr.€/MWh'].values - flow_system = fx.FlowSystem(pd.DatetimeIndex(data.index)) - thermal_load_ts, electrical_load_ts = ( - fx.TimeSeriesData(thermal_load, coords={'time': flow_system.timesteps}), - fx.TimeSeriesData(electrical_load, aggregation_weight=0.7, coords={'time': flow_system.timesteps}), + fx.TimeSeriesData(thermal_load), + fx.TimeSeriesData(electrical_load, aggregation_weight=0.7), ) p_feed_in, p_sell = ( - fx.TimeSeriesData(-(p_el - 0.5), aggregation_group='p_el', coords={'time': flow_system.timesteps}), - fx.TimeSeriesData(p_el + 0.5, aggregation_group='p_el', coords={'time': flow_system.timesteps}), + fx.TimeSeriesData(-(p_el - 0.5), aggregation_group='p_el'), + fx.TimeSeriesData(p_el + 0.5, aggregation_group='p_el'), ) + flow_system = fx.FlowSystem(pd.DatetimeIndex(data.index)) flow_system.add_elements( - fx.Bus('Strom'), - fx.Bus('Fernwärme'), - fx.Bus('Gas'), - fx.Bus('Kohle'), - fx.Effect('costs', '€', 'Kosten', is_standard=True, is_objective=True), - fx.Effect('CO2', 'kg', 'CO2_e-Emissionen'), - fx.Effect('PE', 'kWh_PE', 'Primärenergie'), + *Buses.defaults(), + Buses.coal(), + Effects.costs(), + Effects.co2(), + Effects.primary_energy(), fx.Sink( 'Wärmelast', sink=fx.Flow('Q_th_Last', bus='Fernwärme', size=1, fixed_relative_profile=thermal_load_ts) ), @@ -487,6 +591,51 @@ def flow_system_long(): } +@pytest.fixture(params=['h', '3h']) +def timesteps_linopy(request): + return pd.date_range('2020-01-01', periods=10, freq=request.param, name='time') + + +@pytest.fixture +def basic_flow_system_linopy(timesteps_linopy) -> fx.FlowSystem: + """Create basic elements for component testing""" + flow_system = fx.FlowSystem(timesteps_linopy) + + thermal_load = LoadProfiles.random_thermal(10) + p_el = LoadProfiles.random_electrical(10) + + costs = Effects.costs() + heat_load = Sinks.heat_load(thermal_load) + gas_source = Sources.gas_with_costs() + electricity_sink = Sinks.electricity_feed_in(p_el) + + flow_system.add_elements(*Buses.defaults()) + flow_system.add_elements(costs, heat_load, gas_source, electricity_sink) + + return flow_system + + +# ============================================================================ +# UTILITY FUNCTIONS (kept for backward compatibility) +# ============================================================================ + + +# Custom assertion function +def assert_almost_equal_numeric( + actual, desired, err_msg, relative_error_range_in_percent=0.011, absolute_tolerance=1e-7 +): + """ + Custom assertion function for comparing numeric values with relative and absolute tolerances + """ + relative_tol = relative_error_range_in_percent / 100 + + if isinstance(desired, (int, float)): + delta = abs(relative_tol * desired) if desired != 0 else absolute_tolerance + assert np.isclose(actual, desired, atol=delta), err_msg + else: + np.testing.assert_allclose(actual, desired, rtol=relative_tol, atol=absolute_tolerance, err_msg=err_msg) + + def create_calculation_and_solve( flow_system: fx.FlowSystem, solver, name: str, allow_infeasible: bool = False ) -> fx.FullCalculation: @@ -508,31 +657,6 @@ def create_linopy_model(flow_system: fx.FlowSystem) -> FlowSystemModel: return calculation.model -@pytest.fixture(params=['h', '3h']) -def timesteps_linopy(request): - return pd.date_range('2020-01-01', periods=10, freq=request.param, name='time') - - -@pytest.fixture -def basic_flow_system_linopy(timesteps_linopy) -> fx.FlowSystem: - """Create basic elements for component testing""" - flow_system = fx.FlowSystem(timesteps_linopy) - thermal_load = np.array([np.random.random() for _ in range(10)]) * 180 - p_el = (np.array([np.random.random() for _ in range(10)]) + 0.5) / 1.5 * 50 - - flow_system.add_elements( - fx.Bus('Strom'), - fx.Bus('Fernwärme'), - fx.Bus('Gas'), - fx.Effect('Costs', '€', 'Kosten', is_standard=True, is_objective=True), - fx.Sink('Wärmelast', sink=fx.Flow('Q_th_Last', 'Fernwärme', size=1, fixed_relative_profile=thermal_load)), - fx.Source('Gastarif', source=fx.Flow('Q_Gas', 'Gas', size=1000, effects_per_flow_hour=0.04)), - fx.Sink('Einspeisung', sink=fx.Flow('P_el', 'Strom', effects_per_flow_hour=-1 * p_el)), - ) - - return flow_system - - def assert_conequal(actual: linopy.Constraint, desired: linopy.Constraint): """Assert that two constraints are equal with detailed error messages.""" name = actual.name diff --git a/tests/test_effect.py b/tests/test_effect.py index e2807cc89..13e878041 100644 --- a/tests/test_effect.py +++ b/tests/test_effect.py @@ -204,7 +204,7 @@ def test_shares(self, basic_flow_system_linopy): class TestEffectResults: def test_shares(self, basic_flow_system_linopy): flow_system = basic_flow_system_linopy - flow_system.effects['Costs'].specific_share_to_other_effects_operation['Effect1'] = 0.5 + flow_system.effects['costs'].specific_share_to_other_effects_operation['Effect1'] = 0.5 flow_system.add_elements( fx.Effect( 'Effect1', @@ -230,9 +230,9 @@ def test_shares(self, basic_flow_system_linopy): effect_share_factors = { 'operation': { - ('Costs', 'Effect1'): 0.5, - ('Costs', 'Effect2'): 0.5 * 1.1, - ('Costs', 'Effect3'): 0.5 * 1.1 * 5 + 0.5 * 1.2, # This is where the issue lies + ('costs', 'Effect1'): 0.5, + ('costs', 'Effect2'): 0.5 * 1.1, + ('costs', 'Effect3'): 0.5 * 1.1 * 5 + 0.5 * 1.2, # This is where the issue lies ('Effect1', 'Effect2'): 1.1, ('Effect1', 'Effect3'): 1.2 + 1.1 * 5, ('Effect2', 'Effect3'): 5, @@ -249,8 +249,8 @@ def test_shares(self, basic_flow_system_linopy): np.testing.assert_allclose(results.effect_share_factors['invest'][key].values, value) xr.testing.assert_allclose( - results.effects_per_component('operation').sum('component')['Costs'], - results.solution['Costs(operation)|total_per_timestep'].fillna(0), + results.effects_per_component('operation').sum('component')['costs'], + results.solution['costs(operation)|total_per_timestep'].fillna(0), ) xr.testing.assert_allclose( @@ -270,7 +270,7 @@ def test_shares(self, basic_flow_system_linopy): # Invest mode checks xr.testing.assert_allclose( - results.effects_per_component('invest').sum('component')['Costs'], results.solution['Costs(invest)|total'] + results.effects_per_component('invest').sum('component')['costs'], results.solution['costs(invest)|total'] ) xr.testing.assert_allclose( @@ -290,7 +290,7 @@ def test_shares(self, basic_flow_system_linopy): # Total mode checks xr.testing.assert_allclose( - results.effects_per_component('total').sum('component')['Costs'], results.solution['Costs|total'] + results.effects_per_component('total').sum('component')['costs'], results.solution['costs|total'] ) xr.testing.assert_allclose( diff --git a/tests/test_effects_shares_summation.py b/tests/test_effects_shares_summation.py index d4d22d6df..15de93481 100644 --- a/tests/test_effects_shares_summation.py +++ b/tests/test_effects_shares_summation.py @@ -99,7 +99,7 @@ def test_effect_shares_example(): """Test the specific example from the effects share factors test.""" # Create the conversion dictionary based on test example conversion_dict = { - 'Costs': {'Effect1': xr.DataArray(0.5)}, + 'costs': {'Effect1': xr.DataArray(0.5)}, 'Effect1': {'Effect2': xr.DataArray(1.1), 'Effect3': xr.DataArray(1.2)}, 'Effect2': {'Effect3': xr.DataArray(5.0)}, } @@ -107,19 +107,19 @@ def test_effect_shares_example(): result = calculate_all_conversion_paths(conversion_dict) # Test direct paths - assert result[('Costs', 'Effect1')].item() == 0.5 + assert result[('costs', 'Effect1')].item() == 0.5 assert result[('Effect1', 'Effect2')].item() == 1.1 assert result[('Effect2', 'Effect3')].item() == 5.0 # Test indirect paths - # Costs -> Effect2 = Costs -> Effect1 -> Effect2 = 0.5 * 1.1 - assert result[('Costs', 'Effect2')].item() == 0.5 * 1.1 + # costs -> Effect2 = costs -> Effect1 -> Effect2 = 0.5 * 1.1 + assert result[('costs', 'Effect2')].item() == 0.5 * 1.1 - # Costs -> Effect3 has two paths: - # 1. Costs -> Effect1 -> Effect3 = 0.5 * 1.2 = 0.6 - # 2. Costs -> Effect1 -> Effect2 -> Effect3 = 0.5 * 1.1 * 5 = 2.75 + # costs -> Effect3 has two paths: + # 1. costs -> Effect1 -> Effect3 = 0.5 * 1.2 = 0.6 + # 2. costs -> Effect1 -> Effect2 -> Effect3 = 0.5 * 1.1 * 5 = 2.75 # Total = 0.6 + 2.75 = 3.35 - assert result[('Costs', 'Effect3')].item() == 0.5 * 1.2 + 0.5 * 1.1 * 5 + assert result[('costs', 'Effect3')].item() == 0.5 * 1.2 + 0.5 * 1.1 * 5 # Effect1 -> Effect3 has two paths: # 1. Effect1 -> Effect2 -> Effect3 = 1.1 * 5.0 = 5.5 diff --git a/tests/test_flow.py b/tests/test_flow.py index 93658bf2e..2ee609f68 100644 --- a/tests/test_flow.py +++ b/tests/test_flow.py @@ -101,11 +101,11 @@ def test_effects_per_flow_hour(self, basic_flow_system_linopy): co2_per_flow_hour = xr.DataArray(np.linspace(4, 5, timesteps.size), coords=(timesteps,)) flow = fx.Flow( - 'Wärme', bus='Fernwärme', effects_per_flow_hour={'Costs': costs_per_flow_hour, 'CO2': co2_per_flow_hour} + 'Wärme', bus='Fernwärme', effects_per_flow_hour={'costs': costs_per_flow_hour, 'CO2': co2_per_flow_hour} ) flow_system.add_elements(fx.Sink('Sink', sink=flow), fx.Effect('CO2', 't', '')) model = create_linopy_model(flow_system) - costs, co2 = flow_system.effects['Costs'], flow_system.effects['CO2'] + costs, co2 = flow_system.effects['costs'], flow_system.effects['CO2'] assert_sets_equal( set(flow.submodel.variables), @@ -114,12 +114,12 @@ def test_effects_per_flow_hour(self, basic_flow_system_linopy): ) assert_sets_equal(set(flow.submodel.constraints), {'Sink(Wärme)|total_flow_hours'}, msg='Incorrect constraints') - assert 'Sink(Wärme)->Costs(operation)' in set(costs.submodel.constraints) + assert 'Sink(Wärme)->costs(operation)' in set(costs.submodel.constraints) assert 'Sink(Wärme)->CO2(operation)' in set(co2.submodel.constraints) assert_conequal( - model.constraints['Sink(Wärme)->Costs(operation)'], - model.variables['Sink(Wärme)->Costs(operation)'] + model.constraints['Sink(Wärme)->costs(operation)'], + model.variables['Sink(Wärme)->costs(operation)'] == flow.submodel.variables['Sink(Wärme)|flow_rate'] * model.hours_per_step * costs_per_flow_hour, ) @@ -426,8 +426,8 @@ def test_flow_invest_with_effects(self, basic_flow_system_linopy): minimum_size=20, maximum_size=100, optional=True, - fix_effects={'Costs': 1000, 'CO2': 5}, # Fixed investment effects - specific_effects={'Costs': 500, 'CO2': 0.1}, # Specific investment effects + fix_effects={'costs': 1000, 'CO2': 5}, # Fixed investment effects + specific_effects={'costs': 500, 'CO2': 0.1}, # Specific investment effects ), ) @@ -435,13 +435,13 @@ def test_flow_invest_with_effects(self, basic_flow_system_linopy): model = create_linopy_model(flow_system) # Check investment effects - assert 'Sink(Wärme)->Costs(invest)' in model.variables + assert 'Sink(Wärme)->costs(invest)' in model.variables assert 'Sink(Wärme)->CO2(invest)' in model.variables # Check fix effects (applied only when is_invested=1) assert_conequal( - model.constraints['Sink(Wärme)->Costs(invest)'], - model.variables['Sink(Wärme)->Costs(invest)'] + model.constraints['Sink(Wärme)->costs(invest)'], + model.variables['Sink(Wärme)->costs(invest)'] == flow.submodel.variables['Sink(Wärme)|is_invested'] * 1000 + flow.submodel.variables['Sink(Wärme)|size'] * 500, ) @@ -464,7 +464,7 @@ def test_flow_invest_divest_effects(self, basic_flow_system_linopy): minimum_size=20, maximum_size=100, optional=True, - divest_effects={'Costs': 500}, # Cost incurred when NOT investing + divest_effects={'costs': 500}, # Cost incurred when NOT investing ), ) @@ -472,11 +472,11 @@ def test_flow_invest_divest_effects(self, basic_flow_system_linopy): model = create_linopy_model(flow_system) # Check divestment effects - assert 'Sink(Wärme)->Costs(invest)' in model.constraints + assert 'Sink(Wärme)->costs(invest)' in model.constraints assert_conequal( - model.constraints['Sink(Wärme)->Costs(invest)'], - model.variables['Sink(Wärme)->Costs(invest)'] + (model.variables['Sink(Wärme)|is_invested'] - 1) * 500 == 0, + model.constraints['Sink(Wärme)->costs(invest)'], + model.variables['Sink(Wärme)->costs(invest)'] + (model.variables['Sink(Wärme)|is_invested'] - 1) * 500 == 0, ) @@ -558,12 +558,12 @@ def test_effects_per_running_hour(self, basic_flow_system_linopy): 'Wärme', bus='Fernwärme', on_off_parameters=fx.OnOffParameters( - effects_per_running_hour={'Costs': costs_per_running_hour, 'CO2': co2_per_running_hour} + effects_per_running_hour={'costs': costs_per_running_hour, 'CO2': co2_per_running_hour} ), ) flow_system.add_elements(fx.Sink('Sink', sink=flow), fx.Effect('CO2', 't', '')) model = create_linopy_model(flow_system) - costs, co2 = flow_system.effects['Costs'], flow_system.effects['CO2'] + costs, co2 = flow_system.effects['costs'], flow_system.effects['CO2'] assert_sets_equal( set(flow.submodel.variables), @@ -586,12 +586,12 @@ def test_effects_per_running_hour(self, basic_flow_system_linopy): msg='Incorrect constraints', ) - assert 'Sink(Wärme)->Costs(operation)' in set(costs.submodel.constraints) + assert 'Sink(Wärme)->costs(operation)' in set(costs.submodel.constraints) assert 'Sink(Wärme)->CO2(operation)' in set(co2.submodel.constraints) assert_conequal( - model.constraints['Sink(Wärme)->Costs(operation)'], - model.variables['Sink(Wärme)->Costs(operation)'] + model.constraints['Sink(Wärme)->costs(operation)'], + model.variables['Sink(Wärme)->costs(operation)'] == flow.submodel.variables['Sink(Wärme)|on'] * model.hours_per_step * costs_per_running_hour, ) @@ -943,7 +943,7 @@ def test_switch_on_constraints(self, basic_flow_system_linopy): size=100, on_off_parameters=fx.OnOffParameters( switch_on_total_max=5, # Maximum 5 startups - effects_per_switch_on={'Costs': 100}, # 100 EUR startup cost + effects_per_switch_on={'costs': 100}, # 100 EUR startup cost ), ) @@ -984,12 +984,12 @@ def test_switch_on_constraints(self, basic_flow_system_linopy): ) # Check that startup cost effect constraint exists - assert 'Sink(Wärme)->Costs(operation)' in model.constraints + assert 'Sink(Wärme)->costs(operation)' in model.constraints # Verify the startup cost effect constraint assert_conequal( - model.constraints['Sink(Wärme)->Costs(operation)'], - model.variables['Sink(Wärme)->Costs(operation)'] == flow.submodel.variables['Sink(Wärme)|switch|on'] * 100, + model.constraints['Sink(Wärme)->costs(operation)'], + model.variables['Sink(Wärme)->costs(operation)'] == flow.submodel.variables['Sink(Wärme)|switch|on'] * 100, ) def test_on_hours_limits(self, basic_flow_system_linopy): diff --git a/tests/test_integration.py b/tests/test_integration.py index babc7b131..97876c251 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -248,7 +248,7 @@ def test_piecewise_conversion(self, flow_system_piecewise_conversion, highs_solv assert_almost_equal_numeric( comps['Speicher'].submodel.variables['Speicher|PiecewiseEffects|costs'].solution.values, 454.74666666666667, - 'Speicher investCosts_segmented_costs doesnt match expected value', + 'Speicher investcosts_segmented_costs doesnt match expected value', ) @@ -309,13 +309,13 @@ def test_modeling_types_costs(self, modeling_calculation): assert_almost_equal_numeric( calc.results.model['costs|total'].solution.item(), expected_costs[modeling_type], - f'Costs do not match for {modeling_type} modeling type', + f'costs do not match for {modeling_type} modeling type', ) else: assert_almost_equal_numeric( calc.results.solution_without_overlap('costs(operation)|total_per_timestep').sum(), expected_costs[modeling_type], - f'Costs do not match for {modeling_type} modeling type', + f'costs do not match for {modeling_type} modeling type', ) def test_segmented_io(self, modeling_calculation): diff --git a/tests/test_linear_converter.py b/tests/test_linear_converter.py index 42dc80077..322a5f6f0 100644 --- a/tests/test_linear_converter.py +++ b/tests/test_linear_converter.py @@ -146,7 +146,7 @@ def test_linear_converter_with_on_off(self, basic_flow_system_linopy): # Create OnOffParameters on_off_params = fx.OnOffParameters( - on_hours_total_min=10, on_hours_total_max=40, effects_per_running_hour={'Costs': 5} + on_hours_total_min=10, on_hours_total_max=40, effects_per_running_hour={'costs': 5} ) # Create a linear converter with OnOffParameters @@ -186,10 +186,10 @@ def test_linear_converter_with_on_off(self, basic_flow_system_linopy): ) # Check on_off effects - assert 'Converter->Costs(operation)' in model.constraints + assert 'Converter->costs(operation)' in model.constraints assert_conequal( - model.constraints['Converter->Costs(operation)'], - model.variables['Converter->Costs(operation)'] + model.constraints['Converter->costs(operation)'], + model.variables['Converter->costs(operation)'] == model.variables['Converter|on'] * model.hours_per_step * 5, ) @@ -398,7 +398,7 @@ def test_piecewise_conversion_with_onoff(self, basic_flow_system_linopy): # Create OnOffParameters on_off_params = fx.OnOffParameters( - on_hours_total_min=10, on_hours_total_max=40, effects_per_running_hour={'Costs': 5} + on_hours_total_min=10, on_hours_total_max=40, effects_per_running_hour={'costs': 5} ) # Create a linear converter with piecewise conversion and on/off parameters @@ -489,10 +489,10 @@ def test_piecewise_conversion_with_onoff(self, basic_flow_system_linopy): ) # Verify that the costs effect is applied - assert 'Converter->Costs(operation)' in model.constraints + assert 'Converter->costs(operation)' in model.constraints assert_conequal( - model.constraints['Converter->Costs(operation)'], - model.variables['Converter->Costs(operation)'] + model.constraints['Converter->costs(operation)'], + model.variables['Converter->costs(operation)'] == model.variables['Converter|on'] * model.hours_per_step * 5, ) diff --git a/tests/test_scenarios.py b/tests/test_scenarios.py index 0ccc1a5dd..897122242 100644 --- a/tests/test_scenarios.py +++ b/tests/test_scenarios.py @@ -67,9 +67,9 @@ def test_system(): size=InvestParameters( minimum_size=0, maximum_size=20, - specific_effects={'Costs': 100}, # €/kW + specific_effects={'costs': 100}, # €/kW ), - effects_per_flow_hour={'Costs': 20}, # €/MWh + effects_per_flow_hour={'costs': 20}, # €/MWh ) generator = Source('Generator', source=power_gen) @@ -83,7 +83,7 @@ def test_system(): capacity_in_flow_hours=InvestParameters( minimum_size=0, maximum_size=50, - specific_effects={'Costs': 50}, # €/kWh + specific_effects={'costs': 50}, # €/kWh ), eta_charge=0.95, eta_discharge=0.95, @@ -91,7 +91,7 @@ def test_system(): ) # Create effects and objective - cost_effect = Effect(label='Costs', unit='€', description='Total costs', is_standard=True, is_objective=True) + cost_effect = Effect(label='costs', unit='€', description='Total costs', is_standard=True, is_objective=True) # Add all elements to the flow system flow_system.add_elements(electricity_bus, generator, demand_sink, storage, cost_effect) From b7734f8f2241b661d73d6b12cc1b42e106460788 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 23 Jul 2025 18:08:58 +0200 Subject: [PATCH 263/336] Improve some of the modeling and coord handling --- flixopt/features.py | 9 ++- flixopt/modeling.py | 131 ++++++++++++++++---------------------------- 2 files changed, 53 insertions(+), 87 deletions(-) diff --git a/flixopt/features.py b/flixopt/features.py index fc80f0eb3..d07844c8d 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -171,7 +171,7 @@ def _do_modeling(self): self.parameters.on_hours_total_max if self.parameters.on_hours_total_max is not None else np.inf, ), # TODO: self._model.hours_per_step.sum('time').item() + self._get_previous_on_duration()) short_name='on_hours_total', - coords=self.get_coords(['year', 'scenario']), + coords=['year', 'scenario'], ) # 4. Switch tracking using existing pattern @@ -179,13 +179,14 @@ def _do_modeling(self): self.add_variables(binary=True, short_name='switch|on', coords=self.get_coords()) self.add_variables(binary=True, short_name='switch|off', coords=self.get_coords()) - ModelingPrimitives.state_transition_variables( + BoundingPatterns.state_transition_bounds( self, state_variable=self.on, switch_on=self.switch_on, switch_off=self.switch_off, name=f'{self.label_of_model}|switch', previous_state=self._previous_states.isel(time=-1) if self._previous_states is not None else 0, + coord='time', ) if self.parameters.switch_on_total_max is not None: @@ -408,7 +409,9 @@ def _do_modeling(self): rhs = self.zero_point elif self._zero_point is True: self.zero_point = self.add_variables( - coords=self._model.get_coords(), binary=True, short_name='zero_point' + coords=self._model.get_coords(('year', 'scenario') if self._as_time_series else None), + binary=True, + short_name='zero_point', ) rhs = self.zero_point else: diff --git a/flixopt/modeling.py b/flixopt/modeling.py index fa13aeea8..14f8c45f3 100644 --- a/flixopt/modeling.py +++ b/flixopt/modeling.py @@ -189,7 +189,7 @@ def expression_tracking_variable( name: str = None, short_name: str = None, bounds: Tuple[TemporalData, TemporalData] = None, - coords: List[str] = None, + coords: Optional[Union[str, List[str]]] = None, ) -> Tuple[linopy.Variable, linopy.Constraint]: """ Creates variable that equals a given expression. @@ -205,8 +205,6 @@ def expression_tracking_variable( if not isinstance(model, Submodel): raise ValueError('ModelingPrimitives.expression_tracking_variable() can only be used with a Submodel') - coords = coords or ['year', 'scenario'] - if not bounds: tracker = model.add_variables(name=name, coords=model.get_coords(coords), short_name=short_name) else: @@ -223,86 +221,6 @@ def expression_tracking_variable( return tracker, tracking - @staticmethod - def state_transition_variables( - model: Submodel, - state_variable: linopy.Variable, - switch_on: linopy.Variable, - switch_off: linopy.Variable, - name: str, - previous_state=0, - ) -> Tuple[linopy.Constraint, linopy.Constraint, linopy.Constraint]: - """ - Creates switch-on/off variables with state transition logic. - - Mathematical formulation: - switch_on[t] - switch_off[t] = state[t] - state[t-1] ∀t > 0 - switch_on[0] - switch_off[0] = state[0] - previous_state - switch_on[t] + switch_off[t] ≤ 1 ∀t - switch_on[t], switch_off[t] ∈ {0, 1} - - Returns: - variables: {'switch_on': binary_var, 'switch_off': binary_var} - constraints: {'transition': constraint, 'initial': constraint, 'mutex': constraint} - """ - if not isinstance(model, Submodel): - raise ValueError('ModelingPrimitives.state_transition_variables() can only be used with a Submodel') - - # State transition constraints for t > 0 - transition = model.add_constraints( - switch_on.isel(time=slice(1, None)) - switch_off.isel(time=slice(1, None)) - == state_variable.isel(time=slice(1, None)) - state_variable.isel(time=slice(None, -1)), - name=f'{name}|transition', - ) - - # Initial state transition for t = 0 - initial = model.add_constraints( - switch_on.isel(time=0) - switch_off.isel(time=0) == state_variable.isel(time=0) - previous_state, - name=f'{name}|initial', - ) - - # At most one switch per timestep - mutex = model.add_constraints(switch_on + switch_off <= 1, name=f'{name}|mutex') - - return transition, initial, mutex - - @staticmethod - def sum_up_variable( - model: Submodel, - variable_to_count: linopy.Variable, - name: str = None, - bounds: Tuple[NonTemporalData, NonTemporalData] = None, - factor: TemporalData = 1, - ) -> Tuple[linopy.Variable, linopy.Constraint]: - """ - SUms up a variable over time, applying a factor to the variable. - - Args: - model: The optimization model instance - variable_to_count: The variable to be summed up - name: The name of the constraint - bounds: The bounds of the constraint - factor: The factor to be applied to the variable - """ - if not isinstance(model, Submodel): - raise ValueError('ModelingPrimitives.sum_up_variable() can only be used with a Submodel') - - if bounds is None: - bounds = (0, np.inf) - else: - bounds = (bounds[0] if bounds[0] is not None else 0, bounds[1] if bounds[1] is not None else np.inf) - - count = model.add_variables( - lower=bounds[0], - upper=bounds[1], - coords=model.get_coords(['year', 'scenario']), - name=name, - ) - - count_constraint = model.add_constraints(count == (variable_to_count * factor).sum('time'), name=name) - - return count, count_constraint - @staticmethod def consecutive_duration_tracking( model: Submodel, @@ -346,7 +264,7 @@ def consecutive_duration_tracking( duration = model.add_variables( lower=0, upper=maximum_duration if maximum_duration is not None else mega, - coords=model.get_coords(['time']), + coords=model.get_coords(), name=name, short_name=short_name, ) @@ -625,3 +543,48 @@ def scaled_bounds_with_state( binary_lower = model.add_constraints(variable_state * big_m_lower <= variable, name=f'{name}|lb1') return [scaling_lower, scaling_upper, binary_lower, binary_upper] + + @staticmethod + def state_transition_bounds( + model: Submodel, + state_variable: linopy.Variable, + switch_on: linopy.Variable, + switch_off: linopy.Variable, + name: str, + previous_state=0, + coord: str = 'time', + ) -> Tuple[linopy.Constraint, linopy.Constraint, linopy.Constraint]: + """ + Creates switch-on/off variables with state transition logic. + + Mathematical formulation: + switch_on[t] - switch_off[t] = state[t] - state[t-1] ∀t > 0 + switch_on[0] - switch_off[0] = state[0] - previous_state + switch_on[t] + switch_off[t] ≤ 1 ∀t + switch_on[t], switch_off[t] ∈ {0, 1} + + Returns: + variables: {'switch_on': binary_var, 'switch_off': binary_var} + constraints: {'transition': constraint, 'initial': constraint, 'mutex': constraint} + """ + if not isinstance(model, Submodel): + raise ValueError('ModelingPrimitives.state_transition_variables() can only be used with a Submodel') + + # State transition constraints for t > 0 + transition = model.add_constraints( + switch_on.isel({coord: slice(1, None)}) - switch_off.isel({coord: slice(1, None)}) + == state_variable.isel({coord: slice(1, None)}) - state_variable.isel({coord: slice(None, -1)}), + name=f'{name}|transition', + ) + + # Initial state transition for t = 0 + initial = model.add_constraints( + switch_on.isel({coord: 0}) - switch_off.isel({coord: 0}) + == state_variable.isel({coord: 0}) - previous_state, + name=f'{name}|initial', + ) + + # At most one switch per timestep + mutex = model.add_constraints(switch_on + switch_off <= 1, name=f'{name}|mutex') + + return transition, initial, mutex From e06692b473f1c2cfd298efa2e4e335a7780e1c07 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 23 Jul 2025 21:28:23 +0200 Subject: [PATCH 264/336] Add tests with years and scenarios --- tests/conftest.py | 45 +++++++++++++++++++++++++++++++++++++++++++++ tests/test_bus.py | 43 ++++++++++++++++++++++++++++++++++++------- 2 files changed, 81 insertions(+), 7 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 3d6623eb7..b581233fb 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -36,6 +36,32 @@ def solver_fixture(request): return request.getfixturevalue(request.param.__name__) +# ================================= +# COORDINATE CONFIGURATION FIXTURES +# ================================= + + +@pytest.fixture( + params=[ + {'timesteps': pd.date_range('2020-01-01', periods=10, freq='h', name='time'), 'years': None, 'scenarios': None}, + { + 'timesteps': pd.date_range('2020-01-01', periods=10, freq='h', name='time'), + 'years': pd.Index([2020, 2030, 2040], name='year'), + 'scenarios': None, + }, + { + 'timesteps': pd.date_range('2020-01-01', periods=10, freq='h', name='time'), + 'years': pd.Index([2020, 2030, 2040], name='year'), + 'scenarios': pd.Index(['A', 'B'], name='scenario'), + }, + ], + ids=['time_only', 'time+years', 'time+years+scenarios'], +) +def coords_config(request): + """Coordinate configurations for parametrized testing.""" + return request.param + + # ============================================================================ # HIERARCHICAL ELEMENT LIBRARY # ============================================================================ @@ -615,6 +641,25 @@ def basic_flow_system_linopy(timesteps_linopy) -> fx.FlowSystem: return flow_system +@pytest.fixture +def basic_flow_system_linopy_coords(coords_config) -> fx.FlowSystem: + """Create basic elements for component testing with coordinate parametrization.""" + flow_system = fx.FlowSystem(**coords_config) + + thermal_load = LoadProfiles.random_thermal(10) + p_el = LoadProfiles.random_electrical(10) + + costs = Effects.costs() + heat_load = Sinks.heat_load(thermal_load) + gas_source = Sources.gas_with_costs() + electricity_sink = Sinks.electricity_feed_in(p_el) + + flow_system.add_elements(*Buses.defaults()) + flow_system.add_elements(costs, heat_load, gas_source, electricity_sink) + + return flow_system + + # ============================================================================ # UTILITY FUNCTIONS (kept for backward compatibility) # ============================================================================ diff --git a/tests/test_bus.py b/tests/test_bus.py index c9bf3956c..e4e0de6fd 100644 --- a/tests/test_bus.py +++ b/tests/test_bus.py @@ -11,9 +11,9 @@ class TestBusModel: """Test the FlowModel class.""" - def test_bus(self, basic_flow_system_linopy): + def test_bus(self, basic_flow_system_linopy_coords, coords_config): """Test that flow model constraints are correctly generated.""" - flow_system = basic_flow_system_linopy + flow_system = basic_flow_system_linopy_coords bus = fx.Bus('TestBus', excess_penalty_per_flow_hour=None) flow_system.add_elements( bus, @@ -30,10 +30,9 @@ def test_bus(self, basic_flow_system_linopy): model.variables['GastarifTest(Q_Gas)|flow_rate'] == model.variables['WärmelastTest(Q_th_Last)|flow_rate'], ) - def test_bus_penalty(self, basic_flow_system_linopy): + def test_bus_penalty(self, basic_flow_system_linopy_coords, coords_config): """Test that flow model constraints are correctly generated.""" - flow_system = basic_flow_system_linopy - timesteps = flow_system.timesteps + flow_system = basic_flow_system_linopy_coords bus = fx.Bus('TestBus') flow_system.add_elements( bus, @@ -50,8 +49,12 @@ def test_bus_penalty(self, basic_flow_system_linopy): } assert set(bus.submodel.constraints) == {'TestBus|balance'} - assert_var_equal(model.variables['TestBus|excess_input'], model.add_variables(lower=0, coords=(timesteps,))) - assert_var_equal(model.variables['TestBus|excess_output'], model.add_variables(lower=0, coords=(timesteps,))) + assert_var_equal( + model.variables['TestBus|excess_input'], model.add_variables(lower=0, coords=model.get_coords()) + ) + assert_var_equal( + model.variables['TestBus|excess_output'], model.add_variables(lower=0, coords=model.get_coords()) + ) assert_conequal( model.constraints['TestBus|balance'], @@ -68,3 +71,29 @@ def test_bus_penalty(self, basic_flow_system_linopy): == (model.variables['TestBus|excess_input'] * 1e5 * model.hours_per_step).sum() + (model.variables['TestBus|excess_output'] * 1e5 * model.hours_per_step).sum(), ) + + def test_bus_with_coords(self, basic_flow_system_linopy_coords, coords_config): + """Test bus behavior across different coordinate configurations.""" + flow_system = basic_flow_system_linopy_coords + bus = fx.Bus('TestBus', excess_penalty_per_flow_hour=None) + flow_system.add_elements( + bus, + fx.Sink('WärmelastTest', sink=fx.Flow('Q_th_Last', 'TestBus')), + fx.Source('GastarifTest', source=fx.Flow('Q_Gas', 'TestBus')), + ) + model = create_linopy_model(flow_system) + + # Same core assertions as your existing test + assert set(bus.submodel.variables) == {'WärmelastTest(Q_th_Last)|flow_rate', 'GastarifTest(Q_Gas)|flow_rate'} + assert set(bus.submodel.constraints) == {'TestBus|balance'} + + assert_conequal( + model.constraints['TestBus|balance'], + model.variables['GastarifTest(Q_Gas)|flow_rate'] == model.variables['WärmelastTest(Q_th_Last)|flow_rate'], + ) + + # Just verify coordinate dimensions are correct + gas_var = model.variables['GastarifTest(Q_Gas)|flow_rate'] + if flow_system.scenarios is not None: + assert 'scenario' in gas_var.dims + assert 'time' in gas_var.dims From cf0186cec45d98de1629338bbadd55154f3df2e6 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 23 Jul 2025 22:13:52 +0200 Subject: [PATCH 265/336] Update tests to run with multiple coords --- tests/test_bus.py | 6 +-- tests/test_component.py | 19 ++++---- tests/test_effect.py | 57 ++++++++++++++-------- tests/test_flow.py | 88 +++++++++++++++++----------------- tests/test_linear_converter.py | 32 ++++++------- tests/test_storage.py | 38 ++++++++------- 6 files changed, 131 insertions(+), 109 deletions(-) diff --git a/tests/test_bus.py b/tests/test_bus.py index e4e0de6fd..58e00a1dc 100644 --- a/tests/test_bus.py +++ b/tests/test_bus.py @@ -13,7 +13,7 @@ class TestBusModel: def test_bus(self, basic_flow_system_linopy_coords, coords_config): """Test that flow model constraints are correctly generated.""" - flow_system = basic_flow_system_linopy_coords + flow_system, coords_config = basic_flow_system_linopy_coords, coords_config bus = fx.Bus('TestBus', excess_penalty_per_flow_hour=None) flow_system.add_elements( bus, @@ -32,7 +32,7 @@ def test_bus(self, basic_flow_system_linopy_coords, coords_config): def test_bus_penalty(self, basic_flow_system_linopy_coords, coords_config): """Test that flow model constraints are correctly generated.""" - flow_system = basic_flow_system_linopy_coords + flow_system, coords_config = basic_flow_system_linopy_coords, coords_config bus = fx.Bus('TestBus') flow_system.add_elements( bus, @@ -74,7 +74,7 @@ def test_bus_penalty(self, basic_flow_system_linopy_coords, coords_config): def test_bus_with_coords(self, basic_flow_system_linopy_coords, coords_config): """Test bus behavior across different coordinate configurations.""" - flow_system = basic_flow_system_linopy_coords + flow_system, coords_config = basic_flow_system_linopy_coords, coords_config bus = fx.Bus('TestBus', excess_penalty_per_flow_hour=None) flow_system.add_elements( bus, diff --git a/tests/test_component.py b/tests/test_component.py index 90388ef26..2ed9bea3c 100644 --- a/tests/test_component.py +++ b/tests/test_component.py @@ -17,9 +17,8 @@ class TestComponentModel: - def test_flow_label_check(self, basic_flow_system_linopy): + def test_flow_label_check(self): """Test that flow model constraints are correctly generated.""" - _ = basic_flow_system_linopy inputs = [ fx.Flow('Q_th_Last', 'Fernwärme', relative_minimum=np.ones(10) * 0.1), fx.Flow('Q_Gas', 'Fernwärme', relative_minimum=np.ones(10) * 0.1), @@ -31,9 +30,9 @@ def test_flow_label_check(self, basic_flow_system_linopy): with pytest.raises(ValueError, match='Flow names must be unique!'): _ = flixopt.elements.Component('TestComponent', inputs=inputs, outputs=outputs) - def test_component(self, basic_flow_system_linopy): + def test_component(self, basic_flow_system_linopy_coords, coords_config): """Test that flow model constraints are correctly generated.""" - flow_system = basic_flow_system_linopy + flow_system, coords_config = basic_flow_system_linopy_coords, coords_config inputs = [ fx.Flow('In1', 'Fernwärme', relative_minimum=np.ones(10) * 0.1), fx.Flow('In2', 'Fernwärme', relative_minimum=np.ones(10) * 0.1), @@ -72,9 +71,9 @@ def test_component(self, basic_flow_system_linopy): msg='Incorrect constraints', ) - def test_on_with_multiple_flows(self, basic_flow_system_linopy): + def test_on_with_multiple_flows(self, basic_flow_system_linopy_coords, coords_config): """Test that flow model constraints are correctly generated.""" - flow_system = basic_flow_system_linopy + flow_system, coords_config = basic_flow_system_linopy_coords, coords_config timesteps = flow_system.timesteps ub_out2 = np.linspace(1, 1.5, 10).round(2) inputs = [ @@ -171,9 +170,9 @@ def test_on_with_multiple_flows(self, basic_flow_system_linopy): + 1e-5, ) - def test_on_with_single_flow(self, basic_flow_system_linopy): + def test_on_with_single_flow(self, basic_flow_system_linopy_coords, coords_config): """Test that flow model constraints are correctly generated.""" - flow_system = basic_flow_system_linopy + flow_system, coords_config = basic_flow_system_linopy_coords, coords_config timesteps = flow_system.timesteps inputs = [ fx.Flow('In1', 'Fernwärme', relative_minimum=np.ones(10) * 0.1, size=100), @@ -231,9 +230,9 @@ def test_on_with_single_flow(self, basic_flow_system_linopy): model.variables['TestComponent|on'] == model.variables['TestComponent(In1)|on'], ) - def test_previous_states_with_multiple_flows(self, basic_flow_system_linopy): + def test_previous_states_with_multiple_flows(self, basic_flow_system_linopy_coords, coords_config): """Test that flow model constraints are correctly generated.""" - flow_system = basic_flow_system_linopy + flow_system, coords_config = basic_flow_system_linopy_coords, coords_config timesteps = flow_system.timesteps ub_out2 = np.linspace(1, 1.5, 10).round(2) inputs = [ diff --git a/tests/test_effect.py b/tests/test_effect.py index 13e878041..a0a6fecfe 100644 --- a/tests/test_effect.py +++ b/tests/test_effect.py @@ -17,9 +17,8 @@ class TestEffectModel: """Test the FlowModel class.""" - def test_minimal(self, basic_flow_system_linopy): - flow_system = basic_flow_system_linopy - timesteps = flow_system.timesteps + def test_minimal(self, basic_flow_system_linopy_coords, coords_config): + flow_system, coords_config = basic_flow_system_linopy_coords, coords_config effect = fx.Effect('Effect1', '€', 'Testing Effect') flow_system.add_elements(effect) @@ -47,11 +46,18 @@ def test_minimal(self, basic_flow_system_linopy): msg='Incorrect constraints', ) - assert_var_equal(model.variables['Effect1|total'], model.add_variables()) - assert_var_equal(model.variables['Effect1(invest)|total'], model.add_variables()) - assert_var_equal(model.variables['Effect1(operation)|total'], model.add_variables()) assert_var_equal( - model.variables['Effect1(operation)|total_per_timestep'], model.add_variables(coords=(timesteps,)) + model.variables['Effect1|total'], model.add_variables(coords=model.get_coords(['year', 'scenario'])) + ) + assert_var_equal( + model.variables['Effect1(invest)|total'], model.add_variables(coords=model.get_coords(['year', 'scenario'])) + ) + assert_var_equal( + model.variables['Effect1(operation)|total'], + model.add_variables(coords=model.get_coords(['year', 'scenario'])), + ) + assert_var_equal( + model.variables['Effect1(operation)|total_per_timestep'], model.add_variables(coords=model.get_coords()) ) assert_conequal( @@ -63,16 +69,15 @@ def test_minimal(self, basic_flow_system_linopy): assert_conequal( model.constraints['Effect1(operation)|total'], model.variables['Effect1(operation)|total'] - == model.variables['Effect1(operation)|total_per_timestep'].sum(), + == model.variables['Effect1(operation)|total_per_timestep'].sum('time'), ) assert_conequal( model.constraints['Effect1(operation)|total_per_timestep'], model.variables['Effect1(operation)|total_per_timestep'] == 0, ) - def test_bounds(self, basic_flow_system_linopy): - flow_system = basic_flow_system_linopy - timesteps = flow_system.timesteps + def test_bounds(self, basic_flow_system_linopy_coords, coords_config): + flow_system, coords_config = basic_flow_system_linopy_coords, coords_config effect = fx.Effect( 'Effect1', '€', @@ -112,13 +117,24 @@ def test_bounds(self, basic_flow_system_linopy): msg='Incorrect constraints', ) - assert_var_equal(model.variables['Effect1|total'], model.add_variables(lower=3.0, upper=3.1)) - assert_var_equal(model.variables['Effect1(invest)|total'], model.add_variables(lower=2.0, upper=2.1)) - assert_var_equal(model.variables['Effect1(operation)|total'], model.add_variables(lower=1.0, upper=1.1)) + assert_var_equal( + model.variables['Effect1|total'], + model.add_variables(lower=3.0, upper=3.1, coords=model.get_coords(['year', 'scenario'])), + ) + assert_var_equal( + model.variables['Effect1(invest)|total'], + model.add_variables(lower=2.0, upper=2.1, coords=model.get_coords(['year', 'scenario'])), + ) + assert_var_equal( + model.variables['Effect1(operation)|total'], + model.add_variables(lower=1.0, upper=1.1, coords=model.get_coords(['year', 'scenario'])), + ) assert_var_equal( model.variables['Effect1(operation)|total_per_timestep'], model.add_variables( - lower=4.0 * model.hours_per_step, upper=4.1 * model.hours_per_step, coords=(timesteps,) + lower=4.0 * model.hours_per_step, + upper=4.1 * model.hours_per_step, + coords=model.get_coords(['time', 'year', 'scenario']), ), ) @@ -131,15 +147,15 @@ def test_bounds(self, basic_flow_system_linopy): assert_conequal( model.constraints['Effect1(operation)|total'], model.variables['Effect1(operation)|total'] - == model.variables['Effect1(operation)|total_per_timestep'].sum(), + == model.variables['Effect1(operation)|total_per_timestep'].sum('time'), ) assert_conequal( model.constraints['Effect1(operation)|total_per_timestep'], model.variables['Effect1(operation)|total_per_timestep'] == 0, ) - def test_shares(self, basic_flow_system_linopy): - flow_system = basic_flow_system_linopy + def test_shares(self, basic_flow_system_linopy_coords, coords_config): + flow_system, coords_config = basic_flow_system_linopy_coords, coords_config effect1 = fx.Effect( 'Effect1', '€', @@ -202,8 +218,8 @@ def test_shares(self, basic_flow_system_linopy): class TestEffectResults: - def test_shares(self, basic_flow_system_linopy): - flow_system = basic_flow_system_linopy + def test_shares(self, basic_flow_system_linopy_coords, coords_config): + flow_system, coords_config = basic_flow_system_linopy_coords, coords_config flow_system.effects['costs'].specific_share_to_other_effects_operation['Effect1'] = 0.5 flow_system.add_elements( fx.Effect( @@ -221,6 +237,7 @@ def test_shares(self, basic_flow_system_linopy): Q_th=fx.Flow( 'Q_th', bus='Fernwärme', + size=fx.InvestParameters(specific_effects=10, minimum_size=20, optional=False), ), Q_fu=fx.Flow('Q_fu', bus='Gas'), ), diff --git a/tests/test_flow.py b/tests/test_flow.py index 2ee609f68..357b0a352 100644 --- a/tests/test_flow.py +++ b/tests/test_flow.py @@ -11,9 +11,9 @@ class TestFlowModel: """Test the FlowModel class.""" - def test_flow_minimal(self, basic_flow_system_linopy): + def test_flow_minimal(self, basic_flow_system_linopy_coords, coords_config): """Test that flow model constraints are correctly generated.""" - flow_system = basic_flow_system_linopy + flow_system, coords_config = basic_flow_system_linopy_coords, coords_config timesteps = flow_system.timesteps flow = fx.Flow('Wärme', bus='Fernwärme', size=100) @@ -36,8 +36,8 @@ def test_flow_minimal(self, basic_flow_system_linopy): ) assert_sets_equal(set(flow.submodel.constraints), {'Sink(Wärme)|total_flow_hours'}, msg='Incorrect constraints') - def test_flow(self, basic_flow_system_linopy): - flow_system = basic_flow_system_linopy + def test_flow(self, basic_flow_system_linopy_coords, coords_config): + flow_system, coords_config = basic_flow_system_linopy_coords, coords_config timesteps = flow_system.timesteps flow = fx.Flow( 'Wärme', @@ -93,8 +93,8 @@ def test_flow(self, basic_flow_system_linopy): msg='Incorrect constraints', ) - def test_effects_per_flow_hour(self, basic_flow_system_linopy): - flow_system = basic_flow_system_linopy + def test_effects_per_flow_hour(self, basic_flow_system_linopy_coords, coords_config): + flow_system, coords_config = basic_flow_system_linopy_coords, coords_config timesteps = flow_system.timesteps costs_per_flow_hour = xr.DataArray(np.linspace(1, 2, timesteps.size), coords=(timesteps,)) @@ -133,8 +133,8 @@ def test_effects_per_flow_hour(self, basic_flow_system_linopy): class TestFlowInvestModel: """Test the FlowModel class.""" - def test_flow_invest(self, basic_flow_system_linopy): - flow_system = basic_flow_system_linopy + def test_flow_invest(self, basic_flow_system_linopy_coords, coords_config): + flow_system, coords_config = basic_flow_system_linopy_coords, coords_config timesteps = flow_system.timesteps flow = fx.Flow( @@ -192,8 +192,8 @@ def test_flow_invest(self, basic_flow_system_linopy): * xr.DataArray(np.linspace(0.5, 1, timesteps.size), coords=(timesteps,)), ) - def test_flow_invest_optional(self, basic_flow_system_linopy): - flow_system = basic_flow_system_linopy + def test_flow_invest_optional(self, basic_flow_system_linopy_coords, coords_config): + flow_system, coords_config = basic_flow_system_linopy_coords, coords_config timesteps = flow_system.timesteps flow = fx.Flow( @@ -260,8 +260,8 @@ def test_flow_invest_optional(self, basic_flow_system_linopy): flow.submodel.variables['Sink(Wärme)|size'] >= flow.submodel.variables['Sink(Wärme)|is_invested'] * 20, ) - def test_flow_invest_optional_wo_min_size(self, basic_flow_system_linopy): - flow_system = basic_flow_system_linopy + def test_flow_invest_optional_wo_min_size(self, basic_flow_system_linopy_coords, coords_config): + flow_system, coords_config = basic_flow_system_linopy_coords, coords_config timesteps = flow_system.timesteps flow = fx.Flow( @@ -328,8 +328,8 @@ def test_flow_invest_optional_wo_min_size(self, basic_flow_system_linopy): flow.submodel.variables['Sink(Wärme)|size'] >= flow.submodel.variables['Sink(Wärme)|is_invested'] * 1e-5, ) - def test_flow_invest_wo_min_size_non_optional(self, basic_flow_system_linopy): - flow_system = basic_flow_system_linopy + def test_flow_invest_wo_min_size_non_optional(self, basic_flow_system_linopy_coords, coords_config): + flow_system, coords_config = basic_flow_system_linopy_coords, coords_config timesteps = flow_system.timesteps flow = fx.Flow( @@ -382,9 +382,9 @@ def test_flow_invest_wo_min_size_non_optional(self, basic_flow_system_linopy): * xr.DataArray(np.linspace(0.5, 1, timesteps.size), coords=(timesteps,)), ) - def test_flow_invest_fixed_size(self, basic_flow_system_linopy): + def test_flow_invest_fixed_size(self, basic_flow_system_linopy_coords, coords_config): """Test flow with fixed size investment.""" - flow_system = basic_flow_system_linopy + flow_system, coords_config = basic_flow_system_linopy_coords, coords_config timesteps = flow_system.timesteps flow = fx.Flow( @@ -412,9 +412,9 @@ def test_flow_invest_fixed_size(self, basic_flow_system_linopy): flow.submodel.flow_rate, model.add_variables(lower=0.2 * 75, upper=0.9 * 75, coords=(timesteps,)) ) - def test_flow_invest_with_effects(self, basic_flow_system_linopy): + def test_flow_invest_with_effects(self, basic_flow_system_linopy_coords, coords_config): """Test flow with investment effects.""" - flow_system = basic_flow_system_linopy + flow_system, coords_config = basic_flow_system_linopy_coords, coords_config # Create effects co2 = fx.Effect(label='CO2', unit='ton', description='CO2 emissions') @@ -453,9 +453,9 @@ def test_flow_invest_with_effects(self, basic_flow_system_linopy): + flow.submodel.variables['Sink(Wärme)|size'] * 0.1, ) - def test_flow_invest_divest_effects(self, basic_flow_system_linopy): + def test_flow_invest_divest_effects(self, basic_flow_system_linopy_coords, coords_config): """Test flow with divestment effects.""" - flow_system = basic_flow_system_linopy + flow_system, coords_config = basic_flow_system_linopy_coords, coords_config flow = fx.Flow( 'Wärme', @@ -483,8 +483,8 @@ def test_flow_invest_divest_effects(self, basic_flow_system_linopy): class TestFlowOnModel: """Test the FlowModel class.""" - def test_flow_on(self, basic_flow_system_linopy): - flow_system = basic_flow_system_linopy + def test_flow_on(self, basic_flow_system_linopy_coords, coords_config): + flow_system, coords_config = basic_flow_system_linopy_coords, coords_config timesteps = flow_system.timesteps flow = fx.Flow( 'Wärme', @@ -547,8 +547,8 @@ def test_flow_on(self, basic_flow_system_linopy): == (flow.submodel.variables['Sink(Wärme)|on'] * model.hours_per_step).sum(), ) - def test_effects_per_running_hour(self, basic_flow_system_linopy): - flow_system = basic_flow_system_linopy + def test_effects_per_running_hour(self, basic_flow_system_linopy_coords, coords_config): + flow_system, coords_config = basic_flow_system_linopy_coords, coords_config timesteps = flow_system.timesteps costs_per_running_hour = xr.DataArray(np.linspace(1, 2, timesteps.size), coords=(timesteps,)) @@ -601,9 +601,9 @@ def test_effects_per_running_hour(self, basic_flow_system_linopy): == flow.submodel.variables['Sink(Wärme)|on'] * model.hours_per_step * co2_per_running_hour, ) - def test_consecutive_on_hours(self, basic_flow_system_linopy): + def test_consecutive_on_hours(self, basic_flow_system_linopy_coords, coords_config): """Test flow with minimum and maximum consecutive on hours.""" - flow_system = basic_flow_system_linopy + flow_system, coords_config = basic_flow_system_linopy_coords, coords_config timesteps = flow_system.timesteps flow = fx.Flow( @@ -684,9 +684,9 @@ def test_consecutive_on_hours(self, basic_flow_system_linopy): * 2, ) - def test_consecutive_on_hours_previous(self, basic_flow_system_linopy): + def test_consecutive_on_hours_previous(self, basic_flow_system_linopy_coords, coords_config): """Test flow with minimum and maximum consecutive on hours.""" - flow_system = basic_flow_system_linopy + flow_system, coords_config = basic_flow_system_linopy_coords, coords_config timesteps = flow_system.timesteps flow = fx.Flow( @@ -766,9 +766,9 @@ def test_consecutive_on_hours_previous(self, basic_flow_system_linopy): * 2, ) - def test_consecutive_off_hours(self, basic_flow_system_linopy): + def test_consecutive_off_hours(self, basic_flow_system_linopy_coords, coords_config): """Test flow with minimum and maximum consecutive off hours.""" - flow_system = basic_flow_system_linopy + flow_system, coords_config = basic_flow_system_linopy_coords, coords_config timesteps = flow_system.timesteps flow = fx.Flow( @@ -849,9 +849,9 @@ def test_consecutive_off_hours(self, basic_flow_system_linopy): * 4, ) - def test_consecutive_off_hours_previous(self, basic_flow_system_linopy): + def test_consecutive_off_hours_previous(self, basic_flow_system_linopy_coords, coords_config): """Test flow with minimum and maximum consecutive off hours.""" - flow_system = basic_flow_system_linopy + flow_system, coords_config = basic_flow_system_linopy_coords, coords_config timesteps = flow_system.timesteps flow = fx.Flow( @@ -933,9 +933,9 @@ def test_consecutive_off_hours_previous(self, basic_flow_system_linopy): * 4, ) - def test_switch_on_constraints(self, basic_flow_system_linopy): + def test_switch_on_constraints(self, basic_flow_system_linopy_coords, coords_config): """Test flow with constraints on the number of startups.""" - flow_system = basic_flow_system_linopy + flow_system, coords_config = basic_flow_system_linopy_coords, coords_config flow = fx.Flow( 'Wärme', @@ -992,9 +992,9 @@ def test_switch_on_constraints(self, basic_flow_system_linopy): model.variables['Sink(Wärme)->costs(operation)'] == flow.submodel.variables['Sink(Wärme)|switch|on'] * 100, ) - def test_on_hours_limits(self, basic_flow_system_linopy): + def test_on_hours_limits(self, basic_flow_system_linopy_coords, coords_config): """Test flow with limits on total on hours.""" - flow_system = basic_flow_system_linopy + flow_system, coords_config = basic_flow_system_linopy_coords, coords_config flow = fx.Flow( 'Wärme', @@ -1031,8 +1031,8 @@ def test_on_hours_limits(self, basic_flow_system_linopy): class TestFlowOnInvestModel: """Test the FlowModel class.""" - def test_flow_on_invest_optional(self, basic_flow_system_linopy): - flow_system = basic_flow_system_linopy + def test_flow_on_invest_optional(self, basic_flow_system_linopy_coords, coords_config): + flow_system, coords_config = basic_flow_system_linopy_coords, coords_config timesteps = flow_system.timesteps flow = fx.Flow( 'Wärme', @@ -1130,8 +1130,8 @@ def test_flow_on_invest_optional(self, basic_flow_system_linopy): flow.submodel.variables['Sink(Wärme)|flow_rate'] <= flow.submodel.variables['Sink(Wärme)|size'] * 0.8, ) - def test_flow_on_invest_non_optional(self, basic_flow_system_linopy): - flow_system = basic_flow_system_linopy + def test_flow_on_invest_non_optional(self, basic_flow_system_linopy_coords, coords_config): + flow_system, coords_config = basic_flow_system_linopy_coords, coords_config timesteps = flow_system.timesteps flow = fx.Flow( 'Wärme', @@ -1222,9 +1222,9 @@ def test_flow_on_invest_non_optional(self, basic_flow_system_linopy): class TestFlowWithFixedProfile: """Test Flow with fixed relative profile.""" - def test_fixed_relative_profile(self, basic_flow_system_linopy): + def test_fixed_relative_profile(self, basic_flow_system_linopy_coords, coords_config): """Test flow with a fixed relative profile.""" - flow_system = basic_flow_system_linopy + flow_system, coords_config = basic_flow_system_linopy_coords, coords_config timesteps = flow_system.timesteps # Create a time-varying profile (e.g., for a load or renewable generation) @@ -1242,9 +1242,9 @@ def test_fixed_relative_profile(self, basic_flow_system_linopy): model.add_variables(lower=profile * 100, upper=profile * 100, coords=(timesteps,)), ) - def test_fixed_profile_with_investment(self, basic_flow_system_linopy): + def test_fixed_profile_with_investment(self, basic_flow_system_linopy_coords, coords_config): """Test flow with fixed profile and investment.""" - flow_system = basic_flow_system_linopy + flow_system, coords_config = basic_flow_system_linopy_coords, coords_config timesteps = flow_system.timesteps # Create a fixed profile diff --git a/tests/test_linear_converter.py b/tests/test_linear_converter.py index 322a5f6f0..e90d52f40 100644 --- a/tests/test_linear_converter.py +++ b/tests/test_linear_converter.py @@ -12,9 +12,9 @@ class TestLinearConverterModel: """Test the LinearConverterModel class.""" - def test_basic_linear_converter(self, basic_flow_system_linopy): + def test_basic_linear_converter(self, basic_flow_system_linopy_coords, coords_config): """Test basic initialization and modeling of a LinearConverter.""" - flow_system = basic_flow_system_linopy + flow_system, coords_config = basic_flow_system_linopy_coords, coords_config # Create input and output flows input_flow = fx.Flow('input', bus='input_bus', size=100) @@ -45,9 +45,9 @@ def test_basic_linear_converter(self, basic_flow_system_linopy): input_flow.submodel.flow_rate * 0.8 == output_flow.submodel.flow_rate * 1.0, ) - def test_linear_converter_time_varying(self, basic_flow_system_linopy): + def test_linear_converter_time_varying(self, basic_flow_system_linopy_coords, coords_config): """Test a LinearConverter with time-varying conversion factors.""" - flow_system = basic_flow_system_linopy + flow_system, coords_config = basic_flow_system_linopy_coords, coords_config timesteps = flow_system.timesteps # Create time-varying efficiency (e.g., temperature-dependent) @@ -83,9 +83,9 @@ def test_linear_converter_time_varying(self, basic_flow_system_linopy): input_flow.submodel.flow_rate * efficiency_series == output_flow.submodel.flow_rate * 1.0, ) - def test_linear_converter_multiple_factors(self, basic_flow_system_linopy): + def test_linear_converter_multiple_factors(self, basic_flow_system_linopy_coords, coords_config): """Test a LinearConverter with multiple conversion factors.""" - flow_system = basic_flow_system_linopy + flow_system, coords_config = basic_flow_system_linopy_coords, coords_config # Create flows input_flow1 = fx.Flow('input1', bus='input_bus1', size=100) @@ -136,9 +136,9 @@ def test_linear_converter_multiple_factors(self, basic_flow_system_linopy): input_flow1.submodel.flow_rate * 0.2 == output_flow2.submodel.flow_rate * 0.3, ) - def test_linear_converter_with_on_off(self, basic_flow_system_linopy): + def test_linear_converter_with_on_off(self, basic_flow_system_linopy_coords, coords_config): """Test a LinearConverter with OnOffParameters.""" - flow_system = basic_flow_system_linopy + flow_system, coords_config = basic_flow_system_linopy_coords, coords_config # Create input and output flows input_flow = fx.Flow('input', bus='input_bus', size=100) @@ -193,9 +193,9 @@ def test_linear_converter_with_on_off(self, basic_flow_system_linopy): == model.variables['Converter|on'] * model.hours_per_step * 5, ) - def test_linear_converter_multidimensional(self, basic_flow_system_linopy): + def test_linear_converter_multidimensional(self, basic_flow_system_linopy_coords, coords_config): """Test LinearConverter with multiple inputs, outputs, and connections between them.""" - flow_system = basic_flow_system_linopy + flow_system, coords_config = basic_flow_system_linopy_coords, coords_config # Create a more complex setup with multiple flows input_flow1 = fx.Flow('fuel', bus='fuel_bus', size=100) @@ -247,9 +247,9 @@ def test_linear_converter_multidimensional(self, basic_flow_system_linopy): input_flow1.submodel.flow_rate * 0.1 == output_flow2.submodel.flow_rate * 0.5, ) - def test_edge_case_time_varying_conversion(self, basic_flow_system_linopy): + def test_edge_case_time_varying_conversion(self, basic_flow_system_linopy_coords, coords_config): """Test edge case with extreme time-varying conversion factors.""" - flow_system = basic_flow_system_linopy + flow_system, coords_config = basic_flow_system_linopy_coords, coords_config timesteps = flow_system.timesteps # Create fluctuating conversion efficiency (e.g., for a heat pump) @@ -288,9 +288,9 @@ def test_edge_case_time_varying_conversion(self, basic_flow_system_linopy): input_flow.submodel.flow_rate * fluctuating_cop == output_flow.submodel.flow_rate * 1.0, ) - def test_piecewise_conversion(self, basic_flow_system_linopy): + def test_piecewise_conversion(self, basic_flow_system_linopy_coords, coords_config): """Test a LinearConverter with PiecewiseConversion.""" - flow_system = basic_flow_system_linopy + flow_system, coords_config = basic_flow_system_linopy_coords, coords_config timesteps = flow_system.timesteps # Create input and output flows @@ -377,9 +377,9 @@ def test_piecewise_conversion(self, basic_flow_system_linopy): <= 1, ) - def test_piecewise_conversion_with_onoff(self, basic_flow_system_linopy): + def test_piecewise_conversion_with_onoff(self, basic_flow_system_linopy_coords, coords_config): """Test a LinearConverter with PiecewiseConversion and OnOffParameters.""" - flow_system = basic_flow_system_linopy + flow_system, coords_config = basic_flow_system_linopy_coords, coords_config timesteps = flow_system.timesteps # Create input and output flows diff --git a/tests/test_storage.py b/tests/test_storage.py index f6b6f2079..479f66a87 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -11,9 +11,9 @@ class TestStorageModel: """Test that storage model variables and constraints are correctly generated.""" - def test_basic_storage(self, basic_flow_system_linopy): + def test_basic_storage(self, basic_flow_system_linopy_coords, coords_config): """Test that basic storage model variables and constraints are correctly generated.""" - flow_system = basic_flow_system_linopy + flow_system, coords_config = basic_flow_system_linopy_coords, coords_config timesteps = flow_system.timesteps timesteps_extra = flow_system.timesteps_extra @@ -85,9 +85,9 @@ def test_basic_storage(self, basic_flow_system_linopy): model.variables['TestStorage|charge_state'].isel(time=0) == 0, ) - def test_lossy_storage(self, basic_flow_system_linopy): + def test_lossy_storage(self, basic_flow_system_linopy_coords, coords_config): """Test that basic storage model variables and constraints are correctly generated.""" - flow_system = basic_flow_system_linopy + flow_system, coords_config = basic_flow_system_linopy_coords, coords_config timesteps = flow_system.timesteps timesteps_extra = flow_system.timesteps_extra @@ -170,9 +170,9 @@ def test_lossy_storage(self, basic_flow_system_linopy): model.variables['TestStorage|charge_state'].isel(time=0) == 0, ) - def test_charge_state_bounds(self, basic_flow_system_linopy): + def test_charge_state_bounds(self, basic_flow_system_linopy_coords, coords_config): """Test that basic storage model variables and constraints are correctly generated.""" - flow_system = basic_flow_system_linopy + flow_system, coords_config = basic_flow_system_linopy_coords, coords_config timesteps = flow_system.timesteps timesteps_extra = flow_system.timesteps_extra @@ -251,9 +251,9 @@ def test_charge_state_bounds(self, basic_flow_system_linopy): model.variables['TestStorage|charge_state'].isel(time=0) == 3, ) - def test_storage_with_investment(self, basic_flow_system_linopy): + def test_storage_with_investment(self, basic_flow_system_linopy_coords, coords_config): """Test storage with investment parameters.""" - flow_system = basic_flow_system_linopy + flow_system, coords_config = basic_flow_system_linopy_coords, coords_config # Create storage with investment parameters storage = fx.Storage( @@ -297,9 +297,9 @@ def test_storage_with_investment(self, basic_flow_system_linopy): model.variables['InvestStorage|size'] >= model.variables['InvestStorage|is_invested'] * 20, ) - def test_storage_with_final_state_constraints(self, basic_flow_system_linopy): + def test_storage_with_final_state_constraints(self, basic_flow_system_linopy_coords, coords_config): """Test storage with final state constraints.""" - flow_system = basic_flow_system_linopy + flow_system, coords_config = basic_flow_system_linopy_coords, coords_config # Create storage with final state constraints storage = fx.Storage( @@ -342,9 +342,9 @@ def test_storage_with_final_state_constraints(self, basic_flow_system_linopy): model.variables['FinalStateStorage|charge_state'].isel(time=-1) <= 25, ) - def test_storage_cyclic_initialization(self, basic_flow_system_linopy): + def test_storage_cyclic_initialization(self, basic_flow_system_linopy_coords, coords_config): """Test storage with cyclic initialization.""" - flow_system = basic_flow_system_linopy + flow_system, coords_config = basic_flow_system_linopy_coords, coords_config # Create storage with cyclic initialization storage = fx.Storage( @@ -375,9 +375,9 @@ def test_storage_cyclic_initialization(self, basic_flow_system_linopy): 'prevent_simultaneous', [True, False], ) - def test_simultaneous_charge_discharge(self, basic_flow_system_linopy, prevent_simultaneous): + def test_simultaneous_charge_discharge(self, basic_flow_system_linopy_coords, coords_config, prevent_simultaneous): """Test prevent_simultaneous_charge_and_discharge parameter.""" - flow_system = basic_flow_system_linopy + flow_system, coords_config = basic_flow_system_linopy_coords, coords_config # Create storage with or without simultaneous charge/discharge prevention storage = fx.Storage( @@ -424,10 +424,16 @@ def test_simultaneous_charge_discharge(self, basic_flow_system_linopy, prevent_s ], ) def test_investment_parameters( - self, basic_flow_system_linopy, optional, minimum_size, expected_vars, expected_constraints + self, + basic_flow_system_linopy_coords, + coords_config, + optional, + minimum_size, + expected_vars, + expected_constraints, ): """Test different investment parameter combinations.""" - flow_system = basic_flow_system_linopy + flow_system, coords_config = basic_flow_system_linopy_coords, coords_config # Create investment parameters invest_params = { From 5510297e0ba5f63887f2b531384c39ce923ba1ab Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 23 Jul 2025 22:15:03 +0200 Subject: [PATCH 266/336] Fix Effects dataset computation in case of empty effects --- flixopt/interface.py | 20 ++++++------ flixopt/results.py | 76 +++++++++++++++++++++++++++++--------------- tests/test_effect.py | 29 ++++++++++------- 3 files changed, 76 insertions(+), 49 deletions(-) diff --git a/flixopt/interface.py b/flixopt/interface.py index 374c3fb44..8bcab1de0 100644 --- a/flixopt/interface.py +++ b/flixopt/interface.py @@ -339,15 +339,13 @@ def use_consecutive_off_hours(self) -> bool: @property def use_switch_on(self) -> bool: """Determines wether a Variable for SWITCH-ON is needed or not""" - return ( - any( - param not in (None, {}) - for param in [ - self.effects_per_switch_on, - self.switch_on_total_max, - self.on_hours_total_min, - self.on_hours_total_max, - ] - ) - or self.force_switch_on + if self.force_switch_on: + return True + + return any( + param is not None or param != {} + for param in [ + self.effects_per_switch_on, + self.switch_on_total_max, + ] ) diff --git a/flixopt/results.py b/flixopt/results.py index 512af4ad7..dfe6a759b 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -200,7 +200,7 @@ def __init__( self._flow_rates = None self._flow_hours = None self._sizes = None - self._effects_per_component = {'operation': None, 'invest': None, 'total': None} + self._effects_per_component = None def __getitem__(self, key: str) -> Union['ComponentResults', 'BusResults', 'EffectResults', 'FlowResults']: if key in self.components: @@ -312,20 +312,24 @@ def filter_solution( startswith=startswith, ) - def effects_per_component(self, mode: Literal['operation', 'invest', 'total'] = 'total') -> xr.Dataset: - """Returns a dataset containing effect totals for each components (including their flows). - - Args: - mode: Which effects to contain. (operation, invest, total) + @property + def effects_per_component(self) -> xr.Dataset: + """Returns a dataset containing effect results for each mode, aggregated by Component Returns: An xarray Dataset with an additional component dimension and effects as variables. """ - if mode not in ['operation', 'invest', 'total']: - raise ValueError(f'Invalid mode {mode}') - if self._effects_per_component[mode] is None: - self._effects_per_component[mode] = self._create_effects_dataset(mode) - return self._effects_per_component[mode] + if self._effects_per_component is None: + self._effects_per_component = xr.Dataset( + { + mode: self._create_effects_dataset(mode).to_dataarray('effect', name=mode) + for mode in ['operation', 'invest', 'total'] + } + ) + dim_order = ['time', 'year', 'scenario', 'component', 'effect'] + self._effects_per_component = self._effects_per_component.transpose(*dim_order, missing_dims='ignore') + + return self._effects_per_component def flow_rates( self, @@ -580,7 +584,7 @@ def _compute_effect_total( total = xr.DataArray(np.nan) return total.rename(f'{element}->{effect}({mode})') - def _create_effects_dataset(self, mode: Literal['operation', 'invest', 'total'] = 'total') -> xr.Dataset: + def _create_effects_dataset(self, mode: Literal['operation', 'invest', 'total']) -> xr.Dataset: """Creates a dataset containing effect totals for all components (including their flows). The dataset does contain the direct as well as the indirect effects of each component. @@ -590,24 +594,44 @@ def _create_effects_dataset(self, mode: Literal['operation', 'invest', 'total'] Returns: An xarray Dataset with components as dimension and effects as variables. """ - # Create an empty dataset ds = xr.Dataset() + all_arrays = {} + template = None # Template is needed to determine the dimensions of the arrays. This handles the case of no shares for an effect + + components_list = list(self.components) - # Add each effect as a variable to the dataset + # First pass: collect arrays and find template for effect in self.effects: - # Create a list of DataArrays, one for each component - component_arrays = [ - self._compute_effect_total(element=component, effect=effect, mode=mode, include_flows=True).expand_dims( - component=[component] - ) # Add component dimension to each array - for component in list(self.components) - ] + effect_arrays = [] + for component in components_list: + da = self._compute_effect_total(element=component, effect=effect, mode=mode, include_flows=True) + effect_arrays.append(da) + + if template is None and (da.dims or not da.isnull().all()): + template = da + + all_arrays[effect] = effect_arrays + + # Ensure we have a template + if template is None: + raise ValueError( + f"No template with proper dimensions found for mode '{mode}'. " + f'All computed arrays are scalars, which indicates a data issue.' + ) + + # Second pass: process all effects (guaranteed to include all) + for effect in self.effects: + dataarrays = all_arrays[effect] + component_arrays = [] + + for component, arr in zip(components_list, dataarrays, strict=False): + # Expand scalar NaN arrays to match template dimensions + if not arr.dims and np.isnan(arr.item()): + arr = xr.full_like(template, np.nan, dtype=float).rename(arr.name) + + component_arrays.append(arr.expand_dims(component=[component])) - # Combine all components into one DataArray for this effect - if component_arrays: - effect_array = xr.concat(component_arrays, dim='component', coords='minimal') - # Add this effect as a variable to the dataset - ds[effect] = effect_array + ds[effect] = xr.concat(component_arrays, dim='component', coords='minimal') # For now include a test to ensure correctness suffix = { diff --git a/tests/test_effect.py b/tests/test_effect.py index a0a6fecfe..cc4841900 100644 --- a/tests/test_effect.py +++ b/tests/test_effect.py @@ -266,58 +266,63 @@ def test_shares(self, basic_flow_system_linopy_coords, coords_config): np.testing.assert_allclose(results.effect_share_factors['invest'][key].values, value) xr.testing.assert_allclose( - results.effects_per_component('operation').sum('component')['costs'], + results.effects_per_component['operation'].sum('component').sel(effect='costs', drop=True), results.solution['costs(operation)|total_per_timestep'].fillna(0), ) xr.testing.assert_allclose( - results.effects_per_component('operation').sum('component')['Effect1'], + results.effects_per_component['operation'].sum('component').sel(effect='Effect1', drop=True), results.solution['Effect1(operation)|total_per_timestep'].fillna(0), ) xr.testing.assert_allclose( - results.effects_per_component('operation').sum('component')['Effect2'], + results.effects_per_component['operation'].sum('component').sel(effect='Effect2', drop=True), results.solution['Effect2(operation)|total_per_timestep'].fillna(0), ) xr.testing.assert_allclose( - results.effects_per_component('operation').sum('component')['Effect3'], + results.effects_per_component['operation'].sum('component').sel(effect='Effect3', drop=True), results.solution['Effect3(operation)|total_per_timestep'].fillna(0), ) # Invest mode checks xr.testing.assert_allclose( - results.effects_per_component('invest').sum('component')['costs'], results.solution['costs(invest)|total'] + results.effects_per_component['invest'].sum('component').sel(effect='costs', drop=True), + results.solution['costs(invest)|total'], ) xr.testing.assert_allclose( - results.effects_per_component('invest').sum('component')['Effect1'], + results.effects_per_component['invest'].sum('component').sel(effect='Effect1', drop=True), results.solution['Effect1(invest)|total'], ) xr.testing.assert_allclose( - results.effects_per_component('invest').sum('component')['Effect2'], + results.effects_per_component['invest'].sum('component').sel(effect='Effect2', drop=True), results.solution['Effect2(invest)|total'], ) xr.testing.assert_allclose( - results.effects_per_component('invest').sum('component')['Effect3'], + results.effects_per_component['invest'].sum('component').sel(effect='Effect3', drop=True), results.solution['Effect3(invest)|total'], ) # Total mode checks xr.testing.assert_allclose( - results.effects_per_component('total').sum('component')['costs'], results.solution['costs|total'] + results.effects_per_component['total'].sum('component').sel(effect='costs', drop=True), + results.solution['costs|total'], ) xr.testing.assert_allclose( - results.effects_per_component('total').sum('component')['Effect1'], results.solution['Effect1|total'] + results.effects_per_component['total'].sum('component').sel(effect='Effect1', drop=True), + results.solution['Effect1|total'], ) xr.testing.assert_allclose( - results.effects_per_component('total').sum('component')['Effect2'], results.solution['Effect2|total'] + results.effects_per_component['total'].sum('component').sel(effect='Effect2', drop=True), + results.solution['Effect2|total'], ) xr.testing.assert_allclose( - results.effects_per_component('total').sum('component')['Effect3'], results.solution['Effect3|total'] + results.effects_per_component['total'].sum('component').sel(effect='Effect3', drop=True), + results.solution['Effect3|total'], ) From b694dbe1b7605495a1e5902fdbacd8f7e3d24877 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 24 Jul 2025 13:13:41 +0200 Subject: [PATCH 267/336] Update Test for multiple dims Fix Dim order in scaled_bounds_with_state Bugfix logic in .use_switch_on --- flixopt/interface.py | 6 +- flixopt/modeling.py | 4 +- tests/test_flow.py | 220 ++++++++++++++++++++++++++----------------- 3 files changed, 140 insertions(+), 90 deletions(-) diff --git a/flixopt/interface.py b/flixopt/interface.py index 8bcab1de0..cae1757c7 100644 --- a/flixopt/interface.py +++ b/flixopt/interface.py @@ -246,8 +246,8 @@ def maximum_or_fixed_size(self) -> NonTemporalData: class OnOffParameters(Interface): def __init__( self, - effects_per_switch_on: Optional['NonTemporalEffectsUser'] = None, - effects_per_running_hour: Optional['NonTemporalEffectsUser'] = None, + effects_per_switch_on: Optional['TemporalEffectsUser'] = None, + effects_per_running_hour: Optional['TemporalEffectsUser'] = None, on_hours_total_min: Optional[int] = None, on_hours_total_max: Optional[int] = None, consecutive_on_hours_min: Optional[TemporalDataUser] = None, @@ -343,7 +343,7 @@ def use_switch_on(self) -> bool: return True return any( - param is not None or param != {} + param is not None and param != {} for param in [ self.effects_per_switch_on, self.switch_on_total_max, diff --git a/flixopt/modeling.py b/flixopt/modeling.py index 14f8c45f3..11880c5e8 100644 --- a/flixopt/modeling.py +++ b/flixopt/modeling.py @@ -536,8 +536,8 @@ def scaled_bounds_with_state( ) scaling_upper = model.add_constraints(variable <= scaling_variable * rel_upper, name=f'{name}|ub2') - big_m_upper = scaling_max * rel_upper - big_m_lower = np.maximum(CONFIG.modeling.EPSILON, scaling_min * rel_lower) + big_m_upper = rel_upper * scaling_max + big_m_lower = np.maximum(CONFIG.modeling.EPSILON, rel_lower * scaling_min) binary_upper = model.add_constraints(variable_state * big_m_upper >= variable, name=f'{name}|ub1') binary_lower = model.add_constraints(variable_state * big_m_lower <= variable, name=f'{name}|lb1') diff --git a/tests/test_flow.py b/tests/test_flow.py index 357b0a352..0d60a8bfe 100644 --- a/tests/test_flow.py +++ b/tests/test_flow.py @@ -14,7 +14,7 @@ class TestFlowModel: def test_flow_minimal(self, basic_flow_system_linopy_coords, coords_config): """Test that flow model constraints are correctly generated.""" flow_system, coords_config = basic_flow_system_linopy_coords, coords_config - timesteps = flow_system.timesteps + flow = fx.Flow('Wärme', bus='Fernwärme', size=100) flow_system.add_elements(fx.Sink('Sink', sink=flow)) @@ -24,10 +24,12 @@ def test_flow_minimal(self, basic_flow_system_linopy_coords, coords_config): assert_conequal( model.constraints['Sink(Wärme)|total_flow_hours'], flow.submodel.variables['Sink(Wärme)|total_flow_hours'] - == (flow.submodel.variables['Sink(Wärme)|flow_rate'] * model.hours_per_step).sum(), + == (flow.submodel.variables['Sink(Wärme)|flow_rate'] * model.hours_per_step).sum('time'), + ) + assert_var_equal(flow.submodel.flow_rate, model.add_variables(lower=0, upper=100, coords=model.get_coords())) + assert_var_equal( + flow.submodel.total_flow_hours, model.add_variables(lower=0, coords=model.get_coords(['year', 'scenario'])) ) - assert_var_equal(flow.submodel.flow_rate, model.add_variables(lower=0, upper=100, coords=(timesteps,))) - assert_var_equal(flow.submodel.total_flow_hours, model.add_variables(lower=0)) assert_sets_equal( set(flow.submodel.variables), @@ -39,6 +41,7 @@ def test_flow_minimal(self, basic_flow_system_linopy_coords, coords_config): def test_flow(self, basic_flow_system_linopy_coords, coords_config): flow_system, coords_config = basic_flow_system_linopy_coords, coords_config timesteps = flow_system.timesteps + flow = fx.Flow( 'Wärme', bus='Fernwärme', @@ -58,17 +61,23 @@ def test_flow(self, basic_flow_system_linopy_coords, coords_config): assert_conequal( model.constraints['Sink(Wärme)|total_flow_hours'], flow.submodel.variables['Sink(Wärme)|total_flow_hours'] - == (flow.submodel.variables['Sink(Wärme)|flow_rate'] * model.hours_per_step).sum(), + == (flow.submodel.variables['Sink(Wärme)|flow_rate'] * model.hours_per_step).sum('time'), ) - assert_var_equal(flow.submodel.total_flow_hours, model.add_variables(lower=10, upper=1000)) + assert_var_equal( + flow.submodel.total_flow_hours, + model.add_variables(lower=10, upper=1000, coords=model.get_coords(['year', 'scenario'])), + ) + + assert flow.relative_minimum.dims == tuple(model.get_coords()) + assert flow.relative_maximum.dims == tuple(model.get_coords()) assert_var_equal( flow.submodel.flow_rate, model.add_variables( - lower=np.linspace(0, 0.5, timesteps.size) * 100, - upper=np.linspace(0.5, 1, timesteps.size) * 100, - coords=(timesteps,), + lower=flow.relative_minimum * 100, + upper=flow.relative_maximum * 100, + coords=model.get_coords(), ), ) @@ -168,28 +177,32 @@ def test_flow_invest(self, basic_flow_system_linopy_coords, coords_config): ) # size - assert_var_equal(model['Sink(Wärme)|size'], model.add_variables(lower=20, upper=100)) + assert_var_equal( + model['Sink(Wärme)|size'], + model.add_variables(lower=20, upper=100, coords=model.get_coords(['year', 'scenario'])), + ) + + assert flow.relative_minimum.dims == tuple(model.get_coords()) + assert flow.relative_maximum.dims == tuple(model.get_coords()) # flow_rate assert_var_equal( flow.submodel.flow_rate, model.add_variables( - lower=np.linspace(0.1, 0.5, timesteps.size) * 20, - upper=np.linspace(0.5, 1, timesteps.size) * 100, - coords=(timesteps,), + lower=flow.relative_minimum * 20, + upper=flow.relative_maximum * 100, + coords=model.get_coords(), ), ) assert_conequal( model.constraints['Sink(Wärme)|flow_rate|lb'], flow.submodel.variables['Sink(Wärme)|flow_rate'] - >= flow.submodel.variables['Sink(Wärme)|size'] - * xr.DataArray(np.linspace(0.1, 0.5, timesteps.size), coords=(timesteps,)), + >= flow.submodel.variables['Sink(Wärme)|size'] * flow.relative_minimum, ) assert_conequal( model.constraints['Sink(Wärme)|flow_rate|ub'], flow.submodel.variables['Sink(Wärme)|flow_rate'] - <= flow.submodel.variables['Sink(Wärme)|size'] - * xr.DataArray(np.linspace(0.5, 1, timesteps.size), coords=(timesteps,)), + <= flow.submodel.variables['Sink(Wärme)|size'] * flow.relative_maximum, ) def test_flow_invest_optional(self, basic_flow_system_linopy_coords, coords_config): @@ -224,30 +237,37 @@ def test_flow_invest_optional(self, basic_flow_system_linopy_coords, coords_conf msg='Incorrect constraints', ) - assert_var_equal(model['Sink(Wärme)|size'], model.add_variables(lower=0, upper=100)) + assert_var_equal( + model['Sink(Wärme)|size'], + model.add_variables(lower=0, upper=100, coords=model.get_coords(['year', 'scenario'])), + ) + + assert_var_equal( + model['Sink(Wärme)|is_invested'], + model.add_variables(binary=True, coords=model.get_coords(['year', 'scenario'])), + ) - assert_var_equal(model['Sink(Wärme)|is_invested'], model.add_variables(binary=True)) + assert flow.relative_minimum.dims == tuple(model.get_coords()) + assert flow.relative_maximum.dims == tuple(model.get_coords()) # flow_rate assert_var_equal( flow.submodel.flow_rate, model.add_variables( lower=0, # Optional investment - upper=np.linspace(0.5, 1, timesteps.size) * 100, - coords=(timesteps,), + upper=flow.relative_maximum * 100, + coords=model.get_coords(), ), ) assert_conequal( model.constraints['Sink(Wärme)|flow_rate|lb'], flow.submodel.variables['Sink(Wärme)|flow_rate'] - >= flow.submodel.variables['Sink(Wärme)|size'] - * xr.DataArray(np.linspace(0.1, 0.5, timesteps.size), coords=(timesteps,)), + >= flow.submodel.variables['Sink(Wärme)|size'] * flow.relative_minimum, ) assert_conequal( model.constraints['Sink(Wärme)|flow_rate|ub'], flow.submodel.variables['Sink(Wärme)|flow_rate'] - <= flow.submodel.variables['Sink(Wärme)|size'] - * xr.DataArray(np.linspace(0.5, 1, timesteps.size), coords=(timesteps,)), + <= flow.submodel.variables['Sink(Wärme)|size'] * flow.relative_maximum, ) # Is invested @@ -292,30 +312,37 @@ def test_flow_invest_optional_wo_min_size(self, basic_flow_system_linopy_coords, msg='Incorrect constraints', ) - assert_var_equal(model['Sink(Wärme)|size'], model.add_variables(lower=0, upper=100)) + assert_var_equal( + model['Sink(Wärme)|size'], + model.add_variables(lower=0, upper=100, coords=model.get_coords(['year', 'scenario'])), + ) - assert_var_equal(model['Sink(Wärme)|is_invested'], model.add_variables(binary=True)) + assert_var_equal( + model['Sink(Wärme)|is_invested'], + model.add_variables(binary=True, coords=model.get_coords(['year', 'scenario'])), + ) + + assert flow.relative_minimum.dims == tuple(model.get_coords()) + assert flow.relative_maximum.dims == tuple(model.get_coords()) # flow_rate assert_var_equal( flow.submodel.flow_rate, model.add_variables( lower=0, # Optional investment - upper=np.linspace(0.5, 1, timesteps.size) * 100, - coords=(timesteps,), + upper=flow.relative_maximum * 100, + coords=model.get_coords(), ), ) assert_conequal( model.constraints['Sink(Wärme)|flow_rate|lb'], flow.submodel.variables['Sink(Wärme)|flow_rate'] - >= flow.submodel.variables['Sink(Wärme)|size'] - * xr.DataArray(np.linspace(0.1, 0.5, timesteps.size), coords=(timesteps,)), + >= flow.submodel.variables['Sink(Wärme)|size'] * flow.relative_minimum, ) assert_conequal( model.constraints['Sink(Wärme)|flow_rate|ub'], flow.submodel.variables['Sink(Wärme)|flow_rate'] - <= flow.submodel.variables['Sink(Wärme)|size'] - * xr.DataArray(np.linspace(0.5, 1, timesteps.size), coords=(timesteps,)), + <= flow.submodel.variables['Sink(Wärme)|size'] * flow.relative_maximum, ) # Is invested @@ -358,34 +385,37 @@ def test_flow_invest_wo_min_size_non_optional(self, basic_flow_system_linopy_coo msg='Incorrect constraints', ) - assert_var_equal(model['Sink(Wärme)|size'], model.add_variables(lower=1e-5, upper=100)) + assert_var_equal( + model['Sink(Wärme)|size'], + model.add_variables(lower=1e-5, upper=100, coords=model.get_coords(['year', 'scenario'])), + ) + + assert flow.relative_minimum.dims == tuple(model.get_coords()) + assert flow.relative_maximum.dims == tuple(model.get_coords()) # flow_rate assert_var_equal( flow.submodel.flow_rate, model.add_variables( - lower=np.linspace(0.1, 0.5, timesteps.size) * 1e-5, - upper=np.linspace(0.5, 1, timesteps.size) * 100, - coords=(timesteps,), + lower=flow.relative_minimum * 1e-5, + upper=flow.relative_maximum * 100, + coords=model.get_coords(), ), ) assert_conequal( model.constraints['Sink(Wärme)|flow_rate|lb'], flow.submodel.variables['Sink(Wärme)|flow_rate'] - >= flow.submodel.variables['Sink(Wärme)|size'] - * xr.DataArray(np.linspace(0.1, 0.5, timesteps.size), coords=(timesteps,)), + >= flow.submodel.variables['Sink(Wärme)|size'] * flow.relative_minimum, ) assert_conequal( model.constraints['Sink(Wärme)|flow_rate|ub'], flow.submodel.variables['Sink(Wärme)|flow_rate'] - <= flow.submodel.variables['Sink(Wärme)|size'] - * xr.DataArray(np.linspace(0.5, 1, timesteps.size), coords=(timesteps,)), + <= flow.submodel.variables['Sink(Wärme)|size'] * flow.relative_maximum, ) def test_flow_invest_fixed_size(self, basic_flow_system_linopy_coords, coords_config): """Test flow with fixed size investment.""" flow_system, coords_config = basic_flow_system_linopy_coords, coords_config - timesteps = flow_system.timesteps flow = fx.Flow( 'Wärme', @@ -405,11 +435,14 @@ def test_flow_invest_fixed_size(self, basic_flow_system_linopy_coords, coords_co ) # Check that size is fixed to 75 - assert_var_equal(flow.submodel.variables['Sink(Wärme)|size'], model.add_variables(lower=75, upper=75)) + assert_var_equal( + flow.submodel.variables['Sink(Wärme)|size'], + model.add_variables(lower=75, upper=75, coords=model.get_coords(['year', 'scenario'])), + ) # Check flow rate bounds assert_var_equal( - flow.submodel.flow_rate, model.add_variables(lower=0.2 * 75, upper=0.9 * 75, coords=(timesteps,)) + flow.submodel.flow_rate, model.add_variables(lower=0.2 * 75, upper=0.9 * 75, coords=model.get_coords()) ) def test_flow_invest_with_effects(self, basic_flow_system_linopy_coords, coords_config): @@ -485,13 +518,13 @@ class TestFlowOnModel: def test_flow_on(self, basic_flow_system_linopy_coords, coords_config): flow_system, coords_config = basic_flow_system_linopy_coords, coords_config - timesteps = flow_system.timesteps + flow = fx.Flow( 'Wärme', bus='Fernwärme', size=100, - relative_minimum=xr.DataArray(0.2, coords=(timesteps,)), - relative_maximum=xr.DataArray(0.8, coords=(timesteps,)), + relative_minimum=0.2, + relative_maximum=0.8, on_off_parameters=fx.OnOffParameters(), ) flow_system.add_elements(fx.Sink('Sink', sink=flow)) @@ -519,18 +552,18 @@ def test_flow_on(self, basic_flow_system_linopy_coords, coords_config): model.add_variables( lower=0, upper=0.8 * 100, - coords=(timesteps,), + coords=model.get_coords(), ), ) # OnOff assert_var_equal( flow.submodel.on_off.on, - model.add_variables(binary=True, coords=(timesteps,)), + model.add_variables(binary=True, coords=model.get_coords()), ) assert_var_equal( model.variables['Sink(Wärme)|on_hours_total'], - model.add_variables(lower=0), + model.add_variables(lower=0, coords=model.get_coords(['year', 'scenario'])), ) assert_conequal( model.constraints['Sink(Wärme)|flow_rate|lb'], @@ -544,15 +577,15 @@ def test_flow_on(self, basic_flow_system_linopy_coords, coords_config): assert_conequal( model.constraints['Sink(Wärme)|on_hours_total'], flow.submodel.variables['Sink(Wärme)|on_hours_total'] - == (flow.submodel.variables['Sink(Wärme)|on'] * model.hours_per_step).sum(), + == (flow.submodel.variables['Sink(Wärme)|on'] * model.hours_per_step).sum('time'), ) def test_effects_per_running_hour(self, basic_flow_system_linopy_coords, coords_config): flow_system, coords_config = basic_flow_system_linopy_coords, coords_config timesteps = flow_system.timesteps - costs_per_running_hour = xr.DataArray(np.linspace(1, 2, timesteps.size), coords=(timesteps,)) - co2_per_running_hour = xr.DataArray(np.linspace(4, 5, timesteps.size), coords=(timesteps,)) + costs_per_running_hour = np.linspace(1, 2, timesteps.size) + co2_per_running_hour = np.linspace(4, 5, timesteps.size) flow = fx.Flow( 'Wärme', @@ -589,6 +622,12 @@ def test_effects_per_running_hour(self, basic_flow_system_linopy_coords, coords_ assert 'Sink(Wärme)->costs(operation)' in set(costs.submodel.constraints) assert 'Sink(Wärme)->CO2(operation)' in set(co2.submodel.constraints) + costs_per_running_hour = flow.on_off_parameters.effects_per_running_hour['costs'] + co2_per_running_hour = flow.on_off_parameters.effects_per_running_hour['CO2'] + + assert costs_per_running_hour.dims == tuple(model.get_coords()) + assert co2_per_running_hour.dims == tuple(model.get_coords()) + assert_conequal( model.constraints['Sink(Wärme)->costs(operation)'], model.variables['Sink(Wärme)->costs(operation)'] @@ -604,7 +643,6 @@ def test_effects_per_running_hour(self, basic_flow_system_linopy_coords, coords_ def test_consecutive_on_hours(self, basic_flow_system_linopy_coords, coords_config): """Test flow with minimum and maximum consecutive on hours.""" flow_system, coords_config = basic_flow_system_linopy_coords, coords_config - timesteps = flow_system.timesteps flow = fx.Flow( 'Wärme', @@ -642,7 +680,7 @@ def test_consecutive_on_hours(self, basic_flow_system_linopy_coords, coords_conf assert_var_equal( model.variables['Sink(Wärme)|consecutive_on_hours'], - model.add_variables(lower=0, upper=8, coords=(timesteps,)), + model.add_variables(lower=0, upper=8, coords=model.get_coords()), ) mega = model.hours_per_step.sum('time') @@ -687,7 +725,6 @@ def test_consecutive_on_hours(self, basic_flow_system_linopy_coords, coords_conf def test_consecutive_on_hours_previous(self, basic_flow_system_linopy_coords, coords_config): """Test flow with minimum and maximum consecutive on hours.""" flow_system, coords_config = basic_flow_system_linopy_coords, coords_config - timesteps = flow_system.timesteps flow = fx.Flow( 'Wärme', @@ -724,7 +761,7 @@ def test_consecutive_on_hours_previous(self, basic_flow_system_linopy_coords, co assert_var_equal( model.variables['Sink(Wärme)|consecutive_on_hours'], - model.add_variables(lower=0, upper=8, coords=(timesteps,)), + model.add_variables(lower=0, upper=8, coords=model.get_coords()), ) mega = model.hours_per_step.sum('time') + model.hours_per_step.isel(time=0) * 3 @@ -769,7 +806,6 @@ def test_consecutive_on_hours_previous(self, basic_flow_system_linopy_coords, co def test_consecutive_off_hours(self, basic_flow_system_linopy_coords, coords_config): """Test flow with minimum and maximum consecutive off hours.""" flow_system, coords_config = basic_flow_system_linopy_coords, coords_config - timesteps = flow_system.timesteps flow = fx.Flow( 'Wärme', @@ -807,7 +843,7 @@ def test_consecutive_off_hours(self, basic_flow_system_linopy_coords, coords_con assert_var_equal( model.variables['Sink(Wärme)|consecutive_off_hours'], - model.add_variables(lower=0, upper=12, coords=(timesteps,)), + model.add_variables(lower=0, upper=12, coords=model.get_coords()), ) mega = model.hours_per_step.sum('time') + model.hours_per_step.isel(time=0) * 1 # previously off for 1h @@ -852,7 +888,6 @@ def test_consecutive_off_hours(self, basic_flow_system_linopy_coords, coords_con def test_consecutive_off_hours_previous(self, basic_flow_system_linopy_coords, coords_config): """Test flow with minimum and maximum consecutive off hours.""" flow_system, coords_config = basic_flow_system_linopy_coords, coords_config - timesteps = flow_system.timesteps flow = fx.Flow( 'Wärme', @@ -891,7 +926,7 @@ def test_consecutive_off_hours_previous(self, basic_flow_system_linopy_coords, c assert_var_equal( model.variables['Sink(Wärme)|consecutive_off_hours'], - model.add_variables(lower=0, upper=12, coords=(timesteps,)), + model.add_variables(lower=0, upper=12, coords=model.get_coords()), ) mega = model.hours_per_step.sum('time') + model.hours_per_step.isel(time=0) * 2 @@ -974,7 +1009,10 @@ def test_switch_on_constraints(self, basic_flow_system_linopy_coords, coords_con ) # Check switch_on_nr variable bounds - assert_var_equal(flow.submodel.variables['Sink(Wärme)|switch|count'], model.add_variables(lower=0, upper=5)) + assert_var_equal( + flow.submodel.variables['Sink(Wärme)|switch|count'], + model.add_variables(lower=0, upper=5, coords=model.get_coords(['year', 'scenario'])), + ) # Verify switch_on_nr constraint (limits number of startups) assert_conequal( @@ -1017,14 +1055,15 @@ def test_on_hours_limits(self, basic_flow_system_linopy_coords, coords_config): # Check on_hours_total variable bounds assert_var_equal( - flow.submodel.variables['Sink(Wärme)|on_hours_total'], model.add_variables(lower=20, upper=100) + flow.submodel.variables['Sink(Wärme)|on_hours_total'], + model.add_variables(lower=20, upper=100, coords=model.get_coords(['year', 'scenario'])), ) # Check on_hours_total constraint assert_conequal( model.constraints['Sink(Wärme)|on_hours_total'], flow.submodel.variables['Sink(Wärme)|on_hours_total'] - == (flow.submodel.variables['Sink(Wärme)|on'] * model.hours_per_step).sum(), + == (flow.submodel.variables['Sink(Wärme)|on'] * model.hours_per_step).sum('time'), ) @@ -1033,13 +1072,12 @@ class TestFlowOnInvestModel: def test_flow_on_invest_optional(self, basic_flow_system_linopy_coords, coords_config): flow_system, coords_config = basic_flow_system_linopy_coords, coords_config - timesteps = flow_system.timesteps flow = fx.Flow( 'Wärme', bus='Fernwärme', size=fx.InvestParameters(minimum_size=20, maximum_size=200, optional=True), - relative_minimum=xr.DataArray(0.2, coords=(timesteps,)), - relative_maximum=xr.DataArray(0.8, coords=(timesteps,)), + relative_minimum=0.2, + relative_maximum=0.8, on_off_parameters=fx.OnOffParameters(), ) flow_system.add_elements(fx.Sink('Sink', sink=flow)) @@ -1079,18 +1117,18 @@ def test_flow_on_invest_optional(self, basic_flow_system_linopy_coords, coords_c model.add_variables( lower=0, upper=0.8 * 200, - coords=(timesteps,), + coords=model.get_coords(), ), ) # OnOff assert_var_equal( flow.submodel.on_off.on, - model.add_variables(binary=True, coords=(timesteps,)), + model.add_variables(binary=True, coords=model.get_coords()), ) assert_var_equal( model.variables['Sink(Wärme)|on_hours_total'], - model.add_variables(lower=0), + model.add_variables(lower=0, coords=model.get_coords(['year', 'scenario'])), ) assert_conequal( model.constraints['Sink(Wärme)|size|lb'], @@ -1111,11 +1149,14 @@ def test_flow_on_invest_optional(self, basic_flow_system_linopy_coords, coords_c assert_conequal( model.constraints['Sink(Wärme)|on_hours_total'], flow.submodel.variables['Sink(Wärme)|on_hours_total'] - == (flow.submodel.variables['Sink(Wärme)|on'] * model.hours_per_step).sum(), + == (flow.submodel.variables['Sink(Wärme)|on'] * model.hours_per_step).sum('time'), ) # Investment - assert_var_equal(model['Sink(Wärme)|size'], model.add_variables(lower=0, upper=200)) + assert_var_equal( + model['Sink(Wärme)|size'], + model.add_variables(lower=0, upper=200, coords=model.get_coords(['year', 'scenario'])), + ) mega = 0.2 * 200 # Relative minimum * maximum size assert_conequal( @@ -1132,13 +1173,12 @@ def test_flow_on_invest_optional(self, basic_flow_system_linopy_coords, coords_c def test_flow_on_invest_non_optional(self, basic_flow_system_linopy_coords, coords_config): flow_system, coords_config = basic_flow_system_linopy_coords, coords_config - timesteps = flow_system.timesteps flow = fx.Flow( 'Wärme', bus='Fernwärme', size=fx.InvestParameters(minimum_size=20, maximum_size=200, optional=False), - relative_minimum=xr.DataArray(0.2, coords=(timesteps,)), - relative_maximum=xr.DataArray(0.8, coords=(timesteps,)), + relative_minimum=0.2, + relative_maximum=0.8, on_off_parameters=fx.OnOffParameters(), ) flow_system.add_elements(fx.Sink('Sink', sink=flow)) @@ -1175,18 +1215,18 @@ def test_flow_on_invest_non_optional(self, basic_flow_system_linopy_coords, coor model.add_variables( lower=0, upper=0.8 * 200, - coords=(timesteps,), + coords=model.get_coords(), ), ) # OnOff assert_var_equal( flow.submodel.on_off.on, - model.add_variables(binary=True, coords=(timesteps,)), + model.add_variables(binary=True, coords=model.get_coords()), ) assert_var_equal( model.variables['Sink(Wärme)|on_hours_total'], - model.add_variables(lower=0), + model.add_variables(lower=0, coords=model.get_coords(['year', 'scenario'])), ) assert_conequal( model.constraints['Sink(Wärme)|flow_rate|lb1'], @@ -1199,11 +1239,14 @@ def test_flow_on_invest_non_optional(self, basic_flow_system_linopy_coords, coor assert_conequal( model.constraints['Sink(Wärme)|on_hours_total'], flow.submodel.variables['Sink(Wärme)|on_hours_total'] - == (flow.submodel.variables['Sink(Wärme)|on'] * model.hours_per_step).sum(), + == (flow.submodel.variables['Sink(Wärme)|on'] * model.hours_per_step).sum('time'), ) # Investment - assert_var_equal(model['Sink(Wärme)|size'], model.add_variables(lower=20, upper=200)) + assert_var_equal( + model['Sink(Wärme)|size'], + model.add_variables(lower=20, upper=200, coords=model.get_coords(['year', 'scenario'])), + ) mega = 0.2 * 200 # Relative minimum * maximum size assert_conequal( @@ -1231,7 +1274,10 @@ def test_fixed_relative_profile(self, basic_flow_system_linopy_coords, coords_co profile = np.sin(np.linspace(0, 2 * np.pi, len(timesteps))) * 0.5 + 0.5 # Values between 0 and 1 flow = fx.Flow( - 'Wärme', bus='Fernwärme', size=100, fixed_relative_profile=xr.DataArray(profile, coords=(timesteps,)) + 'Wärme', + bus='Fernwärme', + size=100, + fixed_relative_profile=profile, ) flow_system.add_elements(fx.Sink('Sink', sink=flow)) @@ -1239,7 +1285,11 @@ def test_fixed_relative_profile(self, basic_flow_system_linopy_coords, coords_co assert_var_equal( flow.submodel.variables['Sink(Wärme)|flow_rate'], - model.add_variables(lower=profile * 100, upper=profile * 100, coords=(timesteps,)), + model.add_variables( + lower=flow.fixed_relative_profile * 100, + upper=flow.fixed_relative_profile * 100, + coords=model.get_coords(), + ), ) def test_fixed_profile_with_investment(self, basic_flow_system_linopy_coords, coords_config): @@ -1254,7 +1304,7 @@ def test_fixed_profile_with_investment(self, basic_flow_system_linopy_coords, co 'Wärme', bus='Fernwärme', size=fx.InvestParameters(minimum_size=50, maximum_size=200, optional=True), - fixed_relative_profile=xr.DataArray(profile, coords=(timesteps,)), + fixed_relative_profile=profile, ) flow_system.add_elements(fx.Sink('Sink', sink=flow)) @@ -1262,14 +1312,14 @@ def test_fixed_profile_with_investment(self, basic_flow_system_linopy_coords, co assert_var_equal( flow.submodel.variables['Sink(Wärme)|flow_rate'], - model.add_variables(lower=0, upper=profile * 200, coords=(timesteps,)), + model.add_variables(lower=0, upper=flow.fixed_relative_profile * 200, coords=model.get_coords()), ) # The constraint should link flow_rate to size * profile assert_conequal( model.constraints['Sink(Wärme)|flow_rate|fixed'], flow.submodel.variables['Sink(Wärme)|flow_rate'] - == flow.submodel.variables['Sink(Wärme)|size'] * xr.DataArray(profile, coords=(timesteps,)), + == flow.submodel.variables['Sink(Wärme)|size'] * flow.fixed_relative_profile, ) From 262e8b4f3a69125dc890a2c6815e3c1f5f9cbfe4 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 28 Jul 2025 21:33:45 +0200 Subject: [PATCH 268/336] Fix test with multiple dims --- tests/test_linear_converter.py | 24 ++++++++++-------- tests/test_storage.py | 46 ++++++++++++++++++++-------------- 2 files changed, 40 insertions(+), 30 deletions(-) diff --git a/tests/test_linear_converter.py b/tests/test_linear_converter.py index e90d52f40..62e5cbcad 100644 --- a/tests/test_linear_converter.py +++ b/tests/test_linear_converter.py @@ -176,7 +176,7 @@ def test_linear_converter_with_on_off(self, basic_flow_system_linopy_coords, coo assert_conequal( model.constraints['Converter|on_hours_total'], model.variables['Converter|on_hours_total'] - == (model.variables['Converter|on'] * model.hours_per_step).sum(), + == (model.variables['Converter|on'] * model.hours_per_step).sum('time'), ) # Check conversion constraint @@ -282,16 +282,19 @@ def test_edge_case_time_varying_conversion(self, basic_flow_system_linopy_coords # Check that the correct constraint was created assert 'VariableConverter|conversion_0' in model.constraints + factor = converter.conversion_factors[0]['electricity'] + + assert factor.dims == tuple(model.get_coords()) + # Verify the constraint has the time-varying coefficient assert_conequal( model.constraints['VariableConverter|conversion_0'], - input_flow.submodel.flow_rate * fluctuating_cop == output_flow.submodel.flow_rate * 1.0, + input_flow.submodel.flow_rate * factor == output_flow.submodel.flow_rate * 1.0, ) def test_piecewise_conversion(self, basic_flow_system_linopy_coords, coords_config): """Test a LinearConverter with PiecewiseConversion.""" flow_system, coords_config = basic_flow_system_linopy_coords, coords_config - timesteps = flow_system.timesteps # Create input and output flows input_flow = fx.Flow('input', bus='input_bus', size=100) @@ -339,9 +342,9 @@ def test_piecewise_conversion(self, basic_flow_system_linopy_coords, coords_conf lambda1 = model.variables[f'Converter|Piece_{i}|lambda1'] inside_piece = model.variables[f'Converter|Piece_{i}|inside_piece'] - assert_var_equal(inside_piece, model.add_variables(binary=True, coords=(timesteps,))) - assert_var_equal(lambda0, model.add_variables(lower=0, upper=1, coords=(timesteps,))) - assert_var_equal(lambda1, model.add_variables(lower=0, upper=1, coords=(timesteps,))) + assert_var_equal(inside_piece, model.add_variables(binary=True, coords=model.get_coords())) + assert_var_equal(lambda0, model.add_variables(lower=0, upper=1, coords=model.get_coords())) + assert_var_equal(lambda1, model.add_variables(lower=0, upper=1, coords=model.get_coords())) # Check that the inside_piece constraint exists assert f'Converter|Piece_{i}|inside_piece' in model.constraints @@ -380,7 +383,6 @@ def test_piecewise_conversion(self, basic_flow_system_linopy_coords, coords_conf def test_piecewise_conversion_with_onoff(self, basic_flow_system_linopy_coords, coords_config): """Test a LinearConverter with PiecewiseConversion and OnOffParameters.""" flow_system, coords_config = basic_flow_system_linopy_coords, coords_config - timesteps = flow_system.timesteps # Create input and output flows input_flow = fx.Flow('input', bus='input_bus', size=100) @@ -444,9 +446,9 @@ def test_piecewise_conversion_with_onoff(self, basic_flow_system_linopy_coords, lambda1 = model.variables[f'Converter|Piece_{i}|lambda1'] inside_piece = model.variables[f'Converter|Piece_{i}|inside_piece'] - assert_var_equal(inside_piece, model.add_variables(binary=True, coords=(timesteps,))) - assert_var_equal(lambda0, model.add_variables(lower=0, upper=1, coords=(timesteps,))) - assert_var_equal(lambda1, model.add_variables(lower=0, upper=1, coords=(timesteps,))) + assert_var_equal(inside_piece, model.add_variables(binary=True, coords=model.get_coords())) + assert_var_equal(lambda0, model.add_variables(lower=0, upper=1, coords=model.get_coords())) + assert_var_equal(lambda1, model.add_variables(lower=0, upper=1, coords=model.get_coords())) # Check that the inside_piece constraint exists assert f'Converter|Piece_{i}|inside_piece' in model.constraints @@ -485,7 +487,7 @@ def test_piecewise_conversion_with_onoff(self, basic_flow_system_linopy_coords, assert 'Converter|on_hours_total' in model.constraints assert_conequal( model.constraints['Converter|on_hours_total'], - model['Converter|on_hours_total'] == (model['Converter|on'] * model.hours_per_step).sum(), + model['Converter|on_hours_total'] == (model['Converter|on'] * model.hours_per_step).sum('time'), ) # Verify that the costs effect is applied diff --git a/tests/test_storage.py b/tests/test_storage.py index 479f66a87..76226be3b 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -14,8 +14,6 @@ class TestStorageModel: def test_basic_storage(self, basic_flow_system_linopy_coords, coords_config): """Test that basic storage model variables and constraints are correctly generated.""" flow_system, coords_config = basic_flow_system_linopy_coords, coords_config - timesteps = flow_system.timesteps - timesteps_extra = flow_system.timesteps_extra # Create a simple storage storage = fx.Storage( @@ -55,13 +53,14 @@ def test_basic_storage(self, basic_flow_system_linopy_coords, coords_config): # Check variable properties assert_var_equal( - model['TestStorage(Q_th_in)|flow_rate'], model.add_variables(lower=0, upper=20, coords=(timesteps,)) + model['TestStorage(Q_th_in)|flow_rate'], model.add_variables(lower=0, upper=20, coords=model.get_coords()) ) assert_var_equal( - model['TestStorage(Q_th_out)|flow_rate'], model.add_variables(lower=0, upper=20, coords=(timesteps,)) + model['TestStorage(Q_th_out)|flow_rate'], model.add_variables(lower=0, upper=20, coords=model.get_coords()) ) assert_var_equal( - model['TestStorage|charge_state'], model.add_variables(lower=0, upper=30, coords=(timesteps_extra,)) + model['TestStorage|charge_state'], + model.add_variables(lower=0, upper=30, coords=model.get_coords(extra_timestep=True)), ) # Check constraint formulations @@ -88,8 +87,6 @@ def test_basic_storage(self, basic_flow_system_linopy_coords, coords_config): def test_lossy_storage(self, basic_flow_system_linopy_coords, coords_config): """Test that basic storage model variables and constraints are correctly generated.""" flow_system, coords_config = basic_flow_system_linopy_coords, coords_config - timesteps = flow_system.timesteps - timesteps_extra = flow_system.timesteps_extra # Create a simple storage storage = fx.Storage( @@ -132,13 +129,14 @@ def test_lossy_storage(self, basic_flow_system_linopy_coords, coords_config): # Check variable properties assert_var_equal( - model['TestStorage(Q_th_in)|flow_rate'], model.add_variables(lower=0, upper=20, coords=(timesteps,)) + model['TestStorage(Q_th_in)|flow_rate'], model.add_variables(lower=0, upper=20, coords=model.get_coords()) ) assert_var_equal( - model['TestStorage(Q_th_out)|flow_rate'], model.add_variables(lower=0, upper=20, coords=(timesteps,)) + model['TestStorage(Q_th_out)|flow_rate'], model.add_variables(lower=0, upper=20, coords=model.get_coords()) ) assert_var_equal( - model['TestStorage|charge_state'], model.add_variables(lower=0, upper=30, coords=(timesteps_extra,)) + model['TestStorage|charge_state'], + model.add_variables(lower=0, upper=30, coords=model.get_coords(extra_timestep=True)), ) # Check constraint formulations @@ -173,8 +171,6 @@ def test_lossy_storage(self, basic_flow_system_linopy_coords, coords_config): def test_charge_state_bounds(self, basic_flow_system_linopy_coords, coords_config): """Test that basic storage model variables and constraints are correctly generated.""" flow_system, coords_config = basic_flow_system_linopy_coords, coords_config - timesteps = flow_system.timesteps - timesteps_extra = flow_system.timesteps_extra # Create a simple storage storage = fx.Storage( @@ -216,17 +212,23 @@ def test_charge_state_bounds(self, basic_flow_system_linopy_coords, coords_confi # Check variable properties assert_var_equal( - model['TestStorage(Q_th_in)|flow_rate'], model.add_variables(lower=0, upper=20, coords=(timesteps,)) + model['TestStorage(Q_th_in)|flow_rate'], model.add_variables(lower=0, upper=20, coords=model.get_coords()) ) assert_var_equal( - model['TestStorage(Q_th_out)|flow_rate'], model.add_variables(lower=0, upper=20, coords=(timesteps,)) + model['TestStorage(Q_th_out)|flow_rate'], model.add_variables(lower=0, upper=20, coords=model.get_coords()) ) assert_var_equal( model['TestStorage|charge_state'], model.add_variables( - lower=np.array([0.07, 0.11, 0.15, 0.19, 0.23, 0.27, 0.31, 0.35, 0.39, 0.43, 0.43]) * 30, - upper=np.array([0.14, 0.22, 0.3, 0.38, 0.46, 0.54, 0.62, 0.7, 0.78, 0.86, 0.86]) * 30, - coords=(timesteps_extra,), + lower=storage.relative_minimum_charge_state.reindex( + time=model.get_coords(extra_timestep=True)['time'] + ).ffill('time') + * 30, + upper=storage.relative_maximum_charge_state.reindex( + time=model.get_coords(extra_timestep=True)['time'] + ).ffill('time') + * 30, + coords=model.get_coords(extra_timestep=True), ), ) @@ -286,8 +288,14 @@ def test_storage_with_investment(self, basic_flow_system_linopy_coords, coords_c assert con_name in model.constraints, f'Missing investment constraint: {con_name}' # Check variable properties - assert_var_equal(model['InvestStorage|size'], model.add_variables(lower=0, upper=100)) - assert_var_equal(model['InvestStorage|is_invested'], model.add_variables(binary=True)) + assert_var_equal( + model['InvestStorage|size'], + model.add_variables(lower=0, upper=100, coords=model.get_coords(['year', 'scenario'])), + ) + assert_var_equal( + model['InvestStorage|is_invested'], + model.add_variables(binary=True, coords=model.get_coords(['year', 'scenario'])), + ) assert_conequal( model.constraints['InvestStorage|size|ub'], model.variables['InvestStorage|size'] <= model.variables['InvestStorage|is_invested'] * 100, From 2a469f19f2d39f355d77f34571f5987cf30f5b10 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 28 Jul 2025 21:36:33 +0200 Subject: [PATCH 269/336] Fix test with multiple dims --- tests/test_component.py | 35 +++++++++++++++++++++-------------- 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/tests/test_component.py b/tests/test_component.py index 2ed9bea3c..384a5ed28 100644 --- a/tests/test_component.py +++ b/tests/test_component.py @@ -74,7 +74,7 @@ def test_component(self, basic_flow_system_linopy_coords, coords_config): def test_on_with_multiple_flows(self, basic_flow_system_linopy_coords, coords_config): """Test that flow model constraints are correctly generated.""" flow_system, coords_config = basic_flow_system_linopy_coords, coords_config - timesteps = flow_system.timesteps + ub_out2 = np.linspace(1, 1.5, 10).round(2) inputs = [ fx.Flow('In1', 'Fernwärme', relative_minimum=np.ones(10) * 0.1, size=100), @@ -132,12 +132,16 @@ def test_on_with_multiple_flows(self, basic_flow_system_linopy_coords, coords_co msg='Incorrect constraints', ) + upper_bound_flow_rate = outputs[1].relative_maximum + + assert upper_bound_flow_rate.dims == tuple(model.get_coords()) + assert_var_equal( model['TestComponent(Out2)|flow_rate'], - model.add_variables(lower=0, upper=300 * ub_out2, coords=(timesteps,)), + model.add_variables(lower=0, upper=300 * upper_bound_flow_rate, coords=model.get_coords()), ) - assert_var_equal(model['TestComponent|on'], model.add_variables(binary=True, coords=(timesteps,))) - assert_var_equal(model['TestComponent(Out2)|on'], model.add_variables(binary=True, coords=(timesteps,))) + assert_var_equal(model['TestComponent|on'], model.add_variables(binary=True, coords=model.get_coords())) + assert_var_equal(model['TestComponent(Out2)|on'], model.add_variables(binary=True, coords=model.get_coords())) assert_conequal( model.constraints['TestComponent(Out2)|flow_rate|lb'], @@ -146,7 +150,7 @@ def test_on_with_multiple_flows(self, basic_flow_system_linopy_coords, coords_co assert_conequal( model.constraints['TestComponent(Out2)|flow_rate|ub'], model.variables['TestComponent(Out2)|flow_rate'] - <= model.variables['TestComponent(Out2)|on'] * 300 * ub_out2, + <= model.variables['TestComponent(Out2)|on'] * 300 * upper_bound_flow_rate, ) assert_conequal( @@ -173,7 +177,6 @@ def test_on_with_multiple_flows(self, basic_flow_system_linopy_coords, coords_co def test_on_with_single_flow(self, basic_flow_system_linopy_coords, coords_config): """Test that flow model constraints are correctly generated.""" flow_system, coords_config = basic_flow_system_linopy_coords, coords_config - timesteps = flow_system.timesteps inputs = [ fx.Flow('In1', 'Fernwärme', relative_minimum=np.ones(10) * 0.1, size=100), ] @@ -211,10 +214,10 @@ def test_on_with_single_flow(self, basic_flow_system_linopy_coords, coords_confi ) assert_var_equal( - model['TestComponent(In1)|flow_rate'], model.add_variables(lower=0, upper=100, coords=(timesteps,)) + model['TestComponent(In1)|flow_rate'], model.add_variables(lower=0, upper=100, coords=model.get_coords()) ) - assert_var_equal(model['TestComponent|on'], model.add_variables(binary=True, coords=(timesteps,))) - assert_var_equal(model['TestComponent(In1)|on'], model.add_variables(binary=True, coords=(timesteps,))) + assert_var_equal(model['TestComponent|on'], model.add_variables(binary=True, coords=model.get_coords())) + assert_var_equal(model['TestComponent(In1)|on'], model.add_variables(binary=True, coords=model.get_coords())) assert_conequal( model.constraints['TestComponent(In1)|flow_rate|lb'], @@ -233,7 +236,7 @@ def test_on_with_single_flow(self, basic_flow_system_linopy_coords, coords_confi def test_previous_states_with_multiple_flows(self, basic_flow_system_linopy_coords, coords_config): """Test that flow model constraints are correctly generated.""" flow_system, coords_config = basic_flow_system_linopy_coords, coords_config - timesteps = flow_system.timesteps + ub_out2 = np.linspace(1, 1.5, 10).round(2) inputs = [ fx.Flow( @@ -304,12 +307,16 @@ def test_previous_states_with_multiple_flows(self, basic_flow_system_linopy_coor msg='Incorrect constraints', ) + upper_bound_flow_rate = outputs[1].relative_maximum + + assert upper_bound_flow_rate.dims == tuple(model.get_coords()) + assert_var_equal( model['TestComponent(Out2)|flow_rate'], - model.add_variables(lower=0, upper=300 * ub_out2, coords=(timesteps,)), + model.add_variables(lower=0, upper=300 * upper_bound_flow_rate, coords=model.get_coords()), ) - assert_var_equal(model['TestComponent|on'], model.add_variables(binary=True, coords=(timesteps,))) - assert_var_equal(model['TestComponent(Out2)|on'], model.add_variables(binary=True, coords=(timesteps,))) + assert_var_equal(model['TestComponent|on'], model.add_variables(binary=True, coords=model.get_coords())) + assert_var_equal(model['TestComponent(Out2)|on'], model.add_variables(binary=True, coords=model.get_coords())) assert_conequal( model.constraints['TestComponent(Out2)|flow_rate|lb'], @@ -318,7 +325,7 @@ def test_previous_states_with_multiple_flows(self, basic_flow_system_linopy_coor assert_conequal( model.constraints['TestComponent(Out2)|flow_rate|ub'], model.variables['TestComponent(Out2)|flow_rate'] - <= model.variables['TestComponent(Out2)|on'] * 300 * ub_out2, + <= model.variables['TestComponent(Out2)|on'] * 300 * upper_bound_flow_rate, ) assert_conequal( From 159bcb3c11a6fd5165b0820a317f1d75032bd7c8 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 28 Jul 2025 21:36:53 +0200 Subject: [PATCH 270/336] New test --- tests/test_component.py | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/tests/test_component.py b/tests/test_component.py index 384a5ed28..1d1792a65 100644 --- a/tests/test_component.py +++ b/tests/test_component.py @@ -349,6 +349,47 @@ def test_previous_states_with_multiple_flows(self, basic_flow_system_linopy_coor + 1e-5, ) + def test_previous_states_with_multiple_flows_2(self, basic_flow_system_linopy_coords, coords_config): + """Test that flow model constraints are correctly generated.""" + flow_system, coords_config = basic_flow_system_linopy_coords, coords_config + + ub_out2 = np.linspace(1, 1.5, 10).round(2) + inputs = [ + fx.Flow( + 'In1', + 'Fernwärme', + relative_minimum=np.ones(10) * 0.1, + size=100, + previous_flow_rate=np.array([0, 0, 1e-6, 1e-5, 1e-4, 3, 4]), + on_off_parameters=fx.OnOffParameters(consecutive_on_hours_min=3), + ), + ] + outputs = [ + fx.Flow('Out1', 'Gas', relative_minimum=np.ones(10) * 0.2, size=200, previous_flow_rate=[3, 4, 5]), + fx.Flow( + 'Out2', + 'Gas', + relative_minimum=np.ones(10) * 0.3, + relative_maximum=ub_out2, + size=300, + previous_flow_rate=20, + ), + ] + comp = flixopt.elements.Component( + 'TestComponent', + inputs=inputs, + outputs=outputs, + on_off_parameters=fx.OnOffParameters(consecutive_on_hours_min=3), + ) + flow_system.add_elements(comp) + create_linopy_model(flow_system) + + assert_conequal( + comp.submodel.constraints['TestComponent|consecutive_on_hours|initial'], + comp.submodel.variables['TestComponent|consecutive_on_hours'].isel(time=0) + == comp.submodel.variables['TestComponent|on'].isel(time=0) * 5, + ) + class TestTransmissionModel: def test_transmission_basic(self, basic_flow_system, highs_solver): From e764a1392113cce01f0726d7bc76be88c6c4fdd9 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 28 Jul 2025 21:45:33 +0200 Subject: [PATCH 271/336] New test for previous flow_rates --- flixopt/elements.py | 4 +++- tests/test_component.py | 32 ++++++++++++++++++++++++++------ 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/flixopt/elements.py b/flixopt/elements.py index 499ab66db..e1c0fcbc3 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -199,6 +199,7 @@ def __init__( If the load-profile is just an upper limit, use relative_maximum instead. previous_flow_rate: previous flow rate of the flow. Used to determine if and how long the flow is already on / off. If None, the flow is considered to be off for one timestep. + Currently does not support different values in different years or scenarios! meta_data: used to store more information about the Element. Is not used internally, but saved in the results. Only use python native types. """ super().__init__(label, meta_data=meta_data) @@ -305,7 +306,8 @@ def _plausibility_checks(self) -> None: ] ): raise TypeError( - f'previous_flow_rate must be None, a scalar, a list of scalars or a 1D-numpy-array. Got {type(self.previous_flow_rate)}' + f'previous_flow_rate must be None, a scalar, a list of scalars or a 1D-numpy-array. Got {type(self.previous_flow_rate)}.' + f'Different values in different years or scenarios are not yetsupported.' ) @property diff --git a/tests/test_component.py b/tests/test_component.py index 1d1792a65..ab05db5e8 100644 --- a/tests/test_component.py +++ b/tests/test_component.py @@ -349,8 +349,26 @@ def test_previous_states_with_multiple_flows(self, basic_flow_system_linopy_coor + 1e-5, ) - def test_previous_states_with_multiple_flows_2(self, basic_flow_system_linopy_coords, coords_config): - """Test that flow model constraints are correctly generated.""" + @pytest.mark.parametrize( + 'in1_previous_flow_rate, out1_previous_flow_rate, out2_previous_flow_rate, previous_on_hours', + [ + (None, None, None, 0), + (np.array([0, 1e-6, 1e-4, 5]), None, None, 2), + (np.array([0, 5, 0, 5]), None, None, 1), + (np.array([0, 5, 0, 0]), 3, 0, 1), + (np.array([0, 0, 2, 0, 4, 5]), [3, 4, 5], None, 4), + ], + ) + def test_previous_states_with_multiple_flows_parameterized( + self, + basic_flow_system_linopy_coords, + coords_config, + in1_previous_flow_rate, + out1_previous_flow_rate, + out2_previous_flow_rate, + previous_on_hours, + ): + """Test that flow model constraints are correctly generated with different previous flow rates and constraint factors.""" flow_system, coords_config = basic_flow_system_linopy_coords, coords_config ub_out2 = np.linspace(1, 1.5, 10).round(2) @@ -360,19 +378,21 @@ def test_previous_states_with_multiple_flows_2(self, basic_flow_system_linopy_co 'Fernwärme', relative_minimum=np.ones(10) * 0.1, size=100, - previous_flow_rate=np.array([0, 0, 1e-6, 1e-5, 1e-4, 3, 4]), + previous_flow_rate=in1_previous_flow_rate, on_off_parameters=fx.OnOffParameters(consecutive_on_hours_min=3), ), ] outputs = [ - fx.Flow('Out1', 'Gas', relative_minimum=np.ones(10) * 0.2, size=200, previous_flow_rate=[3, 4, 5]), + fx.Flow( + 'Out1', 'Gas', relative_minimum=np.ones(10) * 0.2, size=200, previous_flow_rate=out1_previous_flow_rate + ), fx.Flow( 'Out2', 'Gas', relative_minimum=np.ones(10) * 0.3, relative_maximum=ub_out2, size=300, - previous_flow_rate=20, + previous_flow_rate=out2_previous_flow_rate, ), ] comp = flixopt.elements.Component( @@ -387,7 +407,7 @@ def test_previous_states_with_multiple_flows_2(self, basic_flow_system_linopy_co assert_conequal( comp.submodel.constraints['TestComponent|consecutive_on_hours|initial'], comp.submodel.variables['TestComponent|consecutive_on_hours'].isel(time=0) - == comp.submodel.variables['TestComponent|on'].isel(time=0) * 5, + == comp.submodel.variables['TestComponent|on'].isel(time=0) * (previous_on_hours + 1), ) From 2c1c7501a674e6e0328894e1772817c4b6f22383 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 29 Jul 2025 14:52:49 +0200 Subject: [PATCH 272/336] Add Model for YearAwareInvestments --- flixopt/features.py | 108 +++++++++++++++++++++++++++++++++++++++++++- flixopt/modeling.py | 66 +++++++++++++++++++++++++++ 2 files changed, 172 insertions(+), 2 deletions(-) diff --git a/flixopt/features.py b/flixopt/features.py index d07844c8d..fd48e0654 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -19,8 +19,6 @@ class InvestmentModel(Submodel): - """Investment model using factory patterns but keeping old interface""" - def __init__( self, model: FlowSystemModel, @@ -126,6 +124,112 @@ def is_invested(self) -> Optional[linopy.Variable]: return self._variables['is_invested'] +class YearAwareInvestmentModel(Submodel): + def __init__( + self, + model: FlowSystemModel, + label_of_element: str, + parameters: InvestParameters, + label_of_model: Optional[str] = None, + ): + """ + This feature model is used to model the investment of a variable. + It applies the corresponding bounds to the variable and the on/off state of the variable. + + Args: + model: The optimization model instance + label_of_element: The label of the parent (Element). Used to construct the full label of the model. + parameters: The parameters of the feature model. + label_of_model: The label of the model. This is needed to construct the full label of the model. + + """ + self.piecewise_effects: Optional[PiecewiseEffectsModel] = None + self.parameters = parameters + super().__init__(model, label_of_element=label_of_element, label_of_model=label_of_model) + + def _do_modeling(self): + self._basic_modeling() + self._custom_modeling() + + def _basic_modeling(self): + size_min, size_max = (self.parameters.minimum_or_fixed_size, self.parameters.maximum_or_fixed_size) + self.add_variables( + short_name='size', + lower=0 if self.parameters.optional else size_min, + upper=size_max, + coords=self._model.get_coords(['year', 'scenario']), + ) + + if self.parameters.optional or self.parameters.year_aware: + self.add_variables( + binary=True, + coords=self._model.get_coords(['year', 'scenario']), + short_name='is_invested', + ) + + BoundingPatterns.bounds_with_state( + self, + variable=self.size, + variable_state=self.is_invested, + bounds=(self.parameters.minimum_or_fixed_size, self.parameters.maximum_or_fixed_size), + ) + + if self.parameters.year_aware: + # Track when the investment/divestment happens + increase = self.add_variables( + binary=True, + coords=self._model.get_coords(['year', 'scenario']), + short_name='increase', + ) + decrease = self.add_variables( + binary=True, + coords=self._model.get_coords(['year', 'scenario']), + short_name='decrease', + ) + BoundingPatterns.state_transition_bounds( + self, + state_variable=self.is_invested, + switch_on=increase, + switch_off=decrease, + name=self.is_invested.name, + previous_state=0, + coord='year', + ) + + self.add_constraints(increase.sum('year') <= 1, name=f'{increase.name}|count') + self.add_constraints(decrease.sum('year') <= 1, name=f'{decrease.name}|count') + + # Ensures size can only change when increase or decrease is 1 + BoundingPatterns.continuous_transition_bounds( + model=self, + continuous_variable=self.size, + switch_on=increase, + switch_off=decrease, + name=self.size.name, + max_change=size_max, + previous_value=0, + coord='year', + ) + + super()._do_modeling() + + def _custom_modeling(self): + # Add usefull constraints for investment decisions, parameterized by the user + pass + + @property + def size(self) -> linopy.Variable: + """Investment size variable""" + return self._variables['size'] + + @property + def is_invested(self) -> Optional[linopy.Variable]: + """Binary investment decision variable""" + if 'is_invested' not in self._variables: + return None + return self._variables['is_invested'] + + class OnOffModel(Submodel): """OnOff model using factory patterns""" diff --git a/flixopt/modeling.py b/flixopt/modeling.py index 11880c5e8..874ef1c0f 100644 --- a/flixopt/modeling.py +++ b/flixopt/modeling.py @@ -588,3 +588,69 @@ def state_transition_bounds( mutex = model.add_constraints(switch_on + switch_off <= 1, name=f'{name}|mutex') return transition, initial, mutex + + @staticmethod + def continuous_transition_bounds( + model: Submodel, + continuous_variable: linopy.Variable, + switch_on: linopy.Variable, + switch_off: linopy.Variable, + name: str, + max_change: Union[float, xr.DataArray], + previous_value: Union[float, xr.DataArray] = 0, + coord: str = 'time', + ) -> Tuple[linopy.Constraint, linopy.Constraint, linopy.Constraint, linopy.Constraint]: + """ + Constrains a continuous variable to only change when switch variables are active. + + Mathematical formulation: + -max_change * (switch_on[t] + switch_off[t]) <= continuous[t] - continuous[t-1] <= max_change * (switch_on[t] + switch_off[t]) ∀t > 0 + -max_change * (switch_on[0] + switch_off[0]) <= continuous[0] - previous_value <= max_change * (switch_on[0] + switch_off[0]) + switch_on[t], switch_off[t] ∈ {0, 1} + + This ensures the continuous variable can only change when switch_on or switch_off is 1. + When both switches are 0, the variable must stay exactly constant. + + Args: + model: The submodel to add constraints to + continuous_variable: The continuous variable to constrain + switch_on: Binary variable indicating when changes are allowed (typically transitions to active state) + switch_off: Binary variable indicating when changes are allowed (typically transitions to inactive state) + name: Base name for the constraints + max_change: Maximum possible change in the continuous variable (Big-M value) + previous_value: Initial value of the continuous variable before first period + coord: Coordinate name for time dimension + + Returns: + Tuple of constraints: (transition_upper, transition_lower, initial_upper, initial_lower) + """ + if not isinstance(model, Submodel): + raise ValueError('BoundingPatterns.continuous_transition_bounds() can only be used with a Submodel') + + # Transition constraints for t > 0: continuous variable can only change when switches are active + transition_upper = model.add_constraints( + continuous_variable.isel({coord: slice(1, None)}) - continuous_variable.isel({coord: slice(None, -1)}) + <= max_change * (switch_on.isel({coord: slice(1, None)}) + switch_off.isel({coord: slice(1, None)})), + name=f'{name}|transition_ub', + ) + + transition_lower = model.add_constraints( + continuous_variable.isel({coord: slice(None, -1)}) - continuous_variable.isel({coord: slice(1, None)}) + <= max_change * (switch_on.isel({coord: slice(1, None)}) + switch_off.isel({coord: slice(1, None)})), + name=f'{name}|transition_lb', + ) + + # Initial constraints for t = 0 + initial_upper = model.add_constraints( + continuous_variable.isel({coord: 0}) - previous_value + <= max_change * (switch_on.isel({coord: 0}) + switch_off.isel({coord: 0})), + name=f'{name}|initial_ub', + ) + + initial_lower = model.add_constraints( + previous_value - continuous_variable.isel({coord: 0}) + <= max_change * (switch_on.isel({coord: 0}) + switch_off.isel({coord: 0})), + name=f'{name}|initial_lb', + ) + + return transition_upper, transition_lower, initial_upper, initial_lower From 70979fb89bd1d1fbe37c1ecf62a9a850d3066113 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 29 Jul 2025 15:46:03 +0200 Subject: [PATCH 273/336] Add FlowSystem.years_per_year attribute and "years_of_last_year" parameter to FlowSystem() --- flixopt/flow_system.py | 14 ++++ flixopt/interface.py | 158 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 172 insertions(+) diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index dd202114e..3cead49e0 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -63,6 +63,7 @@ def __init__( scenarios: Optional[pd.Index] = None, hours_of_last_timestep: Optional[float] = None, hours_of_previous_timesteps: Optional[Union[int, float, np.ndarray]] = None, + years_of_last_year: Optional[int] = None, weights: Optional[NonTemporalDataUser] = None, ): """ @@ -86,6 +87,8 @@ def __init__( self.years = None if years is None else self._validate_years(years) + self.years_per_year = None if years is None else self.calculate_years_per_year(years, years_of_last_year) + self.scenarios = None if scenarios is None else self._validate_scenarios(scenarios) self.weights = weights @@ -176,6 +179,17 @@ def calculate_hours_per_timestep(timesteps_extra: pd.DatetimeIndex) -> xr.DataAr hours_per_step, coords={'time': timesteps_extra[:-1]}, dims='time', name='hours_per_timestep' ) + @staticmethod + def calculate_years_per_year(years: pd.Index, years_of_last_year: Optional[int] = None) -> xr.DataArray: + """Calculate duration of each timestep as a 1D DataArray.""" + years_per_year = np.diff(years) + return xr.DataArray( + np.append(years_per_year, years_of_last_year or years_per_year[-1]), + coords={'year': years}, + dims='year', + name='years_per_year', + ) + @staticmethod def _calculate_hours_of_previous_timesteps( timesteps: pd.DatetimeIndex, hours_of_previous_timesteps: Optional[Union[float, np.ndarray]] diff --git a/flixopt/interface.py b/flixopt/interface.py index cae1757c7..f981cd121 100644 --- a/flixopt/interface.py +++ b/flixopt/interface.py @@ -242,6 +242,164 @@ def maximum_or_fixed_size(self) -> NonTemporalData: return self.fixed_size if self.fixed_size is not None else self.maximum_size +@register_class_for_io +class YearAwareInvestParameters(Interface): + """ + Parameters for year-aware investment modeling with multi-year optimization and timing constraints. + + This interface supports investment decisions that can change over time, with constraints on + when investments can be made, modified, or removed. Useful for modeling: + - Capacity expansion planning over multiple years + - Technology replacement strategies + - Retrofit and upgrade scheduling + - Investment timing optimization with changing costs/benefits + """ + + def __init__( + self, + # Basic sizing parameters + minimum_size: Optional[Scalar] = None, + maximum_size: Optional[Scalar] = None, + fixed_size: Optional[Scalar] = None, + previous_size: Scalar = 0, + # Timing constraints - flexible combinations + fixed_start_year: Optional[int] = None, + fixed_end_year: Optional[int] = None, + fixed_duration: Optional[int] = None, + earliest_start_year: Optional[int] = None, + latest_start_year: Optional[int] = None, + earliest_end_year: Optional[int] = None, + latest_end_year: Optional[int] = None, + minimum_duration: Optional[int] = None, + maximum_duration: Optional[int] = None, + # Direct effects + effects_of_investment_per_size: Optional['NonTemporalEffectsUser'] = None, + effects_of_investment: Optional['NonTemporalEffectsUser'] = None, + # Divestment constraints + allow_divestment: bool = False, + effects_of_divestment_per_size: Optional['NonTemporalEffectsUser'] = None, + effects_of_divestment: Optional['NonTemporalEffectsUser'] = None, + ): + """ + Initialize year-aware investment parameters. + + Args: + minimum_size: Minimum investment size when invested. Defaults to CONFIG.modeling.EPSILON. + maximum_size: Maximum possible investment size. Defaults to CONFIG.modeling.BIG. + fixed_size: If specified, investment size is fixed to this value (if investment is taken). + previous_size: THe size previous to the evaluated period (relevant for divestment decisions). + + fixed_start_year: If specified, investment must start in this exact year. + fixed_end_year: If specified, investment must end in this exact year. + fixed_duration: If specified, investment must last exactly this many years. + earliest_start_year: Earliest year investment can start. + latest_start_year: Latest year investment can start. + earliest_end_year: Earliest year investment can end. + latest_end_year: Latest year investment can end. + minimum_duration: Minimum duration investment must last (in years). + maximum_duration: Maximum duration investment can last (in years). + + effects_per_size: Effects applied per unit of investment size for each year invested. + Example: {'costs': 100} applies 100 * size * years_invested to total costs. + effects_per_investment: One-time effects applied when investment decision is made. + Example: {'costs': 1000} applies 1000 to costs in the investment year. + + allow_divestment: If True, investment can be terminated before the end of time horizon. + If False, once invested, remains invested until end of horizon. + effects_per_divestment: One-time effects applied when divestment decision is made. + Example: {'costs': 500} applies 500 to costs in the divestment year. + """ + self.minimum_size = minimum_size if minimum_size is not None else CONFIG.modeling.EPSILON + self.maximum_size = maximum_size if maximum_size is not None else CONFIG.modeling.BIG + self.fixed_size = fixed_size + + self.fixed_start_year = fixed_start_year + self.fixed_end_year = fixed_end_year + self.fixed_duration = fixed_duration + self.earliest_start_year = earliest_start_year + self.latest_start_year = latest_start_year + self.earliest_end_year = earliest_end_year + self.latest_end_year = latest_end_year + self.minimum_duration = minimum_duration + self.maximum_duration = maximum_duration + + self.effects_of_investment_per_size: 'NonTemporalEffectsUser' = ( + effects_of_investment_per_size if effects_of_investment_per_size is not None else {} + ) + self.effects_of_investment: 'NonTemporalEffectsUser' = ( + effects_of_investment if effects_of_investment is not None else {} + ) + + self.allow_divestment = allow_divestment + self.effects_of_divestment: 'NonTemporalEffectsUser' = ( + effects_of_divestment if effects_of_divestment is not None else {} + ) + self.effects_of_divestment_per_size: 'NonTemporalEffectsUser' = ( + effects_of_divestment_per_size if effects_of_divestment_per_size is not None else {} + ) + + def transform_data(self, flow_system: 'FlowSystem', name_prefix: str): + """Transform all parameter data to match the flow system's coordinate structure.""" + self._plausibility_checks(flow_system) + + self.effects_of_investment_per_size = flow_system.fit_effects_to_model_coords( + label_prefix=name_prefix, + effect_values=self.effects_of_investment_per_size, + label_suffix='effects_of_investment_per_size', + has_time_dim=False, + ) + self.effects_of_investment = flow_system.fit_effects_to_model_coords( + label_prefix=name_prefix, + effect_values=self.effects_of_investment, + label_suffix='effects_of_investment', + has_time_dim=False, + ) + self.effects_of_divestment = flow_system.fit_effects_to_model_coords( + label_prefix=name_prefix, + effect_values=self.effects_of_divestment, + label_suffix='effects_of_divestment', + has_time_dim=False, + ) + + self.effects_of_divestment_per_size = flow_system.fit_effects_to_model_coords( + label_prefix=name_prefix, + effect_values=self.effects_of_divestment_per_size, + label_suffix='effects_of_divestment_per_size', + has_time_dim=False, + ) + + self.minimum_size = flow_system.fit_to_model_coords( + f'{name_prefix}|minimum_size', self.minimum_size, has_time_dim=False + ) + self.maximum_size = flow_system.fit_to_model_coords( + f'{name_prefix}|maximum_size', self.maximum_size, has_time_dim=False + ) + if self.fixed_size is not None: + self.fixed_size = flow_system.fit_to_model_coords( + f'{name_prefix}|fixed_size', self.fixed_size, has_time_dim=False + ) + + def _plausibility_checks(self, flow_system): + """Validate parameter consistency and compatibility with the flow system.""" + if flow_system.years is None: + raise ValueError("YearAwareInvestParameters requires the flow_system to have a 'years' dimension.") + + @property + def minimum_or_fixed_size(self) -> NonTemporalData: + """Get the effective minimum size (fixed size takes precedence).""" + return self.fixed_size if self.fixed_size is not None else self.minimum_size + + @property + def maximum_or_fixed_size(self) -> NonTemporalData: + """Get the effective maximum size (fixed size takes precedence).""" + return self.fixed_size if self.fixed_size is not None else self.maximum_size + + @property + def is_fixed_size(self) -> bool: + """Check if investment size is fixed.""" + return self.fixed_size is not None + + @register_class_for_io class OnOffParameters(Interface): def __init__( From c9c4ddfcf6d01562412ad2c1b75c7bf310c89a49 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 29 Jul 2025 16:09:11 +0200 Subject: [PATCH 274/336] Add YearAwareInvestmentModel --- flixopt/__init__.py | 1 + flixopt/calculation.py | 8 ++- flixopt/commons.py | 11 +++- flixopt/elements.py | 45 +++++++++------ flixopt/features.py | 128 ++++++++++++++++++++++++----------------- flixopt/modeling.py | 2 +- 6 files changed, 121 insertions(+), 74 deletions(-) diff --git a/flixopt/__init__.py b/flixopt/__init__.py index b92766449..7e82cd2ba 100644 --- a/flixopt/__init__.py +++ b/flixopt/__init__.py @@ -25,6 +25,7 @@ Storage, TimeSeriesData, Transmission, + YearAwareInvestParameters, change_logging_level, linear_converters, plotting, diff --git a/flixopt/calculation.py b/flixopt/calculation.py index d4d4e306d..a8bc4c22c 100644 --- a/flixopt/calculation.py +++ b/flixopt/calculation.py @@ -28,7 +28,7 @@ from .config import CONFIG from .core import DataConverter, Scalar, TimeSeriesData, drop_constant_arrays from .elements import Component -from .features import InvestmentModel +from .features import InvestmentModel, YearAwareInvestmentModel from .flow_system import FlowSystem from .results import CalculationResults, SegmentedCalculationResults from .solvers import _Solver @@ -117,13 +117,15 @@ def main_results(self) -> Dict[str, Union[Scalar, Dict]]: model.label_of_element: model.size.solution for component in self.flow_system.components.values() for model in component.submodel.all_submodels - if isinstance(model, InvestmentModel) and model.size.solution.max() >= CONFIG.modeling.EPSILON + if isinstance(model, (InvestmentModel, YearAwareInvestmentModel)) + and model.size.solution.max() >= CONFIG.modeling.EPSILON }, 'Not invested': { model.label_of_element: model.size.solution for component in self.flow_system.components.values() for model in component.submodel.all_submodels - if isinstance(model, InvestmentModel) and model.size.solution.max() < CONFIG.modeling.EPSILON + if isinstance(model, (InvestmentModel, YearAwareInvestmentModel)) + and model.size.solution.max() < CONFIG.modeling.EPSILON }, }, 'Buses with excess': [ diff --git a/flixopt/commons.py b/flixopt/commons.py index 68412d6fe..77750525f 100644 --- a/flixopt/commons.py +++ b/flixopt/commons.py @@ -18,7 +18,15 @@ from .effects import Effect from .elements import Bus, Flow from .flow_system import FlowSystem -from .interface import InvestParameters, OnOffParameters, Piece, Piecewise, PiecewiseConversion, PiecewiseEffects +from .interface import ( + InvestParameters, + OnOffParameters, + Piece, + Piecewise, + PiecewiseConversion, + PiecewiseEffects, + YearAwareInvestParameters, +) __all__ = [ 'TimeSeriesData', @@ -48,4 +56,5 @@ 'results', 'linear_converters', 'solvers', + 'YearAwareInvestParameters', ] diff --git a/flixopt/elements.py b/flixopt/elements.py index e1c0fcbc3..deb7ea219 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -13,8 +13,8 @@ from .config import CONFIG from .core import PlausibilityError, Scalar, TemporalData, TemporalDataUser from .effects import TemporalEffectsUser -from .features import InvestmentModel, ModelingPrimitives, OnOffModel -from .interface import InvestParameters, OnOffParameters +from .features import InvestmentModel, ModelingPrimitives, OnOffModel, YearAwareInvestmentModel +from .interface import InvestParameters, OnOffParameters, YearAwareInvestParameters from .modeling import BoundingPatterns, ModelingUtilitiesAbstract from .structure import Element, ElementModel, FlowSystemModel, register_class_for_io @@ -158,7 +158,7 @@ def __init__( self, label: str, bus: str, - size: Union[Scalar, InvestParameters] = None, + size: Union[Scalar, InvestParameters, YearAwareInvestParameters] = None, fixed_relative_profile: Optional[TemporalDataUser] = None, relative_minimum: TemporalDataUser = 0, relative_maximum: TemporalDataUser = 1, @@ -266,7 +266,7 @@ def transform_data(self, flow_system: 'FlowSystem'): if self.on_off_parameters is not None: self.on_off_parameters.transform_data(flow_system, self.label_full) - if isinstance(self.size, InvestParameters): + if isinstance(self.size, (InvestParameters, YearAwareInvestParameters)): self.size.transform_data(flow_system, self.label_full) else: self.size = flow_system.fit_to_model_coords(f'{self.label_full}|size', self.size, has_time_dim=False) @@ -276,7 +276,7 @@ def _plausibility_checks(self) -> None: if np.any(self.relative_minimum > self.relative_maximum): raise PlausibilityError(self.label_full + ': Take care, that relative_minimum <= relative_maximum!') - if not isinstance(self.size, InvestParameters) and ( + if not isinstance(self.size, (InvestParameters, YearAwareInvestParameters)) and ( np.any(self.size == CONFIG.modeling.BIG) and self.fixed_relative_profile is not None ): # Default Size --> Most likely by accident logger.warning( @@ -377,15 +377,28 @@ def _create_on_off_model(self): ) def _create_investment_model(self): - self.add_submodels( - InvestmentModel( - model=self._model, - label_of_element=self.label_of_element, - parameters=self.element.size, - label_of_model=self.label_of_element, - ), - 'investment', - ) + if isinstance(self.element.size, InvestParameters): + self.add_submodels( + InvestmentModel( + model=self._model, + label_of_element=self.label_of_element, + parameters=self.element.size, + label_of_model=self.label_of_element, + ), + 'investment', + ) + elif isinstance(self.element.size, YearAwareInvestParameters): + self.add_submodels( + YearAwareInvestmentModel( + model=self._model, + label_of_element=self.label_of_element, + parameters=self.element.size, + label_of_model=self.label_of_element, + ), + 'investment', + ) + else: + raise ValueError(f'Invalid InvestParameters type: {type(self.element.size)}') def _constraint_flow_rate(self): if not self.with_investment and not self.with_on_off: @@ -435,7 +448,7 @@ def with_on_off(self) -> bool: @property def with_investment(self) -> bool: - return isinstance(self.element.size, InvestParameters) + return isinstance(self.element.size, (InvestParameters, YearAwareInvestParameters)) # Properties for clean access to variables @property @@ -508,7 +521,7 @@ def absolute_flow_rate_bounds(self) -> Tuple[TemporalData, TemporalData]: if not self.with_investment: # Basic case without investment and without OnOff lb = lb_relative * self.element.size - elif not self.element.size.optional: + elif isinstance(self.element.size, InvestParameters) and not self.element.size.optional: # With non-optional Investment lb = lb_relative * self.element.size.minimum_or_fixed_size diff --git a/flixopt/features.py b/flixopt/features.py index fd48e0654..a82c1e838 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -11,7 +11,7 @@ from .config import CONFIG from .core import FlowSystemDimensions, NonTemporalData, Scalar, TemporalData -from .interface import InvestParameters, OnOffParameters, Piecewise, PiecewiseEffects +from .interface import InvestParameters, OnOffParameters, Piecewise, PiecewiseEffects, YearAwareInvestParameters from .modeling import BoundingPatterns, ModelingPrimitives, ModelingUtilities from .structure import FlowSystemModel, Submodel @@ -125,11 +125,13 @@ def is_invested(self) -> Optional[linopy.Variable]: class YearAwareInvestmentModel(Submodel): + parameters: YearAwareInvestParameters + def __init__( self, model: FlowSystemModel, label_of_element: str, - parameters: InvestParameters, + parameters: YearAwareInvestParameters, label_of_model: Optional[str] = None, ): """ @@ -150,73 +152,93 @@ def __init__( def _do_modeling(self): self._basic_modeling() self._custom_modeling() + self._add_effects() + + super()._do_modeling() def _basic_modeling(self): - size_min, size_max = (self.parameters.minimum_or_fixed_size, self.parameters.maximum_or_fixed_size) + _, size_max = (self.parameters.minimum_or_fixed_size, self.parameters.maximum_or_fixed_size) self.add_variables( short_name='size', - lower=0 if self.parameters.optional else size_min, + lower=0, upper=size_max, coords=self._model.get_coords(['year', 'scenario']), ) - if self.parameters.optional or self.parameters.year_aware: - self.add_variables( - binary=True, - coords=self._model.get_coords(['year', 'scenario']), - short_name='is_invested', - ) - - BoundingPatterns.bounds_with_state( - self, - variable=self.size, - variable_state=self.is_invested, - bounds=(self.parameters.minimum_or_fixed_size, self.parameters.maximum_or_fixed_size), - ) + self.add_variables( + binary=True, + coords=self._model.get_coords(['year', 'scenario']), + short_name='is_invested', + ) - if self.parameters.year_aware: - # Track when the investment/divestment happens - increase = self.add_variables( - binary=True, - coords=self._model.get_coords(['year', 'scenario']), - short_name='increase', - ) - decrease = self.add_variables( - binary=True, - coords=self._model.get_coords(['year', 'scenario']), - short_name='decrease', - ) - BoundingPatterns.state_transition_bounds( - self, - state_variable=self.is_invested, - switch_on=increase, - switch_off=decrease, - name=self.is_invested.name, - previous_state=0, - coord='year', - ) + BoundingPatterns.bounds_with_state( + self, + variable=self.size, + variable_state=self.is_invested, + bounds=(self.parameters.minimum_or_fixed_size, self.parameters.maximum_or_fixed_size), + ) - self.add_constraints(increase.sum('year') <= 1, name=f'{increase.name}|count') - self.add_constraints(decrease.sum('year') <= 1, name=f'{decrease.name}|count') - - # Ensures size can only change when increase or decrease is 1 - BoundingPatterns.continuous_transition_bounds( - model=self, - continuous_variable=self.size, - switch_on=increase, - switch_off=decrease, - name=self.size.name, - max_change=size_max, - previous_value=0, - coord='year', - ) + increase = self.add_variables( + binary=True, + coords=self._model.get_coords(['year', 'scenario']), + short_name='increase', + ) + decrease = self.add_variables( + binary=True, + coords=self._model.get_coords(['year', 'scenario']), + short_name='decrease', + ) + BoundingPatterns.state_transition_bounds( + self, + state_variable=self.is_invested, + switch_on=increase, + switch_off=decrease, + name=self.is_invested.name, + previous_state=0, + coord='year', + ) - super()._do_modeling() + self.add_constraints(increase.sum('year') <= 1, name=f'{increase.name}|count') + self.add_constraints(decrease.sum('year') <= 1, name=f'{decrease.name}|count') + + # Ensures size can only change when increase or decrease is 1 + BoundingPatterns.continuous_transition_bounds( + model=self, + continuous_variable=self.size, + switch_on=increase, + switch_off=decrease, + name=self.size.name, + max_change=size_max, + previous_value=0, + coord='year', + ) def _custom_modeling(self): # Add usefull constraints for investment decisions, parameterized by the user + pass + def _add_effects(self): + if self.parameters.effects_of_investment: + self._model.effects.add_share_to_effects( + name=self.label_of_element, + expressions={ + effect: self.is_invested * factor if self.is_invested is not None else factor + for effect, factor in self.parameters.fix_effects.items() + }, + target='invest', + ) + + if self.parameters.effects_of_investment_per_size: + self._model.effects.add_share_to_effects( + name=self.label_of_element, + expressions={ + effect: self.size * factor + for effect, factor in self.parameters.effects_of_investment_per_size.items() + }, + target='invest', + ) + @property def size(self) -> linopy.Variable: """Investment size variable""" diff --git a/flixopt/modeling.py b/flixopt/modeling.py index 874ef1c0f..f523346cc 100644 --- a/flixopt/modeling.py +++ b/flixopt/modeling.py @@ -648,7 +648,7 @@ def continuous_transition_bounds( ) initial_lower = model.add_constraints( - previous_value - continuous_variable.isel({coord: 0}) + -continuous_variable.isel({coord: 0}) + previous_value <= max_change * (switch_on.isel({coord: 0}) + switch_off.isel({coord: 0})), name=f'{name}|initial_lb', ) From 85ec956a21a8b88d025299f55f86adb3f1f32867 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 29 Jul 2025 16:09:50 +0200 Subject: [PATCH 275/336] Add new Interface --- flixopt/interface.py | 41 +++++------------------------------------ 1 file changed, 5 insertions(+), 36 deletions(-) diff --git a/flixopt/interface.py b/flixopt/interface.py index f981cd121..5b11de05f 100644 --- a/flixopt/interface.py +++ b/flixopt/interface.py @@ -261,24 +261,17 @@ def __init__( minimum_size: Optional[Scalar] = None, maximum_size: Optional[Scalar] = None, fixed_size: Optional[Scalar] = None, - previous_size: Scalar = 0, # Timing constraints - flexible combinations - fixed_start_year: Optional[int] = None, - fixed_end_year: Optional[int] = None, - fixed_duration: Optional[int] = None, earliest_start_year: Optional[int] = None, latest_start_year: Optional[int] = None, earliest_end_year: Optional[int] = None, latest_end_year: Optional[int] = None, - minimum_duration: Optional[int] = None, - maximum_duration: Optional[int] = None, + duration: Optional[int] = None, + # minimum_duration: Optional[int] = None, + # maximum_duration: Optional[int] = None, # Direct effects effects_of_investment_per_size: Optional['NonTemporalEffectsUser'] = None, effects_of_investment: Optional['NonTemporalEffectsUser'] = None, - # Divestment constraints - allow_divestment: bool = False, - effects_of_divestment_per_size: Optional['NonTemporalEffectsUser'] = None, - effects_of_divestment: Optional['NonTemporalEffectsUser'] = None, ): """ Initialize year-aware investment parameters. @@ -313,15 +306,12 @@ def __init__( self.maximum_size = maximum_size if maximum_size is not None else CONFIG.modeling.BIG self.fixed_size = fixed_size - self.fixed_start_year = fixed_start_year - self.fixed_end_year = fixed_end_year - self.fixed_duration = fixed_duration self.earliest_start_year = earliest_start_year self.latest_start_year = latest_start_year self.earliest_end_year = earliest_end_year self.latest_end_year = latest_end_year - self.minimum_duration = minimum_duration - self.maximum_duration = maximum_duration + # self.minimum_duration = minimum_duration + # self.maximum_duration = maximum_duration self.effects_of_investment_per_size: 'NonTemporalEffectsUser' = ( effects_of_investment_per_size if effects_of_investment_per_size is not None else {} @@ -330,14 +320,6 @@ def __init__( effects_of_investment if effects_of_investment is not None else {} ) - self.allow_divestment = allow_divestment - self.effects_of_divestment: 'NonTemporalEffectsUser' = ( - effects_of_divestment if effects_of_divestment is not None else {} - ) - self.effects_of_divestment_per_size: 'NonTemporalEffectsUser' = ( - effects_of_divestment_per_size if effects_of_divestment_per_size is not None else {} - ) - def transform_data(self, flow_system: 'FlowSystem', name_prefix: str): """Transform all parameter data to match the flow system's coordinate structure.""" self._plausibility_checks(flow_system) @@ -354,19 +336,6 @@ def transform_data(self, flow_system: 'FlowSystem', name_prefix: str): label_suffix='effects_of_investment', has_time_dim=False, ) - self.effects_of_divestment = flow_system.fit_effects_to_model_coords( - label_prefix=name_prefix, - effect_values=self.effects_of_divestment, - label_suffix='effects_of_divestment', - has_time_dim=False, - ) - - self.effects_of_divestment_per_size = flow_system.fit_effects_to_model_coords( - label_prefix=name_prefix, - effect_values=self.effects_of_divestment_per_size, - label_suffix='effects_of_divestment_per_size', - has_time_dim=False, - ) self.minimum_size = flow_system.fit_to_model_coords( f'{name_prefix}|minimum_size', self.minimum_size, has_time_dim=False From 18146c2d85d7813b6c98efbd46dc50747a65f453 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 29 Jul 2025 16:26:43 +0200 Subject: [PATCH 276/336] Improve YearAwareInvestmentModel --- flixopt/features.py | 86 +++++++++++++++++++++++++++++++++++++++------ 1 file changed, 75 insertions(+), 11 deletions(-) diff --git a/flixopt/features.py b/flixopt/features.py index a82c1e838..2d491c7ee 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -153,7 +153,6 @@ def _do_modeling(self): self._basic_modeling() self._custom_modeling() self._add_effects() - super()._do_modeling() def _basic_modeling(self): @@ -214,22 +213,65 @@ def _basic_modeling(self): ) def _custom_modeling(self): - # Add usefull constraints for investment decisions, parameterized by the user + """Add timing constraints for investment decisions based on user parameters.""" + + # Get the year dimension coordinates + year_coords = self._model.get_coords(['year'])[0] if self._model.get_coords(['year']) else None + if year_coords is None: + return # No year dimension, skip timing constraints + + # Apply timing constraints based on parameters + self._apply_start_year_constraints(self.increase) + self._apply_end_year_constraints(self.decrease) + + def _apply_start_year_constraints(self, increase): + """Apply start year related constraints.""" + if self.parameters.earliest_start_year is not None: + # TODO: ensure that the year is present + self.add_constraints( + increase.sel(year=slice(None, self.parameters.earliest_start_year)) == 0, + name=f'{increase.name}|earliest_start', + ) + if self.parameters.latest_start_year is not None: + # TODO: ensure that the year is present + self.add_constraints( + increase.sel(year=slice(self.parameters.latest_start_year + 1, None)) == 0, + name=f'{increase.name}|latest_start', + ) - pass + def _apply_end_year_constraints(self, decrease): + """Apply end year related constraints.""" + + if self.parameters.earliest_end_year is not None: + # TODO: ensure that the year is present + self.add_constraints( + decrease.sel(year=slice(None, self.parameters.earliest_end_year)) == 0, + name=f'{decrease.name}|earliest_end', + ) + if self.parameters.latest_end_year is not None: + # TODO: ensure that the year is present + self.add_constraints( + decrease.sel(year=slice(self.parameters.latest_end_year + 1, None)) == 0, + name=f'{decrease.name}|latest_end', + ) def _add_effects(self): + """Add investment effects to the model.""" + if self.parameters.effects_of_investment: - self._model.effects.add_share_to_effects( - name=self.label_of_element, - expressions={ - effect: self.is_invested * factor if self.is_invested is not None else factor - for effect, factor in self.parameters.fix_effects.items() - }, - target='invest', - ) + # One-time effects when investment is made + increase = self._variables.get('increase') + if increase is not None: + self._model.effects.add_share_to_effects( + name=self.label_of_element, + expressions={ + effect: increase * factor for effect, factor in self.parameters.effects_of_investment.items() + }, + target='invest', + ) if self.parameters.effects_of_investment_per_size: + # Annual effects proportional to investment size self._model.effects.add_share_to_effects( name=self.label_of_element, expressions={ @@ -239,6 +281,18 @@ def _add_effects(self): target='invest', ) + if self.parameters.effects_of_divestment and self.parameters.allow_divestment: + # One-time effects when divestment is made + decrease = self._variables.get('decrease') + if decrease is not None: + self._model.effects.add_share_to_effects( + name=self.label_of_element, + expressions={ + effect: decrease * factor for effect, factor in self.parameters.effects_of_divestment.items() + }, + target='invest', + ) + @property def size(self) -> linopy.Variable: """Investment size variable""" @@ -251,6 +305,16 @@ def is_invested(self) -> Optional[linopy.Variable]: return None return self._variables['is_invested'] + @property + def increase(self) -> linopy.Variable: + """Binary increase decision variable""" + return self._variables['increase'] + + @property + def decrease(self) -> linopy.Variable: + """Binary decrease decision variable""" + return self._variables['decrease'] + class OnOffModel(Submodel): """OnOff model using factory patterns""" From 015aa1dbc94a687c255589315f1fbb862471600b Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 30 Jul 2025 10:35:46 +0200 Subject: [PATCH 277/336] Rename and improve --- flixopt/__init__.py | 2 +- flixopt/calculation.py | 6 +- flixopt/commons.py | 4 +- flixopt/elements.py | 16 +-- flixopt/features.py | 123 +++++++--------- flixopt/interface.py | 311 +++++++++++++++++++++++++++++++++++------ 6 files changed, 331 insertions(+), 131 deletions(-) diff --git a/flixopt/__init__.py b/flixopt/__init__.py index 7e82cd2ba..1fea6ef4b 100644 --- a/flixopt/__init__.py +++ b/flixopt/__init__.py @@ -12,6 +12,7 @@ FlowSystem, FullCalculation, InvestParameters, + InvestTimingParameters, LinearConverter, OnOffParameters, Piece, @@ -25,7 +26,6 @@ Storage, TimeSeriesData, Transmission, - YearAwareInvestParameters, change_logging_level, linear_converters, plotting, diff --git a/flixopt/calculation.py b/flixopt/calculation.py index a8bc4c22c..1fb13d743 100644 --- a/flixopt/calculation.py +++ b/flixopt/calculation.py @@ -28,7 +28,7 @@ from .config import CONFIG from .core import DataConverter, Scalar, TimeSeriesData, drop_constant_arrays from .elements import Component -from .features import InvestmentModel, YearAwareInvestmentModel +from .features import InvestmentModel, InvestmentTimingModel from .flow_system import FlowSystem from .results import CalculationResults, SegmentedCalculationResults from .solvers import _Solver @@ -117,14 +117,14 @@ def main_results(self) -> Dict[str, Union[Scalar, Dict]]: model.label_of_element: model.size.solution for component in self.flow_system.components.values() for model in component.submodel.all_submodels - if isinstance(model, (InvestmentModel, YearAwareInvestmentModel)) + if isinstance(model, (InvestmentModel, InvestmentTimingModel)) and model.size.solution.max() >= CONFIG.modeling.EPSILON }, 'Not invested': { model.label_of_element: model.size.solution for component in self.flow_system.components.values() for model in component.submodel.all_submodels - if isinstance(model, (InvestmentModel, YearAwareInvestmentModel)) + if isinstance(model, (InvestmentModel, InvestmentTimingModel)) and model.size.solution.max() < CONFIG.modeling.EPSILON }, }, diff --git a/flixopt/commons.py b/flixopt/commons.py index 77750525f..68461f10e 100644 --- a/flixopt/commons.py +++ b/flixopt/commons.py @@ -20,12 +20,12 @@ from .flow_system import FlowSystem from .interface import ( InvestParameters, + InvestTimingParameters, OnOffParameters, Piece, Piecewise, PiecewiseConversion, PiecewiseEffects, - YearAwareInvestParameters, ) __all__ = [ @@ -56,5 +56,5 @@ 'results', 'linear_converters', 'solvers', - 'YearAwareInvestParameters', + 'InvestTimingParameters', ] diff --git a/flixopt/elements.py b/flixopt/elements.py index deb7ea219..2aed7d713 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -13,8 +13,8 @@ from .config import CONFIG from .core import PlausibilityError, Scalar, TemporalData, TemporalDataUser from .effects import TemporalEffectsUser -from .features import InvestmentModel, ModelingPrimitives, OnOffModel, YearAwareInvestmentModel -from .interface import InvestParameters, OnOffParameters, YearAwareInvestParameters +from .features import InvestmentModel, InvestmentTimingModel, ModelingPrimitives, OnOffModel +from .interface import InvestParameters, InvestTimingParameters, OnOffParameters from .modeling import BoundingPatterns, ModelingUtilitiesAbstract from .structure import Element, ElementModel, FlowSystemModel, register_class_for_io @@ -158,7 +158,7 @@ def __init__( self, label: str, bus: str, - size: Union[Scalar, InvestParameters, YearAwareInvestParameters] = None, + size: Union[Scalar, InvestParameters, InvestTimingParameters] = None, fixed_relative_profile: Optional[TemporalDataUser] = None, relative_minimum: TemporalDataUser = 0, relative_maximum: TemporalDataUser = 1, @@ -266,7 +266,7 @@ def transform_data(self, flow_system: 'FlowSystem'): if self.on_off_parameters is not None: self.on_off_parameters.transform_data(flow_system, self.label_full) - if isinstance(self.size, (InvestParameters, YearAwareInvestParameters)): + if isinstance(self.size, (InvestParameters, InvestTimingParameters)): self.size.transform_data(flow_system, self.label_full) else: self.size = flow_system.fit_to_model_coords(f'{self.label_full}|size', self.size, has_time_dim=False) @@ -276,7 +276,7 @@ def _plausibility_checks(self) -> None: if np.any(self.relative_minimum > self.relative_maximum): raise PlausibilityError(self.label_full + ': Take care, that relative_minimum <= relative_maximum!') - if not isinstance(self.size, (InvestParameters, YearAwareInvestParameters)) and ( + if not isinstance(self.size, (InvestParameters, InvestTimingParameters)) and ( np.any(self.size == CONFIG.modeling.BIG) and self.fixed_relative_profile is not None ): # Default Size --> Most likely by accident logger.warning( @@ -387,9 +387,9 @@ def _create_investment_model(self): ), 'investment', ) - elif isinstance(self.element.size, YearAwareInvestParameters): + elif isinstance(self.element.size, InvestTimingParameters): self.add_submodels( - YearAwareInvestmentModel( + InvestmentTimingModel( model=self._model, label_of_element=self.label_of_element, parameters=self.element.size, @@ -448,7 +448,7 @@ def with_on_off(self) -> bool: @property def with_investment(self) -> bool: - return isinstance(self.element.size, (InvestParameters, YearAwareInvestParameters)) + return isinstance(self.element.size, (InvestParameters, InvestTimingParameters)) # Properties for clean access to variables @property diff --git a/flixopt/features.py b/flixopt/features.py index 2d491c7ee..c669abea5 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -8,10 +8,19 @@ import linopy import numpy as np +import xarray as xr from .config import CONFIG from .core import FlowSystemDimensions, NonTemporalData, Scalar, TemporalData -from .interface import InvestParameters, OnOffParameters, Piecewise, PiecewiseEffects, YearAwareInvestParameters +from .interface import ( + FixedEndInvestTimingParameters, + FixedStartInvestTimingParameters, + InvestParameters, + InvestTimingParameters, + OnOffParameters, + Piecewise, + PiecewiseEffects, +) from .modeling import BoundingPatterns, ModelingPrimitives, ModelingUtilities from .structure import FlowSystemModel, Submodel @@ -124,30 +133,18 @@ def is_invested(self) -> Optional[linopy.Variable]: return self._variables['is_invested'] -class YearAwareInvestmentModel(Submodel): - parameters: YearAwareInvestParameters +class InvestmentTimingModel(Submodel): + parameters: InvestTimingParameters def __init__( self, model: FlowSystemModel, label_of_element: str, - parameters: YearAwareInvestParameters, + parameters: InvestTimingParameters, label_of_model: Optional[str] = None, ): - """ - This feature model is used to model the investment of a variable. - It applies the corresponding bounds to the variable and the on/off state of the variable. - - Args: - model: The optimization model instance - label_of_element: The label of the parent (Element). Used to construct the full label of the model. - parameters: The parameters of the feature model. - label_of_model: The label of the model. This is needed to construct the full label of the model. - - """ - self.piecewise_effects: Optional[PiecewiseEffectsModel] = None self.parameters = parameters - super().__init__(model, label_of_element=label_of_element, label_of_model=label_of_model) + super().__init__(model, label_of_element, label_of_model) def _do_modeling(self): self._basic_modeling() @@ -156,7 +153,8 @@ def _do_modeling(self): super()._do_modeling() def _basic_modeling(self): - _, size_max = (self.parameters.minimum_or_fixed_size, self.parameters.maximum_or_fixed_size) + size_min, size_max = self.parameters.minimum_or_fixed_size, self.parameters.maximum_or_fixed_size + self.add_variables( short_name='size', lower=0, @@ -174,7 +172,7 @@ def _basic_modeling(self): self, variable=self.size, variable_state=self.is_invested, - bounds=(self.parameters.minimum_or_fixed_size, self.parameters.maximum_or_fixed_size), + bounds=(size_min, size_max), ) increase = self.add_variables( @@ -197,9 +195,6 @@ def _basic_modeling(self): coord='year', ) - self.add_constraints(increase.sum('year') <= 1, name=f'{increase.name}|count') - self.add_constraints(decrease.sum('year') <= 1, name=f'{decrease.name}|count') - # Ensures size can only change when increase or decrease is 1 BoundingPatterns.continuous_transition_bounds( model=self, @@ -212,47 +207,20 @@ def _basic_modeling(self): coord='year', ) - def _custom_modeling(self): - """Add timing constraints for investment decisions based on user parameters.""" - - # Get the year dimension coordinates - year_coords = self._model.get_coords(['year'])[0] if self._model.get_coords(['year']) else None - if year_coords is None: - return # No year dimension, skip timing constraints + self.add_constraints(self.increase.sum('year') <= 1, name=f'{self.increase.name}|count') + self.add_constraints(self.decrease.sum('year') <= 1, name=f'{self.decrease.name}|count') - # Apply timing constraints based on parameters - self._apply_start_year_constraints(self.increase) - self._apply_end_year_constraints(self.decrease) - - def _apply_start_year_constraints(self, increase): - """Apply start year related constraints.""" - if self.parameters.earliest_start_year is not None: - # TODO: ensure that the year is present - self.add_constraints( - increase.sel(year=slice(None, self.parameters.earliest_start_year)) == 0, - name=f'{increase.name}|earliest_start', - ) - if self.parameters.latest_start_year is not None: - # TODO: ensure that the year is present - self.add_constraints( - increase.sel(year=slice(self.parameters.latest_start_year + 1, None)) == 0, - name=f'{increase.name}|latest_start', - ) - - def _apply_end_year_constraints(self, decrease): - """Apply end year related constraints.""" + def _custom_modeling(self): + # Fixed end and start year + self.add_constraints( + self.increase.sel(year=self.parameters.start_year) == self.decrease.sel(year=self.parameters.end_year), + name=f'{self.increase.name}|fixed_start_and_end', + ) - if self.parameters.earliest_end_year is not None: - # TODO: ensure that the year is present + if not self.parameters.optional: self.add_constraints( - decrease.sel(year=slice(None, self.parameters.earliest_end_year)) == 0, - name=f'{decrease.name}|earliest_end', - ) - if self.parameters.latest_end_year is not None: - # TODO: ensure that the year is present - self.add_constraints( - decrease.sel(year=slice(self.parameters.latest_end_year + 1, None)) == 0, - name=f'{decrease.name}|latest_end', + self.increase.sel(year=self.parameters.start_year) == 1, + name=f'{self.increase.name}|non_optional', ) def _add_effects(self): @@ -281,18 +249,6 @@ def _add_effects(self): target='invest', ) - if self.parameters.effects_of_divestment and self.parameters.allow_divestment: - # One-time effects when divestment is made - decrease = self._variables.get('decrease') - if decrease is not None: - self._model.effects.add_share_to_effects( - name=self.label_of_element, - expressions={ - effect: decrease * factor for effect, factor in self.parameters.effects_of_divestment.items() - }, - target='invest', - ) - @property def size(self) -> linopy.Variable: """Investment size variable""" @@ -316,6 +272,29 @@ def decrease(self) -> linopy.Variable: return self._variables['decrease'] +class FixedStartInvementTimingModel(InvestmentTimingModel): + parameters: FixedStartInvestTimingParameters + + def _custom_modeling(self): + # Fixed start year + self.add_constraints( + (self.increase * 1).where( + xr.DataArray( + self._model.flow_system.years != self.parameters.start_year, + coords=self.get_coords(['year', 'scenario']), + ) + ) + == 0, + name=f'{self.increase.name}|fixed_start_and_end', + ) + + if not self.parameters.optional: + self.add_constraints( + self.increase.sel(year=self.parameters.start_year) == 1, + name=f'{self.increase.name}|non_optional', + ) + + class OnOffModel(Submodel): """OnOff model using factory patterns""" diff --git a/flixopt/interface.py b/flixopt/interface.py index 5b11de05f..926e481b7 100644 --- a/flixopt/interface.py +++ b/flixopt/interface.py @@ -242,17 +242,12 @@ def maximum_or_fixed_size(self) -> NonTemporalData: return self.fixed_size if self.fixed_size is not None else self.maximum_size +# Base interface for common parameters @register_class_for_io -class YearAwareInvestParameters(Interface): +class _BaseYearAwareInvestParameters(Interface): """ - Parameters for year-aware investment modeling with multi-year optimization and timing constraints. - - This interface supports investment decisions that can change over time, with constraints on - when investments can be made, modified, or removed. Useful for modeling: - - Capacity expansion planning over multiple years - - Technology replacement strategies - - Retrofit and upgrade scheduling - - Investment timing optimization with changing costs/benefits + Base parameters for year-aware investment modeling. + Contains common sizing and effects parameters used by all variants. """ def __init__( @@ -261,57 +256,27 @@ def __init__( minimum_size: Optional[Scalar] = None, maximum_size: Optional[Scalar] = None, fixed_size: Optional[Scalar] = None, - # Timing constraints - flexible combinations - earliest_start_year: Optional[int] = None, - latest_start_year: Optional[int] = None, - earliest_end_year: Optional[int] = None, - latest_end_year: Optional[int] = None, - duration: Optional[int] = None, - # minimum_duration: Optional[int] = None, - # maximum_duration: Optional[int] = None, + optional: bool = False, # Direct effects effects_of_investment_per_size: Optional['NonTemporalEffectsUser'] = None, effects_of_investment: Optional['NonTemporalEffectsUser'] = None, ): """ - Initialize year-aware investment parameters. + Initialize base year-aware investment parameters. Args: minimum_size: Minimum investment size when invested. Defaults to CONFIG.modeling.EPSILON. maximum_size: Maximum possible investment size. Defaults to CONFIG.modeling.BIG. - fixed_size: If specified, investment size is fixed to this value (if investment is taken). - previous_size: THe size previous to the evaluated period (relevant for divestment decisions). - - fixed_start_year: If specified, investment must start in this exact year. - fixed_end_year: If specified, investment must end in this exact year. - fixed_duration: If specified, investment must last exactly this many years. - earliest_start_year: Earliest year investment can start. - latest_start_year: Latest year investment can start. - earliest_end_year: Earliest year investment can end. - latest_end_year: Latest year investment can end. - minimum_duration: Minimum duration investment must last (in years). - maximum_duration: Maximum duration investment can last (in years). - - effects_per_size: Effects applied per unit of investment size for each year invested. + fixed_size: If specified, investment size is fixed to this value. + effects_of_investment_per_size: Effects applied per unit of investment size for each year invested. Example: {'costs': 100} applies 100 * size * years_invested to total costs. - effects_per_investment: One-time effects applied when investment decision is made. + effects_of_investment: One-time effects applied when investment decision is made. Example: {'costs': 1000} applies 1000 to costs in the investment year. - - allow_divestment: If True, investment can be terminated before the end of time horizon. - If False, once invested, remains invested until end of horizon. - effects_per_divestment: One-time effects applied when divestment decision is made. - Example: {'costs': 500} applies 500 to costs in the divestment year. """ self.minimum_size = minimum_size if minimum_size is not None else CONFIG.modeling.EPSILON self.maximum_size = maximum_size if maximum_size is not None else CONFIG.modeling.BIG self.fixed_size = fixed_size - - self.earliest_start_year = earliest_start_year - self.latest_start_year = latest_start_year - self.earliest_end_year = earliest_end_year - self.latest_end_year = latest_end_year - # self.minimum_duration = minimum_duration - # self.maximum_duration = maximum_duration + self.optional = optional self.effects_of_investment_per_size: 'NonTemporalEffectsUser' = ( effects_of_investment_per_size if effects_of_investment_per_size is not None else {} @@ -369,6 +334,262 @@ def is_fixed_size(self) -> bool: return self.fixed_size is not None +# Variant 1: Fixed Start and End Year +@register_class_for_io +class InvestTimingParameters(_BaseYearAwareInvestParameters): + """ + Investment with fixed start and end years. + + This is the simplest variant - investment is completely scheduled. + No optimization variables needed for timing, just size optimization. + """ + + def __init__( + self, + start_year: int, + end_year: int, + # Base parameters + minimum_size: Optional[Scalar] = None, + maximum_size: Optional[Scalar] = None, + fixed_size: Optional[Scalar] = None, + optional: bool = False, + effects_of_investment_per_size: Optional['NonTemporalEffectsUser'] = None, + effects_of_investment: Optional['NonTemporalEffectsUser'] = None, + ): + """ + Initialize fixed start and end year investment parameters. + + Args: + start_year: Year when investment must start (0-indexed). + end_year: Year when investment must end (0-indexed). + **kwargs: Base parameters (size, effects) + """ + super().__init__( + minimum_size=minimum_size, + maximum_size=maximum_size, + fixed_size=fixed_size, + optional=optional, + effects_of_investment_per_size=effects_of_investment_per_size, + effects_of_investment=effects_of_investment, + ) + + self.start_year = start_year + self.end_year = end_year + + def _plausibility_checks(self, flow_system): + """Validate parameter consistency.""" + super()._plausibility_checks(flow_system) + + if self.start_year < flow_system.years[0] or self.start_year > flow_system.years[-1]: + raise ValueError( + f'start_year ({self.start_year}) must be between {flow_system.years[0]} and {flow_system.years[-1]}' + ) + + if self.end_year < flow_system.years[0] or self.end_year > flow_system.years[-1]: + raise ValueError(f'end_year ({self.end_year}) must be between 0 and {flow_system.years[-1]}') + + if self.start_year >= self.end_year: + raise ValueError(f'start_year ({self.start_year}) must be before end_year ({self.end_year})') + + @property + def duration(self) -> int: + """Get the investment duration.""" + return self.end_year - self.start_year + 1 + + +# Variant 2: Fixed Start Year, Variable End Year +@register_class_for_io +class FixedStartInvestTimingParameters(_BaseYearAwareInvestParameters): + """ + Investment with fixed start year but optimizable end year. + + Start timing is known, but duration/end timing is optimized. + Good for modeling investments that must start at a specific time + but can run for different durations. + """ + + def __init__( + self, + start_year: int, + earliest_end_year: Optional[int] = None, + latest_end_year: Optional[int] = None, + # Base parameters + minimum_size: Optional[Scalar] = None, + maximum_size: Optional[Scalar] = None, + fixed_size: Optional[Scalar] = None, + effects_of_investment_per_size: Optional['NonTemporalEffectsUser'] = None, + effects_of_investment: Optional['NonTemporalEffectsUser'] = None, + effects_of_divestment: Optional['NonTemporalEffectsUser'] = None, + ): + """ + Initialize fixed start, variable end investment parameters. + + Args: + start_year: Year when investment must start (0-indexed). + earliest_end_year: Earliest year investment can end (0-indexed). + latest_end_year: Latest year investment can end (0-indexed). + effects_of_divestment: One-time effects when investment ends. + **kwargs: Base parameters (size, effects) + """ + super().__init__( + minimum_size=minimum_size, + maximum_size=maximum_size, + fixed_size=fixed_size, + effects_of_investment_per_size=effects_of_investment_per_size, + effects_of_investment=effects_of_investment, + ) + + self.start_year = start_year + self.earliest_end_year = earliest_end_year + self.latest_end_year = latest_end_year + self.effects_of_divestment: 'NonTemporalEffectsUser' = ( + effects_of_divestment if effects_of_divestment is not None else {} + ) + + def transform_data(self, flow_system: 'FlowSystem', name_prefix: str): + """Transform parameter data.""" + super().transform_data(flow_system, name_prefix) + + self.effects_of_divestment = flow_system.fit_effects_to_model_coords( + label_prefix=name_prefix, + effect_values=self.effects_of_divestment, + label_suffix='effects_of_divestment', + has_time_dim=False, + ) + + def _plausibility_checks(self, flow_system): + """Validate parameter consistency.""" + super()._plausibility_checks(flow_system) + + total_years = len(flow_system.years) + + if self.start_year < 0 or self.start_year >= total_years: + raise ValueError(f'start_year ({self.start_year}) must be between 0 and {total_years - 1}') + + if self.earliest_end_year is not None: + if self.earliest_end_year <= self.start_year or self.earliest_end_year >= total_years: + raise ValueError( + f'earliest_end_year ({self.earliest_end_year}) must be after start_year and before {total_years}' + ) + + if self.latest_end_year is not None: + if self.latest_end_year <= self.start_year or self.latest_end_year >= total_years: + raise ValueError( + f'latest_end_year ({self.latest_end_year}) must be after start_year and before {total_years}' + ) + + if ( + self.earliest_end_year is not None + and self.latest_end_year is not None + and self.earliest_end_year > self.latest_end_year + ): + raise ValueError( + f'earliest_end_year ({self.earliest_end_year}) must be <= latest_end_year ({self.latest_end_year})' + ) + + if self.minimum_duration is not None and self.minimum_duration < 1: + raise ValueError(f'minimum_duration ({self.minimum_duration}) must be at least 1') + + if self.maximum_duration is not None and self.maximum_duration < 1: + raise ValueError(f'maximum_duration ({self.maximum_duration}) must be at least 1') + + if ( + self.minimum_duration is not None + and self.maximum_duration is not None + and self.minimum_duration > self.maximum_duration + ): + raise ValueError( + f'minimum_duration ({self.minimum_duration}) must be <= maximum_duration ({self.maximum_duration})' + ) + + +# Variant 3: Variable Start Year, Fixed End Year +@register_class_for_io +class FixedEndInvestTimingParameters(_BaseYearAwareInvestParameters): + """ + Investment with optimizable start year but fixed end year. + + End timing is known (e.g., regulatory deadline), but start timing is optimized. + Good for modeling investments that must be completed by a specific deadline + but timing of start can be optimized. + """ + + def __init__( + self, + end_year: int, + earliest_start_year: Optional[int] = None, + latest_start_year: Optional[int] = None, + # Base parameters + minimum_size: Optional[Scalar] = None, + maximum_size: Optional[Scalar] = None, + fixed_size: Optional[Scalar] = None, + effects_of_investment_per_size: Optional['NonTemporalEffectsUser'] = None, + effects_of_investment: Optional['NonTemporalEffectsUser'] = None, + effects_of_divestment: Optional['NonTemporalEffectsUser'] = None, + ): + """ + Initialize variable start, fixed end investment parameters. + + Args: + end_year: Year when investment must end (0-indexed). + earliest_start_year: Earliest year investment can start (0-indexed). + latest_start_year: Latest year investment can start (0-indexed). + effects_of_divestment: One-time effects when investment ends. + **kwargs: Base parameters (size, effects) + """ + super().__init__( + minimum_size=minimum_size, + maximum_size=maximum_size, + fixed_size=fixed_size, + effects_of_investment_per_size=effects_of_investment_per_size, + effects_of_investment=effects_of_investment, + ) + + self.end_year = end_year + self.earliest_start_year = earliest_start_year + self.latest_start_year = latest_start_year + self.effects_of_divestment: 'NonTemporalEffectsUser' = ( + effects_of_divestment if effects_of_divestment is not None else {} + ) + + def transform_data(self, flow_system: 'FlowSystem', name_prefix: str): + """Transform parameter data.""" + super().transform_data(flow_system, name_prefix) + + self.effects_of_divestment = flow_system.fit_effects_to_model_coords( + label_prefix=name_prefix, + effect_values=self.effects_of_divestment, + label_suffix='effects_of_divestment', + has_time_dim=False, + ) + + def _plausibility_checks(self, flow_system): + """Validate parameter consistency.""" + super()._plausibility_checks(flow_system) + + total_years = len(flow_system.years) + + if self.end_year < 0 or self.end_year >= total_years: + raise ValueError(f'end_year ({self.end_year}) must be between 0 and {total_years - 1}') + + if self.earliest_start_year is not None: + if self.earliest_start_year < 0 or self.earliest_start_year >= self.end_year: + raise ValueError(f'earliest_start_year ({self.earliest_start_year}) must be >= 0 and < end_year') + + if self.latest_start_year is not None: + if self.latest_start_year < 0 or self.latest_start_year >= self.end_year: + raise ValueError(f'latest_start_year ({self.latest_start_year}) must be >= 0 and < end_year') + + if ( + self.earliest_start_year is not None + and self.latest_start_year is not None + and self.earliest_start_year > self.latest_start_year + ): + raise ValueError( + f'earliest_start_year ({self.earliest_start_year}) must be <= latest_start_year ({self.latest_start_year})' + ) + + @register_class_for_io class OnOffParameters(Interface): def __init__( From e6da5ada16479cfc9960dac11621d9821bc64222 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 30 Jul 2025 12:03:40 +0200 Subject: [PATCH 278/336] Move piecewise_effects --- flixopt/features.py | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/flixopt/features.py b/flixopt/features.py index c669abea5..bd6abc6f0 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -78,19 +78,6 @@ def _create_variables_and_constraints(self): bounds=(self.parameters.minimum_or_fixed_size, self.parameters.maximum_or_fixed_size), ) - if self.parameters.piecewise_effects: - self.piecewise_effects = self.add_submodels( - PiecewiseEffectsModel( - model=self._model, - label_of_element=self.label_of_element, - label_of_model=f'{self.label_of_element}|PiecewiseEffects', - piecewise_origin=(self.size.name, self.parameters.piecewise_effects.piecewise_origin), - piecewise_shares=self.parameters.piecewise_effects.piecewise_shares, - zero_point=self.is_invested, - ), - short_name='segments', - ) - def _add_effects(self): """Add investment effects""" if self.parameters.fix_effects: @@ -120,6 +107,19 @@ def _add_effects(self): target='invest', ) + if self.parameters.piecewise_effects: + self.piecewise_effects = self.add_submodels( + PiecewiseEffectsModel( + model=self._model, + label_of_element=self.label_of_element, + label_of_model=f'{self.label_of_element}|PiecewiseEffects', + piecewise_origin=(self.size.name, self.parameters.piecewise_effects.piecewise_origin), + piecewise_shares=self.parameters.piecewise_effects.piecewise_shares, + zero_point=self.is_invested, + ), + short_name='segments', + ) + @property def size(self) -> linopy.Variable: """Investment size variable""" From aafe95bb23c8e5b5016b771413132294db251caf Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 30 Jul 2025 12:04:40 +0200 Subject: [PATCH 279/336] COmbine TImingInvestment into a single interface --- flixopt/features.py | 59 +++++++------ flixopt/interface.py | 200 ++----------------------------------------- 2 files changed, 36 insertions(+), 223 deletions(-) diff --git a/flixopt/features.py b/flixopt/features.py index bd6abc6f0..d7dd03cdc 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -13,8 +13,6 @@ from .config import CONFIG from .core import FlowSystemDimensions, NonTemporalData, Scalar, TemporalData from .interface import ( - FixedEndInvestTimingParameters, - FixedStartInvestTimingParameters, InvestParameters, InvestTimingParameters, OnOffParameters, @@ -147,10 +145,14 @@ def __init__( super().__init__(model, label_of_element, label_of_model) def _do_modeling(self): + super()._do_modeling() self._basic_modeling() - self._custom_modeling() self._add_effects() - super()._do_modeling() + + if self.parameters.start_year is not None and self.parameters.end_year is not None: + self._fixed_start_fixed_end_constraints() + elif self.parameters.end_year is not None: + self._fixed_end_constraints() def _basic_modeling(self): size_min, size_max = self.parameters.minimum_or_fixed_size, self.parameters.maximum_or_fixed_size @@ -210,19 +212,6 @@ def _basic_modeling(self): self.add_constraints(self.increase.sum('year') <= 1, name=f'{self.increase.name}|count') self.add_constraints(self.decrease.sum('year') <= 1, name=f'{self.decrease.name}|count') - def _custom_modeling(self): - # Fixed end and start year - self.add_constraints( - self.increase.sel(year=self.parameters.start_year) == self.decrease.sel(year=self.parameters.end_year), - name=f'{self.increase.name}|fixed_start_and_end', - ) - - if not self.parameters.optional: - self.add_constraints( - self.increase.sel(year=self.parameters.start_year) == 1, - name=f'{self.increase.name}|non_optional', - ) - def _add_effects(self): """Add investment effects to the model.""" @@ -249,6 +238,25 @@ def _add_effects(self): target='invest', ) + def _fixed_start_fixed_end_constraints(self): + """Add constraints for fixed start year.""" + self.add_constraints( + self.increase.sel(year=self.parameters.start_year) == self.decrease.sel(year=self.parameters.end_year), + name=f'{self.increase.name}|fixed_start_and_end', + ) + + if not self.parameters.optional: + self.add_constraints( + self.increase.sel(year=self.parameters.start_year) == 1, + name=f'{self.increase.name}|non_optional', + ) + + def _fixed_end_constraints(self): + pass + + def _fixed_start_constraints(self): + pass + @property def size(self) -> linopy.Variable: """Investment size variable""" @@ -272,19 +280,14 @@ def decrease(self) -> linopy.Variable: return self._variables['decrease'] -class FixedStartInvementTimingModel(InvestmentTimingModel): - parameters: FixedStartInvestTimingParameters +class FixedStartFixedEndInvestmentTimingModel(InvestmentTimingModel): + parameters: InvestTimingParameters + + def _basic_modeling(self): + super()._basic_modeling() - def _custom_modeling(self): - # Fixed start year self.add_constraints( - (self.increase * 1).where( - xr.DataArray( - self._model.flow_system.years != self.parameters.start_year, - coords=self.get_coords(['year', 'scenario']), - ) - ) - == 0, + self.increase.sel(year=self.parameters.start_year) == self.decrease.sel(year=self.parameters.end_year), name=f'{self.increase.name}|fixed_start_and_end', ) diff --git a/flixopt/interface.py b/flixopt/interface.py index 926e481b7..1825ea773 100644 --- a/flixopt/interface.py +++ b/flixopt/interface.py @@ -346,8 +346,8 @@ class InvestTimingParameters(_BaseYearAwareInvestParameters): def __init__( self, - start_year: int, - end_year: int, + start_year: Optional[int] = None, + end_year: Optional[int] = None, # Base parameters minimum_size: Optional[Scalar] = None, maximum_size: Optional[Scalar] = None, @@ -380,6 +380,9 @@ def _plausibility_checks(self, flow_system): """Validate parameter consistency.""" super()._plausibility_checks(flow_system) + if self.start_year is None and self.end_year is None: + raise ValueError('Either start_year or end_year must be specified.') + if self.start_year < flow_system.years[0] or self.start_year > flow_system.years[-1]: raise ValueError( f'start_year ({self.start_year}) must be between {flow_system.years[0]} and {flow_system.years[-1]}' @@ -397,199 +400,6 @@ def duration(self) -> int: return self.end_year - self.start_year + 1 -# Variant 2: Fixed Start Year, Variable End Year -@register_class_for_io -class FixedStartInvestTimingParameters(_BaseYearAwareInvestParameters): - """ - Investment with fixed start year but optimizable end year. - - Start timing is known, but duration/end timing is optimized. - Good for modeling investments that must start at a specific time - but can run for different durations. - """ - - def __init__( - self, - start_year: int, - earliest_end_year: Optional[int] = None, - latest_end_year: Optional[int] = None, - # Base parameters - minimum_size: Optional[Scalar] = None, - maximum_size: Optional[Scalar] = None, - fixed_size: Optional[Scalar] = None, - effects_of_investment_per_size: Optional['NonTemporalEffectsUser'] = None, - effects_of_investment: Optional['NonTemporalEffectsUser'] = None, - effects_of_divestment: Optional['NonTemporalEffectsUser'] = None, - ): - """ - Initialize fixed start, variable end investment parameters. - - Args: - start_year: Year when investment must start (0-indexed). - earliest_end_year: Earliest year investment can end (0-indexed). - latest_end_year: Latest year investment can end (0-indexed). - effects_of_divestment: One-time effects when investment ends. - **kwargs: Base parameters (size, effects) - """ - super().__init__( - minimum_size=minimum_size, - maximum_size=maximum_size, - fixed_size=fixed_size, - effects_of_investment_per_size=effects_of_investment_per_size, - effects_of_investment=effects_of_investment, - ) - - self.start_year = start_year - self.earliest_end_year = earliest_end_year - self.latest_end_year = latest_end_year - self.effects_of_divestment: 'NonTemporalEffectsUser' = ( - effects_of_divestment if effects_of_divestment is not None else {} - ) - - def transform_data(self, flow_system: 'FlowSystem', name_prefix: str): - """Transform parameter data.""" - super().transform_data(flow_system, name_prefix) - - self.effects_of_divestment = flow_system.fit_effects_to_model_coords( - label_prefix=name_prefix, - effect_values=self.effects_of_divestment, - label_suffix='effects_of_divestment', - has_time_dim=False, - ) - - def _plausibility_checks(self, flow_system): - """Validate parameter consistency.""" - super()._plausibility_checks(flow_system) - - total_years = len(flow_system.years) - - if self.start_year < 0 or self.start_year >= total_years: - raise ValueError(f'start_year ({self.start_year}) must be between 0 and {total_years - 1}') - - if self.earliest_end_year is not None: - if self.earliest_end_year <= self.start_year or self.earliest_end_year >= total_years: - raise ValueError( - f'earliest_end_year ({self.earliest_end_year}) must be after start_year and before {total_years}' - ) - - if self.latest_end_year is not None: - if self.latest_end_year <= self.start_year or self.latest_end_year >= total_years: - raise ValueError( - f'latest_end_year ({self.latest_end_year}) must be after start_year and before {total_years}' - ) - - if ( - self.earliest_end_year is not None - and self.latest_end_year is not None - and self.earliest_end_year > self.latest_end_year - ): - raise ValueError( - f'earliest_end_year ({self.earliest_end_year}) must be <= latest_end_year ({self.latest_end_year})' - ) - - if self.minimum_duration is not None and self.minimum_duration < 1: - raise ValueError(f'minimum_duration ({self.minimum_duration}) must be at least 1') - - if self.maximum_duration is not None and self.maximum_duration < 1: - raise ValueError(f'maximum_duration ({self.maximum_duration}) must be at least 1') - - if ( - self.minimum_duration is not None - and self.maximum_duration is not None - and self.minimum_duration > self.maximum_duration - ): - raise ValueError( - f'minimum_duration ({self.minimum_duration}) must be <= maximum_duration ({self.maximum_duration})' - ) - - -# Variant 3: Variable Start Year, Fixed End Year -@register_class_for_io -class FixedEndInvestTimingParameters(_BaseYearAwareInvestParameters): - """ - Investment with optimizable start year but fixed end year. - - End timing is known (e.g., regulatory deadline), but start timing is optimized. - Good for modeling investments that must be completed by a specific deadline - but timing of start can be optimized. - """ - - def __init__( - self, - end_year: int, - earliest_start_year: Optional[int] = None, - latest_start_year: Optional[int] = None, - # Base parameters - minimum_size: Optional[Scalar] = None, - maximum_size: Optional[Scalar] = None, - fixed_size: Optional[Scalar] = None, - effects_of_investment_per_size: Optional['NonTemporalEffectsUser'] = None, - effects_of_investment: Optional['NonTemporalEffectsUser'] = None, - effects_of_divestment: Optional['NonTemporalEffectsUser'] = None, - ): - """ - Initialize variable start, fixed end investment parameters. - - Args: - end_year: Year when investment must end (0-indexed). - earliest_start_year: Earliest year investment can start (0-indexed). - latest_start_year: Latest year investment can start (0-indexed). - effects_of_divestment: One-time effects when investment ends. - **kwargs: Base parameters (size, effects) - """ - super().__init__( - minimum_size=minimum_size, - maximum_size=maximum_size, - fixed_size=fixed_size, - effects_of_investment_per_size=effects_of_investment_per_size, - effects_of_investment=effects_of_investment, - ) - - self.end_year = end_year - self.earliest_start_year = earliest_start_year - self.latest_start_year = latest_start_year - self.effects_of_divestment: 'NonTemporalEffectsUser' = ( - effects_of_divestment if effects_of_divestment is not None else {} - ) - - def transform_data(self, flow_system: 'FlowSystem', name_prefix: str): - """Transform parameter data.""" - super().transform_data(flow_system, name_prefix) - - self.effects_of_divestment = flow_system.fit_effects_to_model_coords( - label_prefix=name_prefix, - effect_values=self.effects_of_divestment, - label_suffix='effects_of_divestment', - has_time_dim=False, - ) - - def _plausibility_checks(self, flow_system): - """Validate parameter consistency.""" - super()._plausibility_checks(flow_system) - - total_years = len(flow_system.years) - - if self.end_year < 0 or self.end_year >= total_years: - raise ValueError(f'end_year ({self.end_year}) must be between 0 and {total_years - 1}') - - if self.earliest_start_year is not None: - if self.earliest_start_year < 0 or self.earliest_start_year >= self.end_year: - raise ValueError(f'earliest_start_year ({self.earliest_start_year}) must be >= 0 and < end_year') - - if self.latest_start_year is not None: - if self.latest_start_year < 0 or self.latest_start_year >= self.end_year: - raise ValueError(f'latest_start_year ({self.latest_start_year}) must be >= 0 and < end_year') - - if ( - self.earliest_start_year is not None - and self.latest_start_year is not None - and self.earliest_start_year > self.latest_start_year - ): - raise ValueError( - f'earliest_start_year ({self.earliest_start_year}) must be <= latest_start_year ({self.latest_start_year})' - ) - - @register_class_for_io class OnOffParameters(Interface): def __init__( From 9316d8a6482534c1d2ba7ba1a869ea27756a82bf Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 30 Jul 2025 13:42:24 +0200 Subject: [PATCH 280/336] Add model tests for investment --- tests/test_models.py | 125 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 tests/test_models.py diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 000000000..c446313b8 --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,125 @@ +import numpy as np +import pandas as pd +import pytest +import linopy + +import flixopt as fx + +from .conftest import assert_conequal, assert_sets_equal, assert_var_equal, create_linopy_model, LoadProfiles, Effects, Sinks, Sources, Buses + +@pytest.fixture +def flow_system() -> fx.FlowSystem: + """Create basic elements for component testing with coordinate parametrization.""" + years = pd.Index([2020, 2021, 2022, 2023, 2024], name='year') + timesteps = pd.date_range('2020-01-01', periods=24, freq='h', name='time') + flow_system = fx.FlowSystem(timesteps=timesteps, years=years) + + thermal_load = LoadProfiles.random_thermal(len(timesteps)) + p_el = LoadProfiles.random_electrical(len(timesteps)) + + costs = Effects.costs() + heat_load = Sinks.heat_load(thermal_load) + gas_source = Sources.gas_with_costs() + electricity_sink = Sinks.electricity_feed_in(p_el) + + flow_system.add_elements(*Buses.defaults()) + flow_system.add_elements(costs, heat_load, gas_source, electricity_sink) + + return flow_system + + +class TestYearAwareInvestParameters: + """Test the YearAwareInvestParameters interface.""" + + def test_basic_initialization(self): + """Test basic parameter initialization.""" + params = fx.YearAwareInvestParameters( + minimum_size=10, + maximum_size=100, + ) + + assert params.minimum_size == 10 + assert params.maximum_size == 100 + assert params.fixed_size is None + assert not params.allow_divestment + assert params.fixed_start_year is None + assert params.fixed_end_year is None + assert params.fixed_duration is None + + def test_fixed_size_initialization(self): + """Test initialization with fixed size.""" + params = fx.YearAwareInvestParameters(fixed_size=50) + + assert params.minimum_or_fixed_size == 50 + assert params.maximum_or_fixed_size == 50 + assert params.is_fixed_size + + def test_timing_constraints_initialization(self): + """Test initialization with various timing constraints.""" + params = fx.YearAwareInvestParameters( + fixed_start_year=2, + minimum_duration=3, + maximum_duration=5, + earliest_end_year=4, + ) + + assert params.fixed_start_year == 2 + assert params.minimum_duration == 3 + assert params.maximum_duration == 5 + assert params.earliest_end_year == 4 + + def test_effects_initialization(self): + """Test initialization with effects.""" + params = fx.YearAwareInvestParameters( + effects_of_investment={'costs': 1000}, + effects_of_investment_per_size={'costs': 100}, + allow_divestment=True, + effects_of_divestment={'costs': 500}, + effects_of_divestment_per_size={'costs': 50}, + ) + + assert params.effects_of_investment == {'costs': 1000} + assert params.effects_of_investment_per_size == {'costs': 100} + assert params.allow_divestment + assert params.effects_of_divestment == {'costs': 500} + assert params.effects_of_divestment_per_size == {'costs': 50} + + def test_property_methods(self): + """Test property methods.""" + # Test with fixed size + params_fixed = fx.YearAwareInvestParameters(fixed_size=50) + assert params_fixed.minimum_or_fixed_size == 50 + assert params_fixed.maximum_or_fixed_size == 50 + assert params_fixed.is_fixed_size + + # Test with min/max size + params_range = fx.YearAwareInvestParameters(minimum_size=10, maximum_size=100) + assert params_range.minimum_or_fixed_size == 10 + assert params_range.maximum_or_fixed_size == 100 + assert not params_range.is_fixed_size + + +class TestYearAwareInvestmentModelDirect: + """Test the YearAwareInvestmentModel class directly with linopy.""" + + def test_flow_invest_new(self, flow_system): + flow = fx.Flow( + 'Wärme', + bus='Fernwärme', + size=fx.InvestTimingParameters(start_year=2021, end_year=2023, minimum_size=20, maximum_size=1000, effects_of_investment_per_size=200), + relative_maximum=np.linspace(0.5, 1, flow_system.timesteps.size), + ) + + flow_system.add_elements(fx.Source('Source', source=flow)) + calculation = fx.FullCalculation('GenericName', flow_system) + calculation.do_modeling() + #calculation.model.add_constraints(calculation.model['Source(Wärme)|decrease'].isel(year=2) == 1) + calculation.solve(fx.solvers.HighsSolver(0, 60)) + + ds = calculation.results['Source'].solution + filtered_ds = ds[[v for v in ds.data_vars if ds[v].dims == ('year',)]] + print(filtered_ds.to_pandas().T) + + print('##') + + From 5ecb3c9ffb23e3c81c5c77e96a96a2f0f6557a9f Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 31 Jul 2025 17:16:03 +0200 Subject: [PATCH 281/336] Add size_changes variables --- flixopt/features.py | 106 ++++++++++++++++++++------------------------ flixopt/modeling.py | 77 ++++++++++++++++++++++++++++++++ 2 files changed, 126 insertions(+), 57 deletions(-) diff --git a/flixopt/features.py b/flixopt/features.py index d7dd03cdc..db645c612 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -177,86 +177,68 @@ def _basic_modeling(self): bounds=(size_min, size_max), ) - increase = self.add_variables( + has_increase = self.add_variables( binary=True, coords=self._model.get_coords(['year', 'scenario']), - short_name='increase', + short_name='has_increase', ) - decrease = self.add_variables( + has_decrease = self.add_variables( binary=True, coords=self._model.get_coords(['year', 'scenario']), - short_name='decrease', + short_name='has_decrease', ) BoundingPatterns.state_transition_bounds( self, state_variable=self.is_invested, - switch_on=increase, - switch_off=decrease, + switch_on=has_increase, + switch_off=has_decrease, name=self.is_invested.name, previous_state=0, coord='year', ) + self.add_variables( + coords=self._model.get_coords(['year', 'scenario']), + short_name='size_increase', + lower=0, + upper=size_max, + ) + + self.add_variables( + coords=self._model.get_coords(['year', 'scenario']), + short_name='size_decrease', + lower=0, + upper=size_max, + ) + # Ensures size can only change when increase or decrease is 1 BoundingPatterns.continuous_transition_bounds( model=self, continuous_variable=self.size, - switch_on=increase, - switch_off=decrease, + switch_on=has_increase, + switch_off=has_decrease, name=self.size.name, max_change=size_max, previous_value=0, coord='year', ) - self.add_constraints(self.increase.sum('year') <= 1, name=f'{self.increase.name}|count') - self.add_constraints(self.decrease.sum('year') <= 1, name=f'{self.decrease.name}|count') - - def _add_effects(self): - """Add investment effects to the model.""" - - if self.parameters.effects_of_investment: - # One-time effects when investment is made - increase = self._variables.get('increase') - if increase is not None: - self._model.effects.add_share_to_effects( - name=self.label_of_element, - expressions={ - effect: increase * factor for effect, factor in self.parameters.effects_of_investment.items() - }, - target='invest', - ) - - if self.parameters.effects_of_investment_per_size: - # Annual effects proportional to investment size - self._model.effects.add_share_to_effects( - name=self.label_of_element, - expressions={ - effect: self.size * factor - for effect, factor in self.parameters.effects_of_investment_per_size.items() - }, - target='invest', - ) + self.add_constraints(self.has_increase.sum('year') <= 1, name=f'{self.has_increase.name}|count') + self.add_constraints(self.has_decrease.sum('year') <= 1, name=f'{self.has_decrease.name}|count') - def _fixed_start_fixed_end_constraints(self): - """Add constraints for fixed start year.""" - self.add_constraints( - self.increase.sel(year=self.parameters.start_year) == self.decrease.sel(year=self.parameters.end_year), - name=f'{self.increase.name}|fixed_start_and_end', + BoundingPatterns.link_changes_to_level_with_binaries( + self, + level_variable=self.size, + increase_variable=self.size_increase, + decrease_variable=self.size_decrease, + increase_binary=self.has_increase, + decrease_binary=self.has_decrease, + name=f'{self.label_of_element}|size_changes', + max_change=size_max, + initial_level=0, + coord='year', ) - if not self.parameters.optional: - self.add_constraints( - self.increase.sel(year=self.parameters.start_year) == 1, - name=f'{self.increase.name}|non_optional', - ) - - def _fixed_end_constraints(self): - pass - - def _fixed_start_constraints(self): - pass - @property def size(self) -> linopy.Variable: """Investment size variable""" @@ -270,14 +252,24 @@ def is_invested(self) -> Optional[linopy.Variable]: return self._variables['is_invested'] @property - def increase(self) -> linopy.Variable: + def has_increase(self) -> linopy.Variable: """Binary increase decision variable""" - return self._variables['increase'] + return self._variables['has_increase'] + + @property + def has_decrease(self) -> linopy.Variable: + """Binary decrease decision variable""" + return self._variables['has_decrease'] @property - def decrease(self) -> linopy.Variable: + def size_decrease(self) -> linopy.Variable: """Binary decrease decision variable""" - return self._variables['decrease'] + return self._variables['size_decrease'] + + @property + def size_increase(self) -> linopy.Variable: + """Binary increase decision variable""" + return self._variables['size_increase'] class FixedStartFixedEndInvestmentTimingModel(InvestmentTimingModel): diff --git a/flixopt/modeling.py b/flixopt/modeling.py index f523346cc..ef7cdc27a 100644 --- a/flixopt/modeling.py +++ b/flixopt/modeling.py @@ -654,3 +654,80 @@ def continuous_transition_bounds( ) return transition_upper, transition_lower, initial_upper, initial_lower + + @staticmethod + def link_changes_to_level_with_binaries( + model: Submodel, + level_variable: linopy.Variable, + increase_variable: linopy.Variable, + decrease_variable: linopy.Variable, + increase_binary: linopy.Variable, + decrease_binary: linopy.Variable, + name: str, + max_change: Union[float, xr.DataArray], + initial_level: Union[float, xr.DataArray] = 0, + coord: str = 'year', + ) -> Tuple[linopy.Constraint, linopy.Constraint, linopy.Constraint, linopy.Constraint, linopy.Constraint]: + """ + Link changes to level evolution with binary control and mutual exclusivity. + + Creates the complete constraint system for ALL time periods: + 1. level[0] = initial_level + increase[0] - decrease[0] + 2. level[t] = level[t-1] + increase[t] - decrease[t] ∀t > 0 + 3. increase[t] <= max_change * increase_binary[t] ∀t + 4. decrease[t] <= max_change * decrease_binary[t] ∀t + 5. increase_binary[t] + decrease_binary[t] <= 1 ∀t + + Args: + model: The submodel to add constraints to + increase_variable: Incremental additions for ALL periods (>= 0) + decrease_variable: Incremental reductions for ALL periods (>= 0) + increase_binary: Binary indicators for increases for ALL periods + decrease_binary: Binary indicators for decreases for ALL periods + level_variable: Level variable for ALL periods + name: Base name for constraints + max_change: Maximum change per period + initial_level: Starting level before first period + coord: Time coordinate name + + Returns: + Tuple of (initial_constraint, transition_constraints, increase_bounds, decrease_bounds, mutual_exclusion) + """ + if not isinstance(model, Submodel): + raise ValueError('BoundingPatterns.link_changes_to_level_with_binaries() can only be used with a Submodel') + + # 1. Initial period: level[0] = initial_level + increase[0] - decrease[0] + initial_constraint = model.add_constraints( + level_variable.isel({coord: 0}) + == initial_level + increase_variable.isel({coord: 0}) - decrease_variable.isel({coord: 0}), + name=f'{name}|initial_level', + ) + + # 2. Transition periods: level[t] = level[t-1] + increase[t] - decrease[t] for t > 0 + transition_constraints = model.add_constraints( + level_variable.isel({coord: slice(1, None)}) + == level_variable.isel({coord: slice(None, -1)}) + + increase_variable.isel({coord: slice(1, None)}) + - decrease_variable.isel({coord: slice(1, None)}), + name=f'{name}|transitions', + ) + + # 3. Increase bounds: increase[t] <= max_change * increase_binary[t] for all t + increase_bounds = model.add_constraints( + increase_variable <= increase_binary * max_change, + name=f'{name}|increase_bounds', + ) + + # 4. Decrease bounds: decrease[t] <= max_change * decrease_binary[t] for all t + decrease_bounds = model.add_constraints( + decrease_variable <= decrease_binary * max_change, + name=f'{name}|decrease_bounds', + ) + + # 5. Mutual exclusivity: increase_binary[t] + decrease_binary[t] <= 1 for all t + mutual_exclusion = model.add_constraints( + increase_binary + decrease_binary <= 1, + name=f'{name}|mutual_exclusion', + ) + + return initial_constraint, transition_constraints, increase_bounds, decrease_bounds, mutual_exclusion From b1c9b71f29871369b37cfb1a3e4a2b7dc4198b6e Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 31 Jul 2025 17:25:57 +0200 Subject: [PATCH 282/336] Add size_changes variables --- flixopt/features.py | 79 +++++++++++++++++++++++++++++++-------------- 1 file changed, 54 insertions(+), 25 deletions(-) diff --git a/flixopt/features.py b/flixopt/features.py index db645c612..a1f084240 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -177,12 +177,12 @@ def _basic_modeling(self): bounds=(size_min, size_max), ) - has_increase = self.add_variables( + self.add_variables( binary=True, coords=self._model.get_coords(['year', 'scenario']), short_name='has_increase', ) - has_decrease = self.add_variables( + self.add_variables( binary=True, coords=self._model.get_coords(['year', 'scenario']), short_name='has_decrease', @@ -190,12 +190,14 @@ def _basic_modeling(self): BoundingPatterns.state_transition_bounds( self, state_variable=self.is_invested, - switch_on=has_increase, - switch_off=has_decrease, + switch_on=self.has_increase, + switch_off=self.has_decrease, name=self.is_invested.name, previous_state=0, coord='year', ) + self.add_constraints(self.has_increase.sum('year') <= 1, name=f'{self.has_increase.name}|count') + self.add_constraints(self.has_decrease.sum('year') <= 1, name=f'{self.has_decrease.name}|count') self.add_variables( coords=self._model.get_coords(['year', 'scenario']), @@ -203,29 +205,12 @@ def _basic_modeling(self): lower=0, upper=size_max, ) - self.add_variables( coords=self._model.get_coords(['year', 'scenario']), short_name='size_decrease', lower=0, upper=size_max, ) - - # Ensures size can only change when increase or decrease is 1 - BoundingPatterns.continuous_transition_bounds( - model=self, - continuous_variable=self.size, - switch_on=has_increase, - switch_off=has_decrease, - name=self.size.name, - max_change=size_max, - previous_value=0, - coord='year', - ) - - self.add_constraints(self.has_increase.sum('year') <= 1, name=f'{self.has_increase.name}|count') - self.add_constraints(self.has_decrease.sum('year') <= 1, name=f'{self.has_decrease.name}|count') - BoundingPatterns.link_changes_to_level_with_binaries( self, level_variable=self.size, @@ -239,6 +224,49 @@ def _basic_modeling(self): coord='year', ) + def _add_effects(self): + """Add investment effects to the model.""" + + if self.parameters.effects_of_investment: + # One-time effects when investment is made + increase = self._variables.get('increase') + if increase is not None: + self._model.effects.add_share_to_effects( + name=self.label_of_element, + expressions={ + effect: increase * factor for effect, factor in self.parameters.effects_of_investment.items() + }, + target='invest', + ) + + if self.parameters.effects_of_investment_per_size: + # Annual effects proportional to investment size + self._model.effects.add_share_to_effects( + name=self.label_of_element, + expressions={ + effect: self.size * factor + for effect, factor in self.parameters.effects_of_investment_per_size.items() + }, + target='invest', + ) + + def _fixed_start_fixed_end_constraints(self): + """Add constraints for fixed start year.""" + self.add_constraints( + self.has_increase.sel(year=self.parameters.start_year) + == self.has_decrease.sel(year=self.parameters.end_year), + name=f'{self.has_increase.name}|fixed_start_and_end', + ) + + if not self.parameters.optional: + self.add_constraints( + self.has_increase.sel(year=self.parameters.start_year) == 1, + name=f'{self.has_increase.name}|non_optional', + ) + + def _fixed_end_constraints(self): + pass + @property def size(self) -> linopy.Variable: """Investment size variable""" @@ -279,14 +307,15 @@ def _basic_modeling(self): super()._basic_modeling() self.add_constraints( - self.increase.sel(year=self.parameters.start_year) == self.decrease.sel(year=self.parameters.end_year), - name=f'{self.increase.name}|fixed_start_and_end', + self.has_increase.sel(year=self.parameters.start_year) + == self.has_decrease.sel(year=self.parameters.end_year), + name=f'{self.has_increase.name}|fixed_start_and_end', ) if not self.parameters.optional: self.add_constraints( - self.increase.sel(year=self.parameters.start_year) == 1, - name=f'{self.increase.name}|non_optional', + self.has_increase.sel(year=self.parameters.start_year) == 1, + name=f'{self.has_increase.name}|non_optional', ) From 3c98553a0c53323424f34c19b42c289120899abe Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 31 Jul 2025 18:07:45 +0200 Subject: [PATCH 283/336] Improve InvestmentModel --- flixopt/features.py | 91 +++++++++++++++++++++++++++++--------------- flixopt/interface.py | 89 ++++++++++++++++++++++++++++++++++++------- tests/test_models.py | 25 ++++++++---- 3 files changed, 155 insertions(+), 50 deletions(-) diff --git a/flixopt/features.py b/flixopt/features.py index a1f084240..44bea1329 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -149,10 +149,10 @@ def _do_modeling(self): self._basic_modeling() self._add_effects() - if self.parameters.start_year is not None and self.parameters.end_year is not None: - self._fixed_start_fixed_end_constraints() - elif self.parameters.end_year is not None: - self._fixed_end_constraints() + if self.parameters.start_year is not None: + self._fixed_start_constraint() + if self.parameters.end_year is not None: + self._fixed_end_constraint() def _basic_modeling(self): size_min, size_max = self.parameters.minimum_or_fixed_size, self.parameters.maximum_or_fixed_size @@ -170,6 +170,20 @@ def _basic_modeling(self): short_name='is_invested', ) + if self.parameters.optional_investment: + self.add_variables( + binary=True, + coords=self._model.get_coords(['scenario']), + short_name='investment_used', + ) + + if self.parameters.optional_divestment: + self.add_variables( + binary=True, + coords=self._model.get_coords(['scenario']), + short_name='divestment_used', + ) + BoundingPatterns.bounds_with_state( self, variable=self.size, @@ -180,12 +194,12 @@ def _basic_modeling(self): self.add_variables( binary=True, coords=self._model.get_coords(['year', 'scenario']), - short_name='has_increase', + short_name='size|has_increase', ) self.add_variables( binary=True, coords=self._model.get_coords(['year', 'scenario']), - short_name='has_decrease', + short_name='size|has_decrease', ) BoundingPatterns.state_transition_bounds( self, @@ -196,20 +210,26 @@ def _basic_modeling(self): previous_state=0, coord='year', ) - self.add_constraints(self.has_increase.sum('year') <= 1, name=f'{self.has_increase.name}|count') - self.add_constraints(self.has_decrease.sum('year') <= 1, name=f'{self.has_decrease.name}|count') + self.add_constraints( + self.has_increase.sum('year') == (self.investment_used if self.investment_used is not None else 1), + name=f'{self.has_increase.name}|count', + ) + self.add_constraints( + self.has_decrease.sum('year') == (self.divestment_used if self.divestment_used is not None else 1), + name=f'{self.has_decrease.name}|count', + ) self.add_variables( coords=self._model.get_coords(['year', 'scenario']), - short_name='size_increase', + short_name='size|increase', lower=0, upper=size_max, ) self.add_variables( coords=self._model.get_coords(['year', 'scenario']), - short_name='size_decrease', + short_name='size|decrease', lower=0, - upper=size_max, + upper=CONFIG.modeling.BIG, ) BoundingPatterns.link_changes_to_level_with_binaries( self, @@ -218,7 +238,7 @@ def _basic_modeling(self): decrease_variable=self.size_decrease, increase_binary=self.has_increase, decrease_binary=self.has_decrease, - name=f'{self.label_of_element}|size_changes', + name=f'{self.label_of_element}|size|changes', max_change=size_max, initial_level=0, coord='year', @@ -250,22 +270,19 @@ def _add_effects(self): target='invest', ) - def _fixed_start_fixed_end_constraints(self): - """Add constraints for fixed start year.""" + def _fixed_start_constraint(self): self.add_constraints( self.has_increase.sel(year=self.parameters.start_year) - == self.has_decrease.sel(year=self.parameters.end_year), - name=f'{self.has_increase.name}|fixed_start_and_end', + == (self.investment_used if self.investment_used is not None else 1), + short_name='size|changes|fixed_start', ) - if not self.parameters.optional: - self.add_constraints( - self.has_increase.sel(year=self.parameters.start_year) == 1, - name=f'{self.has_increase.name}|non_optional', - ) - - def _fixed_end_constraints(self): - pass + def _fixed_end_constraint(self): + self.add_constraints( + self.has_decrease.sel(year=self.parameters.end_year) + == (self.divestment_used if self.divestment_used is not None else 1), + short_name='size|changes|fixed_end', + ) @property def size(self) -> linopy.Variable: @@ -282,22 +299,36 @@ def is_invested(self) -> Optional[linopy.Variable]: @property def has_increase(self) -> linopy.Variable: """Binary increase decision variable""" - return self._variables['has_increase'] + return self._variables['size|has_increase'] @property def has_decrease(self) -> linopy.Variable: """Binary decrease decision variable""" - return self._variables['has_decrease'] + return self._variables['size|has_decrease'] @property def size_decrease(self) -> linopy.Variable: """Binary decrease decision variable""" - return self._variables['size_decrease'] + return self._variables['size|decrease'] @property def size_increase(self) -> linopy.Variable: """Binary increase decision variable""" - return self._variables['size_increase'] + return self._variables['size|increase'] + + @property + def investment_used(self) -> Optional[linopy.Variable]: + """Binary investment decision variable""" + if 'investment_used' not in self._variables: + return None + return self._variables['investment_used'] + + @property + def divestment_used(self) -> Optional[linopy.Variable]: + """Binary investment decision variable""" + if 'divestment_used' not in self._variables: + return None + return self._variables['divestment_used'] class FixedStartFixedEndInvestmentTimingModel(InvestmentTimingModel): @@ -309,13 +340,13 @@ def _basic_modeling(self): self.add_constraints( self.has_increase.sel(year=self.parameters.start_year) == self.has_decrease.sel(year=self.parameters.end_year), - name=f'{self.has_increase.name}|fixed_start_and_end', + short_name='size|changes|fixed_start_and_end', ) if not self.parameters.optional: self.add_constraints( self.has_increase.sel(year=self.parameters.start_year) == 1, - name=f'{self.has_increase.name}|non_optional', + name='size|changes|non_optional', ) diff --git a/flixopt/interface.py b/flixopt/interface.py index 1825ea773..3763d01be 100644 --- a/flixopt/interface.py +++ b/flixopt/interface.py @@ -336,7 +336,7 @@ def is_fixed_size(self) -> bool: # Variant 1: Fixed Start and End Year @register_class_for_io -class InvestTimingParameters(_BaseYearAwareInvestParameters): +class InvestTimingParameters(Interface): """ Investment with fixed start and end years. @@ -348,11 +348,13 @@ def __init__( self, start_year: Optional[int] = None, end_year: Optional[int] = None, - # Base parameters minimum_size: Optional[Scalar] = None, maximum_size: Optional[Scalar] = None, fixed_size: Optional[Scalar] = None, - optional: bool = False, + optional_investment: bool = False, + optional_divestment: bool = False, + fix_effects: Optional['NonTemporalEffectsUser'] = None, + specific_effects: Optional['NonTemporalEffectsUser'] = None, # costs per Flow-Unit/Storage-Size/... effects_of_investment_per_size: Optional['NonTemporalEffectsUser'] = None, effects_of_investment: Optional['NonTemporalEffectsUser'] = None, ): @@ -362,23 +364,41 @@ def __init__( Args: start_year: Year when investment must start (0-indexed). end_year: Year when investment must end (0-indexed). - **kwargs: Base parameters (size, effects) + minimum_size: Minimum possible size of the investment. + maximum_size: Maximum possible size of the investment. + fixed_size: If specified, investment size is fixed to this value. + optional_investment: If False, the investment is mandatory. + optional_divestment: If False, the divestment is mandatory. + specific_effects: Specific costs, e.g., in €/kW_nominal or €/m²_nominal. + Example: {costs: 3, CO2: 0.3} with costs and CO2 representing an Object of class Effect + (Attention: Annualize costs to chosen period!) + effects_of_investment: Effects depending on when an investment decision is made. These can occur in the investment year or in multiple years. + If the effects need to occur in multiple years, you need to pass an xr.DataArray with the coord 'year_of_investment'. + Example: {'costs': 1000} applies 1000 to costs in the investment year. + """ - super().__init__( - minimum_size=minimum_size, - maximum_size=maximum_size, - fixed_size=fixed_size, - optional=optional, - effects_of_investment_per_size=effects_of_investment_per_size, - effects_of_investment=effects_of_investment, - ) + self.minimum_size = minimum_size if minimum_size is not None else CONFIG.modeling.EPSILON + self.maximum_size = maximum_size if maximum_size is not None else CONFIG.modeling.BIG + self.fixed_size = fixed_size + self.optional_investment = optional_investment + self.optional_divestment = optional_divestment self.start_year = start_year self.end_year = end_year + self.fix_effects: 'NonTemporalEffectsUser' = fix_effects if fix_effects is not None else {} + self.specific_effects: 'NonTemporalEffectsUser' = specific_effects if specific_effects is not None else {} + self.effects_of_investment_per_size: 'NonTemporalEffectsUser' = ( + effects_of_investment_per_size if effects_of_investment_per_size is not None else {} + ) + self.effects_of_investment: 'NonTemporalEffectsUser' = ( + effects_of_investment if effects_of_investment is not None else {} + ) + def _plausibility_checks(self, flow_system): """Validate parameter consistency.""" - super()._plausibility_checks(flow_system) + if flow_system.years is None: + raise ValueError("YearAwareInvestParameters requires the flow_system to have a 'years' dimension.") if self.start_year is None and self.end_year is None: raise ValueError('Either start_year or end_year must be specified.') @@ -399,6 +419,49 @@ def duration(self) -> int: """Get the investment duration.""" return self.end_year - self.start_year + 1 + def transform_data(self, flow_system: 'FlowSystem', name_prefix: str): + """Transform all parameter data to match the flow system's coordinate structure.""" + self._plausibility_checks(flow_system) + + self.effects_of_investment_per_size = flow_system.fit_effects_to_model_coords( + label_prefix=name_prefix, + effect_values=self.effects_of_investment_per_size, + label_suffix='effects_of_investment_per_size', + has_time_dim=False, + ) + self.effects_of_investment = flow_system.fit_effects_to_model_coords( + label_prefix=name_prefix, + effect_values=self.effects_of_investment, + label_suffix='effects_of_investment', + has_time_dim=False, + ) + + self.minimum_size = flow_system.fit_to_model_coords( + f'{name_prefix}|minimum_size', self.minimum_size, has_time_dim=False + ) + self.maximum_size = flow_system.fit_to_model_coords( + f'{name_prefix}|maximum_size', self.maximum_size, has_time_dim=False + ) + if self.fixed_size is not None: + self.fixed_size = flow_system.fit_to_model_coords( + f'{name_prefix}|fixed_size', self.fixed_size, has_time_dim=False + ) + + @property + def minimum_or_fixed_size(self) -> NonTemporalData: + """Get the effective minimum size (fixed size takes precedence).""" + return self.fixed_size if self.fixed_size is not None else self.minimum_size + + @property + def maximum_or_fixed_size(self) -> NonTemporalData: + """Get the effective maximum size (fixed size takes precedence).""" + return self.fixed_size if self.fixed_size is not None else self.maximum_size + + @property + def is_fixed_size(self) -> bool: + """Check if investment size is fixed.""" + return self.fixed_size is not None + @register_class_for_io class OnOffParameters(Interface): diff --git a/tests/test_models.py b/tests/test_models.py index c446313b8..97480b900 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1,11 +1,22 @@ +import linopy import numpy as np import pandas as pd import pytest -import linopy import flixopt as fx -from .conftest import assert_conequal, assert_sets_equal, assert_var_equal, create_linopy_model, LoadProfiles, Effects, Sinks, Sources, Buses +from .conftest import ( + Buses, + Effects, + LoadProfiles, + Sinks, + Sources, + assert_conequal, + assert_sets_equal, + assert_var_equal, + create_linopy_model, +) + @pytest.fixture def flow_system() -> fx.FlowSystem: @@ -106,20 +117,20 @@ def test_flow_invest_new(self, flow_system): flow = fx.Flow( 'Wärme', bus='Fernwärme', - size=fx.InvestTimingParameters(start_year=2021, end_year=2023, minimum_size=20, maximum_size=1000, effects_of_investment_per_size=200), + size=fx.InvestTimingParameters( + start_year=2021, end_year=2023, minimum_size=900, maximum_size=1000, effects_of_investment_per_size=200 + ), relative_maximum=np.linspace(0.5, 1, flow_system.timesteps.size), ) flow_system.add_elements(fx.Source('Source', source=flow)) calculation = fx.FullCalculation('GenericName', flow_system) calculation.do_modeling() - #calculation.model.add_constraints(calculation.model['Source(Wärme)|decrease'].isel(year=2) == 1) + # calculation.model.add_constraints(calculation.model['Source(Wärme)|decrease'].isel(year=2) == 1) calculation.solve(fx.solvers.HighsSolver(0, 60)) ds = calculation.results['Source'].solution filtered_ds = ds[[v for v in ds.data_vars if ds[v].dims == ('year',)]] - print(filtered_ds.to_pandas().T) + print(filtered_ds.round(0).to_pandas().T) print('##') - - From 5688c8ffeef6a3c57b035547f8324016574d488e Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 4 Aug 2025 11:59:23 +0200 Subject: [PATCH 284/336] Improve InvestmentModel --- flixopt/features.py | 51 +++++++++++++++++++++++++++------------------ 1 file changed, 31 insertions(+), 20 deletions(-) diff --git a/flixopt/features.py b/flixopt/features.py index 44bea1329..18c43fdb2 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -157,6 +157,7 @@ def _do_modeling(self): def _basic_modeling(self): size_min, size_max = self.parameters.minimum_or_fixed_size, self.parameters.maximum_or_fixed_size + ######################################################################## self.add_variables( short_name='size', lower=0, @@ -170,20 +171,6 @@ def _basic_modeling(self): short_name='is_invested', ) - if self.parameters.optional_investment: - self.add_variables( - binary=True, - coords=self._model.get_coords(['scenario']), - short_name='investment_used', - ) - - if self.parameters.optional_divestment: - self.add_variables( - binary=True, - coords=self._model.get_coords(['scenario']), - short_name='divestment_used', - ) - BoundingPatterns.bounds_with_state( self, variable=self.size, @@ -191,6 +178,7 @@ def _basic_modeling(self): bounds=(size_min, size_max), ) + ######################################################################## self.add_variables( binary=True, coords=self._model.get_coords(['year', 'scenario']), @@ -210,15 +198,38 @@ def _basic_modeling(self): previous_state=0, coord='year', ) + + ######################################################################## + self.add_variables( + binary=True, + coords=self._model.get_coords(['scenario']), + short_name='size|investment_used', + ) + self.add_variables( + binary=True, + coords=self._model.get_coords(['scenario']), + short_name='size|divestment_used', + ) self.add_constraints( - self.has_increase.sum('year') == (self.investment_used if self.investment_used is not None else 1), + self.has_increase.sum('year') == self.investment_used, name=f'{self.has_increase.name}|count', ) self.add_constraints( - self.has_decrease.sum('year') == (self.divestment_used if self.divestment_used is not None else 1), + self.has_decrease.sum('year') == self.divestment_used, name=f'{self.has_decrease.name}|count', ) + if not self.parameters.optional_investment: + self.add_constraints( + self.investment_used == 1, + name='investment_used|fixed', + ) + if not self.parameters.optional_divestment: + self.add_constraints( + self.divestment_used == 1, + name='divestment_used|fixed', + ) + ######################################################################## self.add_variables( coords=self._model.get_coords(['year', 'scenario']), short_name='size|increase', @@ -319,16 +330,16 @@ def size_increase(self) -> linopy.Variable: @property def investment_used(self) -> Optional[linopy.Variable]: """Binary investment decision variable""" - if 'investment_used' not in self._variables: + if 'size|investment_used' not in self._variables: return None - return self._variables['investment_used'] + return self._variables['size|investment_used'] @property def divestment_used(self) -> Optional[linopy.Variable]: """Binary investment decision variable""" - if 'divestment_used' not in self._variables: + if 'size|divestment_used' not in self._variables: return None - return self._variables['divestment_used'] + return self._variables['size|divestment_used'] class FixedStartFixedEndInvestmentTimingModel(InvestmentTimingModel): From 84c8f1518a2bb89838aa3db9133142bdbee22574 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 4 Aug 2025 12:51:23 +0200 Subject: [PATCH 285/336] Rename parameters --- flixopt/features.py | 17 ++++++++--------- flixopt/interface.py | 36 ++++++++++++++++++++---------------- tests/test_models.py | 18 +++++++++++------- 3 files changed, 39 insertions(+), 32 deletions(-) diff --git a/flixopt/features.py b/flixopt/features.py index 18c43fdb2..e1a53df46 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -10,7 +10,6 @@ import numpy as np import xarray as xr -from .config import CONFIG from .core import FlowSystemDimensions, NonTemporalData, Scalar, TemporalData from .interface import ( InvestParameters, @@ -149,9 +148,9 @@ def _do_modeling(self): self._basic_modeling() self._add_effects() - if self.parameters.start_year is not None: + if self.parameters.year_of_investment is not None: self._fixed_start_constraint() - if self.parameters.end_year is not None: + if self.parameters.year_of_decommissioning is not None: self._fixed_end_constraint() def _basic_modeling(self): @@ -240,7 +239,7 @@ def _basic_modeling(self): coords=self._model.get_coords(['year', 'scenario']), short_name='size|decrease', lower=0, - upper=CONFIG.modeling.BIG, + upper=size_max, ) BoundingPatterns.link_changes_to_level_with_binaries( self, @@ -283,14 +282,14 @@ def _add_effects(self): def _fixed_start_constraint(self): self.add_constraints( - self.has_increase.sel(year=self.parameters.start_year) + self.has_increase.sel(year=self.parameters.year_of_investment) == (self.investment_used if self.investment_used is not None else 1), short_name='size|changes|fixed_start', ) def _fixed_end_constraint(self): self.add_constraints( - self.has_decrease.sel(year=self.parameters.end_year) + self.has_decrease.sel(year=self.parameters.year_of_decommissioning) == (self.divestment_used if self.divestment_used is not None else 1), short_name='size|changes|fixed_end', ) @@ -349,14 +348,14 @@ def _basic_modeling(self): super()._basic_modeling() self.add_constraints( - self.has_increase.sel(year=self.parameters.start_year) - == self.has_decrease.sel(year=self.parameters.end_year), + self.has_increase.sel(year=self.parameters.year_of_investment) + == self.has_decrease.sel(year=self.parameters.year_of_decommissioning), short_name='size|changes|fixed_start_and_end', ) if not self.parameters.optional: self.add_constraints( - self.has_increase.sel(year=self.parameters.start_year) == 1, + self.has_increase.sel(year=self.parameters.year_of_investment) == 1, name='size|changes|non_optional', ) diff --git a/flixopt/interface.py b/flixopt/interface.py index 3763d01be..b564c8c68 100644 --- a/flixopt/interface.py +++ b/flixopt/interface.py @@ -346,8 +346,8 @@ class InvestTimingParameters(Interface): def __init__( self, - start_year: Optional[int] = None, - end_year: Optional[int] = None, + year_of_investment: Optional[int] = None, + year_of_decommissioning: Optional[int] = None, minimum_size: Optional[Scalar] = None, maximum_size: Optional[Scalar] = None, fixed_size: Optional[Scalar] = None, @@ -362,8 +362,8 @@ def __init__( Initialize fixed start and end year investment parameters. Args: - start_year: Year when investment must start (0-indexed). - end_year: Year when investment must end (0-indexed). + year_of_investment: Year in which the investment occurs. The unit is there present from this year onwards. + year_of_decommissioning: Year in which the unit is decommissioned. The unit is there present up to this year (exclusive). minimum_size: Minimum possible size of the investment. maximum_size: Maximum possible size of the investment. fixed_size: If specified, investment size is fixed to this value. @@ -383,8 +383,8 @@ def __init__( self.optional_investment = optional_investment self.optional_divestment = optional_divestment - self.start_year = start_year - self.end_year = end_year + self.year_of_investment = year_of_investment + self.year_of_decommissioning = year_of_decommissioning self.fix_effects: 'NonTemporalEffectsUser' = fix_effects if fix_effects is not None else {} self.specific_effects: 'NonTemporalEffectsUser' = specific_effects if specific_effects is not None else {} @@ -400,24 +400,28 @@ def _plausibility_checks(self, flow_system): if flow_system.years is None: raise ValueError("YearAwareInvestParameters requires the flow_system to have a 'years' dimension.") - if self.start_year is None and self.end_year is None: - raise ValueError('Either start_year or end_year must be specified.') + if self.year_of_investment is None and self.year_of_decommissioning is None: + raise ValueError('Either year_of_investment or year_of_decommissioning must be specified.') - if self.start_year < flow_system.years[0] or self.start_year > flow_system.years[-1]: + if self.year_of_investment < flow_system.years[0] or self.year_of_investment > flow_system.years[-1]: raise ValueError( - f'start_year ({self.start_year}) must be between {flow_system.years[0]} and {flow_system.years[-1]}' + f'year_of_investment ({self.year_of_investment}) must be between {flow_system.years[0]} and {flow_system.years[-1]}' ) - if self.end_year < flow_system.years[0] or self.end_year > flow_system.years[-1]: - raise ValueError(f'end_year ({self.end_year}) must be between 0 and {flow_system.years[-1]}') + if self.year_of_decommissioning < flow_system.years[0] or self.year_of_decommissioning > flow_system.years[-1]: + raise ValueError( + f'year_of_decommissioning ({self.year_of_decommissioning}) must be between {flow_system.years[0]} and {flow_system.years[-1]}' + ) - if self.start_year >= self.end_year: - raise ValueError(f'start_year ({self.start_year}) must be before end_year ({self.end_year})') + if self.year_of_investment >= self.year_of_decommissioning: + raise ValueError( + f'year_of_investment ({self.year_of_investment}) must be before year_of_decommissioning ({self.year_of_decommissioning})' + ) @property def duration(self) -> int: - """Get the investment duration.""" - return self.end_year - self.start_year + 1 + """Get the duration of the investment.""" + return self.year_of_decommissioning - self.year_of_investment def transform_data(self, flow_system: 'FlowSystem', name_prefix: str): """Transform all parameter data to match the flow system's coordinate structure.""" diff --git a/tests/test_models.py b/tests/test_models.py index 97480b900..ac040b1e1 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -53,8 +53,8 @@ def test_basic_initialization(self): assert params.maximum_size == 100 assert params.fixed_size is None assert not params.allow_divestment - assert params.fixed_start_year is None - assert params.fixed_end_year is None + assert params.fixed_year_of_investment is None + assert params.fixed_year_of_decommissioning is None assert params.fixed_duration is None def test_fixed_size_initialization(self): @@ -68,16 +68,16 @@ def test_fixed_size_initialization(self): def test_timing_constraints_initialization(self): """Test initialization with various timing constraints.""" params = fx.YearAwareInvestParameters( - fixed_start_year=2, + fixed_year_of_investment=2, minimum_duration=3, maximum_duration=5, - earliest_end_year=4, + earliest_year_of_decommissioning=4, ) - assert params.fixed_start_year == 2 + assert params.fixed_year_of_investment == 2 assert params.minimum_duration == 3 assert params.maximum_duration == 5 - assert params.earliest_end_year == 4 + assert params.earliest_year_of_decommissioning == 4 def test_effects_initialization(self): """Test initialization with effects.""" @@ -118,7 +118,11 @@ def test_flow_invest_new(self, flow_system): 'Wärme', bus='Fernwärme', size=fx.InvestTimingParameters( - start_year=2021, end_year=2023, minimum_size=900, maximum_size=1000, effects_of_investment_per_size=200 + year_of_investment=2021, + year_of_decommissioning=2023, + minimum_size=900, + maximum_size=1000, + effects_of_investment_per_size=200, ), relative_maximum=np.linspace(0.5, 1, flow_system.timesteps.size), ) From 566bcdf8ce408d3b9286c3e90d6b99404c94d49d Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 4 Aug 2025 12:55:06 +0200 Subject: [PATCH 286/336] remove old code --- flixopt/features.py | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/flixopt/features.py b/flixopt/features.py index e1a53df46..8950c8849 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -341,25 +341,6 @@ def divestment_used(self) -> Optional[linopy.Variable]: return self._variables['size|divestment_used'] -class FixedStartFixedEndInvestmentTimingModel(InvestmentTimingModel): - parameters: InvestTimingParameters - - def _basic_modeling(self): - super()._basic_modeling() - - self.add_constraints( - self.has_increase.sel(year=self.parameters.year_of_investment) - == self.has_decrease.sel(year=self.parameters.year_of_decommissioning), - short_name='size|changes|fixed_start_and_end', - ) - - if not self.parameters.optional: - self.add_constraints( - self.has_increase.sel(year=self.parameters.year_of_investment) == 1, - name='size|changes|non_optional', - ) - - class OnOffModel(Submodel): """OnOff model using factory patterns""" From c334515d8fc090cb50c3bb3b04dc21098990147e Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 4 Aug 2025 13:37:50 +0200 Subject: [PATCH 287/336] Add a duration_in_years to the InvestTimingParameters --- flixopt/features.py | 45 ++++++++++++++++++++++++++++++-------------- flixopt/interface.py | 30 ++++++++++++++++++----------- 2 files changed, 50 insertions(+), 25 deletions(-) diff --git a/flixopt/features.py b/flixopt/features.py index 8950c8849..88c94f97c 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -152,6 +152,8 @@ def _do_modeling(self): self._fixed_start_constraint() if self.parameters.year_of_decommissioning is not None: self._fixed_end_constraint() + if self.parameters.duration_in_years is not None: + self._fixed_duration_constraint() def _basic_modeling(self): size_min, size_max = self.parameters.minimum_or_fixed_size, self.parameters.maximum_or_fixed_size @@ -211,22 +213,32 @@ def _basic_modeling(self): ) self.add_constraints( self.has_increase.sum('year') == self.investment_used, - name=f'{self.has_increase.name}|count', + short_name='size|has_increase|count', ) self.add_constraints( self.has_decrease.sum('year') == self.divestment_used, - name=f'{self.has_decrease.name}|count', + short_name='size|has_decrease|count', ) if not self.parameters.optional_investment: self.add_constraints( self.investment_used == 1, - name='investment_used|fixed', + short_name='investment_used|fixed', ) if not self.parameters.optional_divestment: self.add_constraints( self.divestment_used == 1, - name='divestment_used|fixed', + short_name='divestment_used|fixed', ) + self.add_variables( + lower=0, + upper=self.parameters.duration_in_years if self.parameters.duration_in_years is not None else np.inf, + coords=self._model.get_coords(['scenario']), + short_name='duration', + ) + self.add_constraints( + self.duration == (self.is_invested * self._model.flow_system.years_per_year).sum('year'), + short_name='duration|fixed', + ) ######################################################################## self.add_variables( @@ -282,18 +294,22 @@ def _add_effects(self): def _fixed_start_constraint(self): self.add_constraints( - self.has_increase.sel(year=self.parameters.year_of_investment) - == (self.investment_used if self.investment_used is not None else 1), + self.has_increase.sel(year=self.parameters.year_of_investment) == self.investment_used, short_name='size|changes|fixed_start', ) def _fixed_end_constraint(self): self.add_constraints( - self.has_decrease.sel(year=self.parameters.year_of_decommissioning) - == (self.divestment_used if self.divestment_used is not None else 1), + self.has_decrease.sel(year=self.parameters.year_of_decommissioning) == self.divestment_used, short_name='size|changes|fixed_end', ) + def _fixed_duration_constraint(self): + self.add_constraints( + self.duration == self.parameters.duration_in_years, + short_name='size|duration|fixed', + ) + @property def size(self) -> linopy.Variable: """Investment size variable""" @@ -327,19 +343,20 @@ def size_increase(self) -> linopy.Variable: return self._variables['size|increase'] @property - def investment_used(self) -> Optional[linopy.Variable]: + def investment_used(self) -> linopy.Variable: """Binary investment decision variable""" - if 'size|investment_used' not in self._variables: - return None return self._variables['size|investment_used'] @property - def divestment_used(self) -> Optional[linopy.Variable]: + def divestment_used(self) -> linopy.Variable: """Binary investment decision variable""" - if 'size|divestment_used' not in self._variables: - return None return self._variables['size|divestment_used'] + @property + def duration(self) -> linopy.Variable: + """Investment duration variable""" + return self._variables['duration'] + class OnOffModel(Submodel): """OnOff model using factory patterns""" diff --git a/flixopt/interface.py b/flixopt/interface.py index b564c8c68..459e6fcd1 100644 --- a/flixopt/interface.py +++ b/flixopt/interface.py @@ -348,6 +348,7 @@ def __init__( self, year_of_investment: Optional[int] = None, year_of_decommissioning: Optional[int] = None, + duration_in_years: Optional[int] = None, minimum_size: Optional[Scalar] = None, maximum_size: Optional[Scalar] = None, fixed_size: Optional[Scalar] = None, @@ -362,8 +363,9 @@ def __init__( Initialize fixed start and end year investment parameters. Args: - year_of_investment: Year in which the investment occurs. The unit is there present from this year onwards. - year_of_decommissioning: Year in which the unit is decommissioned. The unit is there present up to this year (exclusive). + year_of_investment: Year in which the investment occurs (inclusive). Present from this year onwards. + year_of_decommissioning: Year in which the unit is decommissioned (exclusive). Present up to this year. + duration_in_years: Duration of the investment in years. minimum_size: Minimum possible size of the investment. maximum_size: Maximum possible size of the investment. fixed_size: If specified, investment size is fixed to this value. @@ -385,6 +387,7 @@ def __init__( self.year_of_investment = year_of_investment self.year_of_decommissioning = year_of_decommissioning + self.duration_in_years = duration_in_years self.fix_effects: 'NonTemporalEffectsUser' = fix_effects if fix_effects is not None else {} self.specific_effects: 'NonTemporalEffectsUser' = specific_effects if specific_effects is not None else {} @@ -400,15 +403,25 @@ def _plausibility_checks(self, flow_system): if flow_system.years is None: raise ValueError("YearAwareInvestParameters requires the flow_system to have a 'years' dimension.") - if self.year_of_investment is None and self.year_of_decommissioning is None: - raise ValueError('Either year_of_investment or year_of_decommissioning must be specified.') + if all( + [param is None for param in (self.year_of_investment, self.year_of_decommissioning, self.duration_in_years)] + ): + # TODO: Should this be an exception or rather a warning? Is there a valid use case for this? + # And a mathematically valid formulation (regarding the effects especially)? + raise ValueError( + 'Either year_of_investment, year_of_decommissioning or duration_in_years must be specified.' + ) - if self.year_of_investment < flow_system.years[0] or self.year_of_investment > flow_system.years[-1]: + if self.year_of_investment is not None and not ( + flow_system.years[0] <= self.year_of_investment <= flow_system.years[-1] + ): raise ValueError( f'year_of_investment ({self.year_of_investment}) must be between {flow_system.years[0]} and {flow_system.years[-1]}' ) - if self.year_of_decommissioning < flow_system.years[0] or self.year_of_decommissioning > flow_system.years[-1]: + if self.year_of_decommissioning is not None and not ( + flow_system.years[0] <= self.year_of_decommissioning <= flow_system.years[-1] + ): raise ValueError( f'year_of_decommissioning ({self.year_of_decommissioning}) must be between {flow_system.years[0]} and {flow_system.years[-1]}' ) @@ -418,11 +431,6 @@ def _plausibility_checks(self, flow_system): f'year_of_investment ({self.year_of_investment}) must be before year_of_decommissioning ({self.year_of_decommissioning})' ) - @property - def duration(self) -> int: - """Get the duration of the investment.""" - return self.year_of_decommissioning - self.year_of_investment - def transform_data(self, flow_system: 'FlowSystem', name_prefix: str): """Transform all parameter data to match the flow system's coordinate structure.""" self._plausibility_checks(flow_system) From 98216e29af10ffb365351c99da55687939a1c23d Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 5 Aug 2025 08:52:22 +0200 Subject: [PATCH 288/336] Improve handling of fixed_duration --- flixopt/features.py | 55 +++++++++++++++++++++++++++++++++------------ 1 file changed, 41 insertions(+), 14 deletions(-) diff --git a/flixopt/features.py b/flixopt/features.py index 88c94f97c..ffbfd7809 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -229,16 +229,6 @@ def _basic_modeling(self): self.divestment_used == 1, short_name='divestment_used|fixed', ) - self.add_variables( - lower=0, - upper=self.parameters.duration_in_years if self.parameters.duration_in_years is not None else np.inf, - coords=self._model.get_coords(['scenario']), - short_name='duration', - ) - self.add_constraints( - self.duration == (self.is_invested * self._model.flow_system.years_per_year).sum('year'), - short_name='duration|fixed', - ) ######################################################################## self.add_variables( @@ -305,10 +295,47 @@ def _fixed_end_constraint(self): ) def _fixed_duration_constraint(self): - self.add_constraints( - self.duration == self.parameters.duration_in_years, - short_name='size|duration|fixed', - ) + years = self._model.flow_system.years + years_of_decommissioning = years + self.parameters.duration_in_years + + # Filter and get actual selected years in one step + valid_mask = years_of_decommissioning <= years[-1] + if valid_mask.any(): + valid_years_of_investment = years[valid_mask] + valid_years_of_decommissioning = years_of_decommissioning[valid_mask] + actual_years_of_decommissioning = ( + self.has_decrease.sel(year=valid_years_of_decommissioning, method='bfill').coords['year'].values + ) + + # Warning for mismatched years + mismatched = valid_years_of_decommissioning != actual_years_of_decommissioning + for inv_year, target_year, actual_year in zip( + valid_years_of_investment[mismatched], + valid_years_of_decommissioning[mismatched], + actual_years_of_decommissioning[mismatched], + strict=False, + ): + logger.warning( + f'year_of_decommissioning {target_year} for {self.size.name} not in flow_system years. For an investment in year {inv_year}, the year_of_decommissioning is set to {actual_year}' + ) + + group = xr.DataArray( + actual_years_of_decommissioning, # values: the actual decommissioning years + coords={'year': valid_years_of_investment}, # coordinates: investment years + dims=['year'], + name='year_of_decommissioning', + ) + + # Now you can use proper xarray groupby + grouped_increases = ( + self.has_increase.sel(year=valid_years_of_investment).groupby(group).sum('year_of_decommissioning') + ) + + # Create constraints + self.add_constraints( + self.has_decrease.sel(year=grouped_increases.coords['year_of_decommissioning']) == grouped_increases, + short_name='size|changes|fixed_duration', + ) @property def size(self) -> linopy.Variable: From 7b45a19a4d2400a8bbbbdd120ac965eb8c238699 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 5 Aug 2025 10:42:23 +0200 Subject: [PATCH 289/336] Improve validation and make Investment/divestment optional by default --- flixopt/interface.py | 43 ++++++++++++++++++++++++++++++------------- 1 file changed, 30 insertions(+), 13 deletions(-) diff --git a/flixopt/interface.py b/flixopt/interface.py index 459e6fcd1..2772c73b6 100644 --- a/flixopt/interface.py +++ b/flixopt/interface.py @@ -334,7 +334,6 @@ def is_fixed_size(self) -> bool: return self.fixed_size is not None -# Variant 1: Fixed Start and End Year @register_class_for_io class InvestTimingParameters(Interface): """ @@ -352,8 +351,8 @@ def __init__( minimum_size: Optional[Scalar] = None, maximum_size: Optional[Scalar] = None, fixed_size: Optional[Scalar] = None, - optional_investment: bool = False, - optional_divestment: bool = False, + optional_investment: bool = True, + optional_divestment: bool = True, fix_effects: Optional['NonTemporalEffectsUser'] = None, specific_effects: Optional['NonTemporalEffectsUser'] = None, # costs per Flow-Unit/Storage-Size/... effects_of_investment_per_size: Optional['NonTemporalEffectsUser'] = None, @@ -412,21 +411,39 @@ def _plausibility_checks(self, flow_system): 'Either year_of_investment, year_of_decommissioning or duration_in_years must be specified.' ) - if self.year_of_investment is not None and not ( - flow_system.years[0] <= self.year_of_investment <= flow_system.years[-1] - ): - raise ValueError( - f'year_of_investment ({self.year_of_investment}) must be between {flow_system.years[0]} and {flow_system.years[-1]}' + if ( + sum( + [ + param is not None + for param in (self.year_of_investment, self.year_of_decommissioning, self.duration_in_years) + ] ) - - if self.year_of_decommissioning is not None and not ( - flow_system.years[0] <= self.year_of_decommissioning <= flow_system.years[-1] + > 2 ): + # TODO: Should this be an exception or rather a warning? raise ValueError( - f'year_of_decommissioning ({self.year_of_decommissioning}) must be between {flow_system.years[0]} and {flow_system.years[-1]}' + f'InvestmentParameters is overdefined. Not all of {self.year_of_investment=}, ' + f'{self.year_of_decommissioning=} and {self.duration_in_years=} can be specified.' ) - if self.year_of_investment >= self.year_of_decommissioning: + if self.year_of_investment is not None: + if not (flow_system.years[0] <= self.year_of_investment <= flow_system.years[-1]): + raise ValueError( + f'year_of_investment ({self.year_of_investment}) must be between ' + f'{flow_system.years[0]} and {flow_system.years[-1]}' + ) + + if self.year_of_decommissioning is not None: + if not (flow_system.years[0] <= self.year_of_decommissioning <= flow_system.years[-1]): + raise ValueError( + f'year_of_decommissioning ({self.year_of_decommissioning}) must be between {flow_system.years[0]} and {flow_system.years[-1]}' + ) + + if ( + self.year_of_decommissioning is not None + and self.year_of_investment is not None + and self.year_of_investment >= self.year_of_decommissioning + ): raise ValueError( f'year_of_investment ({self.year_of_investment}) must be before year_of_decommissioning ({self.year_of_decommissioning})' ) From b919c0a9dc26ba155bc03047cf9d46e9d5f65efe Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 5 Aug 2025 15:16:44 +0200 Subject: [PATCH 290/336] Rename some vars and improve previous handling --- flixopt/features.py | 55 ++++++++++++++++++++++++++++---------------- flixopt/interface.py | 16 ++++++++++--- flixopt/modeling.py | 6 ++--- 3 files changed, 51 insertions(+), 26 deletions(-) diff --git a/flixopt/features.py b/flixopt/features.py index ffbfd7809..17a99d4eb 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -131,6 +131,16 @@ def is_invested(self) -> Optional[linopy.Variable]: class InvestmentTimingModel(Submodel): + """ + This feature model is used to model the timing of investments. + + Such an Investment is defined by a size, a year_of_investment, and a year_of_decommissioning. + In between these years, the size of the investment cannot vary. Outside, its 0. + The year_of_investment is defined as the year, in which the size increases from 0 to the chosen size. + The year_of_decommissioning is defined as the year, in which the size returns to 0. + The year_of_decommissioning can be after the years in the FlowSystem. THis results in no decommissioning. + """ + parameters: InvestTimingParameters def __init__( @@ -183,18 +193,18 @@ def _basic_modeling(self): self.add_variables( binary=True, coords=self._model.get_coords(['year', 'scenario']), - short_name='size|has_increase', + short_name='size|year_of_investment', ) self.add_variables( binary=True, coords=self._model.get_coords(['year', 'scenario']), - short_name='size|has_decrease', + short_name='size|year_of_decommissioning', ) BoundingPatterns.state_transition_bounds( self, state_variable=self.is_invested, - switch_on=self.has_increase, - switch_off=self.has_decrease, + switch_on=self.year_of_investment, + switch_off=self.year_of_decommissioning, name=self.is_invested.name, previous_state=0, coord='year', @@ -212,12 +222,12 @@ def _basic_modeling(self): short_name='size|divestment_used', ) self.add_constraints( - self.has_increase.sum('year') == self.investment_used, - short_name='size|has_increase|count', + self.year_of_investment.sum('year') == self.investment_used, + short_name='size|year_of_investment|count', ) self.add_constraints( - self.has_decrease.sum('year') == self.divestment_used, - short_name='size|has_decrease|count', + self.year_of_decommissioning.sum('year') == self.divestment_used, + short_name='size|year_of_decommissioning|count', ) if not self.parameters.optional_investment: self.add_constraints( @@ -248,11 +258,11 @@ def _basic_modeling(self): level_variable=self.size, increase_variable=self.size_increase, decrease_variable=self.size_decrease, - increase_binary=self.has_increase, - decrease_binary=self.has_decrease, + increase_binary=self.year_of_investment, + decrease_binary=self.year_of_decommissioning, name=f'{self.label_of_element}|size|changes', max_change=size_max, - initial_level=0, + initial_level=self.parameters.previous_size if self.parameters.previous_size is not None else 0, coord='year', ) @@ -284,13 +294,13 @@ def _add_effects(self): def _fixed_start_constraint(self): self.add_constraints( - self.has_increase.sel(year=self.parameters.year_of_investment) == self.investment_used, + self.year_of_investment.sel(year=self.parameters.year_of_investment) == self.investment_used, short_name='size|changes|fixed_start', ) def _fixed_end_constraint(self): self.add_constraints( - self.has_decrease.sel(year=self.parameters.year_of_decommissioning) == self.divestment_used, + self.year_of_decommissioning.sel(year=self.parameters.year_of_decommissioning) == self.divestment_used, short_name='size|changes|fixed_end', ) @@ -304,7 +314,9 @@ def _fixed_duration_constraint(self): valid_years_of_investment = years[valid_mask] valid_years_of_decommissioning = years_of_decommissioning[valid_mask] actual_years_of_decommissioning = ( - self.has_decrease.sel(year=valid_years_of_decommissioning, method='bfill').coords['year'].values + self.years_of_decommissioning.sel(year=valid_years_of_decommissioning, method='bfill') + .coords['year'] + .values ) # Warning for mismatched years @@ -328,12 +340,15 @@ def _fixed_duration_constraint(self): # Now you can use proper xarray groupby grouped_increases = ( - self.has_increase.sel(year=valid_years_of_investment).groupby(group).sum('year_of_decommissioning') + self.year_of_investment.sel(year=valid_years_of_investment) + .groupby(group) + .sum('year_of_decommissioning') ) # Create constraints self.add_constraints( - self.has_decrease.sel(year=grouped_increases.coords['year_of_decommissioning']) == grouped_increases, + self.year_of_decommissioning.sel(year=grouped_increases.coords['year_of_decommissioning']) + == grouped_increases, short_name='size|changes|fixed_duration', ) @@ -350,14 +365,14 @@ def is_invested(self) -> Optional[linopy.Variable]: return self._variables['is_invested'] @property - def has_increase(self) -> linopy.Variable: + def year_of_investment(self) -> linopy.Variable: """Binary increase decision variable""" - return self._variables['size|has_increase'] + return self._variables['size|year_of_investment'] @property - def has_decrease(self) -> linopy.Variable: + def year_of_decommissioning(self) -> linopy.Variable: """Binary decrease decision variable""" - return self._variables['size|has_decrease'] + return self._variables['size|year_of_decommissioning'] @property def size_decrease(self) -> linopy.Variable: diff --git a/flixopt/interface.py b/flixopt/interface.py index 2772c73b6..958390e2f 100644 --- a/flixopt/interface.py +++ b/flixopt/interface.py @@ -357,14 +357,19 @@ def __init__( specific_effects: Optional['NonTemporalEffectsUser'] = None, # costs per Flow-Unit/Storage-Size/... effects_of_investment_per_size: Optional['NonTemporalEffectsUser'] = None, effects_of_investment: Optional['NonTemporalEffectsUser'] = None, + previous_size: Scalar = 0, ): """ - Initialize fixed start and end year investment parameters. + These parameters are used to include the timing of investments in the model. + Two out of three parameters (year_of_investment, year_of_decommissioning, duration_in_years) can be fixed. Args: year_of_investment: Year in which the investment occurs (inclusive). Present from this year onwards. + If None, the year_of_investment is not fixed. year_of_decommissioning: Year in which the unit is decommissioned (exclusive). Present up to this year. - duration_in_years: Duration of the investment in years. + If None, the year_of_decommissioning is not fixed. + duration_in_years: Duration between year_of_investment and year_of_decommissioning. + If None, the duration is not fixed. minimum_size: Minimum possible size of the investment. maximum_size: Maximum possible size of the investment. fixed_size: If specified, investment size is fixed to this value. @@ -376,6 +381,7 @@ def __init__( effects_of_investment: Effects depending on when an investment decision is made. These can occur in the investment year or in multiple years. If the effects need to occur in multiple years, you need to pass an xr.DataArray with the coord 'year_of_investment'. Example: {'costs': 1000} applies 1000 to costs in the investment year. + previous_size: The size of the investment before the first period. Defaults to 0. """ self.minimum_size = minimum_size if minimum_size is not None else CONFIG.modeling.EPSILON @@ -397,6 +403,8 @@ def __init__( effects_of_investment if effects_of_investment is not None else {} ) + self.previous_size = previous_size + def _plausibility_checks(self, flow_system): """Validate parameter consistency.""" if flow_system.years is None: @@ -442,7 +450,7 @@ def _plausibility_checks(self, flow_system): if ( self.year_of_decommissioning is not None and self.year_of_investment is not None - and self.year_of_investment >= self.year_of_decommissioning + and not self.year_of_investment < self.year_of_decommissioning ): raise ValueError( f'year_of_investment ({self.year_of_investment}) must be before year_of_decommissioning ({self.year_of_decommissioning})' @@ -476,6 +484,8 @@ def transform_data(self, flow_system: 'FlowSystem', name_prefix: str): f'{name_prefix}|fixed_size', self.fixed_size, has_time_dim=False ) + # TODO: self.previous_size to only scenarios + @property def minimum_or_fixed_size(self) -> NonTemporalData: """Get the effective minimum size (fixed size takes precedence).""" diff --git a/flixopt/modeling.py b/flixopt/modeling.py index ef7cdc27a..f0e354bc5 100644 --- a/flixopt/modeling.py +++ b/flixopt/modeling.py @@ -696,10 +696,10 @@ def link_changes_to_level_with_binaries( if not isinstance(model, Submodel): raise ValueError('BoundingPatterns.link_changes_to_level_with_binaries() can only be used with a Submodel') - # 1. Initial period: level[0] = initial_level + increase[0] - decrease[0] + # 1. Initial period: level[0] - initial_level = increase[0] - decrease[0] initial_constraint = model.add_constraints( - level_variable.isel({coord: 0}) - == initial_level + increase_variable.isel({coord: 0}) - decrease_variable.isel({coord: 0}), + level_variable.isel({coord: 0}) - initial_level + == increase_variable.isel({coord: 0}) - decrease_variable.isel({coord: 0}), name=f'{name}|initial_level', ) From 8e21165c8c6fe6886924f4fdb830c302087f3331 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 5 Aug 2025 15:20:31 +0200 Subject: [PATCH 291/336] Add validation for previous size --- flixopt/interface.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/flixopt/interface.py b/flixopt/interface.py index 958390e2f..0011d4b2d 100644 --- a/flixopt/interface.py +++ b/flixopt/interface.py @@ -456,6 +456,13 @@ def _plausibility_checks(self, flow_system): f'year_of_investment ({self.year_of_investment}) must be before year_of_decommissioning ({self.year_of_decommissioning})' ) + if self.previous_size != 0: + if not self.minimum_size <= self.previous_size <= self.maximum_size: + raise ValueError( + f'previous_size ({self.previous_size}) must be zero orbetween minimum_size ({self.minimum_size}) ' + f'and maximum_size ({self.maximum_size})' + ) + def transform_data(self, flow_system: 'FlowSystem', name_prefix: str): """Transform all parameter data to match the flow system's coordinate structure.""" self._plausibility_checks(flow_system) From c2ce728280ad6b1e945120355c4ca5adb86d73e5 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 5 Aug 2025 15:29:27 +0200 Subject: [PATCH 292/336] Change fit_to_model_coords to work with a Collection of dims --- flixopt/flow_system.py | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index 3cead49e0..80290f6c7 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -7,7 +7,7 @@ import pathlib import warnings from io import StringIO -from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Tuple, Union +from typing import TYPE_CHECKING, Any, Collection, Dict, List, Literal, Optional, Tuple, Union import numpy as np import pandas as pd @@ -363,6 +363,7 @@ def fit_to_model_coords( name: str, data: Optional[Union[TemporalDataUser, NonTemporalDataUser]], has_time_dim: bool = True, + dims: Optional[Collection[FlowSystemDimensions]] = None, ) -> Optional[Union[TemporalData, NonTemporalData]]: """ Fit data to model coordinate system (currently time, but extensible). @@ -371,6 +372,7 @@ def fit_to_model_coords( name: Name of the data data: Data to fit to model coordinates has_time_dim: Wether to use the time dimension or not + dims: Collection of dimension names to use for fitting. If None, all dimensions are used. Returns: xr.DataArray aligned to model coordinate system. If data is None, returns None. @@ -378,10 +380,19 @@ def fit_to_model_coords( if data is None: return None - coords = self.coords + if dims is None: + coords = self.coords - if not has_time_dim: - coords.pop('time') + if not has_time_dim: + warnings.warn( + 'has_time_dim is deprecated. Please pass dims to fit_to_model_coords instead.', + DeprecationWarning, + stacklevel=2, + ) + coords.pop('time') + else: + coords = self.coords + coords = {k: coords[k] for k in dims if k in coords} # Rest of your method stays the same, just pass coords if isinstance(data, TimeSeriesData): @@ -404,6 +415,7 @@ def fit_effects_to_model_coords( effect_values: Optional[Union[TemporalEffectsUser, NonTemporalEffectsUser]], label_suffix: Optional[str] = None, has_time_dim: bool = True, + dims: Optional[Collection[FlowSystemDimensions]] = None, ) -> Optional[Union[TemporalEffects, NonTemporalEffects]]: """ Transform EffectValues from the user to Internal Datatypes aligned with model coordinates. @@ -415,7 +427,10 @@ def fit_effects_to_model_coords( return { effect: self.fit_to_model_coords( - '|'.join(filter(None, [label_prefix, effect, label_suffix])), value, has_time_dim=has_time_dim + '|'.join(filter(None, [label_prefix, effect, label_suffix])), + value, + has_time_dim=has_time_dim, + dims=dims, ) for effect, value in effect_values_dict.items() } From 02c1f31703e9f4a0e960e6ee8876c60525a67976 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 5 Aug 2025 15:34:42 +0200 Subject: [PATCH 293/336] Improve fit_to_model_coords --- flixopt/components.py | 12 ++++++------ flixopt/effects.py | 17 ++++++++++------- flixopt/elements.py | 10 +++++----- flixopt/flow_system.py | 2 +- flixopt/interface.py | 43 +++++++++++++++++++++--------------------- flixopt/structure.py | 2 +- 6 files changed, 45 insertions(+), 41 deletions(-) diff --git a/flixopt/components.py b/flixopt/components.py index d483ee28c..933ef1791 100644 --- a/flixopt/components.py +++ b/flixopt/components.py @@ -223,29 +223,29 @@ def transform_data(self, flow_system: 'FlowSystem') -> None: ) if not isinstance(self.initial_charge_state, str): self.initial_charge_state = flow_system.fit_to_model_coords( - f'{self.label_full}|initial_charge_state', self.initial_charge_state, has_time_dim=False + f'{self.label_full}|initial_charge_state', self.initial_charge_state, dims=['year', 'scenario'] ) self.minimal_final_charge_state = flow_system.fit_to_model_coords( - f'{self.label_full}|minimal_final_charge_state', self.minimal_final_charge_state, has_time_dim=False + f'{self.label_full}|minimal_final_charge_state', self.minimal_final_charge_state, dims=['year', 'scenario'] ) self.maximal_final_charge_state = flow_system.fit_to_model_coords( - f'{self.label_full}|maximal_final_charge_state', self.maximal_final_charge_state, has_time_dim=False + f'{self.label_full}|maximal_final_charge_state', self.maximal_final_charge_state, dims=['year', 'scenario'] ) self.relative_minimum_final_charge_state = flow_system.fit_to_model_coords( f'{self.label_full}|relative_minimum_final_charge_state', self.relative_minimum_final_charge_state, - has_time_dim=False, + dims=['year', 'scenario'], ) self.relative_maximum_final_charge_state = flow_system.fit_to_model_coords( f'{self.label_full}|relative_maximum_final_charge_state', self.relative_maximum_final_charge_state, - has_time_dim=False, + dims=['year', 'scenario'], ) if isinstance(self.capacity_in_flow_hours, InvestParameters): self.capacity_in_flow_hours.transform_data(flow_system, f'{self.label_full}|InvestParameters') else: self.capacity_in_flow_hours = flow_system.fit_to_model_coords( - f'{self.label_full}|capacity_in_flow_hours', self.capacity_in_flow_hours, has_time_dim=False + f'{self.label_full}|capacity_in_flow_hours', self.capacity_in_flow_hours, dims=['year', 'scenario'] ) def _plausibility_checks(self) -> None: diff --git a/flixopt/effects.py b/flixopt/effects.py index 79a44e67a..d0b552bf8 100644 --- a/flixopt/effects.py +++ b/flixopt/effects.py @@ -105,27 +105,30 @@ def transform_data(self, flow_system: 'FlowSystem'): ) self.minimum_operation = flow_system.fit_to_model_coords( - f'{self.label_full}|minimum_operation', self.minimum_operation, has_time_dim=False + f'{self.label_full}|minimum_operation', self.minimum_operation, dims=['year', 'scenario'] ) self.maximum_operation = flow_system.fit_to_model_coords( - f'{self.label_full}|maximum_operation', self.maximum_operation, has_time_dim=False + f'{self.label_full}|maximum_operation', self.maximum_operation, dims=['year', 'scenario'] ) self.minimum_invest = flow_system.fit_to_model_coords( - f'{self.label_full}|minimum_invest', self.minimum_invest, has_time_dim=False + f'{self.label_full}|minimum_invest', self.minimum_invest, dims=['year', 'scenario'] ) self.maximum_invest = flow_system.fit_to_model_coords( - f'{self.label_full}|maximum_invest', self.maximum_invest, has_time_dim=False + f'{self.label_full}|maximum_invest', self.maximum_invest, dims=['year', 'scenario'] ) self.minimum_total = flow_system.fit_to_model_coords( f'{self.label_full}|minimum_total', self.minimum_total, - has_time_dim=False, + dims=['year', 'scenario'], ) self.maximum_total = flow_system.fit_to_model_coords( - f'{self.label_full}|maximum_total', self.maximum_total, has_time_dim=False + f'{self.label_full}|maximum_total', self.maximum_total, dims=['year', 'scenario'] ) self.specific_share_to_other_effects_invest = flow_system.fit_effects_to_model_coords( - f'{self.label_full}|invest->', self.specific_share_to_other_effects_invest, 'invest', has_time_dim=False + f'{self.label_full}|invest->', + self.specific_share_to_other_effects_invest, + 'invest', + dims=['year', 'scenario'], ) def create_model(self, model: FlowSystemModel) -> 'EffectModel': diff --git a/flixopt/elements.py b/flixopt/elements.py index 2aed7d713..fed8126d2 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -252,16 +252,16 @@ def transform_data(self, flow_system: 'FlowSystem'): self.label_full, self.effects_per_flow_hour, 'per_flow_hour' ) self.flow_hours_total_max = flow_system.fit_to_model_coords( - f'{self.label_full}|flow_hours_total_max', self.flow_hours_total_max, has_time_dim=False + f'{self.label_full}|flow_hours_total_max', self.flow_hours_total_max, dims=['year', 'scenario'] ) self.flow_hours_total_min = flow_system.fit_to_model_coords( - f'{self.label_full}|flow_hours_total_min', self.flow_hours_total_min, has_time_dim=False + f'{self.label_full}|flow_hours_total_min', self.flow_hours_total_min, dims=['year', 'scenario'] ) self.load_factor_max = flow_system.fit_to_model_coords( - f'{self.label_full}|load_factor_max', self.load_factor_max, has_time_dim=False + f'{self.label_full}|load_factor_max', self.load_factor_max, dims=['year', 'scenario'] ) self.load_factor_min = flow_system.fit_to_model_coords( - f'{self.label_full}|load_factor_min', self.load_factor_min, has_time_dim=False + f'{self.label_full}|load_factor_min', self.load_factor_min, dims=['year', 'scenario'] ) if self.on_off_parameters is not None: @@ -269,7 +269,7 @@ def transform_data(self, flow_system: 'FlowSystem'): if isinstance(self.size, (InvestParameters, InvestTimingParameters)): self.size.transform_data(flow_system, self.label_full) else: - self.size = flow_system.fit_to_model_coords(f'{self.label_full}|size', self.size, has_time_dim=False) + self.size = flow_system.fit_to_model_coords(f'{self.label_full}|size', self.size, dims=['year', 'scenario']) def _plausibility_checks(self) -> None: # TODO: Incorporate into Variable? (Lower_bound can not be greater than upper bound diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index 80290f6c7..97db2879e 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -441,7 +441,7 @@ def connect_and_transform(self): logger.debug('FlowSystem already connected and transformed') return - self.weights = self.fit_to_model_coords('weights', self.weights, has_time_dim=False) + self.weights = self.fit_to_model_coords('weights', self.weights, dims=['year', 'scenario']) if self.weights is not None and self.weights.sum() != 1: logger.warning( f'Scenario weights are not normalized to 1. This is recomended for a better scaled model. ' diff --git a/flixopt/interface.py b/flixopt/interface.py index 0011d4b2d..4a7aae255 100644 --- a/flixopt/interface.py +++ b/flixopt/interface.py @@ -33,8 +33,9 @@ def __init__(self, start: TemporalDataUser, end: TemporalDataUser): self.has_time_dim = False def transform_data(self, flow_system: 'FlowSystem', name_prefix: str): - self.start = flow_system.fit_to_model_coords(f'{name_prefix}|start', self.start, has_time_dim=self.has_time_dim) - self.end = flow_system.fit_to_model_coords(f'{name_prefix}|end', self.end, has_time_dim=self.has_time_dim) + dims = None if self.has_time_dim else ['year', 'scenario'] + self.start = flow_system.fit_to_model_coords(f'{name_prefix}|start', self.start, dims=dims) + self.end = flow_system.fit_to_model_coords(f'{name_prefix}|end', self.end, dims=dims) @register_class_for_io @@ -189,33 +190,33 @@ def transform_data(self, flow_system: 'FlowSystem', name_prefix: str): label_prefix=name_prefix, effect_values=self.fix_effects, label_suffix='fix_effects', - has_time_dim=False, + dims=['year', 'scenario'], ) self.divest_effects = flow_system.fit_effects_to_model_coords( label_prefix=name_prefix, effect_values=self.divest_effects, label_suffix='divest_effects', - has_time_dim=False, + dims=['year', 'scenario'], ) self.specific_effects = flow_system.fit_effects_to_model_coords( label_prefix=name_prefix, effect_values=self.specific_effects, label_suffix='specific_effects', - has_time_dim=False, + dims=['year', 'scenario'], ) if self.piecewise_effects is not None: self.piecewise_effects.has_time_dim = False self.piecewise_effects.transform_data(flow_system, f'{name_prefix}|PiecewiseEffects') self.minimum_size = flow_system.fit_to_model_coords( - f'{name_prefix}|minimum_size', self.minimum_size, has_time_dim=False + f'{name_prefix}|minimum_size', self.minimum_size, dims=['year', 'scenario'] ) self.maximum_size = flow_system.fit_to_model_coords( - f'{name_prefix}|maximum_size', self.maximum_size, has_time_dim=False + f'{name_prefix}|maximum_size', self.maximum_size, dims=['year', 'scenario'] ) if self.fixed_size is not None: self.fixed_size = flow_system.fit_to_model_coords( - f'{name_prefix}|fixed_size', self.fixed_size, has_time_dim=False + f'{name_prefix}|fixed_size', self.fixed_size, dims=['year', 'scenario'] ) def _plausibility_checks(self, flow_system): @@ -293,24 +294,24 @@ def transform_data(self, flow_system: 'FlowSystem', name_prefix: str): label_prefix=name_prefix, effect_values=self.effects_of_investment_per_size, label_suffix='effects_of_investment_per_size', - has_time_dim=False, + dims=['year', 'scenario'], ) self.effects_of_investment = flow_system.fit_effects_to_model_coords( label_prefix=name_prefix, effect_values=self.effects_of_investment, label_suffix='effects_of_investment', - has_time_dim=False, + dims=['year', 'scenario'], ) self.minimum_size = flow_system.fit_to_model_coords( - f'{name_prefix}|minimum_size', self.minimum_size, has_time_dim=False + f'{name_prefix}|minimum_size', self.minimum_size, dims=['year', 'scenario'] ) self.maximum_size = flow_system.fit_to_model_coords( - f'{name_prefix}|maximum_size', self.maximum_size, has_time_dim=False + f'{name_prefix}|maximum_size', self.maximum_size, dims=['year', 'scenario'] ) if self.fixed_size is not None: self.fixed_size = flow_system.fit_to_model_coords( - f'{name_prefix}|fixed_size', self.fixed_size, has_time_dim=False + f'{name_prefix}|fixed_size', self.fixed_size, dims=['year', 'scenario'] ) def _plausibility_checks(self, flow_system): @@ -471,24 +472,24 @@ def transform_data(self, flow_system: 'FlowSystem', name_prefix: str): label_prefix=name_prefix, effect_values=self.effects_of_investment_per_size, label_suffix='effects_of_investment_per_size', - has_time_dim=False, + dims=['year', 'scenario'], ) self.effects_of_investment = flow_system.fit_effects_to_model_coords( label_prefix=name_prefix, effect_values=self.effects_of_investment, label_suffix='effects_of_investment', - has_time_dim=False, + dims=['year', 'scenario'], ) self.minimum_size = flow_system.fit_to_model_coords( - f'{name_prefix}|minimum_size', self.minimum_size, has_time_dim=False + f'{name_prefix}|minimum_size', self.minimum_size, dims=['year', 'scenario'] ) self.maximum_size = flow_system.fit_to_model_coords( - f'{name_prefix}|maximum_size', self.maximum_size, has_time_dim=False + f'{name_prefix}|maximum_size', self.maximum_size, dims=['year', 'scenario'] ) if self.fixed_size is not None: self.fixed_size = flow_system.fit_to_model_coords( - f'{name_prefix}|fixed_size', self.fixed_size, has_time_dim=False + f'{name_prefix}|fixed_size', self.fixed_size, dims=['year', 'scenario'] ) # TODO: self.previous_size to only scenarios @@ -579,13 +580,13 @@ def transform_data(self, flow_system: 'FlowSystem', name_prefix: str): f'{name_prefix}|consecutive_off_hours_max', self.consecutive_off_hours_max ) self.on_hours_total_max = flow_system.fit_to_model_coords( - f'{name_prefix}|on_hours_total_max', self.on_hours_total_max, has_time_dim=False + f'{name_prefix}|on_hours_total_max', self.on_hours_total_max, dims=['year', 'scenario'] ) self.on_hours_total_min = flow_system.fit_to_model_coords( - f'{name_prefix}|on_hours_total_min', self.on_hours_total_min, has_time_dim=False + f'{name_prefix}|on_hours_total_min', self.on_hours_total_min, dims=['year', 'scenario'] ) self.switch_on_total_max = flow_system.fit_to_model_coords( - f'{name_prefix}|switch_on_total_max', self.switch_on_total_max, has_time_dim=False + f'{name_prefix}|switch_on_total_max', self.switch_on_total_max, dims=['year', 'scenario'] ) @property diff --git a/flixopt/structure.py b/flixopt/structure.py index da67f9620..24934547b 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -178,7 +178,7 @@ def get_coords( def weights(self) -> Union[int, xr.DataArray]: """Returns the scenario weights of the FlowSystem. If None, return weights that are normalized to 1 (one)""" if self.flow_system.weights is None: - weights = self.flow_system.fit_to_model_coords('weights', 1, has_time_dim=False) + weights = self.flow_system.fit_to_model_coords('weights', 1, dims=['year', 'scenario']) return weights / weights.sum() From 5f8588760510cc426ba9d175cad67ffdec63c8a9 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 5 Aug 2025 15:36:26 +0200 Subject: [PATCH 294/336] Improve test --- tests/test_models.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/tests/test_models.py b/tests/test_models.py index ac040b1e1..37a9fb96c 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -21,7 +21,7 @@ @pytest.fixture def flow_system() -> fx.FlowSystem: """Create basic elements for component testing with coordinate parametrization.""" - years = pd.Index([2020, 2021, 2022, 2023, 2024], name='year') + years = pd.Index([2020, 2021, 2022, 2023, 2024, 2030], name='year') timesteps = pd.date_range('2020-01-01', periods=24, freq='h', name='time') flow_system = fx.FlowSystem(timesteps=timesteps, years=years) @@ -118,11 +118,13 @@ def test_flow_invest_new(self, flow_system): 'Wärme', bus='Fernwärme', size=fx.InvestTimingParameters( - year_of_investment=2021, - year_of_decommissioning=2023, + year_of_investment=2020, + year_of_decommissioning=2030, + # duration_in_years=3, minimum_size=900, maximum_size=1000, - effects_of_investment_per_size=200, + effects_of_investment_per_size=200 * 1e5, + previous_size=900, ), relative_maximum=np.linspace(0.5, 1, flow_system.timesteps.size), ) @@ -131,10 +133,13 @@ def test_flow_invest_new(self, flow_system): calculation = fx.FullCalculation('GenericName', flow_system) calculation.do_modeling() # calculation.model.add_constraints(calculation.model['Source(Wärme)|decrease'].isel(year=2) == 1) - calculation.solve(fx.solvers.HighsSolver(0, 60)) + calculation.solve(fx.solvers.GurobiSolver(0, 60)) ds = calculation.results['Source'].solution - filtered_ds = ds[[v for v in ds.data_vars if ds[v].dims == ('year',)]] - print(filtered_ds.round(0).to_pandas().T) + filtered_ds_year = ds[[v for v in ds.data_vars if ds[v].dims == ('year',)]] + print(filtered_ds_year.round(0).to_pandas().T) + + filtered_ds_scalar = ds[[v for v in ds.data_vars if ds[v].dims == tuple()]] + print(filtered_ds_scalar.round(0).to_pandas().T) print('##') From 815b8bf6bc3743144ff76d2b5976c50f3b9b9f47 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 6 Aug 2025 14:41:13 +0200 Subject: [PATCH 295/336] Update transform_data() --- flixopt/components.py | 6 +++--- flixopt/effects.py | 2 +- flixopt/elements.py | 6 +++--- flixopt/interface.py | 24 ++++++++---------------- flixopt/structure.py | 3 ++- 5 files changed, 17 insertions(+), 24 deletions(-) diff --git a/flixopt/components.py b/flixopt/components.py index 933ef1791..a4a45d7d6 100644 --- a/flixopt/components.py +++ b/flixopt/components.py @@ -93,7 +93,7 @@ def _plausibility_checks(self) -> None: f'(in {self.label_full}) and variable size is uncommon. Please check if this is intended!' ) - def transform_data(self, flow_system: 'FlowSystem'): + def transform_data(self, flow_system: 'FlowSystem', name_prefix: str = '') -> None: super().transform_data(flow_system) if self.conversion_factors: self.conversion_factors = self._transform_conversion_factors(flow_system) @@ -206,7 +206,7 @@ def create_model(self, model: FlowSystemModel) -> 'StorageModel': self.submodel = StorageModel(model, self) return self.submodel - def transform_data(self, flow_system: 'FlowSystem') -> None: + def transform_data(self, flow_system: 'FlowSystem', name_prefix: str = '') -> None: super().transform_data(flow_system) self.relative_minimum_charge_state = flow_system.fit_to_model_coords( f'{self.label_full}|relative_minimum_charge_state', @@ -392,7 +392,7 @@ def create_model(self, model) -> 'TransmissionModel': self.submodel = TransmissionModel(model, self) return self.submodel - def transform_data(self, flow_system: 'FlowSystem') -> None: + def transform_data(self, flow_system: 'FlowSystem', name_prefix: str = '') -> None: super().transform_data(flow_system) self.relative_losses = flow_system.fit_to_model_coords( f'{self.label_full}|relative_losses', self.relative_losses diff --git a/flixopt/effects.py b/flixopt/effects.py index d0b552bf8..da032878f 100644 --- a/flixopt/effects.py +++ b/flixopt/effects.py @@ -91,7 +91,7 @@ def __init__( self.minimum_total = minimum_total self.maximum_total = maximum_total - def transform_data(self, flow_system: 'FlowSystem'): + def transform_data(self, flow_system: 'FlowSystem', name_prefix: str = '') -> None: self.minimum_operation_per_hour = flow_system.fit_to_model_coords( f'{self.label_full}|minimum_operation_per_hour', self.minimum_operation_per_hour ) diff --git a/flixopt/elements.py b/flixopt/elements.py index fed8126d2..6791bf6ae 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -70,7 +70,7 @@ def create_model(self, model: FlowSystemModel) -> 'ComponentModel': self.submodel = ComponentModel(model, self) return self.submodel - def transform_data(self, flow_system: 'FlowSystem') -> None: + def transform_data(self, flow_system: 'FlowSystem', name_prefix: str = '') -> None: if self.on_off_parameters is not None: self.on_off_parameters.transform_data(flow_system, self.label_full) @@ -118,7 +118,7 @@ def create_model(self, model: FlowSystemModel) -> 'BusModel': self.submodel = BusModel(model, self) return self.submodel - def transform_data(self, flow_system: 'FlowSystem'): + def transform_data(self, flow_system: 'FlowSystem', name_prefix: str = '') -> None: self.excess_penalty_per_flow_hour = flow_system.fit_to_model_coords( f'{self.label_full}|excess_penalty_per_flow_hour', self.excess_penalty_per_flow_hour ) @@ -238,7 +238,7 @@ def create_model(self, model: FlowSystemModel) -> 'FlowModel': self.submodel = FlowModel(model, self) return self.submodel - def transform_data(self, flow_system: 'FlowSystem'): + def transform_data(self, flow_system: 'FlowSystem', name_prefix: str = '') -> None: self.relative_minimum = flow_system.fit_to_model_coords( f'{self.label_full}|relative_minimum', self.relative_minimum ) diff --git a/flixopt/interface.py b/flixopt/interface.py index 4a7aae255..d06d0ccb1 100644 --- a/flixopt/interface.py +++ b/flixopt/interface.py @@ -32,7 +32,7 @@ def __init__(self, start: TemporalDataUser, end: TemporalDataUser): self.end = end self.has_time_dim = False - def transform_data(self, flow_system: 'FlowSystem', name_prefix: str): + def transform_data(self, flow_system: 'FlowSystem', name_prefix: str = '') -> None: dims = None if self.has_time_dim else ['year', 'scenario'] self.start = flow_system.fit_to_model_coords(f'{name_prefix}|start', self.start, dims=dims) self.end = flow_system.fit_to_model_coords(f'{name_prefix}|end', self.end, dims=dims) @@ -69,7 +69,7 @@ def __getitem__(self, index) -> Piece: def __iter__(self) -> Iterator[Piece]: return iter(self.pieces) # Enables iteration like for piece in piecewise: ... - def transform_data(self, flow_system: 'FlowSystem', name_prefix: str): + def transform_data(self, flow_system: 'FlowSystem', name_prefix: str = '') -> None: for i, piece in enumerate(self.pieces): piece.transform_data(flow_system, f'{name_prefix}|Piece{i}') @@ -102,7 +102,7 @@ def has_time_dim(self, value): def items(self): return self.piecewises.items() - def transform_data(self, flow_system: 'FlowSystem', name_prefix: str): + def transform_data(self, flow_system: 'FlowSystem', name_prefix: str = '') -> None: for name, piecewise in self.piecewises.items(): piecewise.transform_data(flow_system, f'{name_prefix}|{name}') @@ -133,7 +133,7 @@ def has_time_dim(self, value): for piecewise in self.piecewise_shares.values(): piecewise.has_time_dim = value - def transform_data(self, flow_system: 'FlowSystem', name_prefix: str): + def transform_data(self, flow_system: 'FlowSystem', name_prefix: str = '') -> None: self.piecewise_origin.transform_data(flow_system, f'{name_prefix}|PiecewiseEffects|origin') for effect, piecewise in self.piecewise_shares.items(): piecewise.transform_data(flow_system, f'{name_prefix}|PiecewiseEffects|{effect}') @@ -184,7 +184,7 @@ def __init__( self.maximum_size = maximum_size if maximum_size is not None else CONFIG.modeling.BIG # default maximum self.investment_scenarios = investment_scenarios - def transform_data(self, flow_system: 'FlowSystem', name_prefix: str): + def transform_data(self, flow_system: 'FlowSystem', name_prefix: str = '') -> None: self._plausibility_checks(flow_system) self.fix_effects = flow_system.fit_effects_to_model_coords( label_prefix=name_prefix, @@ -286,7 +286,7 @@ def __init__( effects_of_investment if effects_of_investment is not None else {} ) - def transform_data(self, flow_system: 'FlowSystem', name_prefix: str): + def transform_data(self, flow_system: 'FlowSystem', name_prefix: str = '') -> None: """Transform all parameter data to match the flow system's coordinate structure.""" self._plausibility_checks(flow_system) @@ -356,8 +356,6 @@ def __init__( optional_divestment: bool = True, fix_effects: Optional['NonTemporalEffectsUser'] = None, specific_effects: Optional['NonTemporalEffectsUser'] = None, # costs per Flow-Unit/Storage-Size/... - effects_of_investment_per_size: Optional['NonTemporalEffectsUser'] = None, - effects_of_investment: Optional['NonTemporalEffectsUser'] = None, previous_size: Scalar = 0, ): """ @@ -397,12 +395,6 @@ def __init__( self.fix_effects: 'NonTemporalEffectsUser' = fix_effects if fix_effects is not None else {} self.specific_effects: 'NonTemporalEffectsUser' = specific_effects if specific_effects is not None else {} - self.effects_of_investment_per_size: 'NonTemporalEffectsUser' = ( - effects_of_investment_per_size if effects_of_investment_per_size is not None else {} - ) - self.effects_of_investment: 'NonTemporalEffectsUser' = ( - effects_of_investment if effects_of_investment is not None else {} - ) self.previous_size = previous_size @@ -464,7 +456,7 @@ def _plausibility_checks(self, flow_system): f'and maximum_size ({self.maximum_size})' ) - def transform_data(self, flow_system: 'FlowSystem', name_prefix: str): + def transform_data(self, flow_system: 'FlowSystem', name_prefix: str = '') -> None: """Transform all parameter data to match the flow system's coordinate structure.""" self._plausibility_checks(flow_system) @@ -560,7 +552,7 @@ def __init__( self.switch_on_total_max: Scalar = switch_on_total_max self.force_switch_on: bool = force_switch_on - def transform_data(self, flow_system: 'FlowSystem', name_prefix: str): + def transform_data(self, flow_system: 'FlowSystem', name_prefix: str = '') -> None: self.effects_per_switch_on = flow_system.fit_effects_to_model_coords( name_prefix, self.effects_per_switch_on, 'per_switch_on' ) diff --git a/flixopt/structure.py b/flixopt/structure.py index 24934547b..dec476ac7 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -225,11 +225,12 @@ class Interface: transform_data(flow_system): Transform data to match FlowSystem dimensions """ - def transform_data(self, flow_system: 'FlowSystem'): + def transform_data(self, flow_system: 'FlowSystem', name_prefix: str = '') -> None: """Transform the data of the interface to match the FlowSystem's dimensions. Args: flow_system: The FlowSystem containing timing and dimensional information + name_prefix: The prefix to use for the names of the variables. Defaults to '', which results in no prefix. Raises: NotImplementedError: Must be implemented by subclasses From 1ffe27faed2093a96c44d4f8cf49f3e83f8bd4ed Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 7 Aug 2025 09:30:51 +0200 Subject: [PATCH 296/336] Add new "year of investment" coord to FlowSystem --- flixopt/flow_system.py | 34 +++++++++++++++------------------- 1 file changed, 15 insertions(+), 19 deletions(-) diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index 97db2879e..59b79831f 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -79,15 +79,17 @@ def __init__( weights: The weights of each year and scenario. If None, all have the same weight (normalized to 1). Its recommended to scale the weights to sum up to 1. """ self.timesteps = self._validate_timesteps(timesteps) - self.timesteps_extra = self._create_timesteps_with_extra(timesteps, hours_of_last_timestep) self.hours_of_previous_timesteps = self._calculate_hours_of_previous_timesteps( timesteps, hours_of_previous_timesteps ) - self.years = None if years is None else self._validate_years(years) - - self.years_per_year = None if years is None else self.calculate_years_per_year(years, years_of_last_year) + if years is None: + self.years, self.years_per_year, self.years_of_investment = None, None, None + else: + self.years = self._validate_years(years) + self.years_per_year = self.calculate_years_per_year(self.years, years_of_last_year) + self.years_of_investment = self.years.rename('year_of_investment') self.scenarios = None if scenarios is None else self._validate_scenarios(scenarios) @@ -362,8 +364,8 @@ def fit_to_model_coords( self, name: str, data: Optional[Union[TemporalDataUser, NonTemporalDataUser]], - has_time_dim: bool = True, dims: Optional[Collection[FlowSystemDimensions]] = None, + with_year_of_investment: bool = False, ) -> Optional[Union[TemporalData, NonTemporalData]]: """ Fit data to model coordinate system (currently time, but extensible). @@ -371,8 +373,8 @@ def fit_to_model_coords( Args: name: Name of the data data: Data to fit to model coordinates - has_time_dim: Wether to use the time dimension or not dims: Collection of dimension names to use for fitting. If None, all dimensions are used. + with_year_of_investment: Wether to use the year_of_investment dimension or not. Only if "year" is in dims. Returns: xr.DataArray aligned to model coordinate system. If data is None, returns None. @@ -380,20 +382,14 @@ def fit_to_model_coords( if data is None: return None - if dims is None: - coords = self.coords + coords = self.coords - if not has_time_dim: - warnings.warn( - 'has_time_dim is deprecated. Please pass dims to fit_to_model_coords instead.', - DeprecationWarning, - stacklevel=2, - ) - coords.pop('time') - else: - coords = self.coords + if dims is not None: coords = {k: coords[k] for k in dims if k in coords} + if with_year_of_investment and 'year' in coords: + coords['year_of_investment'] = coords['year'].rename('year_of_investment') + # Rest of your method stays the same, just pass coords if isinstance(data, TimeSeriesData): try: @@ -414,8 +410,8 @@ def fit_effects_to_model_coords( label_prefix: Optional[str], effect_values: Optional[Union[TemporalEffectsUser, NonTemporalEffectsUser]], label_suffix: Optional[str] = None, - has_time_dim: bool = True, dims: Optional[Collection[FlowSystemDimensions]] = None, + with_year_of_investment: bool = False, ) -> Optional[Union[TemporalEffects, NonTemporalEffects]]: """ Transform EffectValues from the user to Internal Datatypes aligned with model coordinates. @@ -429,8 +425,8 @@ def fit_effects_to_model_coords( effect: self.fit_to_model_coords( '|'.join(filter(None, [label_prefix, effect, label_suffix])), value, - has_time_dim=has_time_dim, dims=dims, + with_year_of_investment=with_year_of_investment, ) for effect, value in effect_values_dict.items() } From 3632965e9e76660d69db2aabf73645055f1fb25a Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 7 Aug 2025 11:01:11 +0200 Subject: [PATCH 297/336] Add 'year_of_investment' dimension to FlowSystem --- flixopt/features.py | 33 ++++++++++++++++++--------------- flixopt/interface.py | 24 ++++++++---------------- 2 files changed, 26 insertions(+), 31 deletions(-) diff --git a/flixopt/features.py b/flixopt/features.py index 17a99d4eb..4665aea90 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -262,32 +262,35 @@ def _basic_modeling(self): decrease_binary=self.year_of_decommissioning, name=f'{self.label_of_element}|size|changes', max_change=size_max, - initial_level=self.parameters.previous_size if self.parameters.previous_size is not None else 0, + initial_level=0, coord='year', ) def _add_effects(self): """Add investment effects to the model.""" - if self.parameters.effects_of_investment: - # One-time effects when investment is made - increase = self._variables.get('increase') - if increase is not None: - self._model.effects.add_share_to_effects( - name=self.label_of_element, - expressions={ - effect: increase * factor for effect, factor in self.parameters.effects_of_investment.items() - }, - target='invest', - ) + if self.parameters.fix_effects: + # Effects depending on when the investment is made + remapped_variable = self.year_of_investment.rename({'year': 'year_of_investment'}) + + self._model.effects.add_share_to_effects( + name=self.label_of_element, + expressions={ + effect: (remapped_variable * factor).sum('year_of_investment') + for effect, factor in self.parameters.fix_effects.items() + }, + target='invest', + ) - if self.parameters.effects_of_investment_per_size: + if self.parameters.specific_effects: # Annual effects proportional to investment size + remapped_variable = self.size_increase.rename({'year': 'year_of_investment'}) + self._model.effects.add_share_to_effects( name=self.label_of_element, expressions={ - effect: self.size * factor - for effect, factor in self.parameters.effects_of_investment_per_size.items() + effect: (remapped_variable * factor).sum('year_of_investment') + for effect, factor in self.parameters.specific_effects.items() }, target='invest', ) diff --git a/flixopt/interface.py b/flixopt/interface.py index d06d0ccb1..4ca5093ca 100644 --- a/flixopt/interface.py +++ b/flixopt/interface.py @@ -356,7 +356,6 @@ def __init__( optional_divestment: bool = True, fix_effects: Optional['NonTemporalEffectsUser'] = None, specific_effects: Optional['NonTemporalEffectsUser'] = None, # costs per Flow-Unit/Storage-Size/... - previous_size: Scalar = 0, ): """ These parameters are used to include the timing of investments in the model. @@ -396,8 +395,6 @@ def __init__( self.fix_effects: 'NonTemporalEffectsUser' = fix_effects if fix_effects is not None else {} self.specific_effects: 'NonTemporalEffectsUser' = specific_effects if specific_effects is not None else {} - self.previous_size = previous_size - def _plausibility_checks(self, flow_system): """Validate parameter consistency.""" if flow_system.years is None: @@ -449,28 +446,23 @@ def _plausibility_checks(self, flow_system): f'year_of_investment ({self.year_of_investment}) must be before year_of_decommissioning ({self.year_of_decommissioning})' ) - if self.previous_size != 0: - if not self.minimum_size <= self.previous_size <= self.maximum_size: - raise ValueError( - f'previous_size ({self.previous_size}) must be zero orbetween minimum_size ({self.minimum_size}) ' - f'and maximum_size ({self.maximum_size})' - ) - def transform_data(self, flow_system: 'FlowSystem', name_prefix: str = '') -> None: """Transform all parameter data to match the flow system's coordinate structure.""" self._plausibility_checks(flow_system) - self.effects_of_investment_per_size = flow_system.fit_effects_to_model_coords( + self.fix_effects = flow_system.fit_effects_to_model_coords( label_prefix=name_prefix, - effect_values=self.effects_of_investment_per_size, - label_suffix='effects_of_investment_per_size', + effect_values=self.fix_effects, + label_suffix='fix_effects', dims=['year', 'scenario'], + with_year_of_investment=True, ) - self.effects_of_investment = flow_system.fit_effects_to_model_coords( + self.specific_effects = flow_system.fit_effects_to_model_coords( label_prefix=name_prefix, - effect_values=self.effects_of_investment, - label_suffix='effects_of_investment', + effect_values=self.specific_effects, + label_suffix='specific_effects', dims=['year', 'scenario'], + with_year_of_investment=True, ) self.minimum_size = flow_system.fit_to_model_coords( From 9d78314da37bdccf7fa074fb1c234198d1c1a240 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 7 Aug 2025 12:13:41 +0200 Subject: [PATCH 298/336] Improve InvestmentTiming --- flixopt/features.py | 103 +++++++++++++++++----------------------- flixopt/interface.py | 109 ++++++++++++++++++++++++------------------- 2 files changed, 103 insertions(+), 109 deletions(-) diff --git a/flixopt/features.py b/flixopt/features.py index 4665aea90..f21b73d48 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -136,9 +136,6 @@ class InvestmentTimingModel(Submodel): Such an Investment is defined by a size, a year_of_investment, and a year_of_decommissioning. In between these years, the size of the investment cannot vary. Outside, its 0. - The year_of_investment is defined as the year, in which the size increases from 0 to the chosen size. - The year_of_decommissioning is defined as the year, in which the size returns to 0. - The year_of_decommissioning can be after the years in the FlowSystem. THis results in no decommissioning. """ parameters: InvestTimingParameters @@ -158,10 +155,8 @@ def _do_modeling(self): self._basic_modeling() self._add_effects() - if self.parameters.year_of_investment is not None: - self._fixed_start_constraint() - if self.parameters.year_of_decommissioning is not None: - self._fixed_end_constraint() + self._constraint_investment() + self._constraint_decommissioning() if self.parameters.duration_in_years is not None: self._fixed_duration_constraint() @@ -175,13 +170,11 @@ def _basic_modeling(self): upper=size_max, coords=self._model.get_coords(['year', 'scenario']), ) - self.add_variables( binary=True, coords=self._model.get_coords(['year', 'scenario']), short_name='is_invested', ) - BoundingPatterns.bounds_with_state( self, variable=self.size, @@ -193,52 +186,30 @@ def _basic_modeling(self): self.add_variables( binary=True, coords=self._model.get_coords(['year', 'scenario']), - short_name='size|year_of_investment', + short_name='size|investment_occurs', ) self.add_variables( binary=True, coords=self._model.get_coords(['year', 'scenario']), - short_name='size|year_of_decommissioning', + short_name='size|decommissioning_occurs', ) BoundingPatterns.state_transition_bounds( self, state_variable=self.is_invested, - switch_on=self.year_of_investment, - switch_off=self.year_of_decommissioning, + switch_on=self.investment_occurs, + switch_off=self.decommissioning_occurs, name=self.is_invested.name, previous_state=0, coord='year', ) - - ######################################################################## - self.add_variables( - binary=True, - coords=self._model.get_coords(['scenario']), - short_name='size|investment_used', - ) - self.add_variables( - binary=True, - coords=self._model.get_coords(['scenario']), - short_name='size|divestment_used', - ) self.add_constraints( - self.year_of_investment.sum('year') == self.investment_used, - short_name='size|year_of_investment|count', + self.investment_occurs.sum('year') <= 1, + short_name='investment_occurs|once', ) self.add_constraints( - self.year_of_decommissioning.sum('year') == self.divestment_used, - short_name='size|year_of_decommissioning|count', + self.decommissioning_occurs.sum('year') <= 1, + short_name='decommissioning_occurs|once', ) - if not self.parameters.optional_investment: - self.add_constraints( - self.investment_used == 1, - short_name='investment_used|fixed', - ) - if not self.parameters.optional_divestment: - self.add_constraints( - self.divestment_used == 1, - short_name='divestment_used|fixed', - ) ######################################################################## self.add_variables( @@ -258,8 +229,8 @@ def _basic_modeling(self): level_variable=self.size, increase_variable=self.size_increase, decrease_variable=self.size_decrease, - increase_binary=self.year_of_investment, - decrease_binary=self.year_of_decommissioning, + increase_binary=self.investment_occurs, + decrease_binary=self.decommissioning_occurs, name=f'{self.label_of_element}|size|changes', max_change=size_max, initial_level=0, @@ -271,7 +242,7 @@ def _add_effects(self): if self.parameters.fix_effects: # Effects depending on when the investment is made - remapped_variable = self.year_of_investment.rename({'year': 'year_of_investment'}) + remapped_variable = self.investment_occurs.rename({'year': 'year_of_investment'}) self._model.effects.add_share_to_effects( name=self.label_of_element, @@ -295,17 +266,29 @@ def _add_effects(self): target='invest', ) - def _fixed_start_constraint(self): - self.add_constraints( - self.year_of_investment.sel(year=self.parameters.year_of_investment) == self.investment_used, - short_name='size|changes|fixed_start', - ) + def _constraint_investment(self): + if self.parameters.force_investment.sum() > 0: + self.add_constraints( + self.investment_occurs == self.parameters.force_investment, + short_name='size|changes|fixed_start', + ) + else: + self.add_constraints( + self.investment_occurs <= self.parameters.allow_investment, + short_name='size|changes|restricted_start', + ) - def _fixed_end_constraint(self): - self.add_constraints( - self.year_of_decommissioning.sel(year=self.parameters.year_of_decommissioning) == self.divestment_used, - short_name='size|changes|fixed_end', - ) + def _constraint_decommissioning(self): + if self.parameters.force_decommissioning.sum() > 0: + self.add_constraints( + self.decommissioning_occurs == self.parameters.force_decommissioning, + short_name='size|changes|fixed_end', + ) + else: + self.add_constraints( + self.decommissioning_occurs <= self.parameters.allow_decommissioning, + short_name='size|changes|restricted_end', + ) def _fixed_duration_constraint(self): years = self._model.flow_system.years @@ -368,14 +351,14 @@ def is_invested(self) -> Optional[linopy.Variable]: return self._variables['is_invested'] @property - def year_of_investment(self) -> linopy.Variable: + def investment_occurs(self) -> linopy.Variable: """Binary increase decision variable""" - return self._variables['size|year_of_investment'] + return self._variables['size|investment_occurs'] @property - def year_of_decommissioning(self) -> linopy.Variable: + def decommissioning_occurs(self) -> linopy.Variable: """Binary decrease decision variable""" - return self._variables['size|year_of_decommissioning'] + return self._variables['size|decommissioning_occurs'] @property def size_decrease(self) -> linopy.Variable: @@ -388,14 +371,14 @@ def size_increase(self) -> linopy.Variable: return self._variables['size|increase'] @property - def investment_used(self) -> linopy.Variable: + def investment_used(self) -> linopy.LinearExpression: """Binary investment decision variable""" - return self._variables['size|investment_used'] + return self.investment_occurs.sum('year') @property - def divestment_used(self) -> linopy.Variable: + def divestment_used(self) -> linopy.LinearExpression: """Binary investment decision variable""" - return self._variables['size|divestment_used'] + return self.decommissioning_occurs.sum('year') @property def duration(self) -> linopy.Variable: diff --git a/flixopt/interface.py b/flixopt/interface.py index 4ca5093ca..3ca65868c 100644 --- a/flixopt/interface.py +++ b/flixopt/interface.py @@ -6,6 +6,8 @@ import logging from typing import TYPE_CHECKING, Dict, Iterator, List, Literal, Optional, Union +import xarray as xr + from .config import CONFIG from .core import NonTemporalData, NonTemporalDataUser, Scalar, TemporalDataUser from .structure import Interface, register_class_for_io @@ -335,6 +337,10 @@ def is_fixed_size(self) -> bool: return self.fixed_size is not None +YearOfInvestmentData = Union[int, float, xr.DataArray] +YearOfInvestmentDataBool = Union[bool, xr.DataArray] + + @register_class_for_io class InvestTimingParameters(Interface): """ @@ -346,20 +352,24 @@ class InvestTimingParameters(Interface): def __init__( self, - year_of_investment: Optional[int] = None, - year_of_decommissioning: Optional[int] = None, - duration_in_years: Optional[int] = None, - minimum_size: Optional[Scalar] = None, - maximum_size: Optional[Scalar] = None, - fixed_size: Optional[Scalar] = None, - optional_investment: bool = True, - optional_divestment: bool = True, + allow_investment: YearOfInvestmentDataBool = True, + allow_decommissioning: YearOfInvestmentDataBool = True, + force_investment: YearOfInvestmentDataBool = False, + force_decommissioning: YearOfInvestmentDataBool = False, + duration_in_years: Optional[YearOfInvestmentData] = None, + minimum_size: Optional[YearOfInvestmentData] = None, + maximum_size: Optional[YearOfInvestmentData] = None, + fixed_size: Optional[YearOfInvestmentData] = None, fix_effects: Optional['NonTemporalEffectsUser'] = None, specific_effects: Optional['NonTemporalEffectsUser'] = None, # costs per Flow-Unit/Storage-Size/... ): """ These parameters are used to include the timing of investments in the model. Two out of three parameters (year_of_investment, year_of_decommissioning, duration_in_years) can be fixed. + This has a 'year_of_investment' dimension in some parameters: + allow_investment: Whether investment is allowed in a certain year + allow_decommissioning: Whether divestment is allowed in a certain year + duration_between_investment_and_decommissioning: Duration between investment and decommissioning Args: year_of_investment: Year in which the investment occurs (inclusive). Present from this year onwards. @@ -385,11 +395,11 @@ def __init__( self.minimum_size = minimum_size if minimum_size is not None else CONFIG.modeling.EPSILON self.maximum_size = maximum_size if maximum_size is not None else CONFIG.modeling.BIG self.fixed_size = fixed_size - self.optional_investment = optional_investment - self.optional_divestment = optional_divestment - self.year_of_investment = year_of_investment - self.year_of_decommissioning = year_of_decommissioning + self.allow_investment = allow_investment + self.allow_decommissioning = allow_decommissioning + self.force_investment = force_investment + self.force_decommissioning = force_decommissioning self.duration_in_years = duration_in_years self.fix_effects: 'NonTemporalEffectsUser' = fix_effects if fix_effects is not None else {} @@ -400,56 +410,48 @@ def _plausibility_checks(self, flow_system): if flow_system.years is None: raise ValueError("YearAwareInvestParameters requires the flow_system to have a 'years' dimension.") - if all( - [param is None for param in (self.year_of_investment, self.year_of_decommissioning, self.duration_in_years)] - ): + if (self.force_investment.sum('year') > 1).any(): + raise ValueError('force_investment can only be True for a single year.') + if (self.force_decommissioning.sum('year') > 1).any(): + raise ValueError('force_decommissioning can only be True for a single year.') + + specify_timing = ( + (self.duration_in_years is None) + + bool((self.force_investment.sum('year') > 1).any()) + + bool((self.force_decommissioning.sum('year') > 1).any()) + ) + + if specify_timing == 0: # TODO: Should this be an exception or rather a warning? Is there a valid use case for this? # And a mathematically valid formulation (regarding the effects especially)? raise ValueError( - 'Either year_of_investment, year_of_decommissioning or duration_in_years must be specified.' + 'Either the the duration of an investment needs to be set, or the investment or decommissioning ' + 'needs to be forced in one year.' ) - if ( - sum( - [ - param is not None - for param in (self.year_of_investment, self.year_of_decommissioning, self.duration_in_years) - ] - ) - > 2 - ): - # TODO: Should this be an exception or rather a warning? + if specify_timing == 3: + # TODO: Should this be an exception or rather a warning? Is there a valid use case for this? + # And a mathematically valid formulation (regarding the effects especially)? raise ValueError( - f'InvestmentParameters is overdefined. Not all of {self.year_of_investment=}, ' - f'{self.year_of_decommissioning=} and {self.duration_in_years=} can be specified.' + 'Either the the duration of an investment needs to be set, or the investment or decommissioning ' + 'needs to be forced in one year.' ) - if self.year_of_investment is not None: - if not (flow_system.years[0] <= self.year_of_investment <= flow_system.years[-1]): + if (self.force_investment.sum('year') >= 1).any() and (self.force_decommissioning.sum('year') >= 1).any(): + year_of_forced_investment = ( + self.force_investment.where(self.force_investment) * self.force_investment.year + ).sum('year') + year_of_forced_decommissioning = ( + self.force_decommissioning.where(self.force_decommissioning) * self.force_decommissioning.year + ).sum('year') + if not (year_of_forced_investment < year_of_forced_decommissioning).all(): raise ValueError( - f'year_of_investment ({self.year_of_investment}) must be between ' - f'{flow_system.years[0]} and {flow_system.years[-1]}' + f'force_investment needs to be before force_decommissioning. Got:\n' + f'{self.force_investment}\nand\n{self.force_decommissioning}' ) - if self.year_of_decommissioning is not None: - if not (flow_system.years[0] <= self.year_of_decommissioning <= flow_system.years[-1]): - raise ValueError( - f'year_of_decommissioning ({self.year_of_decommissioning}) must be between {flow_system.years[0]} and {flow_system.years[-1]}' - ) - - if ( - self.year_of_decommissioning is not None - and self.year_of_investment is not None - and not self.year_of_investment < self.year_of_decommissioning - ): - raise ValueError( - f'year_of_investment ({self.year_of_investment}) must be before year_of_decommissioning ({self.year_of_decommissioning})' - ) - def transform_data(self, flow_system: 'FlowSystem', name_prefix: str = '') -> None: """Transform all parameter data to match the flow system's coordinate structure.""" - self._plausibility_checks(flow_system) - self.fix_effects = flow_system.fit_effects_to_model_coords( label_prefix=name_prefix, effect_values=self.fix_effects, @@ -465,6 +467,13 @@ def transform_data(self, flow_system: 'FlowSystem', name_prefix: str = '') -> No with_year_of_investment=True, ) + self.force_investment = flow_system.fit_to_model_coords( + f'{name_prefix}|force_investment', self.force_investment, dims=['year', 'scenario'] + ) + self.force_decommissioning = flow_system.fit_to_model_coords( + f'{name_prefix}|force_decommissioning', self.force_decommissioning, dims=['year', 'scenario'] + ) + self.minimum_size = flow_system.fit_to_model_coords( f'{name_prefix}|minimum_size', self.minimum_size, dims=['year', 'scenario'] ) @@ -478,6 +487,8 @@ def transform_data(self, flow_system: 'FlowSystem', name_prefix: str = '') -> No # TODO: self.previous_size to only scenarios + self._plausibility_checks(flow_system) + @property def minimum_or_fixed_size(self) -> NonTemporalData: """Get the effective minimum size (fixed size takes precedence).""" From ca6fb0679e4cc097d1a54fafd5978e039f883d74 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 7 Aug 2025 12:42:56 +0200 Subject: [PATCH 299/336] Improve InvestmentTiming --- flixopt/features.py | 14 +++++--------- flixopt/interface.py | 30 +++++++++++------------------- 2 files changed, 16 insertions(+), 28 deletions(-) diff --git a/flixopt/features.py b/flixopt/features.py index f21b73d48..7ac2a0c5b 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -158,7 +158,7 @@ def _do_modeling(self): self._constraint_investment() self._constraint_decommissioning() if self.parameters.duration_in_years is not None: - self._fixed_duration_constraint() + self._constraint_duration_between_investment_and_decommissioning() def _basic_modeling(self): size_min, size_max = self.parameters.minimum_or_fixed_size, self.parameters.maximum_or_fixed_size @@ -290,7 +290,7 @@ def _constraint_decommissioning(self): short_name='size|changes|restricted_end', ) - def _fixed_duration_constraint(self): + def _constraint_duration_between_investment_and_decommissioning(self): years = self._model.flow_system.years years_of_decommissioning = years + self.parameters.duration_in_years @@ -300,9 +300,7 @@ def _fixed_duration_constraint(self): valid_years_of_investment = years[valid_mask] valid_years_of_decommissioning = years_of_decommissioning[valid_mask] actual_years_of_decommissioning = ( - self.years_of_decommissioning.sel(year=valid_years_of_decommissioning, method='bfill') - .coords['year'] - .values + self.investment_occurs.sel(year=valid_years_of_decommissioning, method='bfill').coords['year'].values ) # Warning for mismatched years @@ -326,14 +324,12 @@ def _fixed_duration_constraint(self): # Now you can use proper xarray groupby grouped_increases = ( - self.year_of_investment.sel(year=valid_years_of_investment) - .groupby(group) - .sum('year_of_decommissioning') + self.investment_occurs.sel(year=valid_years_of_investment).groupby(group).sum('year_of_decommissioning') ) # Create constraints self.add_constraints( - self.year_of_decommissioning.sel(year=grouped_increases.coords['year_of_decommissioning']) + self.decommissioning_occurs.sel(year=grouped_increases.coords['year_of_decommissioning']) == grouped_increases, short_name='size|changes|fixed_duration', ) diff --git a/flixopt/interface.py b/flixopt/interface.py index 3ca65868c..993f9ec7c 100644 --- a/flixopt/interface.py +++ b/flixopt/interface.py @@ -372,24 +372,16 @@ def __init__( duration_between_investment_and_decommissioning: Duration between investment and decommissioning Args: - year_of_investment: Year in which the investment occurs (inclusive). Present from this year onwards. - If None, the year_of_investment is not fixed. - year_of_decommissioning: Year in which the unit is decommissioned (exclusive). Present up to this year. - If None, the year_of_decommissioning is not fixed. - duration_in_years: Duration between year_of_investment and year_of_decommissioning. - If None, the duration is not fixed. - minimum_size: Minimum possible size of the investment. - maximum_size: Maximum possible size of the investment. - fixed_size: If specified, investment size is fixed to this value. - optional_investment: If False, the investment is mandatory. - optional_divestment: If False, the divestment is mandatory. - specific_effects: Specific costs, e.g., in €/kW_nominal or €/m²_nominal. - Example: {costs: 3, CO2: 0.3} with costs and CO2 representing an Object of class Effect - (Attention: Annualize costs to chosen period!) - effects_of_investment: Effects depending on when an investment decision is made. These can occur in the investment year or in multiple years. - If the effects need to occur in multiple years, you need to pass an xr.DataArray with the coord 'year_of_investment'. - Example: {'costs': 1000} applies 1000 to costs in the investment year. - previous_size: The size of the investment before the first period. Defaults to 0. + allow_investment: Allow investment in a certain year. By default, allow it in all years. + allow_decommissioning: Allow decommissioning in a certain year. By default, allow it in all years. + force_investment: Force the investment to occur in a certain year. + force_decommissioning: Force the decommissioning to occur in a certain year. + duration_in_years: Fix the duration between the year of investment and the year ofdecommissioning. + minimum_size: Minimum possible size of the investment. Can depend on the year of investment. + maximum_size: Maximum possible size of the investment. Can depend on the year of investment. + fixed_size: Fix the size of the investment. Can depend on the year of investment. Can still be 0 if not forced. + specific_effects: Effects of the Investment, dependent on the size. Take care. These are not broadcasted internally! + fix_effects: Effects of the Investment, independent of the size. Take care. These are not broadcasted internally! """ self.minimum_size = minimum_size if minimum_size is not None else CONFIG.modeling.EPSILON @@ -416,7 +408,7 @@ def _plausibility_checks(self, flow_system): raise ValueError('force_decommissioning can only be True for a single year.') specify_timing = ( - (self.duration_in_years is None) + (self.duration_in_years is not None) + bool((self.force_investment.sum('year') > 1).any()) + bool((self.force_decommissioning.sum('year') > 1).any()) ) From abbe5349ed50b3524c071ec2e22b666f9950a1e6 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 7 Aug 2025 12:56:56 +0200 Subject: [PATCH 300/336] Add specific_effect back --- flixopt/effects.py | 4 ++-- flixopt/features.py | 6 +++--- flixopt/interface.py | 30 ++++++++++++++++++++++++++---- 3 files changed, 31 insertions(+), 9 deletions(-) diff --git a/flixopt/effects.py b/flixopt/effects.py index da032878f..d7392e239 100644 --- a/flixopt/effects.py +++ b/flixopt/effects.py @@ -13,7 +13,7 @@ import numpy as np import xarray as xr -from .core import Scalar, TemporalData, TemporalDataUser +from .core import NonTemporalDataUser, Scalar, TemporalData, TemporalDataUser from .features import ShareAllocationModel from .structure import Element, ElementModel, FlowSystemModel, Interface, Submodel, register_class_for_io @@ -192,7 +192,7 @@ def _do_modeling(self): TemporalEffectsUser = Union[TemporalDataUser, Dict[str, TemporalDataUser]] # User-specified Shares to Effects """ This datatype is used to define a temporal share to an effect by a certain attribute. """ -NonTemporalEffectsUser = Union[Scalar, Dict[str, Scalar]] # User-specified Shares to Effects +NonTemporalEffectsUser = Union[NonTemporalDataUser, Dict[str, NonTemporalDataUser]] # User-specified Shares to Effects """ This datatype is used to define a scalar share to an effect by a certain attribute. """ TemporalEffects = Dict[str, TemporalData] # User-specified Shares to Effects diff --git a/flixopt/features.py b/flixopt/features.py index 7ac2a0c5b..281694f75 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -248,12 +248,12 @@ def _add_effects(self): name=self.label_of_element, expressions={ effect: (remapped_variable * factor).sum('year_of_investment') - for effect, factor in self.parameters.fix_effects.items() + for effect, factor in self.parameters.fixed_effects_by_investment_year.items() }, target='invest', ) - if self.parameters.specific_effects: + if self.parameters.specific_effects_by_investment_year: # Annual effects proportional to investment size remapped_variable = self.size_increase.rename({'year': 'year_of_investment'}) @@ -261,7 +261,7 @@ def _add_effects(self): name=self.label_of_element, expressions={ effect: (remapped_variable * factor).sum('year_of_investment') - for effect, factor in self.parameters.specific_effects.items() + for effect, factor in self.parameters.specific_effects_by_investment_year.items() }, target='invest', ) diff --git a/flixopt/interface.py b/flixopt/interface.py index 993f9ec7c..02336de61 100644 --- a/flixopt/interface.py +++ b/flixopt/interface.py @@ -337,8 +337,10 @@ def is_fixed_size(self) -> bool: return self.fixed_size is not None -YearOfInvestmentData = Union[int, float, xr.DataArray] -YearOfInvestmentDataBool = Union[bool, xr.DataArray] +YearOfInvestmentData = NonTemporalDataUser +"""This datatype is used to define things related to the year of investment.""" +YearOfInvestmentDataBool = Union[bool, YearOfInvestmentData] +"""This datatype is used to define things with boolean data related to the year of investment.""" @register_class_for_io @@ -356,12 +358,14 @@ def __init__( allow_decommissioning: YearOfInvestmentDataBool = True, force_investment: YearOfInvestmentDataBool = False, force_decommissioning: YearOfInvestmentDataBool = False, - duration_in_years: Optional[YearOfInvestmentData] = None, + duration_in_years: Optional[Scalar] = None, minimum_size: Optional[YearOfInvestmentData] = None, maximum_size: Optional[YearOfInvestmentData] = None, fixed_size: Optional[YearOfInvestmentData] = None, fix_effects: Optional['NonTemporalEffectsUser'] = None, specific_effects: Optional['NonTemporalEffectsUser'] = None, # costs per Flow-Unit/Storage-Size/... + fixed_effects_by_investment_year: Optional[YearOfInvestmentData] = None, + specific_effects_by_investment_year: Optional[YearOfInvestmentData] = None, ): """ These parameters are used to include the timing of investments in the model. @@ -396,6 +400,12 @@ def __init__( self.fix_effects: 'NonTemporalEffectsUser' = fix_effects if fix_effects is not None else {} self.specific_effects: 'NonTemporalEffectsUser' = specific_effects if specific_effects is not None else {} + self.fixed_effects_by_investment_year = ( + fixed_effects_by_investment_year if fixed_effects_by_investment_year is not None else {} + ) + self.specific_effects_by_investment_year = ( + specific_effects_by_investment_year if specific_effects_by_investment_year is not None else {} + ) def _plausibility_checks(self, flow_system): """Validate parameter consistency.""" @@ -449,13 +459,25 @@ def transform_data(self, flow_system: 'FlowSystem', name_prefix: str = '') -> No effect_values=self.fix_effects, label_suffix='fix_effects', dims=['year', 'scenario'], - with_year_of_investment=True, ) self.specific_effects = flow_system.fit_effects_to_model_coords( label_prefix=name_prefix, effect_values=self.specific_effects, label_suffix='specific_effects', dims=['year', 'scenario'], + ) + self.fixed_effects_by_investment_year = flow_system.fit_effects_to_model_coords( + label_prefix=name_prefix, + effect_values=self.fixed_effects_by_investment_year, + label_suffix='fixed_effects_by_investment_year', + dims=['year', 'scenario'], + with_year_of_investment=True, + ) + self.specific_effects_by_investment_year = flow_system.fit_effects_to_model_coords( + label_prefix=name_prefix, + effect_values=self.specific_effects_by_investment_year, + label_suffix='specific_effects_by_investment_year', + dims=['year', 'scenario'], with_year_of_investment=True, ) From 6f550dd3af0ee8a6b0468227db8c3ee1ddf653e5 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 7 Aug 2025 16:04:54 +0200 Subject: [PATCH 301/336] add effects_by_investment_year back --- flixopt/features.py | 17 +++++++++++++ flixopt/interface.py | 57 ++++++++++++++++++++++++++++++-------------- 2 files changed, 56 insertions(+), 18 deletions(-) diff --git a/flixopt/features.py b/flixopt/features.py index 281694f75..af8c42909 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -241,6 +241,23 @@ def _add_effects(self): """Add investment effects to the model.""" if self.parameters.fix_effects: + self._model.effects.add_share_to_effects( + name=self.label_of_element, + expressions={ + effect: self.is_invested * factor if self.is_invested is not None else factor + for effect, factor in self.parameters.fix_effects.items() + }, + target='invest', + ) + + if self.parameters.specific_effects: + self._model.effects.add_share_to_effects( + name=self.label_of_element, + expressions={effect: self.size * factor for effect, factor in self.parameters.specific_effects.items()}, + target='invest', + ) + + if self.parameters.fixed_effects_by_investment_year: # Effects depending on when the investment is made remapped_variable = self.investment_occurs.rename({'year': 'year_of_investment'}) diff --git a/flixopt/interface.py b/flixopt/interface.py index 02336de61..61b17ecc7 100644 --- a/flixopt/interface.py +++ b/flixopt/interface.py @@ -364,8 +364,8 @@ def __init__( fixed_size: Optional[YearOfInvestmentData] = None, fix_effects: Optional['NonTemporalEffectsUser'] = None, specific_effects: Optional['NonTemporalEffectsUser'] = None, # costs per Flow-Unit/Storage-Size/... - fixed_effects_by_investment_year: Optional[YearOfInvestmentData] = None, - specific_effects_by_investment_year: Optional[YearOfInvestmentData] = None, + fixed_effects_by_investment_year: Optional[xr.DataArray] = None, + specific_effects_by_investment_year: Optional[xr.DataArray] = None, ): """ These parameters are used to include the timing of investments in the model. @@ -384,8 +384,15 @@ def __init__( minimum_size: Minimum possible size of the investment. Can depend on the year of investment. maximum_size: Maximum possible size of the investment. Can depend on the year of investment. fixed_size: Fix the size of the investment. Can depend on the year of investment. Can still be 0 if not forced. - specific_effects: Effects of the Investment, dependent on the size. Take care. These are not broadcasted internally! - fix_effects: Effects of the Investment, independent of the size. Take care. These are not broadcasted internally! + specific_effects: Effects dependent on the size. + fix_effects: Effects of the Investment, independent of the size. + + fixed_effects_by_investment_year: Effects dependent on the year of investment. + The passed xr.DataArray needs to match the FlowSystem dimensions (except time, but including "year_of_investment"). No internal Broadcasting! + "year_of_investment" has the same values as the year dimension. Access it through `flow_system.year_of_investment`. + specific_effects_by_investment_year: Effects dependent on the year of investment and the chosen size. + The passed xr.DataArray needs to match the FlowSystem dimensions (except time, but including "year_of_investment"). No internal Broadcasting! + "year_of_investment" has the same values as the year dimension. Access it through `flow_system.year_of_investment`. """ self.minimum_size = minimum_size if minimum_size is not None else CONFIG.modeling.EPSILON @@ -466,20 +473,6 @@ def transform_data(self, flow_system: 'FlowSystem', name_prefix: str = '') -> No label_suffix='specific_effects', dims=['year', 'scenario'], ) - self.fixed_effects_by_investment_year = flow_system.fit_effects_to_model_coords( - label_prefix=name_prefix, - effect_values=self.fixed_effects_by_investment_year, - label_suffix='fixed_effects_by_investment_year', - dims=['year', 'scenario'], - with_year_of_investment=True, - ) - self.specific_effects_by_investment_year = flow_system.fit_effects_to_model_coords( - label_prefix=name_prefix, - effect_values=self.specific_effects_by_investment_year, - label_suffix='specific_effects_by_investment_year', - dims=['year', 'scenario'], - with_year_of_investment=True, - ) self.force_investment = flow_system.fit_to_model_coords( f'{name_prefix}|force_investment', self.force_investment, dims=['year', 'scenario'] @@ -501,6 +494,34 @@ def transform_data(self, flow_system: 'FlowSystem', name_prefix: str = '') -> No # TODO: self.previous_size to only scenarios + # No Broadcasting! Until a safe way is established, we need to do check for this! + for effect, da in self.fixed_effects_by_investment_year.items(): + if set(da.dims) != set(list(flow_system.coords) + ['year_of_investment']): + raise ValueError( + f'fixed_effects_by_investment_year need to have the same dimensions as the FlowSystem. ' + f'Got {da.dims} and {list(flow_system.coords)} for effect {effect}' + ) + for effect, da in self.specific_effects_by_investment_year.items(): + if set(da.dims) != set(list(flow_system.coords) + ['year_of_investment']): + raise ValueError( + f'specific_effects_by_investment_year need to have the same dimensions as the FlowSystem. ' + f'Got {da.dims} and {list(flow_system.coords)} for effect {effect}' + ) + self.fixed_effects_by_investment_year = flow_system.fit_effects_to_model_coords( + label_prefix=name_prefix, + effect_values=self.fixed_effects_by_investment_year, + label_suffix='fixed_effects_by_investment_year', + dims=['year', 'scenario'], + with_year_of_investment=True, + ) + self.specific_effects_by_investment_year = flow_system.fit_effects_to_model_coords( + label_prefix=name_prefix, + effect_values=self.specific_effects_by_investment_year, + label_suffix='specific_effects_by_investment_year', + dims=['year', 'scenario'], + with_year_of_investment=True, + ) + self._plausibility_checks(flow_system) @property From b6185780adbba06e82093f4da43fa4401b5352d9 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 7 Aug 2025 17:02:56 +0200 Subject: [PATCH 302/336] Add year_of_investment to FLowSystem.sel() --- flixopt/flow_system.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index 59b79831f..69813f4d6 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -709,19 +709,23 @@ def sel( if not self.connected_and_transformed: self.connect_and_transform() + ds = self.to_dataset() + # Build indexers dict from non-None parameters indexers = {} if time is not None: indexers['time'] = time if year is not None: indexers['year'] = year + if 'year_of_investment' in ds.dims: + indexers['year_of_investment'] = year if scenario is not None: indexers['scenario'] = scenario if not indexers: return self.copy() # Return a copy when no selection - selected_dataset = self.to_dataset().sel(**indexers) + selected_dataset = ds.sel(**indexers) return self.__class__.from_dataset(selected_dataset) def isel( @@ -744,19 +748,23 @@ def isel( if not self.connected_and_transformed: self.connect_and_transform() + ds = self.to_dataset() + # Build indexers dict from non-None parameters indexers = {} if time is not None: indexers['time'] = time if year is not None: indexers['year'] = year + if 'year_of_investment' in ds.dims: + indexers['year_of_investment'] = year if scenario is not None: indexers['scenario'] = scenario if not indexers: return self.copy() # Return a copy when no selection - selected_dataset = self.to_dataset().isel(**indexers) + selected_dataset = ds.isel(**indexers) return self.__class__.from_dataset(selected_dataset) def resample( From 4ff3e31699b4f001233e043565071d0704cf9029 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 7 Aug 2025 17:03:17 +0200 Subject: [PATCH 303/336] Improve Interface --- flixopt/interface.py | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/flixopt/interface.py b/flixopt/interface.py index 61b17ecc7..92da4bfba 100644 --- a/flixopt/interface.py +++ b/flixopt/interface.py @@ -385,12 +385,18 @@ def __init__( maximum_size: Maximum possible size of the investment. Can depend on the year of investment. fixed_size: Fix the size of the investment. Can depend on the year of investment. Can still be 0 if not forced. specific_effects: Effects dependent on the size. + These will occur in each year, depending on the size in that year. fix_effects: Effects of the Investment, independent of the size. + These will occur in each year, depending on wether the size is greater zero in that year. fixed_effects_by_investment_year: Effects dependent on the year of investment. + These effects will depend on the year of the investment. The actual effects can occur in other years, + letting you model things like annuities, which depend on when an investment was taken. The passed xr.DataArray needs to match the FlowSystem dimensions (except time, but including "year_of_investment"). No internal Broadcasting! "year_of_investment" has the same values as the year dimension. Access it through `flow_system.year_of_investment`. specific_effects_by_investment_year: Effects dependent on the year of investment and the chosen size. + These effects will depend on the year of the investment. The actual effects can occur in other years, + letting you model things like annuities, which depend on when an investment was taken. The passed xr.DataArray needs to match the FlowSystem dimensions (except time, but including "year_of_investment"). No internal Broadcasting! "year_of_investment" has the same values as the year dimension. Access it through `flow_system.year_of_investment`. @@ -495,17 +501,25 @@ def transform_data(self, flow_system: 'FlowSystem', name_prefix: str = '') -> No # TODO: self.previous_size to only scenarios # No Broadcasting! Until a safe way is established, we need to do check for this! + self.fixed_effects_by_investment_year = flow_system.effects.create_effect_values_dict( + self.fixed_effects_by_investment_year + ) for effect, da in self.fixed_effects_by_investment_year.items(): - if set(da.dims) != set(list(flow_system.coords) + ['year_of_investment']): + dims = set(da.coords) + if not {'year_of_investment', 'year'}.issubset(dims): raise ValueError( - f'fixed_effects_by_investment_year need to have the same dimensions as the FlowSystem. ' - f'Got {da.dims} and {list(flow_system.coords)} for effect {effect}' + f'fixed_effects_by_investment_year need to have a "year_of_investment" dimension and a ' + f'"year" dimension. Got {dims} for effect {effect}' ) + self.specific_effects_by_investment_year = flow_system.effects.create_effect_values_dict( + self.specific_effects_by_investment_year + ) for effect, da in self.specific_effects_by_investment_year.items(): - if set(da.dims) != set(list(flow_system.coords) + ['year_of_investment']): + dims = set(da.coords) + if not {'year_of_investment', 'year'}.issubset(dims): raise ValueError( - f'specific_effects_by_investment_year need to have the same dimensions as the FlowSystem. ' - f'Got {da.dims} and {list(flow_system.coords)} for effect {effect}' + f'specific_effects_by_investment_year need to have a "year_of_investment" dimension and a ' + f'"year" dimension. Got {dims} for effect {effect}' ) self.fixed_effects_by_investment_year = flow_system.fit_effects_to_model_coords( label_prefix=name_prefix, From 98b6072a1e61502c9cc73e5b2a90ed525d91a5ba Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 7 Aug 2025 17:13:19 +0200 Subject: [PATCH 304/336] Handle selection of years properly again --- flixopt/flow_system.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index 69813f4d6..0acee1d55 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -726,6 +726,12 @@ def sel( return self.copy() # Return a copy when no selection selected_dataset = ds.sel(**indexers) + if 'year_of_investment' in selected_dataset.coords and selected_dataset.coords['year_of_investment'].size == 1: + logger.critical( + 'Selected a single year while using InvestmentTiming. This is not supported and will lead to Errors ' + 'when trying to create a Calculation from this FlowSystem. Please select multiple years instead, ' + 'or remove the InvestmentTimingParameters.' + ) return self.__class__.from_dataset(selected_dataset) def isel( @@ -765,6 +771,12 @@ def isel( return self.copy() # Return a copy when no selection selected_dataset = ds.isel(**indexers) + if 'year_of_investment' in selected_dataset.coords and selected_dataset.coords['year_of_investment'].size == 1: + logger.critical( + 'Selected a single year while using InvestmentTiming. This is not supported and will lead to Errors ' + 'when trying to create a Calculation from this FlowSystem. Please select multiple years instead, ' + 'or remove the InvestmentTimingParameters.' + ) return self.__class__.from_dataset(selected_dataset) def resample( From fa4c107c7dd4839b047b830021a99a0a9cdf7283 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 7 Aug 2025 22:30:29 +0200 Subject: [PATCH 305/336] Temp --- tests/test_models.py | 102 ++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 97 insertions(+), 5 deletions(-) diff --git a/tests/test_models.py b/tests/test_models.py index 37a9fb96c..9e2cce36a 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1,7 +1,10 @@ +from typing import Union + import linopy import numpy as np import pandas as pd import pytest +import xarray as xr import flixopt as fx @@ -18,6 +21,75 @@ ) +def calculate_annual_payment(total_cost: float, remaining_years: int, discount_rate: float) -> float: + """Calculate annualized payment for given remaining years. + + Args: + total_cost: Total cost to be annualized. + remaining_years: Number of remaining years. + discount_rate: Discount rate for annualization. + + Returns: + Annual payment amount. + """ + if remaining_years == 1: + return total_cost + + return ( + total_cost + * (discount_rate * (1 + discount_rate) ** remaining_years) + / ((1 + discount_rate) ** remaining_years - 1) + ) + + +def create_annualized_effects( + year_of_investments: Union[range, list, pd.Index], + all_years: Union[range, list, pd.Index], + total_cost: float, + discount_rate: float, + horizon_end: int, + extra_dim: str = 'year_of_investment', +) -> xr.DataArray: + """Create a 2D effects array for annualized costs. + + Creates an array where investing in year Y results in annualized costs + applied to years Y through horizon_end. + + Args: + year_of_investments: Years when investment decisions can be made. + all_years: All years in the model (for the 'year' dimension). + total_cost: Total upfront cost to be annualized. + discount_rate: Discount rate for annualization calculation. + horizon_end: Last year when effects apply. + extra_dim: Name for the investment year dimension. + + Returns: + xr.DataArray with dimensions [extra_dim, 'year'] containing annualized costs. + """ + + # Convert to lists for easier iteration + year_of_investments_list = list(year_of_investments) + all_years_list = list(all_years) + + # Initialize cost matrix + cost_matrix = np.zeros((len(year_of_investments_list), len(all_years_list))) + + # Fill matrix with annualized costs + for i, year_of_investment in enumerate(year_of_investments_list): + remaining_years = horizon_end - year_of_investment + 1 + if remaining_years > 0: + annual_cost = calculate_annual_payment(total_cost, remaining_years, discount_rate) + + # Apply cost to years from year_of_investment through horizon_end + for j, cost_year in enumerate(all_years_list): + if year_of_investment <= cost_year <= horizon_end: + cost_matrix[i, j] = annual_cost + + return xr.DataArray( + cost_matrix, coords={extra_dim: year_of_investments_list, 'year': all_years_list}, dims=[extra_dim, 'year'] + ) + + @pytest.fixture def flow_system() -> fx.FlowSystem: """Create basic elements for component testing with coordinate parametrization.""" @@ -114,17 +186,30 @@ class TestYearAwareInvestmentModelDirect: """Test the YearAwareInvestmentModel class directly with linopy.""" def test_flow_invest_new(self, flow_system): + da = xr.DataArray( + [25, 30, 35, 40, 45, 50], + coords=(flow_system.years_of_investment,), + ).expand_dims(year=flow_system.years) + da = da.where(da.year >= da.year_of_investment).fillna(0) + flow = fx.Flow( 'Wärme', bus='Fernwärme', size=fx.InvestTimingParameters( - year_of_investment=2020, - year_of_decommissioning=2030, - # duration_in_years=3, + force_investment=xr.DataArray( + [False if year != 2021 else True for year in flow_system.years], coords=(flow_system.years,) + ), + # year_of_decommissioning=2030, + duration_in_years=2, minimum_size=900, maximum_size=1000, - effects_of_investment_per_size=200 * 1e5, - previous_size=900, + specific_effects=xr.DataArray( + [25, 30, 35, 40, 45, 50], + coords=(flow_system.years,), + ) + * 0, + # fix_effects=-2e3, + specific_effects_by_investment_year=da, ), relative_maximum=np.linspace(0.5, 1, flow_system.timesteps.size), ) @@ -135,6 +220,11 @@ def test_flow_invest_new(self, flow_system): # calculation.model.add_constraints(calculation.model['Source(Wärme)|decrease'].isel(year=2) == 1) calculation.solve(fx.solvers.GurobiSolver(0, 60)) + calculation = fx.FullCalculation('GenericName', flow_system.sel(year=[2022, 2030])) + calculation.do_modeling() + # calculation.model.add_constraints(calculation.model['Source(Wärme)|decrease'].isel(year=2) == 1) + calculation.solve(fx.solvers.GurobiSolver(0, 60)) + ds = calculation.results['Source'].solution filtered_ds_year = ds[[v for v in ds.data_vars if ds[v].dims == ('year',)]] print(filtered_ds_year.round(0).to_pandas().T) @@ -142,4 +232,6 @@ def test_flow_invest_new(self, flow_system): filtered_ds_scalar = ds[[v for v in ds.data_vars if ds[v].dims == tuple()]] print(filtered_ds_scalar.round(0).to_pandas().T) + print(calculation.results.solution['costs(invest)|total'].to_pandas()) + print('##') From f29dcdef68acae412938735bb585310ec97f8eee Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 13 Aug 2025 17:48:48 +0200 Subject: [PATCH 306/336] Make ModelingPrimitives.consecutive_duration_tracking() dim-agnostic --- flixopt/features.py | 4 ++++ flixopt/modeling.py | 41 +++++++++++++++++++++-------------------- 2 files changed, 25 insertions(+), 20 deletions(-) diff --git a/flixopt/features.py b/flixopt/features.py index af8c42909..ffa70d528 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -479,6 +479,8 @@ def _do_modeling(self): short_name='consecutive_on_hours', minimum_duration=self.parameters.consecutive_on_hours_min, maximum_duration=self.parameters.consecutive_on_hours_max, + duration_per_step=self.hours_per_step, + duration_dim='time', previous_duration=self._get_previous_on_duration(), ) @@ -490,6 +492,8 @@ def _do_modeling(self): short_name='consecutive_off_hours', minimum_duration=self.parameters.consecutive_off_hours_min, maximum_duration=self.parameters.consecutive_off_hours_max, + duration_per_step=self.hours_per_step, + duration_dim='time', previous_duration=self._get_previous_off_duration(), ) # TODO: diff --git a/flixopt/modeling.py b/flixopt/modeling.py index f0e354bc5..a630ac33e 100644 --- a/flixopt/modeling.py +++ b/flixopt/modeling.py @@ -229,6 +229,8 @@ def consecutive_duration_tracking( short_name: str = None, minimum_duration: Optional[TemporalData] = None, maximum_duration: Optional[TemporalData] = None, + duration_dim: str = 'time', + duration_per_step: Union[Scalar, xr.DataArray] = None, previous_duration: TemporalData = 0, ) -> Tuple[linopy.Variable, Tuple[linopy.Constraint, linopy.Constraint, linopy.Constraint]]: """ @@ -236,9 +238,9 @@ def consecutive_duration_tracking( Mathematical formulation: duration[t] ≤ state[t] * M ∀t - duration[t+1] ≤ duration[t] + hours_per_step[t] ∀t - duration[t+1] ≥ duration[t] + hours_per_step[t] + (state[t+1] - 1) * M ∀t - duration[0] = (hours_per_step[0] + previous_duration) * state[0] + duration[t+1] ≤ duration[t] + duration_per_step[t] ∀t + duration[t+1] ≥ duration[t] + duration_per_step[t] + (state[t+1] - 1) * M ∀t + duration[0] = (duration_per_step[0] + previous_duration) * state[0] If minimum_duration provided: duration[t] ≥ (state[t-1] - state[t]) * minimum_duration[t-1] ∀t > 0 @@ -257,14 +259,13 @@ def consecutive_duration_tracking( if not isinstance(model, Submodel): raise ValueError('ModelingPrimitives.sum_up_variable() can only be used with a Submodel') - hours_per_step = model.hours_per_step - mega = hours_per_step.sum('time') + previous_duration # Big-M value + mega = duration_per_step.sum(duration_dim) + previous_duration # Big-M value # Duration variable duration = model.add_variables( lower=0, upper=maximum_duration if maximum_duration is not None else mega, - coords=model.get_coords(), + coords=state_variable.coords, name=name, short_name=short_name, ) @@ -274,25 +275,25 @@ def consecutive_duration_tracking( # Upper bound: duration[t] ≤ state[t] * M constraints['ub'] = model.add_constraints(duration <= state_variable * mega, name=f'{duration.name}|ub') - # Forward constraint: duration[t+1] ≤ duration[t] + hours_per_step[t] + # Forward constraint: duration[t+1] ≤ duration[t] + duration_per_step[t] constraints['forward'] = model.add_constraints( - duration.isel(time=slice(1, None)) - <= duration.isel(time=slice(None, -1)) + hours_per_step.isel(time=slice(None, -1)), + duration.isel({duration_dim: slice(1, None)}) + <= duration.isel({duration_dim: slice(None, -1)}) + duration_per_step.isel({duration_dim: slice(None, -1)}), name=f'{duration.name}|forward', ) - # Backward constraint: duration[t+1] ≥ duration[t] + hours_per_step[t] + (state[t+1] - 1) * M + # Backward constraint: duration[t+1] ≥ duration[t] + duration_per_step[t] + (state[t+1] - 1) * M constraints['backward'] = model.add_constraints( - duration.isel(time=slice(1, None)) - >= duration.isel(time=slice(None, -1)) - + hours_per_step.isel(time=slice(None, -1)) - + (state_variable.isel(time=slice(1, None)) - 1) * mega, + duration.isel({duration_dim: slice(1, None)}) + >= duration.isel({duration_dim: slice(None, -1)}) + + duration_per_step.isel({duration_dim: slice(None, -1)}) + + (state_variable.isel({duration_dim: slice(1, None)}) - 1) * mega, name=f'{duration.name}|backward', ) - # Initial condition: duration[0] = (hours_per_step[0] + previous_duration) * state[0] + # Initial condition: duration[0] = (duration_per_step[0] + previous_duration) * state[0] constraints['initial'] = model.add_constraints( - duration.isel(time=0) == (hours_per_step.isel(time=0) + previous_duration) * state_variable.isel(time=0), + duration.isel({duration_dim: 0}) == (duration_per_step.isel({duration_dim: 0}) + previous_duration) * state_variable.isel({duration_dim: 0}), name=f'{duration.name}|initial', ) @@ -300,15 +301,15 @@ def consecutive_duration_tracking( if minimum_duration is not None: constraints['lb'] = model.add_constraints( duration - >= (state_variable.isel(time=slice(None, -1)) - state_variable.isel(time=slice(1, None))) - * minimum_duration.isel(time=slice(None, -1)), + >= (state_variable.isel({duration_dim: slice(None, -1)}) - state_variable.isel({duration_dim: slice(1, None)})) + * minimum_duration.isel({duration_dim: slice(None, -1)}), name=f'{duration.name}|lb', ) # Handle initial condition for minimum duration - if previous_duration > 0 and previous_duration < minimum_duration.isel(time=0).max(): + if previous_duration > 0 and previous_duration < minimum_duration.isel({duration_dim: 0}).max(): constraints['initial_lb'] = model.add_constraints( - state_variable.isel(time=0) == 1, name=f'{duration.name}|initial_lb' + state_variable.isel({duration_dim: 0}) == 1, name=f'{duration.name}|initial_lb' ) variables = {'duration': duration} From d7574d8ad1eb2860fcd3b8d63f5ab6a1926948fd Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 13 Aug 2025 18:56:41 +0200 Subject: [PATCH 307/336] Use new lifetime variable and constraining methods --- flixopt/features.py | 72 +++++++++++++---------------------- flixopt/interface.py | 91 +++++++++++++++++++++++++++++++++----------- 2 files changed, 94 insertions(+), 69 deletions(-) diff --git a/flixopt/features.py b/flixopt/features.py index ffa70d528..02b4aa5b4 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -157,8 +157,6 @@ def _do_modeling(self): self._constraint_investment() self._constraint_decommissioning() - if self.parameters.duration_in_years is not None: - self._constraint_duration_between_investment_and_decommissioning() def _basic_modeling(self): size_min, size_max = self.parameters.minimum_or_fixed_size, self.parameters.maximum_or_fixed_size @@ -211,6 +209,27 @@ def _basic_modeling(self): short_name='decommissioning_occurs|once', ) + ######################################################################## + previous_lifetime = self.parameters.previous_lifetime if self.parameters.previous_lifetime is not None else 0 + self.add_variables( + lower=0, + upper=self.parameters.maximum_or_fixed_lifetime if self.parameters.maximum_or_fixed_lifetime is not None else self._model.flow_system.years_per_year.sum('year') + previous_lifetime, + coords=self._model.get_coords(['scenario']), + short_name='size|lifetime', + ) + self.add_constraints( + self.lifetime == (self.is_invested * self._model.flow_system.years_per_year).sum('year') + + self.is_invested.isel(year=0) * previous_lifetime, + short_name='size|lifetime', + ) + if self.parameters.minimum_or_fixed_lifetime is not None: + self.add_constraints( + self.lifetime + self.is_invested.isel(year=-1) * self.parameters.minimum_or_fixed_lifetime + >= + self.investment_occurs * self.parameters.minimum_or_fixed_lifetime, + short_name='size|lifetime|lb', + ) + ######################################################################## self.add_variables( coords=self._model.get_coords(['year', 'scenario']), @@ -307,50 +326,6 @@ def _constraint_decommissioning(self): short_name='size|changes|restricted_end', ) - def _constraint_duration_between_investment_and_decommissioning(self): - years = self._model.flow_system.years - years_of_decommissioning = years + self.parameters.duration_in_years - - # Filter and get actual selected years in one step - valid_mask = years_of_decommissioning <= years[-1] - if valid_mask.any(): - valid_years_of_investment = years[valid_mask] - valid_years_of_decommissioning = years_of_decommissioning[valid_mask] - actual_years_of_decommissioning = ( - self.investment_occurs.sel(year=valid_years_of_decommissioning, method='bfill').coords['year'].values - ) - - # Warning for mismatched years - mismatched = valid_years_of_decommissioning != actual_years_of_decommissioning - for inv_year, target_year, actual_year in zip( - valid_years_of_investment[mismatched], - valid_years_of_decommissioning[mismatched], - actual_years_of_decommissioning[mismatched], - strict=False, - ): - logger.warning( - f'year_of_decommissioning {target_year} for {self.size.name} not in flow_system years. For an investment in year {inv_year}, the year_of_decommissioning is set to {actual_year}' - ) - - group = xr.DataArray( - actual_years_of_decommissioning, # values: the actual decommissioning years - coords={'year': valid_years_of_investment}, # coordinates: investment years - dims=['year'], - name='year_of_decommissioning', - ) - - # Now you can use proper xarray groupby - grouped_increases = ( - self.investment_occurs.sel(year=valid_years_of_investment).groupby(group).sum('year_of_decommissioning') - ) - - # Create constraints - self.add_constraints( - self.decommissioning_occurs.sel(year=grouped_increases.coords['year_of_decommissioning']) - == grouped_increases, - short_name='size|changes|fixed_duration', - ) - @property def size(self) -> linopy.Variable: """Investment size variable""" @@ -393,6 +368,11 @@ def divestment_used(self) -> linopy.LinearExpression: """Binary investment decision variable""" return self.decommissioning_occurs.sum('year') + @property + def lifetime(self) -> linopy.Variable: + """Lifetime variable""" + return self._variables['size|lifetime'] + @property def duration(self) -> linopy.Variable: """Investment duration variable""" diff --git a/flixopt/interface.py b/flixopt/interface.py index 92da4bfba..f2ef2ee75 100644 --- a/flixopt/interface.py +++ b/flixopt/interface.py @@ -358,7 +358,9 @@ def __init__( allow_decommissioning: YearOfInvestmentDataBool = True, force_investment: YearOfInvestmentDataBool = False, force_decommissioning: YearOfInvestmentDataBool = False, - duration_in_years: Optional[Scalar] = None, + fixed_lifetime: Optional[Scalar] = None, + minimum_lifetime: Optional[Scalar] = None, + maximum_lifetime: Optional[Scalar] = None, minimum_size: Optional[YearOfInvestmentData] = None, maximum_size: Optional[YearOfInvestmentData] = None, fixed_size: Optional[YearOfInvestmentData] = None, @@ -366,6 +368,7 @@ def __init__( specific_effects: Optional['NonTemporalEffectsUser'] = None, # costs per Flow-Unit/Storage-Size/... fixed_effects_by_investment_year: Optional[xr.DataArray] = None, specific_effects_by_investment_year: Optional[xr.DataArray] = None, + previous_lifetime: Optional[Scalar] = None, ): """ These parameters are used to include the timing of investments in the model. @@ -380,7 +383,7 @@ def __init__( allow_decommissioning: Allow decommissioning in a certain year. By default, allow it in all years. force_investment: Force the investment to occur in a certain year. force_decommissioning: Force the decommissioning to occur in a certain year. - duration_in_years: Fix the duration between the year of investment and the year ofdecommissioning. + fixed_lifetime: Fix the lifetime of an investment (duration between investment and decommissioning). minimum_size: Minimum possible size of the investment. Can depend on the year of investment. maximum_size: Maximum possible size of the investment. Can depend on the year of investment. fixed_size: Fix the size of the investment. Can depend on the year of investment. Can still be 0 if not forced. @@ -409,7 +412,11 @@ def __init__( self.allow_decommissioning = allow_decommissioning self.force_investment = force_investment self.force_decommissioning = force_decommissioning - self.duration_in_years = duration_in_years + + self.maximum_lifetime = maximum_lifetime + self.minimum_lifetime = minimum_lifetime + self.fixed_lifetime = fixed_lifetime + self.previous_lifetime = previous_lifetime self.fix_effects: 'NonTemporalEffectsUser' = fix_effects if fix_effects is not None else {} self.specific_effects: 'NonTemporalEffectsUser' = specific_effects if specific_effects is not None else {} @@ -430,41 +437,60 @@ def _plausibility_checks(self, flow_system): if (self.force_decommissioning.sum('year') > 1).any(): raise ValueError('force_decommissioning can only be True for a single year.') + if (self.force_investment.sum('year') == 1).any() and (self.force_decommissioning.sum('year') == 1).any(): + year_of_forced_investment = ( + self.force_investment.where(self.force_investment) * self.force_investment.year + ).sum('year') + year_of_forced_decommissioning = ( + self.force_decommissioning.where(self.force_decommissioning) * self.force_decommissioning.year + ).sum('year') + if not (year_of_forced_investment < year_of_forced_decommissioning).all(): + raise ValueError( + f'force_investment needs to be before force_decommissioning. Got:\n' + f'{self.force_investment}\nand\n{self.force_decommissioning}' + ) + + if self.previous_lifetime is not None: + if self.fixed_size is None: + #TODO: Might be only a warning + raise ValueError('previous_lifetime can only be used if fixed_size is defined.') + if self.force_investment is False: + #TODO: Might be only a warning + raise ValueError('previous_lifetime can only be used if force_investment is True.') + + if self.minimum_or_fixed_lifetime is not None and self.maximum_or_fixed_lifetime is not None: + lifetime_range = self.maximum_or_fixed_lifetime - self.minimum_or_fixed_lifetime + safe_lifetime_range = flow_system.years_per_year.max().item() + if (safe_lifetime_range > lifetime_range).any(): + logger.warning( + f'Plausibility Check in {self.__class__.__name__}:\n' + f' The lifetime of the investment is tightly constrainted.' + f' The yearly resolution of the FlowSystem is up to {safe_lifetime_range} years.\n' + f' This can prevent certain years of investment or lead to infeasibilities.\n' + f' Consider using more years in your model (currently: {flow_system.years=})\n' + f' or relax the lifetime limits to span {safe_lifetime_range} years to resolve this issue.' + ) + specify_timing = ( - (self.duration_in_years is not None) + (self.fixed_lifetime is not None) + bool((self.force_investment.sum('year') > 1).any()) + bool((self.force_decommissioning.sum('year') > 1).any()) ) if specify_timing == 0: - # TODO: Should this be an exception or rather a warning? Is there a valid use case for this? - # And a mathematically valid formulation (regarding the effects especially)? - raise ValueError( + # TODO: Is there a valid use case for this? Should this be checked at all? + logger.warning( 'Either the the duration of an investment needs to be set, or the investment or decommissioning ' 'needs to be forced in one year.' ) if specify_timing == 3: - # TODO: Should this be an exception or rather a warning? Is there a valid use case for this? - # And a mathematically valid formulation (regarding the effects especially)? - raise ValueError( + # TODO: Is there a valid use case for this? Should this be checked at all? + logger.warning( 'Either the the duration of an investment needs to be set, or the investment or decommissioning ' 'needs to be forced in one year.' ) - if (self.force_investment.sum('year') >= 1).any() and (self.force_decommissioning.sum('year') >= 1).any(): - year_of_forced_investment = ( - self.force_investment.where(self.force_investment) * self.force_investment.year - ).sum('year') - year_of_forced_decommissioning = ( - self.force_decommissioning.where(self.force_decommissioning) * self.force_decommissioning.year - ).sum('year') - if not (year_of_forced_investment < year_of_forced_decommissioning).all(): - raise ValueError( - f'force_investment needs to be before force_decommissioning. Got:\n' - f'{self.force_investment}\nand\n{self.force_decommissioning}' - ) - def transform_data(self, flow_system: 'FlowSystem', name_prefix: str = '') -> None: """Transform all parameter data to match the flow system's coordinate structure.""" self.fix_effects = flow_system.fit_effects_to_model_coords( @@ -479,6 +505,15 @@ def transform_data(self, flow_system: 'FlowSystem', name_prefix: str = '') -> No label_suffix='specific_effects', dims=['year', 'scenario'], ) + self.maximum_lifetime = flow_system.fit_to_model_coords( + f'{name_prefix}|maximum_lifetime', self.maximum_lifetime, dims=['scenario'] + ) + self.minimum_lifetime = flow_system.fit_to_model_coords( + f'{name_prefix}|minimum_lifetime', self.minimum_lifetime, dims=['scenario'] + ) + self.fixed_lifetime = flow_system.fit_to_model_coords( + f'{name_prefix}|fixed_lifetime', self.fixed_lifetime, dims=['scenario'] + ) self.force_investment = flow_system.fit_to_model_coords( f'{name_prefix}|force_investment', self.force_investment, dims=['year', 'scenario'] @@ -553,6 +588,16 @@ def is_fixed_size(self) -> bool: """Check if investment size is fixed.""" return self.fixed_size is not None + @property + def minimum_or_fixed_lifetime(self) -> NonTemporalData: + """Get the effective minimum lifetime (fixed lifetime takes precedence).""" + return self.fixed_lifetime if self.fixed_lifetime is not None else self.minimum_lifetime + + @property + def maximum_or_fixed_lifetime(self) -> NonTemporalData: + """Get the effective maximum lifetime (fixed lifetime takes precedence).""" + return self.fixed_lifetime if self.fixed_lifetime is not None else self.maximum_lifetime + @register_class_for_io class OnOffParameters(Interface): From 1e619378f941a582ca39ad2b354945580fbbe4f0 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 13 Aug 2025 19:12:24 +0200 Subject: [PATCH 308/336] Improve Plausibility check --- flixopt/interface.py | 35 +++++++++++++++++++++++++---------- 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/flixopt/interface.py b/flixopt/interface.py index f2ef2ee75..99927e56f 100644 --- a/flixopt/interface.py +++ b/flixopt/interface.py @@ -452,23 +452,38 @@ def _plausibility_checks(self, flow_system): if self.previous_lifetime is not None: if self.fixed_size is None: - #TODO: Might be only a warning + # TODO: Might be only a warning raise ValueError('previous_lifetime can only be used if fixed_size is defined.') if self.force_investment is False: - #TODO: Might be only a warning + # TODO: Might be only a warning raise ValueError('previous_lifetime can only be used if force_investment is True.') if self.minimum_or_fixed_lifetime is not None and self.maximum_or_fixed_lifetime is not None: - lifetime_range = self.maximum_or_fixed_lifetime - self.minimum_or_fixed_lifetime - safe_lifetime_range = flow_system.years_per_year.max().item() - if (safe_lifetime_range > lifetime_range).any(): + years = flow_system.years.values + + infeasible_years = [] + for i, inv_year in enumerate(years[:-1]): # Exclude last year + future_years = years[i + 1 :] # All years after investment + min_decomm = self.minimum_or_fixed_lifetime + inv_year + max_decomm = self.maximum_or_fixed_lifetime + inv_year + if max_decomm >= years[-1]: + continue + + # Check if any future year falls in decommissioning window + future_years_da = xr.DataArray(future_years, dims=['year']) + valid_decomm = ((min_decomm <= future_years_da) & (future_years_da <= max_decomm)).any('year') + if not valid_decomm.all(): + infeasible_years.append(inv_year) + + if infeasible_years: logger.warning( f'Plausibility Check in {self.__class__.__name__}:\n' - f' The lifetime of the investment is tightly constrainted.' - f' The yearly resolution of the FlowSystem is up to {safe_lifetime_range} years.\n' - f' This can prevent certain years of investment or lead to infeasibilities.\n' - f' Consider using more years in your model (currently: {flow_system.years=})\n' - f' or relax the lifetime limits to span {safe_lifetime_range} years to resolve this issue.' + f' Investment years with no feasible decommissioning: {[int(year) for year in infeasible_years]}\n' + f' Consider relaxing the lifetime constraints or including more years into your model.\n' + f' Lifetime:\n' + f' min={self.minimum_or_fixed_lifetime}\n' + f' max={self.maximum_or_fixed_lifetime}\n' + f' Model years: {list(flow_system.years)}\n' ) specify_timing = ( From da3f29f6ddac4372dbecd475fa44de44580a16a1 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 13 Aug 2025 19:35:33 +0200 Subject: [PATCH 309/336] Improve InvestmentTImingParameters --- flixopt/interface.py | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/flixopt/interface.py b/flixopt/interface.py index 99927e56f..0ee8d1859 100644 --- a/flixopt/interface.py +++ b/flixopt/interface.py @@ -356,8 +356,8 @@ def __init__( self, allow_investment: YearOfInvestmentDataBool = True, allow_decommissioning: YearOfInvestmentDataBool = True, - force_investment: YearOfInvestmentDataBool = False, - force_decommissioning: YearOfInvestmentDataBool = False, + force_investment: YearOfInvestmentDataBool = False, # TODO: Allow to simply pass the year + force_decommissioning: YearOfInvestmentDataBool = False, # TODO: Allow to simply pass the year fixed_lifetime: Optional[Scalar] = None, minimum_lifetime: Optional[Scalar] = None, maximum_lifetime: Optional[Scalar] = None, @@ -492,18 +492,11 @@ def _plausibility_checks(self, flow_system): + bool((self.force_decommissioning.sum('year') > 1).any()) ) - if specify_timing == 0: + if specify_timing in (0, 3): # TODO: Is there a valid use case for this? Should this be checked at all? logger.warning( - 'Either the the duration of an investment needs to be set, or the investment or decommissioning ' - 'needs to be forced in one year.' - ) - - if specify_timing == 3: - # TODO: Is there a valid use case for this? Should this be checked at all? - logger.warning( - 'Either the the duration of an investment needs to be set, or the investment or decommissioning ' - 'needs to be forced in one year.' + 'Either the the lifetime of an investment should be fixed, or the investment or decommissioning ' + 'needs to be forced in a certain year.' ) def transform_data(self, flow_system: 'FlowSystem', name_prefix: str = '') -> None: From 331ed06728744ff4063728d8373faed8deb0faad Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 13 Aug 2025 19:59:39 +0200 Subject: [PATCH 310/336] Improve weights --- flixopt/flow_system.py | 2 +- flixopt/structure.py | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index 0acee1d55..7177cbe62 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -76,7 +76,7 @@ def __init__( If None, the first time increment of time_series is used. This is needed to calculate previous durations (for example consecutive_on_hours). If you use an array, take care that its long enough to cover all previous values! - weights: The weights of each year and scenario. If None, all have the same weight (normalized to 1). Its recommended to scale the weights to sum up to 1. + weights: The weights of each year and scenario. If None, all scenarios have the same weight, while the years have the weight of their represented year (all normalized to 1). Its recommended to scale the weights to sum up to 1. """ self.timesteps = self._validate_timesteps(timesteps) self.timesteps_extra = self._create_timesteps_with_extra(timesteps, hours_of_last_timestep) diff --git a/flixopt/structure.py b/flixopt/structure.py index dec476ac7..413c6d513 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -178,7 +178,9 @@ def get_coords( def weights(self) -> Union[int, xr.DataArray]: """Returns the scenario weights of the FlowSystem. If None, return weights that are normalized to 1 (one)""" if self.flow_system.weights is None: - weights = self.flow_system.fit_to_model_coords('weights', 1, dims=['year', 'scenario']) + weights = self.flow_system.fit_to_model_coords( + 'weights', self.flow_system.years_per_year, dims=['year', 'scenario'] + ) return weights / weights.sum() From 16c74812346e3bf13ebddd4ea9d4239887fc0ff3 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 13 Aug 2025 19:59:48 +0200 Subject: [PATCH 311/336] Adjust test --- tests/test_models.py | 30 ++++++++++++------------------ 1 file changed, 12 insertions(+), 18 deletions(-) diff --git a/tests/test_models.py b/tests/test_models.py index 9e2cce36a..7545371b5 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -93,7 +93,7 @@ def create_annualized_effects( @pytest.fixture def flow_system() -> fx.FlowSystem: """Create basic elements for component testing with coordinate parametrization.""" - years = pd.Index([2020, 2021, 2022, 2023, 2024, 2030], name='year') + years = pd.Index([2020, 2021, 2022, 2023, 2024, 2026, 2028, 2030], name='year') timesteps = pd.date_range('2020-01-01', periods=24, freq='h', name='time') flow_system = fx.FlowSystem(timesteps=timesteps, years=years) @@ -106,6 +106,7 @@ def flow_system() -> fx.FlowSystem: electricity_sink = Sinks.electricity_feed_in(p_el) flow_system.add_elements(*Buses.defaults()) + flow_system.buses['Fernwärme'].excess_penalty_per_flow_hour = 0 flow_system.add_elements(costs, heat_load, gas_source, electricity_sink) return flow_system @@ -187,29 +188,27 @@ class TestYearAwareInvestmentModelDirect: def test_flow_invest_new(self, flow_system): da = xr.DataArray( - [25, 30, 35, 40, 45, 50], + [10] * 8, coords=(flow_system.years_of_investment,), ).expand_dims(year=flow_system.years) - da = da.where(da.year >= da.year_of_investment).fillna(0) + da = da.where(da.year == da.year_of_investment).fillna(0) flow = fx.Flow( 'Wärme', bus='Fernwärme', size=fx.InvestTimingParameters( - force_investment=xr.DataArray( - [False if year != 2021 else True for year in flow_system.years], coords=(flow_system.years,) - ), # year_of_decommissioning=2030, - duration_in_years=2, - minimum_size=900, - maximum_size=1000, + minimum_lifetime=2, + maximum_lifetime=3, + minimum_size=9, + maximum_size=10, specific_effects=xr.DataArray( - [25, 30, 35, 40, 45, 50], + [25, 30, 35, 40, 45, 50, 55, 60], coords=(flow_system.years,), ) - * 0, + * -0, # fix_effects=-2e3, - specific_effects_by_investment_year=da, + specific_effects_by_investment_year=-1 * da, ), relative_maximum=np.linspace(0.5, 1, flow_system.timesteps.size), ) @@ -217,12 +216,7 @@ def test_flow_invest_new(self, flow_system): flow_system.add_elements(fx.Source('Source', source=flow)) calculation = fx.FullCalculation('GenericName', flow_system) calculation.do_modeling() - # calculation.model.add_constraints(calculation.model['Source(Wärme)|decrease'].isel(year=2) == 1) - calculation.solve(fx.solvers.GurobiSolver(0, 60)) - - calculation = fx.FullCalculation('GenericName', flow_system.sel(year=[2022, 2030])) - calculation.do_modeling() - # calculation.model.add_constraints(calculation.model['Source(Wärme)|decrease'].isel(year=2) == 1) + # calculation.model.add_constraints(calculation.model['Source(Wärme)|is_invested'].sel(year=2022) == 1) calculation.solve(fx.solvers.GurobiSolver(0, 60)) ds = calculation.results['Source'].solution From e9e5e049b79c98defd5053cebb581ff7f36a72a3 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 13 Aug 2025 20:04:18 +0200 Subject: [PATCH 312/336] Remove old classes --- flixopt/interface.py | 94 +------------------------------------------- 1 file changed, 1 insertion(+), 93 deletions(-) diff --git a/flixopt/interface.py b/flixopt/interface.py index 0ee8d1859..736e82f08 100644 --- a/flixopt/interface.py +++ b/flixopt/interface.py @@ -245,98 +245,6 @@ def maximum_or_fixed_size(self) -> NonTemporalData: return self.fixed_size if self.fixed_size is not None else self.maximum_size -# Base interface for common parameters -@register_class_for_io -class _BaseYearAwareInvestParameters(Interface): - """ - Base parameters for year-aware investment modeling. - Contains common sizing and effects parameters used by all variants. - """ - - def __init__( - self, - # Basic sizing parameters - minimum_size: Optional[Scalar] = None, - maximum_size: Optional[Scalar] = None, - fixed_size: Optional[Scalar] = None, - optional: bool = False, - # Direct effects - effects_of_investment_per_size: Optional['NonTemporalEffectsUser'] = None, - effects_of_investment: Optional['NonTemporalEffectsUser'] = None, - ): - """ - Initialize base year-aware investment parameters. - - Args: - minimum_size: Minimum investment size when invested. Defaults to CONFIG.modeling.EPSILON. - maximum_size: Maximum possible investment size. Defaults to CONFIG.modeling.BIG. - fixed_size: If specified, investment size is fixed to this value. - effects_of_investment_per_size: Effects applied per unit of investment size for each year invested. - Example: {'costs': 100} applies 100 * size * years_invested to total costs. - effects_of_investment: One-time effects applied when investment decision is made. - Example: {'costs': 1000} applies 1000 to costs in the investment year. - """ - self.minimum_size = minimum_size if minimum_size is not None else CONFIG.modeling.EPSILON - self.maximum_size = maximum_size if maximum_size is not None else CONFIG.modeling.BIG - self.fixed_size = fixed_size - self.optional = optional - - self.effects_of_investment_per_size: 'NonTemporalEffectsUser' = ( - effects_of_investment_per_size if effects_of_investment_per_size is not None else {} - ) - self.effects_of_investment: 'NonTemporalEffectsUser' = ( - effects_of_investment if effects_of_investment is not None else {} - ) - - def transform_data(self, flow_system: 'FlowSystem', name_prefix: str = '') -> None: - """Transform all parameter data to match the flow system's coordinate structure.""" - self._plausibility_checks(flow_system) - - self.effects_of_investment_per_size = flow_system.fit_effects_to_model_coords( - label_prefix=name_prefix, - effect_values=self.effects_of_investment_per_size, - label_suffix='effects_of_investment_per_size', - dims=['year', 'scenario'], - ) - self.effects_of_investment = flow_system.fit_effects_to_model_coords( - label_prefix=name_prefix, - effect_values=self.effects_of_investment, - label_suffix='effects_of_investment', - dims=['year', 'scenario'], - ) - - self.minimum_size = flow_system.fit_to_model_coords( - f'{name_prefix}|minimum_size', self.minimum_size, dims=['year', 'scenario'] - ) - self.maximum_size = flow_system.fit_to_model_coords( - f'{name_prefix}|maximum_size', self.maximum_size, dims=['year', 'scenario'] - ) - if self.fixed_size is not None: - self.fixed_size = flow_system.fit_to_model_coords( - f'{name_prefix}|fixed_size', self.fixed_size, dims=['year', 'scenario'] - ) - - def _plausibility_checks(self, flow_system): - """Validate parameter consistency and compatibility with the flow system.""" - if flow_system.years is None: - raise ValueError("YearAwareInvestParameters requires the flow_system to have a 'years' dimension.") - - @property - def minimum_or_fixed_size(self) -> NonTemporalData: - """Get the effective minimum size (fixed size takes precedence).""" - return self.fixed_size if self.fixed_size is not None else self.minimum_size - - @property - def maximum_or_fixed_size(self) -> NonTemporalData: - """Get the effective maximum size (fixed size takes precedence).""" - return self.fixed_size if self.fixed_size is not None else self.maximum_size - - @property - def is_fixed_size(self) -> bool: - """Check if investment size is fixed.""" - return self.fixed_size is not None - - YearOfInvestmentData = NonTemporalDataUser """This datatype is used to define things related to the year of investment.""" YearOfInvestmentDataBool = Union[bool, YearOfInvestmentData] @@ -430,7 +338,7 @@ def __init__( def _plausibility_checks(self, flow_system): """Validate parameter consistency.""" if flow_system.years is None: - raise ValueError("YearAwareInvestParameters requires the flow_system to have a 'years' dimension.") + raise ValueError("InvestTimingParameters requires the flow_system to have a 'years' dimension.") if (self.force_investment.sum('year') > 1).any(): raise ValueError('force_investment can only be True for a single year.') From 1474af6ecd88aa987bdff6ed35afae35ee6ca590 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 13 Aug 2025 20:10:19 +0200 Subject: [PATCH 313/336] V3.0.0/main fit to model coords improve (#295) * Change fit_to_model_coords to work with a Collection of dims * Improve fit_to_model_coords --- flixopt/components.py | 12 ++++++------ flixopt/effects.py | 17 ++++++++++------- flixopt/elements.py | 10 +++++----- flixopt/flow_system.py | 27 +++++++++++++++++++++------ flixopt/interface.py | 23 ++++++++++++----------- flixopt/structure.py | 2 +- 6 files changed, 55 insertions(+), 36 deletions(-) diff --git a/flixopt/components.py b/flixopt/components.py index d483ee28c..933ef1791 100644 --- a/flixopt/components.py +++ b/flixopt/components.py @@ -223,29 +223,29 @@ def transform_data(self, flow_system: 'FlowSystem') -> None: ) if not isinstance(self.initial_charge_state, str): self.initial_charge_state = flow_system.fit_to_model_coords( - f'{self.label_full}|initial_charge_state', self.initial_charge_state, has_time_dim=False + f'{self.label_full}|initial_charge_state', self.initial_charge_state, dims=['year', 'scenario'] ) self.minimal_final_charge_state = flow_system.fit_to_model_coords( - f'{self.label_full}|minimal_final_charge_state', self.minimal_final_charge_state, has_time_dim=False + f'{self.label_full}|minimal_final_charge_state', self.minimal_final_charge_state, dims=['year', 'scenario'] ) self.maximal_final_charge_state = flow_system.fit_to_model_coords( - f'{self.label_full}|maximal_final_charge_state', self.maximal_final_charge_state, has_time_dim=False + f'{self.label_full}|maximal_final_charge_state', self.maximal_final_charge_state, dims=['year', 'scenario'] ) self.relative_minimum_final_charge_state = flow_system.fit_to_model_coords( f'{self.label_full}|relative_minimum_final_charge_state', self.relative_minimum_final_charge_state, - has_time_dim=False, + dims=['year', 'scenario'], ) self.relative_maximum_final_charge_state = flow_system.fit_to_model_coords( f'{self.label_full}|relative_maximum_final_charge_state', self.relative_maximum_final_charge_state, - has_time_dim=False, + dims=['year', 'scenario'], ) if isinstance(self.capacity_in_flow_hours, InvestParameters): self.capacity_in_flow_hours.transform_data(flow_system, f'{self.label_full}|InvestParameters') else: self.capacity_in_flow_hours = flow_system.fit_to_model_coords( - f'{self.label_full}|capacity_in_flow_hours', self.capacity_in_flow_hours, has_time_dim=False + f'{self.label_full}|capacity_in_flow_hours', self.capacity_in_flow_hours, dims=['year', 'scenario'] ) def _plausibility_checks(self) -> None: diff --git a/flixopt/effects.py b/flixopt/effects.py index 79a44e67a..d0b552bf8 100644 --- a/flixopt/effects.py +++ b/flixopt/effects.py @@ -105,27 +105,30 @@ def transform_data(self, flow_system: 'FlowSystem'): ) self.minimum_operation = flow_system.fit_to_model_coords( - f'{self.label_full}|minimum_operation', self.minimum_operation, has_time_dim=False + f'{self.label_full}|minimum_operation', self.minimum_operation, dims=['year', 'scenario'] ) self.maximum_operation = flow_system.fit_to_model_coords( - f'{self.label_full}|maximum_operation', self.maximum_operation, has_time_dim=False + f'{self.label_full}|maximum_operation', self.maximum_operation, dims=['year', 'scenario'] ) self.minimum_invest = flow_system.fit_to_model_coords( - f'{self.label_full}|minimum_invest', self.minimum_invest, has_time_dim=False + f'{self.label_full}|minimum_invest', self.minimum_invest, dims=['year', 'scenario'] ) self.maximum_invest = flow_system.fit_to_model_coords( - f'{self.label_full}|maximum_invest', self.maximum_invest, has_time_dim=False + f'{self.label_full}|maximum_invest', self.maximum_invest, dims=['year', 'scenario'] ) self.minimum_total = flow_system.fit_to_model_coords( f'{self.label_full}|minimum_total', self.minimum_total, - has_time_dim=False, + dims=['year', 'scenario'], ) self.maximum_total = flow_system.fit_to_model_coords( - f'{self.label_full}|maximum_total', self.maximum_total, has_time_dim=False + f'{self.label_full}|maximum_total', self.maximum_total, dims=['year', 'scenario'] ) self.specific_share_to_other_effects_invest = flow_system.fit_effects_to_model_coords( - f'{self.label_full}|invest->', self.specific_share_to_other_effects_invest, 'invest', has_time_dim=False + f'{self.label_full}|invest->', + self.specific_share_to_other_effects_invest, + 'invest', + dims=['year', 'scenario'], ) def create_model(self, model: FlowSystemModel) -> 'EffectModel': diff --git a/flixopt/elements.py b/flixopt/elements.py index e1c0fcbc3..160dac660 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -252,16 +252,16 @@ def transform_data(self, flow_system: 'FlowSystem'): self.label_full, self.effects_per_flow_hour, 'per_flow_hour' ) self.flow_hours_total_max = flow_system.fit_to_model_coords( - f'{self.label_full}|flow_hours_total_max', self.flow_hours_total_max, has_time_dim=False + f'{self.label_full}|flow_hours_total_max', self.flow_hours_total_max, dims=['year', 'scenario'] ) self.flow_hours_total_min = flow_system.fit_to_model_coords( - f'{self.label_full}|flow_hours_total_min', self.flow_hours_total_min, has_time_dim=False + f'{self.label_full}|flow_hours_total_min', self.flow_hours_total_min, dims=['year', 'scenario'] ) self.load_factor_max = flow_system.fit_to_model_coords( - f'{self.label_full}|load_factor_max', self.load_factor_max, has_time_dim=False + f'{self.label_full}|load_factor_max', self.load_factor_max, dims=['year', 'scenario'] ) self.load_factor_min = flow_system.fit_to_model_coords( - f'{self.label_full}|load_factor_min', self.load_factor_min, has_time_dim=False + f'{self.label_full}|load_factor_min', self.load_factor_min, dims=['year', 'scenario'] ) if self.on_off_parameters is not None: @@ -269,7 +269,7 @@ def transform_data(self, flow_system: 'FlowSystem'): if isinstance(self.size, InvestParameters): self.size.transform_data(flow_system, self.label_full) else: - self.size = flow_system.fit_to_model_coords(f'{self.label_full}|size', self.size, has_time_dim=False) + self.size = flow_system.fit_to_model_coords(f'{self.label_full}|size', self.size, dims=['year', 'scenario']) def _plausibility_checks(self) -> None: # TODO: Incorporate into Variable? (Lower_bound can not be greater than upper bound diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index dd202114e..e8ddc22b9 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -7,7 +7,7 @@ import pathlib import warnings from io import StringIO -from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Tuple, Union +from typing import TYPE_CHECKING, Any, Collection, Dict, List, Literal, Optional, Tuple, Union import numpy as np import pandas as pd @@ -349,6 +349,7 @@ def fit_to_model_coords( name: str, data: Optional[Union[TemporalDataUser, NonTemporalDataUser]], has_time_dim: bool = True, + dims: Optional[Collection[FlowSystemDimensions]] = None, ) -> Optional[Union[TemporalData, NonTemporalData]]: """ Fit data to model coordinate system (currently time, but extensible). @@ -357,6 +358,7 @@ def fit_to_model_coords( name: Name of the data data: Data to fit to model coordinates has_time_dim: Wether to use the time dimension or not + dims: Collection of dimension names to use for fitting. If None, all dimensions are used. Returns: xr.DataArray aligned to model coordinate system. If data is None, returns None. @@ -364,10 +366,19 @@ def fit_to_model_coords( if data is None: return None - coords = self.coords + if dims is None: + coords = self.coords - if not has_time_dim: - coords.pop('time') + if not has_time_dim: + warnings.warn( + 'has_time_dim is deprecated. Please pass dims to fit_to_model_coords instead.', + DeprecationWarning, + stacklevel=2, + ) + coords.pop('time') + else: + coords = self.coords + coords = {k: coords[k] for k in dims if k in coords} # Rest of your method stays the same, just pass coords if isinstance(data, TimeSeriesData): @@ -390,6 +401,7 @@ def fit_effects_to_model_coords( effect_values: Optional[Union[TemporalEffectsUser, NonTemporalEffectsUser]], label_suffix: Optional[str] = None, has_time_dim: bool = True, + dims: Optional[Collection[FlowSystemDimensions]] = None, ) -> Optional[Union[TemporalEffects, NonTemporalEffects]]: """ Transform EffectValues from the user to Internal Datatypes aligned with model coordinates. @@ -401,7 +413,10 @@ def fit_effects_to_model_coords( return { effect: self.fit_to_model_coords( - '|'.join(filter(None, [label_prefix, effect, label_suffix])), value, has_time_dim=has_time_dim + '|'.join(filter(None, [label_prefix, effect, label_suffix])), + value, + has_time_dim=has_time_dim, + dims=dims, ) for effect, value in effect_values_dict.items() } @@ -412,7 +427,7 @@ def connect_and_transform(self): logger.debug('FlowSystem already connected and transformed') return - self.weights = self.fit_to_model_coords('weights', self.weights, has_time_dim=False) + self.weights = self.fit_to_model_coords('weights', self.weights, dims=['year', 'scenario']) if self.weights is not None and self.weights.sum() != 1: logger.warning( f'Scenario weights are not normalized to 1. This is recomended for a better scaled model. ' diff --git a/flixopt/interface.py b/flixopt/interface.py index cae1757c7..7cb9604ac 100644 --- a/flixopt/interface.py +++ b/flixopt/interface.py @@ -33,8 +33,9 @@ def __init__(self, start: TemporalDataUser, end: TemporalDataUser): self.has_time_dim = False def transform_data(self, flow_system: 'FlowSystem', name_prefix: str): - self.start = flow_system.fit_to_model_coords(f'{name_prefix}|start', self.start, has_time_dim=self.has_time_dim) - self.end = flow_system.fit_to_model_coords(f'{name_prefix}|end', self.end, has_time_dim=self.has_time_dim) + dims = None if self.has_time_dim else ['year', 'scenario'] + self.start = flow_system.fit_to_model_coords(f'{name_prefix}|start', self.start, dims=dims) + self.end = flow_system.fit_to_model_coords(f'{name_prefix}|end', self.end, dims=dims) @register_class_for_io @@ -189,33 +190,33 @@ def transform_data(self, flow_system: 'FlowSystem', name_prefix: str): label_prefix=name_prefix, effect_values=self.fix_effects, label_suffix='fix_effects', - has_time_dim=False, + dims=['year', 'scenario'], ) self.divest_effects = flow_system.fit_effects_to_model_coords( label_prefix=name_prefix, effect_values=self.divest_effects, label_suffix='divest_effects', - has_time_dim=False, + dims=['year', 'scenario'], ) self.specific_effects = flow_system.fit_effects_to_model_coords( label_prefix=name_prefix, effect_values=self.specific_effects, label_suffix='specific_effects', - has_time_dim=False, + dims=['year', 'scenario'], ) if self.piecewise_effects is not None: self.piecewise_effects.has_time_dim = False self.piecewise_effects.transform_data(flow_system, f'{name_prefix}|PiecewiseEffects') self.minimum_size = flow_system.fit_to_model_coords( - f'{name_prefix}|minimum_size', self.minimum_size, has_time_dim=False + f'{name_prefix}|minimum_size', self.minimum_size, dims=['year', 'scenario'] ) self.maximum_size = flow_system.fit_to_model_coords( - f'{name_prefix}|maximum_size', self.maximum_size, has_time_dim=False + f'{name_prefix}|maximum_size', self.maximum_size, dims=['year', 'scenario'] ) if self.fixed_size is not None: self.fixed_size = flow_system.fit_to_model_coords( - f'{name_prefix}|fixed_size', self.fixed_size, has_time_dim=False + f'{name_prefix}|fixed_size', self.fixed_size, dims=['year', 'scenario'] ) def _plausibility_checks(self, flow_system): @@ -312,13 +313,13 @@ def transform_data(self, flow_system: 'FlowSystem', name_prefix: str): f'{name_prefix}|consecutive_off_hours_max', self.consecutive_off_hours_max ) self.on_hours_total_max = flow_system.fit_to_model_coords( - f'{name_prefix}|on_hours_total_max', self.on_hours_total_max, has_time_dim=False + f'{name_prefix}|on_hours_total_max', self.on_hours_total_max, dims=['year', 'scenario'] ) self.on_hours_total_min = flow_system.fit_to_model_coords( - f'{name_prefix}|on_hours_total_min', self.on_hours_total_min, has_time_dim=False + f'{name_prefix}|on_hours_total_min', self.on_hours_total_min, dims=['year', 'scenario'] ) self.switch_on_total_max = flow_system.fit_to_model_coords( - f'{name_prefix}|switch_on_total_max', self.switch_on_total_max, has_time_dim=False + f'{name_prefix}|switch_on_total_max', self.switch_on_total_max, dims=['year', 'scenario'] ) @property diff --git a/flixopt/structure.py b/flixopt/structure.py index da67f9620..24934547b 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -178,7 +178,7 @@ def get_coords( def weights(self) -> Union[int, xr.DataArray]: """Returns the scenario weights of the FlowSystem. If None, return weights that are normalized to 1 (one)""" if self.flow_system.weights is None: - weights = self.flow_system.fit_to_model_coords('weights', 1, has_time_dim=False) + weights = self.flow_system.fit_to_model_coords('weights', 1, dims=['year', 'scenario']) return weights / weights.sum() From 3f03d22985578eb0774bef5efb7e86eb004a15fd Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 2 Sep 2025 10:20:18 +0200 Subject: [PATCH 314/336] ruff format --- flixopt/features.py | 10 ++++++---- flixopt/modeling.py | 8 ++++++-- flixopt/structure.py | 2 +- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/flixopt/features.py b/flixopt/features.py index 02b4aa5b4..d2af15aff 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -213,20 +213,22 @@ def _basic_modeling(self): previous_lifetime = self.parameters.previous_lifetime if self.parameters.previous_lifetime is not None else 0 self.add_variables( lower=0, - upper=self.parameters.maximum_or_fixed_lifetime if self.parameters.maximum_or_fixed_lifetime is not None else self._model.flow_system.years_per_year.sum('year') + previous_lifetime, + upper=self.parameters.maximum_or_fixed_lifetime + if self.parameters.maximum_or_fixed_lifetime is not None + else self._model.flow_system.years_per_year.sum('year') + previous_lifetime, coords=self._model.get_coords(['scenario']), short_name='size|lifetime', ) self.add_constraints( - self.lifetime == (self.is_invested * self._model.flow_system.years_per_year).sum('year') + self.lifetime + == (self.is_invested * self._model.flow_system.years_per_year).sum('year') + self.is_invested.isel(year=0) * previous_lifetime, short_name='size|lifetime', ) if self.parameters.minimum_or_fixed_lifetime is not None: self.add_constraints( self.lifetime + self.is_invested.isel(year=-1) * self.parameters.minimum_or_fixed_lifetime - >= - self.investment_occurs * self.parameters.minimum_or_fixed_lifetime, + >= self.investment_occurs * self.parameters.minimum_or_fixed_lifetime, short_name='size|lifetime|lb', ) diff --git a/flixopt/modeling.py b/flixopt/modeling.py index a630ac33e..c5205b07f 100644 --- a/flixopt/modeling.py +++ b/flixopt/modeling.py @@ -293,7 +293,8 @@ def consecutive_duration_tracking( # Initial condition: duration[0] = (duration_per_step[0] + previous_duration) * state[0] constraints['initial'] = model.add_constraints( - duration.isel({duration_dim: 0}) == (duration_per_step.isel({duration_dim: 0}) + previous_duration) * state_variable.isel({duration_dim: 0}), + duration.isel({duration_dim: 0}) + == (duration_per_step.isel({duration_dim: 0}) + previous_duration) * state_variable.isel({duration_dim: 0}), name=f'{duration.name}|initial', ) @@ -301,7 +302,10 @@ def consecutive_duration_tracking( if minimum_duration is not None: constraints['lb'] = model.add_constraints( duration - >= (state_variable.isel({duration_dim: slice(None, -1)}) - state_variable.isel({duration_dim: slice(1, None)})) + >= ( + state_variable.isel({duration_dim: slice(None, -1)}) + - state_variable.isel({duration_dim: slice(1, None)}) + ) * minimum_duration.isel({duration_dim: slice(None, -1)}), name=f'{duration.name}|lb', ) diff --git a/flixopt/structure.py b/flixopt/structure.py index 6371d634c..9fdc9206f 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -181,7 +181,7 @@ def weights(self) -> Union[int, xr.DataArray]: weights = self.flow_system.fit_to_model_coords( 'weights', 1 if self.flow_system.years is None else self.flow_system.years_per_year, - dims=['year', 'scenario'] + dims=['year', 'scenario'], ) return weights / weights.sum() From 42e4f59878a81ca27034bfa845c2f9c27229548a Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 23 Sep 2025 11:37:58 +0200 Subject: [PATCH 315/336] Revert changes --- flixopt/components.py | 6 +++--- flixopt/effects.py | 2 +- flixopt/elements.py | 6 +++--- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/flixopt/components.py b/flixopt/components.py index 14274a191..98624cfe7 100644 --- a/flixopt/components.py +++ b/flixopt/components.py @@ -204,7 +204,7 @@ def _plausibility_checks(self) -> None: f'(in {self.label_full}) and variable size is uncommon. Please check if this is intended!' ) - def transform_data(self, flow_system: FlowSystem) -> None: + def transform_data(self, flow_system: FlowSystem, name_prefix: str = '') -> None: super().transform_data(flow_system) if self.conversion_factors: self.conversion_factors = self._transform_conversion_factors(flow_system) @@ -420,7 +420,7 @@ def create_model(self, model: FlowSystemModel) -> StorageModel: self.submodel = StorageModel(model, self) return self.submodel - def transform_data(self, flow_system: FlowSystem) -> None: + def transform_data(self, flow_system: FlowSystem, name_prefix: str = '') -> None: super().transform_data(flow_system) self.relative_minimum_charge_state = flow_system.fit_to_model_coords( f'{self.label_full}|relative_minimum_charge_state', @@ -691,7 +691,7 @@ def create_model(self, model) -> TransmissionModel: self.submodel = TransmissionModel(model, self) return self.submodel - def transform_data(self, flow_system: FlowSystem) -> None: + def transform_data(self, flow_system: FlowSystem, name_prefix: str = '') -> None: super().transform_data(flow_system) self.relative_losses = flow_system.fit_to_model_coords( f'{self.label_full}|relative_losses', self.relative_losses diff --git a/flixopt/effects.py b/flixopt/effects.py index a7920b1ec..3390bd463 100644 --- a/flixopt/effects.py +++ b/flixopt/effects.py @@ -174,7 +174,7 @@ def __init__( self.minimum_total = minimum_total self.maximum_total = maximum_total - def transform_data(self, flow_system: FlowSystem): + def transform_data(self, flow_system: FlowSystem, name_prefix: str = '') -> None: self.minimum_operation_per_hour = flow_system.fit_to_model_coords( f'{self.label_full}|minimum_operation_per_hour', self.minimum_operation_per_hour ) diff --git a/flixopt/elements.py b/flixopt/elements.py index ee6005d36..89be7e3d9 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -97,7 +97,7 @@ def create_model(self, model: FlowSystemModel) -> ComponentModel: self.submodel = ComponentModel(model, self) return self.submodel - def transform_data(self, flow_system: FlowSystem) -> None: + def transform_data(self, flow_system: FlowSystem, name_prefix: str = '') -> None: if self.on_off_parameters is not None: self.on_off_parameters.transform_data(flow_system, self.label_full) @@ -189,7 +189,7 @@ def create_model(self, model: FlowSystemModel) -> BusModel: self.submodel = BusModel(model, self) return self.submodel - def transform_data(self, flow_system: FlowSystem): + def transform_data(self, flow_system: FlowSystem, name_prefix: str = '') -> None: self.excess_penalty_per_flow_hour = flow_system.fit_to_model_coords( f'{self.label_full}|excess_penalty_per_flow_hour', self.excess_penalty_per_flow_hour ) @@ -417,7 +417,7 @@ def create_model(self, model: FlowSystemModel) -> FlowModel: self.submodel = FlowModel(model, self) return self.submodel - def transform_data(self, flow_system: FlowSystem): + def transform_data(self, flow_system: FlowSystem, name_prefix: str = '') -> None: self.relative_minimum = flow_system.fit_to_model_coords( f'{self.label_full}|relative_minimum', self.relative_minimum ) From e1dcfb8b34dfc3ee6157bc537e3f66ac06fb622c Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 23 Sep 2025 11:39:23 +0200 Subject: [PATCH 316/336] Update type hints --- tests/test_models.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_models.py b/tests/test_models.py index 7545371b5..3fc38c1f1 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -43,8 +43,8 @@ def calculate_annual_payment(total_cost: float, remaining_years: int, discount_r def create_annualized_effects( - year_of_investments: Union[range, list, pd.Index], - all_years: Union[range, list, pd.Index], + year_of_investments: range | list | pd.Index, + all_years: range | list | pd.Index, total_cost: float, discount_rate: float, horizon_end: int, From 948f19c725de612ad3ad1658a010a0fd7be0350d Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 23 Sep 2025 11:50:40 +0200 Subject: [PATCH 317/336] Revert changes introduced by new Multiperiod Invest parameters --- flixopt/__init__.py | 1 - flixopt/calculation.py | 8 +- flixopt/commons.py | 11 +- flixopt/elements.py | 43 +++---- flixopt/features.py | 253 +------------------------------------- flixopt/flow_system.py | 23 ---- flixopt/interface.py | 273 +---------------------------------------- 7 files changed, 21 insertions(+), 591 deletions(-) diff --git a/flixopt/__init__.py b/flixopt/__init__.py index aa2bd7129..34306ae32 100644 --- a/flixopt/__init__.py +++ b/flixopt/__init__.py @@ -16,7 +16,6 @@ FlowSystem, FullCalculation, InvestParameters, - InvestTimingParameters, LinearConverter, OnOffParameters, Piece, diff --git a/flixopt/calculation.py b/flixopt/calculation.py index b2e851ef2..dc525e147 100644 --- a/flixopt/calculation.py +++ b/flixopt/calculation.py @@ -27,7 +27,7 @@ from .components import Storage from .config import CONFIG from .core import DataConverter, Scalar, TimeSeriesData, drop_constant_arrays -from .features import InvestmentModel, InvestmentTimingModel +from .features import InvestmentModel from .flow_system import FlowSystem from .results import CalculationResults, SegmentedCalculationResults @@ -118,15 +118,13 @@ def main_results(self) -> dict[str, Scalar | dict]: model.label_of_element: model.size.solution for component in self.flow_system.components.values() for model in component.submodel.all_submodels - if isinstance(model, (InvestmentModel, InvestmentTimingModel)) - and model.size.solution.max() >= CONFIG.modeling.EPSILON + if isinstance(model, InvestmentModel) and model.size.solution.max() >= CONFIG.modeling.EPSILON }, 'Not invested': { model.label_of_element: model.size.solution for component in self.flow_system.components.values() for model in component.submodel.all_submodels - if isinstance(model, (InvestmentModel, InvestmentTimingModel)) - and model.size.solution.max() < CONFIG.modeling.EPSILON + if isinstance(model, InvestmentModel) and model.size.solution.max() < CONFIG.modeling.EPSILON }, }, 'Buses with excess': [ diff --git a/flixopt/commons.py b/flixopt/commons.py index 68461f10e..68412d6fe 100644 --- a/flixopt/commons.py +++ b/flixopt/commons.py @@ -18,15 +18,7 @@ from .effects import Effect from .elements import Bus, Flow from .flow_system import FlowSystem -from .interface import ( - InvestParameters, - InvestTimingParameters, - OnOffParameters, - Piece, - Piecewise, - PiecewiseConversion, - PiecewiseEffects, -) +from .interface import InvestParameters, OnOffParameters, Piece, Piecewise, PiecewiseConversion, PiecewiseEffects __all__ = [ 'TimeSeriesData', @@ -56,5 +48,4 @@ 'results', 'linear_converters', 'solvers', - 'InvestTimingParameters', ] diff --git a/flixopt/elements.py b/flixopt/elements.py index 89be7e3d9..f3598912c 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -13,8 +13,8 @@ from .config import CONFIG from .core import PlausibilityError, Scalar, TemporalData, TemporalDataUser -from .features import InvestmentModel, InvestmentTimingModel, ModelingPrimitives, OnOffModel -from .interface import InvestParameters, InvestTimingParameters, OnOffParameters +from .features import InvestmentModel, ModelingPrimitives, OnOffModel +from .interface import InvestParameters, OnOffParameters from .modeling import BoundingPatterns, ModelingUtilitiesAbstract from .structure import Element, ElementModel, FlowSystemModel, register_class_for_io @@ -368,7 +368,7 @@ def __init__( self, label: str, bus: str, - size: Scalar | InvestParameters | InvestTimingParameters = None, + size: Scalar | InvestParameters = None, fixed_relative_profile: TemporalDataUser | None = None, relative_minimum: TemporalDataUser = 0, relative_maximum: TemporalDataUser = 1, @@ -445,7 +445,7 @@ def transform_data(self, flow_system: FlowSystem, name_prefix: str = '') -> None if self.on_off_parameters is not None: self.on_off_parameters.transform_data(flow_system, self.label_full) - if isinstance(self.size, (InvestParameters, InvestTimingParameters)): + if isinstance(self.size, InvestParameters): self.size.transform_data(flow_system, self.label_full) else: self.size = flow_system.fit_to_model_coords(f'{self.label_full}|size', self.size, dims=['year', 'scenario']) @@ -455,7 +455,7 @@ def _plausibility_checks(self) -> None: if np.any(self.relative_minimum > self.relative_maximum): raise PlausibilityError(self.label_full + ': Take care, that relative_minimum <= relative_maximum!') - if not isinstance(self.size, (InvestParameters, InvestTimingParameters)) and ( + if not isinstance(self.size, InvestParameters) and ( np.any(self.size == CONFIG.modeling.BIG) and self.fixed_relative_profile is not None ): # Default Size --> Most likely by accident logger.warning( @@ -556,28 +556,15 @@ def _create_on_off_model(self): ) def _create_investment_model(self): - if isinstance(self.element.size, InvestParameters): - self.add_submodels( - InvestmentModel( - model=self._model, - label_of_element=self.label_of_element, - parameters=self.element.size, - label_of_model=self.label_of_element, - ), - 'investment', - ) - elif isinstance(self.element.size, InvestTimingParameters): - self.add_submodels( - InvestmentTimingModel( - model=self._model, - label_of_element=self.label_of_element, - parameters=self.element.size, - label_of_model=self.label_of_element, - ), - 'investment', - ) - else: - raise ValueError(f'Invalid InvestParameters type: {type(self.element.size)}') + self.add_submodels( + InvestmentModel( + model=self._model, + label_of_element=self.label_of_element, + parameters=self.element.size, + label_of_model=self.label_of_element, + ), + 'investment', + ) def _constraint_flow_rate(self): if not self.with_investment and not self.with_on_off: @@ -627,7 +614,7 @@ def with_on_off(self) -> bool: @property def with_investment(self) -> bool: - return isinstance(self.element.size, (InvestParameters, InvestTimingParameters)) + return isinstance(self.element.size, InvestParameters) # Properties for clean access to variables @property diff --git a/flixopt/features.py b/flixopt/features.py index ef1e5cee9..b896576ed 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -16,7 +16,7 @@ if TYPE_CHECKING: from .core import FlowSystemDimensions, Scalar, TemporalData - from .interface import InvestParameters, InvestTimingParameters, OnOffParameters, Piecewise + from .interface import InvestParameters, OnOffParameters, Piecewise logger = logging.getLogger('flixopt') @@ -129,257 +129,6 @@ def is_invested(self) -> linopy.Variable | None: return self._variables['is_invested'] -class InvestmentTimingModel(Submodel): - """ - This feature model is used to model the timing of investments. - - Such an Investment is defined by a size, a year_of_investment, and a year_of_decommissioning. - In between these years, the size of the investment cannot vary. Outside, its 0. - """ - - parameters: InvestTimingParameters - - def __init__( - self, - model: FlowSystemModel, - label_of_element: str, - parameters: InvestTimingParameters, - label_of_model: str | None = None, - ): - self.parameters = parameters - super().__init__(model, label_of_element, label_of_model) - - def _do_modeling(self): - super()._do_modeling() - self._basic_modeling() - self._add_effects() - - self._constraint_investment() - self._constraint_decommissioning() - - def _basic_modeling(self): - size_min, size_max = self.parameters.minimum_or_fixed_size, self.parameters.maximum_or_fixed_size - - ######################################################################## - self.add_variables( - short_name='size', - lower=0, - upper=size_max, - coords=self._model.get_coords(['year', 'scenario']), - ) - self.add_variables( - binary=True, - coords=self._model.get_coords(['year', 'scenario']), - short_name='is_invested', - ) - BoundingPatterns.bounds_with_state( - self, - variable=self.size, - variable_state=self.is_invested, - bounds=(size_min, size_max), - ) - - ######################################################################## - self.add_variables( - binary=True, - coords=self._model.get_coords(['year', 'scenario']), - short_name='size|investment_occurs', - ) - self.add_variables( - binary=True, - coords=self._model.get_coords(['year', 'scenario']), - short_name='size|decommissioning_occurs', - ) - BoundingPatterns.state_transition_bounds( - self, - state_variable=self.is_invested, - switch_on=self.investment_occurs, - switch_off=self.decommissioning_occurs, - name=self.is_invested.name, - previous_state=0, - coord='year', - ) - self.add_constraints( - self.investment_occurs.sum('year') <= 1, - short_name='investment_occurs|once', - ) - self.add_constraints( - self.decommissioning_occurs.sum('year') <= 1, - short_name='decommissioning_occurs|once', - ) - - ######################################################################## - previous_lifetime = self.parameters.previous_lifetime if self.parameters.previous_lifetime is not None else 0 - self.add_variables( - lower=0, - upper=self.parameters.maximum_or_fixed_lifetime - if self.parameters.maximum_or_fixed_lifetime is not None - else self._model.flow_system.years_per_year.sum('year') + previous_lifetime, - coords=self._model.get_coords(['scenario']), - short_name='size|lifetime', - ) - self.add_constraints( - self.lifetime - == (self.is_invested * self._model.flow_system.years_per_year).sum('year') - + self.is_invested.isel(year=0) * previous_lifetime, - short_name='size|lifetime', - ) - if self.parameters.minimum_or_fixed_lifetime is not None: - self.add_constraints( - self.lifetime + self.is_invested.isel(year=-1) * self.parameters.minimum_or_fixed_lifetime - >= self.investment_occurs * self.parameters.minimum_or_fixed_lifetime, - short_name='size|lifetime|lb', - ) - - ######################################################################## - self.add_variables( - coords=self._model.get_coords(['year', 'scenario']), - short_name='size|increase', - lower=0, - upper=size_max, - ) - self.add_variables( - coords=self._model.get_coords(['year', 'scenario']), - short_name='size|decrease', - lower=0, - upper=size_max, - ) - BoundingPatterns.link_changes_to_level_with_binaries( - self, - level_variable=self.size, - increase_variable=self.size_increase, - decrease_variable=self.size_decrease, - increase_binary=self.investment_occurs, - decrease_binary=self.decommissioning_occurs, - name=f'{self.label_of_element}|size|changes', - max_change=size_max, - initial_level=0, - coord='year', - ) - - def _add_effects(self): - """Add investment effects to the model.""" - - if self.parameters.fix_effects: - self._model.effects.add_share_to_effects( - name=self.label_of_element, - expressions={ - effect: self.is_invested * factor if self.is_invested is not None else factor - for effect, factor in self.parameters.fix_effects.items() - }, - target='invest', - ) - - if self.parameters.specific_effects: - self._model.effects.add_share_to_effects( - name=self.label_of_element, - expressions={effect: self.size * factor for effect, factor in self.parameters.specific_effects.items()}, - target='invest', - ) - - if self.parameters.fixed_effects_by_investment_year: - # Effects depending on when the investment is made - remapped_variable = self.investment_occurs.rename({'year': 'year_of_investment'}) - - self._model.effects.add_share_to_effects( - name=self.label_of_element, - expressions={ - effect: (remapped_variable * factor).sum('year_of_investment') - for effect, factor in self.parameters.fixed_effects_by_investment_year.items() - }, - target='invest', - ) - - if self.parameters.specific_effects_by_investment_year: - # Annual effects proportional to investment size - remapped_variable = self.size_increase.rename({'year': 'year_of_investment'}) - - self._model.effects.add_share_to_effects( - name=self.label_of_element, - expressions={ - effect: (remapped_variable * factor).sum('year_of_investment') - for effect, factor in self.parameters.specific_effects_by_investment_year.items() - }, - target='invest', - ) - - def _constraint_investment(self): - if self.parameters.force_investment.sum() > 0: - self.add_constraints( - self.investment_occurs == self.parameters.force_investment, - short_name='size|changes|fixed_start', - ) - else: - self.add_constraints( - self.investment_occurs <= self.parameters.allow_investment, - short_name='size|changes|restricted_start', - ) - - def _constraint_decommissioning(self): - if self.parameters.force_decommissioning.sum() > 0: - self.add_constraints( - self.decommissioning_occurs == self.parameters.force_decommissioning, - short_name='size|changes|fixed_end', - ) - else: - self.add_constraints( - self.decommissioning_occurs <= self.parameters.allow_decommissioning, - short_name='size|changes|restricted_end', - ) - - @property - def size(self) -> linopy.Variable: - """Investment size variable""" - return self._variables['size'] - - @property - def is_invested(self) -> linopy.Variable | None: - """Binary investment decision variable""" - if 'is_invested' not in self._variables: - return None - return self._variables['is_invested'] - - @property - def investment_occurs(self) -> linopy.Variable: - """Binary increase decision variable""" - return self._variables['size|investment_occurs'] - - @property - def decommissioning_occurs(self) -> linopy.Variable: - """Binary decrease decision variable""" - return self._variables['size|decommissioning_occurs'] - - @property - def size_decrease(self) -> linopy.Variable: - """Binary decrease decision variable""" - return self._variables['size|decrease'] - - @property - def size_increase(self) -> linopy.Variable: - """Binary increase decision variable""" - return self._variables['size|increase'] - - @property - def investment_used(self) -> linopy.LinearExpression: - """Binary investment decision variable""" - return self.investment_occurs.sum('year') - - @property - def divestment_used(self) -> linopy.LinearExpression: - """Binary investment decision variable""" - return self.decommissioning_occurs.sum('year') - - @property - def lifetime(self) -> linopy.Variable: - """Lifetime variable""" - return self._variables['size|lifetime'] - - @property - def duration(self) -> linopy.Variable: - """Investment duration variable""" - return self._variables['duration'] - - class OnOffModel(Submodel): """OnOff model using factory patterns""" diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index 8e747bbef..0b4a7c423 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -365,7 +365,6 @@ def fit_to_model_coords( name: str, data: TemporalDataUser | NonTemporalDataUser | None, dims: Collection[FlowSystemDimensions] | None = None, - with_year_of_investment: bool = False, ) -> TemporalData | NonTemporalData | None: """ Fit data to model coordinate system (currently time, but extensible). @@ -374,7 +373,6 @@ def fit_to_model_coords( name: Name of the data data: Data to fit to model coordinates dims: Collection of dimension names to use for fitting. If None, all dimensions are used. - with_year_of_investment: Wether to use the year_of_investment dimension or not. Only if "year" is in dims. Returns: xr.DataArray aligned to model coordinate system. If data is None, returns None. @@ -387,9 +385,6 @@ def fit_to_model_coords( if dims is not None: coords = {k: coords[k] for k in dims if k in coords} - if with_year_of_investment and 'year' in coords: - coords['year_of_investment'] = coords['year'].rename('year_of_investment') - # Rest of your method stays the same, just pass coords if isinstance(data, TimeSeriesData): try: @@ -411,7 +406,6 @@ def fit_effects_to_model_coords( effect_values: TemporalEffectsUser | NonTemporalEffectsUser | None, label_suffix: str | None = None, dims: Collection[FlowSystemDimensions] | None = None, - with_year_of_investment: bool = False, ) -> TemporalEffects | NonTemporalEffects | None: """ Transform EffectValues from the user to Internal Datatypes aligned with model coordinates. @@ -426,7 +420,6 @@ def fit_effects_to_model_coords( '|'.join(filter(None, [label_prefix, effect, label_suffix])), value, dims=dims, - with_year_of_investment=with_year_of_investment, ) for effect, value in effect_values_dict.items() } @@ -791,8 +784,6 @@ def sel( indexers['time'] = time if year is not None: indexers['year'] = year - if 'year_of_investment' in ds.dims: - indexers['year_of_investment'] = year if scenario is not None: indexers['scenario'] = scenario @@ -800,12 +791,6 @@ def sel( return self.copy() # Return a copy when no selection selected_dataset = ds.sel(**indexers) - if 'year_of_investment' in selected_dataset.coords and selected_dataset.coords['year_of_investment'].size == 1: - logger.critical( - 'Selected a single year while using InvestmentTiming. This is not supported and will lead to Errors ' - 'when trying to create a Calculation from this FlowSystem. Please select multiple years instead, ' - 'or remove the InvestmentTimingParameters.' - ) return self.__class__.from_dataset(selected_dataset) def isel( @@ -836,8 +821,6 @@ def isel( indexers['time'] = time if year is not None: indexers['year'] = year - if 'year_of_investment' in ds.dims: - indexers['year_of_investment'] = year if scenario is not None: indexers['scenario'] = scenario @@ -845,12 +828,6 @@ def isel( return self.copy() # Return a copy when no selection selected_dataset = ds.isel(**indexers) - if 'year_of_investment' in selected_dataset.coords and selected_dataset.coords['year_of_investment'].size == 1: - logger.critical( - 'Selected a single year while using InvestmentTiming. This is not supported and will lead to Errors ' - 'when trying to create a Calculation from this FlowSystem. Please select multiple years instead, ' - 'or remove the InvestmentTimingParameters.' - ) return self.__class__.from_dataset(selected_dataset) def resample( diff --git a/flixopt/interface.py b/flixopt/interface.py index e756fcec1..0192497a1 100644 --- a/flixopt/interface.py +++ b/flixopt/interface.py @@ -11,13 +11,12 @@ import xarray as xr from .config import CONFIG -from .core import NonTemporalDataUser from .structure import Interface, register_class_for_io if TYPE_CHECKING: # for type checking and preventing circular imports from collections.abc import Iterator - from .core import NonTemporalData, Scalar, TemporalDataUser + from .core import NonTemporalData, NonTemporalDataUser, Scalar, TemporalDataUser from .effects import NonTemporalEffectsUser, TemporalEffectsUser from .flow_system import FlowSystem @@ -935,276 +934,6 @@ def maximum_or_fixed_size(self) -> NonTemporalData: return self.fixed_size if self.fixed_size is not None else self.maximum_size -YearOfInvestmentData = NonTemporalDataUser -"""This datatype is used to define things related to the year of investment.""" -YearOfInvestmentDataBool = bool | YearOfInvestmentData -"""This datatype is used to define things with boolean data related to the year of investment.""" - - -@register_class_for_io -class InvestTimingParameters(Interface): - """ - Investment with fixed start and end years. - - This is the simplest variant - investment is completely scheduled. - No optimization variables needed for timing, just size optimization. - """ - - def __init__( - self, - allow_investment: YearOfInvestmentDataBool = True, - allow_decommissioning: YearOfInvestmentDataBool = True, - force_investment: YearOfInvestmentDataBool = False, # TODO: Allow to simply pass the year - force_decommissioning: YearOfInvestmentDataBool = False, # TODO: Allow to simply pass the year - fixed_lifetime: Scalar | None = None, - minimum_lifetime: Scalar | None = None, - maximum_lifetime: Scalar | None = None, - minimum_size: YearOfInvestmentData | None = None, - maximum_size: YearOfInvestmentData | None = None, - fixed_size: YearOfInvestmentData | None = None, - fix_effects: NonTemporalEffectsUser | None = None, - specific_effects: NonTemporalEffectsUser | None = None, # costs per Flow-Unit/Storage-Size/... - fixed_effects_by_investment_year: xr.DataArray | None = None, - specific_effects_by_investment_year: xr.DataArray | None = None, - previous_lifetime: Scalar | None = None, - ): - """ - These parameters are used to include the timing of investments in the model. - Two out of three parameters (year_of_investment, year_of_decommissioning, duration_in_years) can be fixed. - This has a 'year_of_investment' dimension in some parameters: - allow_investment: Whether investment is allowed in a certain year - allow_decommissioning: Whether divestment is allowed in a certain year - duration_between_investment_and_decommissioning: Duration between investment and decommissioning - - Args: - allow_investment: Allow investment in a certain year. By default, allow it in all years. - allow_decommissioning: Allow decommissioning in a certain year. By default, allow it in all years. - force_investment: Force the investment to occur in a certain year. - force_decommissioning: Force the decommissioning to occur in a certain year. - fixed_lifetime: Fix the lifetime of an investment (duration between investment and decommissioning). - minimum_size: Minimum possible size of the investment. Can depend on the year of investment. - maximum_size: Maximum possible size of the investment. Can depend on the year of investment. - fixed_size: Fix the size of the investment. Can depend on the year of investment. Can still be 0 if not forced. - specific_effects: Effects dependent on the size. - These will occur in each year, depending on the size in that year. - fix_effects: Effects of the Investment, independent of the size. - These will occur in each year, depending on wether the size is greater zero in that year. - - fixed_effects_by_investment_year: Effects dependent on the year of investment. - These effects will depend on the year of the investment. The actual effects can occur in other years, - letting you model things like annuities, which depend on when an investment was taken. - The passed xr.DataArray needs to match the FlowSystem dimensions (except time, but including "year_of_investment"). No internal Broadcasting! - "year_of_investment" has the same values as the year dimension. Access it through `flow_system.year_of_investment`. - specific_effects_by_investment_year: Effects dependent on the year of investment and the chosen size. - These effects will depend on the year of the investment. The actual effects can occur in other years, - letting you model things like annuities, which depend on when an investment was taken. - The passed xr.DataArray needs to match the FlowSystem dimensions (except time, but including "year_of_investment"). No internal Broadcasting! - "year_of_investment" has the same values as the year dimension. Access it through `flow_system.year_of_investment`. - - """ - self.minimum_size = minimum_size if minimum_size is not None else CONFIG.modeling.EPSILON - self.maximum_size = maximum_size if maximum_size is not None else CONFIG.modeling.BIG - self.fixed_size = fixed_size - - self.allow_investment = allow_investment - self.allow_decommissioning = allow_decommissioning - self.force_investment = force_investment - self.force_decommissioning = force_decommissioning - - self.maximum_lifetime = maximum_lifetime - self.minimum_lifetime = minimum_lifetime - self.fixed_lifetime = fixed_lifetime - self.previous_lifetime = previous_lifetime - - self.fix_effects: NonTemporalEffectsUser = fix_effects if fix_effects is not None else {} - self.specific_effects: NonTemporalEffectsUser = specific_effects if specific_effects is not None else {} - self.fixed_effects_by_investment_year = ( - fixed_effects_by_investment_year if fixed_effects_by_investment_year is not None else {} - ) - self.specific_effects_by_investment_year = ( - specific_effects_by_investment_year if specific_effects_by_investment_year is not None else {} - ) - - def _plausibility_checks(self, flow_system): - """Validate parameter consistency.""" - if flow_system.years is None: - raise ValueError("InvestTimingParameters requires the flow_system to have a 'years' dimension.") - - if (self.force_investment.sum('year') > 1).any(): - raise ValueError('force_investment can only be True for a single year.') - if (self.force_decommissioning.sum('year') > 1).any(): - raise ValueError('force_decommissioning can only be True for a single year.') - - if (self.force_investment.sum('year') == 1).any() and (self.force_decommissioning.sum('year') == 1).any(): - year_of_forced_investment = ( - self.force_investment.where(self.force_investment) * self.force_investment.year - ).sum('year') - year_of_forced_decommissioning = ( - self.force_decommissioning.where(self.force_decommissioning) * self.force_decommissioning.year - ).sum('year') - if not (year_of_forced_investment < year_of_forced_decommissioning).all(): - raise ValueError( - f'force_investment needs to be before force_decommissioning. Got:\n' - f'{self.force_investment}\nand\n{self.force_decommissioning}' - ) - - if self.previous_lifetime is not None: - if self.fixed_size is None: - # TODO: Might be only a warning - raise ValueError('previous_lifetime can only be used if fixed_size is defined.') - if self.force_investment is False: - # TODO: Might be only a warning - raise ValueError('previous_lifetime can only be used if force_investment is True.') - - if self.minimum_or_fixed_lifetime is not None and self.maximum_or_fixed_lifetime is not None: - years = flow_system.years.values - - infeasible_years = [] - for i, inv_year in enumerate(years[:-1]): # Exclude last year - future_years = years[i + 1 :] # All years after investment - min_decomm = self.minimum_or_fixed_lifetime + inv_year - max_decomm = self.maximum_or_fixed_lifetime + inv_year - if max_decomm >= years[-1]: - continue - - # Check if any future year falls in decommissioning window - future_years_da = xr.DataArray(future_years, dims=['year']) - valid_decomm = ((min_decomm <= future_years_da) & (future_years_da <= max_decomm)).any('year') - if not valid_decomm.all(): - infeasible_years.append(inv_year) - - if infeasible_years: - logger.warning( - f'Plausibility Check in {self.__class__.__name__}:\n' - f' Investment years with no feasible decommissioning: {[int(year) for year in infeasible_years]}\n' - f' Consider relaxing the lifetime constraints or including more years into your model.\n' - f' Lifetime:\n' - f' min={self.minimum_or_fixed_lifetime}\n' - f' max={self.maximum_or_fixed_lifetime}\n' - f' Model years: {list(flow_system.years)}\n' - ) - - specify_timing = ( - (self.fixed_lifetime is not None) - + bool((self.force_investment.sum('year') > 1).any()) - + bool((self.force_decommissioning.sum('year') > 1).any()) - ) - - if specify_timing in (0, 3): - # TODO: Is there a valid use case for this? Should this be checked at all? - logger.warning( - 'Either the the lifetime of an investment should be fixed, or the investment or decommissioning ' - 'needs to be forced in a certain year.' - ) - - def transform_data(self, flow_system: FlowSystem, name_prefix: str = '') -> None: - """Transform all parameter data to match the flow system's coordinate structure.""" - self.fix_effects = flow_system.fit_effects_to_model_coords( - label_prefix=name_prefix, - effect_values=self.fix_effects, - label_suffix='fix_effects', - dims=['year', 'scenario'], - ) - self.specific_effects = flow_system.fit_effects_to_model_coords( - label_prefix=name_prefix, - effect_values=self.specific_effects, - label_suffix='specific_effects', - dims=['year', 'scenario'], - ) - self.maximum_lifetime = flow_system.fit_to_model_coords( - f'{name_prefix}|maximum_lifetime', self.maximum_lifetime, dims=['scenario'] - ) - self.minimum_lifetime = flow_system.fit_to_model_coords( - f'{name_prefix}|minimum_lifetime', self.minimum_lifetime, dims=['scenario'] - ) - self.fixed_lifetime = flow_system.fit_to_model_coords( - f'{name_prefix}|fixed_lifetime', self.fixed_lifetime, dims=['scenario'] - ) - - self.force_investment = flow_system.fit_to_model_coords( - f'{name_prefix}|force_investment', self.force_investment, dims=['year', 'scenario'] - ) - self.force_decommissioning = flow_system.fit_to_model_coords( - f'{name_prefix}|force_decommissioning', self.force_decommissioning, dims=['year', 'scenario'] - ) - - self.minimum_size = flow_system.fit_to_model_coords( - f'{name_prefix}|minimum_size', self.minimum_size, dims=['year', 'scenario'] - ) - self.maximum_size = flow_system.fit_to_model_coords( - f'{name_prefix}|maximum_size', self.maximum_size, dims=['year', 'scenario'] - ) - if self.fixed_size is not None: - self.fixed_size = flow_system.fit_to_model_coords( - f'{name_prefix}|fixed_size', self.fixed_size, dims=['year', 'scenario'] - ) - - # TODO: self.previous_size to only scenarios - - # No Broadcasting! Until a safe way is established, we need to do check for this! - self.fixed_effects_by_investment_year = flow_system.effects.create_effect_values_dict( - self.fixed_effects_by_investment_year - ) - for effect, da in self.fixed_effects_by_investment_year.items(): - dims = set(da.coords) - if not {'year_of_investment', 'year'}.issubset(dims): - raise ValueError( - f'fixed_effects_by_investment_year need to have a "year_of_investment" dimension and a ' - f'"year" dimension. Got {dims} for effect {effect}' - ) - self.specific_effects_by_investment_year = flow_system.effects.create_effect_values_dict( - self.specific_effects_by_investment_year - ) - for effect, da in self.specific_effects_by_investment_year.items(): - dims = set(da.coords) - if not {'year_of_investment', 'year'}.issubset(dims): - raise ValueError( - f'specific_effects_by_investment_year need to have a "year_of_investment" dimension and a ' - f'"year" dimension. Got {dims} for effect {effect}' - ) - self.fixed_effects_by_investment_year = flow_system.fit_effects_to_model_coords( - label_prefix=name_prefix, - effect_values=self.fixed_effects_by_investment_year, - label_suffix='fixed_effects_by_investment_year', - dims=['year', 'scenario'], - with_year_of_investment=True, - ) - self.specific_effects_by_investment_year = flow_system.fit_effects_to_model_coords( - label_prefix=name_prefix, - effect_values=self.specific_effects_by_investment_year, - label_suffix='specific_effects_by_investment_year', - dims=['year', 'scenario'], - with_year_of_investment=True, - ) - - self._plausibility_checks(flow_system) - - @property - def minimum_or_fixed_size(self) -> NonTemporalData: - """Get the effective minimum size (fixed size takes precedence).""" - return self.fixed_size if self.fixed_size is not None else self.minimum_size - - @property - def maximum_or_fixed_size(self) -> NonTemporalData: - """Get the effective maximum size (fixed size takes precedence).""" - return self.fixed_size if self.fixed_size is not None else self.maximum_size - - @property - def is_fixed_size(self) -> bool: - """Check if investment size is fixed.""" - return self.fixed_size is not None - - @property - def minimum_or_fixed_lifetime(self) -> NonTemporalData: - """Get the effective minimum lifetime (fixed lifetime takes precedence).""" - return self.fixed_lifetime if self.fixed_lifetime is not None else self.minimum_lifetime - - @property - def maximum_or_fixed_lifetime(self) -> NonTemporalData: - """Get the effective maximum lifetime (fixed lifetime takes precedence).""" - return self.fixed_lifetime if self.fixed_lifetime is not None else self.maximum_lifetime - - @register_class_for_io class OnOffParameters(Interface): """Define operational constraints and effects for binary on/off equipment behavior. From dabe87f67a8495e0be3625e81eaece3e3e8e8530 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 23 Sep 2025 11:59:54 +0200 Subject: [PATCH 318/336] Improve CHnagelog and docstring of Storage --- CHANGELOG.md | 12 ++++++------ flixopt/components.py | 10 ++++++---- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f59b76ec6..b632e99e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -67,17 +67,17 @@ The weighted sum of the total objective effect of each scenario is used as the o ### Added -* FlowSystem Restoring: The used FlowSystem will now get restired from the results (lazily). ALll Parameters can be safely acessed anytime after the solve. -* FLowResults added as a new class to store the results of Flows. They can now be accessed directly. +* FlowSystem Restoring: The used FlowSystem is now acessible directly form the results without manual restoring (lazily). All Parameters can be safely accessed anytime after the solve. +* FlowResults added as a new class to store the results of Flows. They can now be accessed directly. * Added precomputed DataArrays for `size`s, `flow_rate`s and `flow_hour`s. * Added `effects_per_component()`-Dataset to Results that stores the direct (and indirect) effects of each component. This greatly improves the evaluation of the impact of individual Components, even with many and complex effects. -* Improved filter methods for Results -* Balanced storage - Storage charging and discharging sizes can now be forced to be equal in when optimizing their size. +* Improved filter methods in `resulty.py` +* Balanced storage - Storage charging and discharging sizes can now be forced to be equal when optimizing their size. * Added Example for 2-stage Investment decisions leveraging the resampling of a FlowSystem -* New Storage Parameter: `relative_minimum_final_charge_state` and `relative_maximum_final_charge_state` parameter for final state control +* New Storage Parameter: `relative_minimum_final_charge_state` and `relative_maximum_final_charge_state` parameter for final state control. Default to last value of `relative_minimum_charge_state` and `relative_maximum_charge_state`, which will prevent change of behaviour for most users. ### Changed -* **BREAKING**: `relative_minimum_charge_state` and `relative_maximum_charge_state` don't have an extra timestep anymore. The final charge state can now be constrained by parameters `relative_minimum_final_charge_state` and `relative_maximum_final_charge_state` instead +* **BREAKING**: `relative_minimum_charge_state` and `relative_maximum_charge_state` don't have an extra timestep anymore. * **BREAKING**: Renamed class `SystemModel` to `FlowSystemModel` * **BREAKING**: Renamed class `Model` to `Submodel` * **BREAKING**: Renamed `mode` parameter in plotting methods to `style` diff --git a/flixopt/components.py b/flixopt/components.py index 98624cfe7..8e3e0f2e0 100644 --- a/flixopt/components.py +++ b/flixopt/components.py @@ -261,10 +261,12 @@ class Storage(Component): initial_charge_state: Storage charge state at the beginning of the time horizon. Can be numeric value or 'lastValueOfSim', which is recommended for if the initial start state is not known. Default is 0. - minimal_final_charge_state: Minimum absolute charge state required at the end - of the time horizon. Useful for ensuring energy security or meeting contracts. - maximal_final_charge_state: Maximum absolute charge state allowed at the end - of the time horizon. Useful for preventing overcharge or managing inventory. + minimal_final_charge_state: Minimum absolute charge state required at the end of the time horizon. + maximal_final_charge_state: Maximum absolute charge state allowed at the end of the time horizon. + relative_minimum_final_charge_state: Minimum relative charge state required at the end of the time horizon. + Defaults to the last value of the relative_minimum_charge_state. + relative_maximum_final_charge_state: Maximum relative charge state allowed at the end of the time horizon. + Defaults to the last value of the relative_maximum_charge_state. eta_charge: Charging efficiency factor (0-1 range). Accounts for conversion losses during charging. Default is 1 (perfect efficiency). eta_discharge: Discharging efficiency factor (0-1 range). Accounts for From 7d50c11fda3ebc61281de35d50ce91234d4542b9 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 23 Sep 2025 12:05:33 +0200 Subject: [PATCH 319/336] Improve Changelog --- CHANGELOG.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b632e99e1..9b202a13f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,12 +30,14 @@ Please remove all irrelevant sections before releasing. Until here --> ## [Unreleased] - ????-??-?? -Multi-Period and stochastic modeling is coming to flixopt in this release. +This Release brings Multi-year-investments and stochastic modeling to flixopt. +Further, IO methods were improved and resampling and selection of parts of the FlowSystem is now possible. +Several internal improvements were made to the codebase. -In this release, we introduce the following new features: -#### Multi-period-support + +#### Multi-year-investments A flixopt model might be modeled with a "year" dimension. -This enables to model transformation pathways over multiple years. +This enables to model transformation pathways over multiple years with several investment decisions #### Stochastic modeling A flixopt model can be modeled with a scenario dimension. From c90fac9f1398be4d56a54d2655cea1042ef0f72b Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 23 Sep 2025 12:09:47 +0200 Subject: [PATCH 320/336] Improve InvestmentModel --- flixopt/features.py | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/flixopt/features.py b/flixopt/features.py index b896576ed..340b39518 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -22,7 +22,19 @@ class InvestmentModel(Submodel): - """Investment model using factory patterns but keeping old interface""" + """ + This feature model is used to model the investment of a variable. + It applies the corresponding bounds to the variable and the on/off state of the variable. + + Args: + model: The optimization model instance + label_of_element: The label of the parent (Element). Used to construct the full label of the model. + parameters: The parameters of the feature model. + label_of_model: The label of the model. This is needed to construct the full label of the model. + + """ + + parameters: InvestParameters def __init__( self, @@ -31,17 +43,6 @@ def __init__( parameters: InvestParameters, label_of_model: str | None = None, ): - """ - This feature model is used to model the investment of a variable. - It applies the corresponding bounds to the variable and the on/off state of the variable. - - Args: - model: The optimization model instance - label_of_element: The label of the parent (Element). Used to construct the full label of the model. - parameters: The parameters of the feature model. - label_of_model: The label of the model. This is needed to construct the full label of the model. - - """ self.piecewise_effects: PiecewiseEffectsModel | None = None self.parameters = parameters super().__init__(model, label_of_element=label_of_element, label_of_model=label_of_model) From 586f55c749c9b8d789ebee5614aa4ca729df88fd Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 23 Sep 2025 12:23:03 +0200 Subject: [PATCH 321/336] Improve InvestmentModel to have 2 cases. One without years and one with --- flixopt/features.py | 33 +++++++++++++++++++++++++++++---- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/flixopt/features.py b/flixopt/features.py index 340b39518..c042e492b 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -49,11 +49,14 @@ def __init__( def _do_modeling(self): super()._do_modeling() - self._create_variables_and_constraints() + if self._model.flow_system.years is None: + self._create_variables_and_constraints_without_years() + else: + self._create_variables_and_constraints_with_years() self._add_effects() - def _create_variables_and_constraints(self): - size_min, size_max = (self.parameters.minimum_or_fixed_size, self.parameters.maximum_or_fixed_size) + def _create_variables_and_constraints_without_years(self): + size_min, size_max = self.parameters.minimum_or_fixed_size, self.parameters.maximum_or_fixed_size self.add_variables( short_name='size', lower=0 if self.parameters.optional else size_min, @@ -72,9 +75,31 @@ def _create_variables_and_constraints(self): self, variable=self.size, variable_state=self.is_invested, - bounds=(self.parameters.minimum_or_fixed_size, self.parameters.maximum_or_fixed_size), + bounds=(size_min, size_max), ) + def _create_variables_and_constraints_with_years(self): + size_min, size_max = self.parameters.minimum_or_fixed_size, self.parameters.maximum_or_fixed_size + self.add_variables( + short_name='size', + lower=0, + upper=size_max, + coords=self._model.get_coords(['year', 'scenario']), + ) + + self.add_variables( + binary=True, + coords=self._model.get_coords(['year', 'scenario']), + short_name='is_invested', + ) + + BoundingPatterns.bounds_with_state( + self, + variable=self.size, + variable_state=self.is_invested, + bounds=(size_min, size_max), + ) + def _add_effects(self): """Add investment effects""" if self.parameters.fix_effects: From 638d5311d9f72d07caab9a49ae1840a6b9bed9ee Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 23 Sep 2025 12:41:58 +0200 Subject: [PATCH 322/336] Improve InvestmentModel to have 2 cases. One without years and one with years. Further, remove investment_scenarios parameter --- flixopt/features.py | 84 +++++++++++++++++++++++++++++++++++++++++++- flixopt/interface.py | 23 ++++-------- 2 files changed, 89 insertions(+), 18 deletions(-) diff --git a/flixopt/features.py b/flixopt/features.py index c042e492b..35ffed20e 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -11,7 +11,8 @@ import linopy import numpy as np -from .modeling import BoundingPatterns, ModelingPrimitives, ModelingUtilities +from .config import CONFIG +from .modeling import BoundingPatterns, ModelingPrimitives, ModelingUtilities, ModelingUtilitiesAbstract from .structure import FlowSystemModel, Submodel if TYPE_CHECKING: @@ -99,6 +100,67 @@ def _create_variables_and_constraints_with_years(self): variable_state=self.is_invested, bounds=(size_min, size_max), ) + ######################################################################## + previous_size = self.parameters.previous_size if self.parameters.previous_size is not None else 0 + self.add_variables( + binary=True, + coords=self._model.get_coords(['year', 'scenario']), + short_name='size|investment_occurs', + ) + self.add_variables( + binary=True, + coords=self._model.get_coords(['year', 'scenario']), + short_name='size|decommissioning_occurs', + ) + BoundingPatterns.state_transition_bounds( + self, + state_variable=self.is_invested, + switch_on=self.investment_occurs, + switch_off=self.decommissioning_occurs, + name=self.is_invested.name, + previous_state=ModelingUtilitiesAbstract.to_binary(values=previous_size, epsilon=CONFIG.modeling.EPSILON), + coord='year', + ) + if self.parameters.optional: + self.add_constraints( + self.investment_occurs.sum('year') <= 1, + short_name='investment_occurs|once', + ) + else: + self.add_constraints( + self.investment_occurs.sum('year') == 1, + short_name='investment_occurs|once', + ) + + self.add_constraints( + self.decommissioning_occurs.sum('year') <= 1, + short_name='decommissioning_occurs|once', + ) + ######################################################################## + self.add_variables( + coords=self._model.get_coords(['year', 'scenario']), + short_name='size|increase', + lower=0, + upper=size_max, + ) + self.add_variables( + coords=self._model.get_coords(['year', 'scenario']), + short_name='size|decrease', + lower=0, + upper=size_max, + ) + BoundingPatterns.link_changes_to_level_with_binaries( + self, + level_variable=self.size, + increase_variable=self.size_increase, + decrease_variable=self.size_decrease, + increase_binary=self.investment_occurs, + decrease_binary=self.decommissioning_occurs, + name=f'{self.label_of_element}|size|changes', + max_change=size_max, + initial_level=previous_size, + coord='year', + ) def _add_effects(self): """Add investment effects""" @@ -154,6 +216,26 @@ def is_invested(self) -> linopy.Variable | None: return None return self._variables['is_invested'] + @property + def investment_occurs(self) -> linopy.Variable: + """Binary increase decision variable""" + return self._variables['size|investment_occurs'] + + @property + def decommissioning_occurs(self) -> linopy.Variable: + """Binary decrease decision variable""" + return self._variables['size|decommissioning_occurs'] + + @property + def size_decrease(self) -> linopy.Variable: + """Binary decrease decision variable""" + return self._variables['size|decrease'] + + @property + def size_increase(self) -> linopy.Variable: + """Binary increase decision variable""" + return self._variables['size|increase'] + class OnOffModel(Submodel): """OnOff model using factory patterns""" diff --git a/flixopt/interface.py b/flixopt/interface.py index 0192497a1..45cb0ed52 100644 --- a/flixopt/interface.py +++ b/flixopt/interface.py @@ -707,6 +707,7 @@ class InvestParameters(Interface): divest_effects: Costs incurred if the investment is NOT made, such as demolition of existing equipment, contractual penalties, or lost opportunities. Dictionary mapping effect names to values. + previous_size: Initial size of the investment. Only relevant in multi-period investments. Cost Annualization Requirements: All cost values must be properly weighted to match the optimization model's time horizon. @@ -863,7 +864,7 @@ def __init__( specific_effects: NonTemporalEffectsUser | None = None, # costs per Flow-Unit/Storage-Size/... piecewise_effects: PiecewiseEffects | None = None, divest_effects: NonTemporalEffectsUser | None = None, - investment_scenarios: Literal['individual'] | list[int | str] | None = None, + previous_size: NonTemporalDataUser | None = None, ): self.fix_effects: NonTemporalEffectsUser = fix_effects or {} self.divest_effects: NonTemporalEffectsUser = divest_effects or {} @@ -873,7 +874,7 @@ def __init__( self.piecewise_effects = piecewise_effects self.minimum_size = minimum_size if minimum_size is not None else CONFIG.modeling.EPSILON self.maximum_size = maximum_size if maximum_size is not None else CONFIG.modeling.BIG # default maximum - self.investment_scenarios = investment_scenarios + self.previous_size = previous_size def transform_data(self, flow_system: FlowSystem, name_prefix: str = '') -> None: self._plausibility_checks(flow_system) @@ -909,21 +910,9 @@ def transform_data(self, flow_system: FlowSystem, name_prefix: str = '') -> None self.fixed_size = flow_system.fit_to_model_coords( f'{name_prefix}|fixed_size', self.fixed_size, dims=['year', 'scenario'] ) - - def _plausibility_checks(self, flow_system): - if isinstance(self.investment_scenarios, list): - if not set(self.investment_scenarios).issubset(flow_system.scenarios): - raise ValueError( - f'Some scenarios in investment_scenarios are not present in the time_series_collection: ' - f'{set(self.investment_scenarios) - set(flow_system.scenarios)}' - ) - if self.investment_scenarios is not None: - if not self.optional: - if self.minimum_size is not None or self.fixed_size is not None: - logger.warning( - 'When using investment_scenarios, minimum_size and fixed_size should only ne used if optional is True.' - 'Otherwise the investment cannot be 0 incertain scenarios while being non-zero in others.' - ) + self.previous_size = flow_system.fit_to_model_coords( + f'{name_prefix}|previous_size', self.previous_size, dims=['year', 'scenario'] + ) @property def minimum_or_fixed_size(self) -> NonTemporalData: From ea795dc9bf331c20ce6bfc78763c6e2b46b5c8ff Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 23 Sep 2025 19:32:27 +0200 Subject: [PATCH 323/336] Revert some changes regarding Investments --- CHANGELOG.md | 4 +- flixopt/features.py | 136 +++++------------------------------------ flixopt/flow_system.py | 1 - flixopt/interface.py | 7 --- 4 files changed, 17 insertions(+), 131 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b202a13f..fc0a30342 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -73,8 +73,8 @@ The weighted sum of the total objective effect of each scenario is used as the o * FlowResults added as a new class to store the results of Flows. They can now be accessed directly. * Added precomputed DataArrays for `size`s, `flow_rate`s and `flow_hour`s. * Added `effects_per_component()`-Dataset to Results that stores the direct (and indirect) effects of each component. This greatly improves the evaluation of the impact of individual Components, even with many and complex effects. -* Improved filter methods in `resulty.py` -* Balanced storage - Storage charging and discharging sizes can now be forced to be equal when optimizing their size. +* Improved filter methods in `results.py` +* Balanced storage - Storage charging and discharging sizes can now be forced to be equal when optimizing their size by the `balanced` parameter. * Added Example for 2-stage Investment decisions leveraging the resampling of a FlowSystem * New Storage Parameter: `relative_minimum_final_charge_state` and `relative_maximum_final_charge_state` parameter for final state control. Default to last value of `relative_minimum_charge_state` and `relative_maximum_charge_state`, which will prevent change of behaviour for most users. diff --git a/flixopt/features.py b/flixopt/features.py index 35ffed20e..594ece84c 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -50,14 +50,11 @@ def __init__( def _do_modeling(self): super()._do_modeling() - if self._model.flow_system.years is None: - self._create_variables_and_constraints_without_years() - else: - self._create_variables_and_constraints_with_years() + self._create_variables_and_constraints() self._add_effects() - def _create_variables_and_constraints_without_years(self): - size_min, size_max = self.parameters.minimum_or_fixed_size, self.parameters.maximum_or_fixed_size + def _create_variables_and_constraints(self): + size_min, size_max = (self.parameters.minimum_or_fixed_size, self.parameters.maximum_or_fixed_size) self.add_variables( short_name='size', lower=0 if self.parameters.optional else size_min, @@ -76,92 +73,22 @@ def _create_variables_and_constraints_without_years(self): self, variable=self.size, variable_state=self.is_invested, - bounds=(size_min, size_max), + bounds=(self.parameters.minimum_or_fixed_size, self.parameters.maximum_or_fixed_size), ) - def _create_variables_and_constraints_with_years(self): - size_min, size_max = self.parameters.minimum_or_fixed_size, self.parameters.maximum_or_fixed_size - self.add_variables( - short_name='size', - lower=0, - upper=size_max, - coords=self._model.get_coords(['year', 'scenario']), - ) - - self.add_variables( - binary=True, - coords=self._model.get_coords(['year', 'scenario']), - short_name='is_invested', - ) - - BoundingPatterns.bounds_with_state( - self, - variable=self.size, - variable_state=self.is_invested, - bounds=(size_min, size_max), - ) - ######################################################################## - previous_size = self.parameters.previous_size if self.parameters.previous_size is not None else 0 - self.add_variables( - binary=True, - coords=self._model.get_coords(['year', 'scenario']), - short_name='size|investment_occurs', - ) - self.add_variables( - binary=True, - coords=self._model.get_coords(['year', 'scenario']), - short_name='size|decommissioning_occurs', - ) - BoundingPatterns.state_transition_bounds( - self, - state_variable=self.is_invested, - switch_on=self.investment_occurs, - switch_off=self.decommissioning_occurs, - name=self.is_invested.name, - previous_state=ModelingUtilitiesAbstract.to_binary(values=previous_size, epsilon=CONFIG.modeling.EPSILON), - coord='year', - ) - if self.parameters.optional: - self.add_constraints( - self.investment_occurs.sum('year') <= 1, - short_name='investment_occurs|once', - ) - else: - self.add_constraints( - self.investment_occurs.sum('year') == 1, - short_name='investment_occurs|once', + if self.parameters.piecewise_effects: + self.piecewise_effects = self.add_submodels( + PiecewiseEffectsModel( + model=self._model, + label_of_element=self.label_of_element, + label_of_model=f'{self.label_of_element}|PiecewiseEffects', + piecewise_origin=(self.size.name, self.parameters.piecewise_effects.piecewise_origin), + piecewise_shares=self.parameters.piecewise_effects.piecewise_shares, + zero_point=self.is_invested, + ), + short_name='segments', ) - self.add_constraints( - self.decommissioning_occurs.sum('year') <= 1, - short_name='decommissioning_occurs|once', - ) - ######################################################################## - self.add_variables( - coords=self._model.get_coords(['year', 'scenario']), - short_name='size|increase', - lower=0, - upper=size_max, - ) - self.add_variables( - coords=self._model.get_coords(['year', 'scenario']), - short_name='size|decrease', - lower=0, - upper=size_max, - ) - BoundingPatterns.link_changes_to_level_with_binaries( - self, - level_variable=self.size, - increase_variable=self.size_increase, - decrease_variable=self.size_decrease, - increase_binary=self.investment_occurs, - decrease_binary=self.decommissioning_occurs, - name=f'{self.label_of_element}|size|changes', - max_change=size_max, - initial_level=previous_size, - coord='year', - ) - def _add_effects(self): """Add investment effects""" if self.parameters.fix_effects: @@ -191,19 +118,6 @@ def _add_effects(self): target='invest', ) - if self.parameters.piecewise_effects: - self.piecewise_effects = self.add_submodels( - PiecewiseEffectsModel( - model=self._model, - label_of_element=self.label_of_element, - label_of_model=f'{self.label_of_element}|PiecewiseEffects', - piecewise_origin=(self.size.name, self.parameters.piecewise_effects.piecewise_origin), - piecewise_shares=self.parameters.piecewise_effects.piecewise_shares, - zero_point=self.is_invested, - ), - short_name='segments', - ) - @property def size(self) -> linopy.Variable: """Investment size variable""" @@ -216,26 +130,6 @@ def is_invested(self) -> linopy.Variable | None: return None return self._variables['is_invested'] - @property - def investment_occurs(self) -> linopy.Variable: - """Binary increase decision variable""" - return self._variables['size|investment_occurs'] - - @property - def decommissioning_occurs(self) -> linopy.Variable: - """Binary decrease decision variable""" - return self._variables['size|decommissioning_occurs'] - - @property - def size_decrease(self) -> linopy.Variable: - """Binary decrease decision variable""" - return self._variables['size|decrease'] - - @property - def size_increase(self) -> linopy.Variable: - """Binary increase decision variable""" - return self._variables['size|increase'] - class OnOffModel(Submodel): """OnOff model using factory patterns""" diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index 0b4a7c423..40f5e2451 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -58,7 +58,6 @@ class FlowSystem(Interface): If None, the first time increment of time_series is used. This is needed to calculate previous durations (for example consecutive_on_hours). If you use an array, take care that its long enough to cover all previous values! - years_of_last_year: The duration of the last year. Defaults to the duration of the last year interval. weights: The weights of each year and scenario. If None, all scenarios have the same weight, while the years have the weight of their represented year (all normalized to 1). Its recommended to scale the weights to sum up to 1. Notes: diff --git a/flixopt/interface.py b/flixopt/interface.py index 45cb0ed52..ae308d388 100644 --- a/flixopt/interface.py +++ b/flixopt/interface.py @@ -8,8 +8,6 @@ import logging from typing import TYPE_CHECKING, Literal, Optional -import xarray as xr - from .config import CONFIG from .structure import Interface, register_class_for_io @@ -864,7 +862,6 @@ def __init__( specific_effects: NonTemporalEffectsUser | None = None, # costs per Flow-Unit/Storage-Size/... piecewise_effects: PiecewiseEffects | None = None, divest_effects: NonTemporalEffectsUser | None = None, - previous_size: NonTemporalDataUser | None = None, ): self.fix_effects: NonTemporalEffectsUser = fix_effects or {} self.divest_effects: NonTemporalEffectsUser = divest_effects or {} @@ -874,7 +871,6 @@ def __init__( self.piecewise_effects = piecewise_effects self.minimum_size = minimum_size if minimum_size is not None else CONFIG.modeling.EPSILON self.maximum_size = maximum_size if maximum_size is not None else CONFIG.modeling.BIG # default maximum - self.previous_size = previous_size def transform_data(self, flow_system: FlowSystem, name_prefix: str = '') -> None: self._plausibility_checks(flow_system) @@ -910,9 +906,6 @@ def transform_data(self, flow_system: FlowSystem, name_prefix: str = '') -> None self.fixed_size = flow_system.fit_to_model_coords( f'{name_prefix}|fixed_size', self.fixed_size, dims=['year', 'scenario'] ) - self.previous_size = flow_system.fit_to_model_coords( - f'{name_prefix}|previous_size', self.previous_size, dims=['year', 'scenario'] - ) @property def minimum_or_fixed_size(self) -> NonTemporalData: From 9b3e24ad5128c6fb42c0004f26f0fbb4e5a6a3e1 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 23 Sep 2025 19:32:54 +0200 Subject: [PATCH 324/336] Typo --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fc0a30342..8972697fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -69,7 +69,7 @@ The weighted sum of the total objective effect of each scenario is used as the o ### Added -* FlowSystem Restoring: The used FlowSystem is now acessible directly form the results without manual restoring (lazily). All Parameters can be safely accessed anytime after the solve. +* FlowSystem Restoring: The used FlowSystem is now accessible directly form the results without manual restoring (lazily). All Parameters can be safely accessed anytime after the solve. * FlowResults added as a new class to store the results of Flows. They can now be accessed directly. * Added precomputed DataArrays for `size`s, `flow_rate`s and `flow_hour`s. * Added `effects_per_component()`-Dataset to Results that stores the direct (and indirect) effects of each component. This greatly improves the evaluation of the impact of individual Components, even with many and complex effects. From af45788057cfb9c9c0dfadb7c33ca3b16013a66a Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 23 Sep 2025 19:35:38 +0200 Subject: [PATCH 325/336] Remove Investment test file (only local testing) --- tests/test_models.py | 231 ------------------------------------------- 1 file changed, 231 deletions(-) delete mode 100644 tests/test_models.py diff --git a/tests/test_models.py b/tests/test_models.py deleted file mode 100644 index 3fc38c1f1..000000000 --- a/tests/test_models.py +++ /dev/null @@ -1,231 +0,0 @@ -from typing import Union - -import linopy -import numpy as np -import pandas as pd -import pytest -import xarray as xr - -import flixopt as fx - -from .conftest import ( - Buses, - Effects, - LoadProfiles, - Sinks, - Sources, - assert_conequal, - assert_sets_equal, - assert_var_equal, - create_linopy_model, -) - - -def calculate_annual_payment(total_cost: float, remaining_years: int, discount_rate: float) -> float: - """Calculate annualized payment for given remaining years. - - Args: - total_cost: Total cost to be annualized. - remaining_years: Number of remaining years. - discount_rate: Discount rate for annualization. - - Returns: - Annual payment amount. - """ - if remaining_years == 1: - return total_cost - - return ( - total_cost - * (discount_rate * (1 + discount_rate) ** remaining_years) - / ((1 + discount_rate) ** remaining_years - 1) - ) - - -def create_annualized_effects( - year_of_investments: range | list | pd.Index, - all_years: range | list | pd.Index, - total_cost: float, - discount_rate: float, - horizon_end: int, - extra_dim: str = 'year_of_investment', -) -> xr.DataArray: - """Create a 2D effects array for annualized costs. - - Creates an array where investing in year Y results in annualized costs - applied to years Y through horizon_end. - - Args: - year_of_investments: Years when investment decisions can be made. - all_years: All years in the model (for the 'year' dimension). - total_cost: Total upfront cost to be annualized. - discount_rate: Discount rate for annualization calculation. - horizon_end: Last year when effects apply. - extra_dim: Name for the investment year dimension. - - Returns: - xr.DataArray with dimensions [extra_dim, 'year'] containing annualized costs. - """ - - # Convert to lists for easier iteration - year_of_investments_list = list(year_of_investments) - all_years_list = list(all_years) - - # Initialize cost matrix - cost_matrix = np.zeros((len(year_of_investments_list), len(all_years_list))) - - # Fill matrix with annualized costs - for i, year_of_investment in enumerate(year_of_investments_list): - remaining_years = horizon_end - year_of_investment + 1 - if remaining_years > 0: - annual_cost = calculate_annual_payment(total_cost, remaining_years, discount_rate) - - # Apply cost to years from year_of_investment through horizon_end - for j, cost_year in enumerate(all_years_list): - if year_of_investment <= cost_year <= horizon_end: - cost_matrix[i, j] = annual_cost - - return xr.DataArray( - cost_matrix, coords={extra_dim: year_of_investments_list, 'year': all_years_list}, dims=[extra_dim, 'year'] - ) - - -@pytest.fixture -def flow_system() -> fx.FlowSystem: - """Create basic elements for component testing with coordinate parametrization.""" - years = pd.Index([2020, 2021, 2022, 2023, 2024, 2026, 2028, 2030], name='year') - timesteps = pd.date_range('2020-01-01', periods=24, freq='h', name='time') - flow_system = fx.FlowSystem(timesteps=timesteps, years=years) - - thermal_load = LoadProfiles.random_thermal(len(timesteps)) - p_el = LoadProfiles.random_electrical(len(timesteps)) - - costs = Effects.costs() - heat_load = Sinks.heat_load(thermal_load) - gas_source = Sources.gas_with_costs() - electricity_sink = Sinks.electricity_feed_in(p_el) - - flow_system.add_elements(*Buses.defaults()) - flow_system.buses['Fernwärme'].excess_penalty_per_flow_hour = 0 - flow_system.add_elements(costs, heat_load, gas_source, electricity_sink) - - return flow_system - - -class TestYearAwareInvestParameters: - """Test the YearAwareInvestParameters interface.""" - - def test_basic_initialization(self): - """Test basic parameter initialization.""" - params = fx.YearAwareInvestParameters( - minimum_size=10, - maximum_size=100, - ) - - assert params.minimum_size == 10 - assert params.maximum_size == 100 - assert params.fixed_size is None - assert not params.allow_divestment - assert params.fixed_year_of_investment is None - assert params.fixed_year_of_decommissioning is None - assert params.fixed_duration is None - - def test_fixed_size_initialization(self): - """Test initialization with fixed size.""" - params = fx.YearAwareInvestParameters(fixed_size=50) - - assert params.minimum_or_fixed_size == 50 - assert params.maximum_or_fixed_size == 50 - assert params.is_fixed_size - - def test_timing_constraints_initialization(self): - """Test initialization with various timing constraints.""" - params = fx.YearAwareInvestParameters( - fixed_year_of_investment=2, - minimum_duration=3, - maximum_duration=5, - earliest_year_of_decommissioning=4, - ) - - assert params.fixed_year_of_investment == 2 - assert params.minimum_duration == 3 - assert params.maximum_duration == 5 - assert params.earliest_year_of_decommissioning == 4 - - def test_effects_initialization(self): - """Test initialization with effects.""" - params = fx.YearAwareInvestParameters( - effects_of_investment={'costs': 1000}, - effects_of_investment_per_size={'costs': 100}, - allow_divestment=True, - effects_of_divestment={'costs': 500}, - effects_of_divestment_per_size={'costs': 50}, - ) - - assert params.effects_of_investment == {'costs': 1000} - assert params.effects_of_investment_per_size == {'costs': 100} - assert params.allow_divestment - assert params.effects_of_divestment == {'costs': 500} - assert params.effects_of_divestment_per_size == {'costs': 50} - - def test_property_methods(self): - """Test property methods.""" - # Test with fixed size - params_fixed = fx.YearAwareInvestParameters(fixed_size=50) - assert params_fixed.minimum_or_fixed_size == 50 - assert params_fixed.maximum_or_fixed_size == 50 - assert params_fixed.is_fixed_size - - # Test with min/max size - params_range = fx.YearAwareInvestParameters(minimum_size=10, maximum_size=100) - assert params_range.minimum_or_fixed_size == 10 - assert params_range.maximum_or_fixed_size == 100 - assert not params_range.is_fixed_size - - -class TestYearAwareInvestmentModelDirect: - """Test the YearAwareInvestmentModel class directly with linopy.""" - - def test_flow_invest_new(self, flow_system): - da = xr.DataArray( - [10] * 8, - coords=(flow_system.years_of_investment,), - ).expand_dims(year=flow_system.years) - da = da.where(da.year == da.year_of_investment).fillna(0) - - flow = fx.Flow( - 'Wärme', - bus='Fernwärme', - size=fx.InvestTimingParameters( - # year_of_decommissioning=2030, - minimum_lifetime=2, - maximum_lifetime=3, - minimum_size=9, - maximum_size=10, - specific_effects=xr.DataArray( - [25, 30, 35, 40, 45, 50, 55, 60], - coords=(flow_system.years,), - ) - * -0, - # fix_effects=-2e3, - specific_effects_by_investment_year=-1 * da, - ), - relative_maximum=np.linspace(0.5, 1, flow_system.timesteps.size), - ) - - flow_system.add_elements(fx.Source('Source', source=flow)) - calculation = fx.FullCalculation('GenericName', flow_system) - calculation.do_modeling() - # calculation.model.add_constraints(calculation.model['Source(Wärme)|is_invested'].sel(year=2022) == 1) - calculation.solve(fx.solvers.GurobiSolver(0, 60)) - - ds = calculation.results['Source'].solution - filtered_ds_year = ds[[v for v in ds.data_vars if ds[v].dims == ('year',)]] - print(filtered_ds_year.round(0).to_pandas().T) - - filtered_ds_scalar = ds[[v for v in ds.data_vars if ds[v].dims == tuple()]] - print(filtered_ds_scalar.round(0).to_pandas().T) - - print(calculation.results.solution['costs(invest)|total'].to_pandas()) - - print('##') From c151f3d36c2c8996948206c7752a4c63d05350ca Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 23 Sep 2025 19:37:00 +0200 Subject: [PATCH 326/336] More reverted changes --- flixopt/features.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/flixopt/features.py b/flixopt/features.py index 594ece84c..24dd3e844 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -11,8 +11,7 @@ import linopy import numpy as np -from .config import CONFIG -from .modeling import BoundingPatterns, ModelingPrimitives, ModelingUtilities, ModelingUtilitiesAbstract +from .modeling import BoundingPatterns, ModelingPrimitives, ModelingUtilities from .structure import FlowSystemModel, Submodel if TYPE_CHECKING: From 70476a871a57b83a6fff89074e0161dbedf2ff0a Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 23 Sep 2025 19:38:07 +0200 Subject: [PATCH 327/336] More reverted changes --- flixopt/flow_system.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index 40f5e2451..970280b88 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -82,11 +82,10 @@ def __init__( ) if years is None: - self.years, self.years_per_year, self.years_of_investment = None, None, None + self.years, self.years_per_year = None, None else: self.years = self._validate_years(years) self.years_per_year = self.calculate_years_per_year(self.years, years_of_last_year) - self.years_of_investment = self.years.rename('year_of_investment') self.scenarios = None if scenarios is None else self._validate_scenarios(scenarios) From 0c3f985afb06ca323b049ea01cf8aa700760fd32 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 23 Sep 2025 19:40:29 +0200 Subject: [PATCH 328/336] Add years_of_last_year to docstring --- flixopt/flow_system.py | 1 + 1 file changed, 1 insertion(+) diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index 970280b88..9d82598f4 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -58,6 +58,7 @@ class FlowSystem(Interface): If None, the first time increment of time_series is used. This is needed to calculate previous durations (for example consecutive_on_hours). If you use an array, take care that its long enough to cover all previous values! + years_of_last_year: The duration of the last year. Uses the last year interval if not specified weights: The weights of each year and scenario. If None, all scenarios have the same weight, while the years have the weight of their represented year (all normalized to 1). Its recommended to scale the weights to sum up to 1. Notes: From 61515fa5fbaa099b649b64eaa0bdf7e9e3189897 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 23 Sep 2025 19:41:48 +0200 Subject: [PATCH 329/336] Revert change from Investment --- flixopt/interface.py | 1 - 1 file changed, 1 deletion(-) diff --git a/flixopt/interface.py b/flixopt/interface.py index ae308d388..57e16494b 100644 --- a/flixopt/interface.py +++ b/flixopt/interface.py @@ -705,7 +705,6 @@ class InvestParameters(Interface): divest_effects: Costs incurred if the investment is NOT made, such as demolition of existing equipment, contractual penalties, or lost opportunities. Dictionary mapping effect names to values. - previous_size: Initial size of the investment. Only relevant in multi-period investments. Cost Annualization Requirements: All cost values must be properly weighted to match the optimization model's time horizon. From 9f1a1829cf45283b617731df20e9d42957626f79 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 23 Sep 2025 19:46:06 +0200 Subject: [PATCH 330/336] Revert change from Investment --- flixopt/interface.py | 1 - 1 file changed, 1 deletion(-) diff --git a/flixopt/interface.py b/flixopt/interface.py index 57e16494b..fea55b17e 100644 --- a/flixopt/interface.py +++ b/flixopt/interface.py @@ -872,7 +872,6 @@ def __init__( self.maximum_size = maximum_size if maximum_size is not None else CONFIG.modeling.BIG # default maximum def transform_data(self, flow_system: FlowSystem, name_prefix: str = '') -> None: - self._plausibility_checks(flow_system) self.fix_effects = flow_system.fit_effects_to_model_coords( label_prefix=name_prefix, effect_values=self.fix_effects, From a2985e37a42d722b5ddbc1df28660caa654257c7 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 23 Sep 2025 19:52:39 +0200 Subject: [PATCH 331/336] Remove old todos.txt file --- tests/todos.txt | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 tests/todos.txt diff --git a/tests/todos.txt b/tests/todos.txt deleted file mode 100644 index d4628c259..000000000 --- a/tests/todos.txt +++ /dev/null @@ -1,5 +0,0 @@ -# testing of - # abschnittsweise linear testen - # Komponenten mit offenen Flows - # Binärvariablen ohne max-Wert-Vorgabe des Flows (Binärungenauigkeitsproblem) - # Medien-zulässigkeit From 725476ccddb35a7c8ecdd87a639608ad8c29af18 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 23 Sep 2025 20:24:19 +0200 Subject: [PATCH 332/336] Fix typos in CHANGELOG.md --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8972697fd..d16443eeb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -89,7 +89,7 @@ The weighted sum of the total objective effect of each scenario is used as the o * Enhanced FlowSystem interface with improved `__repr__()` and `__str__()` methods * Improved Model Structure - Views and organisation is now divided into: * Model: The main Model (linopy.Model) that is used to create and store the variables and constraints for the flow_system. - * Submodel: The base class for all submodels. Each is a subset of the Model, for simpler acess and clearer code. + * Submodel: The base class for all submodels. Each is a subset of the Model, for simpler access and clearer code. ### Deprecated * The `agg_group` and `agg_weight` parameters of `TimeSeriesData` are deprecated and will be removed in a future version. Use `aggregation_group` and `aggregation_weight` instead. @@ -105,7 +105,7 @@ The weighted sum of the total objective effect of each scenario is used as the o * Better type consistency across all framework components ### Known issues -* IO for single Interfaces/Elemenets to Datasets might not work properly if the Interface/Element is not part of a fully transformed and connected FlowSystem. This arrises from Numeric Data not being stored as xr.DataArray by the user. To avoid this, always use the `to_dataset()` on Elements inside a FlowSystem thats connected and transformed. +* IO for single Interfaces/Elements to Datasets might not work properly if the Interface/Element is not part of a fully transformed and connected FlowSystem. This arises from Numeric Data not being stored as xr.DataArray by the user. To avoid this, always use the `to_dataset()` on Elements inside a FlowSystem that's connected and transformed. ### *Development* * **BREAKING**: Calculation.do_modeling() now returns the Calculation object instead of its linopy.Model From 31e8e73a9a57d1d2983044bd0543aab3af31bc2c Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 23 Sep 2025 20:27:18 +0200 Subject: [PATCH 333/336] Improve usage of name_prefix to intelligently join with the label --- flixopt/components.py | 43 +++++++++++++++++++++---------------------- flixopt/effects.py | 21 +++++++++++---------- flixopt/elements.py | 35 +++++++++++++++++------------------ flixopt/structure.py | 2 +- 4 files changed, 50 insertions(+), 51 deletions(-) diff --git a/flixopt/components.py b/flixopt/components.py index 8e3e0f2e0..4b6f3699e 100644 --- a/flixopt/components.py +++ b/flixopt/components.py @@ -205,12 +205,13 @@ def _plausibility_checks(self) -> None: ) def transform_data(self, flow_system: FlowSystem, name_prefix: str = '') -> None: - super().transform_data(flow_system) + super().transform_data(flow_system, name_prefix) if self.conversion_factors: self.conversion_factors = self._transform_conversion_factors(flow_system) if self.piecewise_conversion: self.piecewise_conversion.has_time_dim = True - self.piecewise_conversion.transform_data(flow_system, f'{self.label_full}|PiecewiseConversion') + prefix = '|'.join(filter(None, [name_prefix, self.label_full])) + self.piecewise_conversion.transform_data(flow_system, f'{prefix}|PiecewiseConversion') def _transform_conversion_factors(self, flow_system: FlowSystem) -> list[dict[str, xr.DataArray]]: """Converts all conversion factors to internal datatypes""" @@ -423,45 +424,46 @@ def create_model(self, model: FlowSystemModel) -> StorageModel: return self.submodel def transform_data(self, flow_system: FlowSystem, name_prefix: str = '') -> None: - super().transform_data(flow_system) + super().transform_data(flow_system, name_prefix) + base = '|'.join(filter(None, [name_prefix, self.label_full])) self.relative_minimum_charge_state = flow_system.fit_to_model_coords( - f'{self.label_full}|relative_minimum_charge_state', + f'{base}|relative_minimum_charge_state', self.relative_minimum_charge_state, ) self.relative_maximum_charge_state = flow_system.fit_to_model_coords( - f'{self.label_full}|relative_maximum_charge_state', + f'{base}|relative_maximum_charge_state', self.relative_maximum_charge_state, ) - self.eta_charge = flow_system.fit_to_model_coords(f'{self.label_full}|eta_charge', self.eta_charge) - self.eta_discharge = flow_system.fit_to_model_coords(f'{self.label_full}|eta_discharge', self.eta_discharge) + self.eta_charge = flow_system.fit_to_model_coords(f'{base}|eta_charge', self.eta_charge) + self.eta_discharge = flow_system.fit_to_model_coords(f'{base}|eta_discharge', self.eta_discharge) self.relative_loss_per_hour = flow_system.fit_to_model_coords( - f'{self.label_full}|relative_loss_per_hour', self.relative_loss_per_hour + f'{base}|relative_loss_per_hour', self.relative_loss_per_hour ) if not isinstance(self.initial_charge_state, str): self.initial_charge_state = flow_system.fit_to_model_coords( - f'{self.label_full}|initial_charge_state', self.initial_charge_state, dims=['year', 'scenario'] + f'{base}|initial_charge_state', self.initial_charge_state, dims=['year', 'scenario'] ) self.minimal_final_charge_state = flow_system.fit_to_model_coords( - f'{self.label_full}|minimal_final_charge_state', self.minimal_final_charge_state, dims=['year', 'scenario'] + f'{base}|minimal_final_charge_state', self.minimal_final_charge_state, dims=['year', 'scenario'] ) self.maximal_final_charge_state = flow_system.fit_to_model_coords( - f'{self.label_full}|maximal_final_charge_state', self.maximal_final_charge_state, dims=['year', 'scenario'] + f'{base}|maximal_final_charge_state', self.maximal_final_charge_state, dims=['year', 'scenario'] ) self.relative_minimum_final_charge_state = flow_system.fit_to_model_coords( - f'{self.label_full}|relative_minimum_final_charge_state', + f'{base}|relative_minimum_final_charge_state', self.relative_minimum_final_charge_state, dims=['year', 'scenario'], ) self.relative_maximum_final_charge_state = flow_system.fit_to_model_coords( - f'{self.label_full}|relative_maximum_final_charge_state', + f'{base}|relative_maximum_final_charge_state', self.relative_maximum_final_charge_state, dims=['year', 'scenario'], ) if isinstance(self.capacity_in_flow_hours, InvestParameters): - self.capacity_in_flow_hours.transform_data(flow_system, f'{self.label_full}|InvestParameters') + self.capacity_in_flow_hours.transform_data(flow_system, f'{base}|InvestParameters') else: self.capacity_in_flow_hours = flow_system.fit_to_model_coords( - f'{self.label_full}|capacity_in_flow_hours', self.capacity_in_flow_hours, dims=['year', 'scenario'] + f'{base}|capacity_in_flow_hours', self.capacity_in_flow_hours, dims=['year', 'scenario'] ) def _plausibility_checks(self) -> None: @@ -694,13 +696,10 @@ def create_model(self, model) -> TransmissionModel: return self.submodel def transform_data(self, flow_system: FlowSystem, name_prefix: str = '') -> None: - super().transform_data(flow_system) - self.relative_losses = flow_system.fit_to_model_coords( - f'{self.label_full}|relative_losses', self.relative_losses - ) - self.absolute_losses = flow_system.fit_to_model_coords( - f'{self.label_full}|absolute_losses', self.absolute_losses - ) + super().transform_data(flow_system, name_prefix) + base = '|'.join(filter(None, [name_prefix, self.label_full])) + self.relative_losses = flow_system.fit_to_model_coords(f'{base}|relative_losses', self.relative_losses) + self.absolute_losses = flow_system.fit_to_model_coords(f'{base}|absolute_losses', self.absolute_losses) class TransmissionModel(ComponentModel): diff --git a/flixopt/effects.py b/flixopt/effects.py index 3390bd463..f465dcc3b 100644 --- a/flixopt/effects.py +++ b/flixopt/effects.py @@ -175,40 +175,41 @@ def __init__( self.maximum_total = maximum_total def transform_data(self, flow_system: FlowSystem, name_prefix: str = '') -> None: + base = '|'.join(filter(None, [name_prefix, self.label_full])) self.minimum_operation_per_hour = flow_system.fit_to_model_coords( - f'{self.label_full}|minimum_operation_per_hour', self.minimum_operation_per_hour + f'{base}|minimum_operation_per_hour', self.minimum_operation_per_hour ) self.maximum_operation_per_hour = flow_system.fit_to_model_coords( - f'{self.label_full}|maximum_operation_per_hour', self.maximum_operation_per_hour + f'{base}|maximum_operation_per_hour', self.maximum_operation_per_hour ) self.specific_share_to_other_effects_operation = flow_system.fit_effects_to_model_coords( - f'{self.label_full}|operation->', self.specific_share_to_other_effects_operation, 'operation' + f'{base}|operation->', self.specific_share_to_other_effects_operation, 'operation' ) self.minimum_operation = flow_system.fit_to_model_coords( - f'{self.label_full}|minimum_operation', self.minimum_operation, dims=['year', 'scenario'] + f'{base}|minimum_operation', self.minimum_operation, dims=['year', 'scenario'] ) self.maximum_operation = flow_system.fit_to_model_coords( - f'{self.label_full}|maximum_operation', self.maximum_operation, dims=['year', 'scenario'] + f'{base}|maximum_operation', self.maximum_operation, dims=['year', 'scenario'] ) self.minimum_invest = flow_system.fit_to_model_coords( - f'{self.label_full}|minimum_invest', self.minimum_invest, dims=['year', 'scenario'] + f'{base}|minimum_invest', self.minimum_invest, dims=['year', 'scenario'] ) self.maximum_invest = flow_system.fit_to_model_coords( - f'{self.label_full}|maximum_invest', self.maximum_invest, dims=['year', 'scenario'] + f'{base}|maximum_invest', self.maximum_invest, dims=['year', 'scenario'] ) self.minimum_total = flow_system.fit_to_model_coords( - f'{self.label_full}|minimum_total', + f'{base}|minimum_total', self.minimum_total, dims=['year', 'scenario'], ) self.maximum_total = flow_system.fit_to_model_coords( - f'{self.label_full}|maximum_total', self.maximum_total, dims=['year', 'scenario'] + f'{base}|maximum_total', self.maximum_total, dims=['year', 'scenario'] ) self.specific_share_to_other_effects_invest = flow_system.fit_effects_to_model_coords( - f'{self.label_full}|invest->', + f'{base}|invest->', self.specific_share_to_other_effects_invest, 'invest', dims=['year', 'scenario'], diff --git a/flixopt/elements.py b/flixopt/elements.py index f3598912c..e67f6bd8f 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -99,10 +99,11 @@ def create_model(self, model: FlowSystemModel) -> ComponentModel: def transform_data(self, flow_system: FlowSystem, name_prefix: str = '') -> None: if self.on_off_parameters is not None: - self.on_off_parameters.transform_data(flow_system, self.label_full) + prefix = '|'.join(filter(None, [name_prefix, self.label_full])) + self.on_off_parameters.transform_data(flow_system, prefix) for flow in self.inputs + self.outputs: - flow.transform_data(flow_system) + flow.transform_data(flow_system, name_prefix) def _check_unique_flow_labels(self): all_flow_labels = [flow.label for flow in self.inputs + self.outputs] @@ -190,8 +191,9 @@ def create_model(self, model: FlowSystemModel) -> BusModel: return self.submodel def transform_data(self, flow_system: FlowSystem, name_prefix: str = '') -> None: + base = '|'.join(filter(None, [name_prefix, self.label_full])) self.excess_penalty_per_flow_hour = flow_system.fit_to_model_coords( - f'{self.label_full}|excess_penalty_per_flow_hour', self.excess_penalty_per_flow_hour + f'{base}|excess_penalty_per_flow_hour', self.excess_penalty_per_flow_hour ) def _plausibility_checks(self) -> None: @@ -418,37 +420,34 @@ def create_model(self, model: FlowSystemModel) -> FlowModel: return self.submodel def transform_data(self, flow_system: FlowSystem, name_prefix: str = '') -> None: - self.relative_minimum = flow_system.fit_to_model_coords( - f'{self.label_full}|relative_minimum', self.relative_minimum - ) - self.relative_maximum = flow_system.fit_to_model_coords( - f'{self.label_full}|relative_maximum', self.relative_maximum - ) + base = '|'.join(filter(None, [name_prefix, self.label_full])) + self.relative_minimum = flow_system.fit_to_model_coords(f'{base}|relative_minimum', self.relative_minimum) + self.relative_maximum = flow_system.fit_to_model_coords(f'{base}|relative_maximum', self.relative_maximum) self.fixed_relative_profile = flow_system.fit_to_model_coords( - f'{self.label_full}|fixed_relative_profile', self.fixed_relative_profile + f'{base}|fixed_relative_profile', self.fixed_relative_profile ) self.effects_per_flow_hour = flow_system.fit_effects_to_model_coords( - self.label_full, self.effects_per_flow_hour, 'per_flow_hour' + base, self.effects_per_flow_hour, 'per_flow_hour' ) self.flow_hours_total_max = flow_system.fit_to_model_coords( - f'{self.label_full}|flow_hours_total_max', self.flow_hours_total_max, dims=['year', 'scenario'] + f'{base}|flow_hours_total_max', self.flow_hours_total_max, dims=['year', 'scenario'] ) self.flow_hours_total_min = flow_system.fit_to_model_coords( - f'{self.label_full}|flow_hours_total_min', self.flow_hours_total_min, dims=['year', 'scenario'] + f'{base}|flow_hours_total_min', self.flow_hours_total_min, dims=['year', 'scenario'] ) self.load_factor_max = flow_system.fit_to_model_coords( - f'{self.label_full}|load_factor_max', self.load_factor_max, dims=['year', 'scenario'] + f'{base}|load_factor_max', self.load_factor_max, dims=['year', 'scenario'] ) self.load_factor_min = flow_system.fit_to_model_coords( - f'{self.label_full}|load_factor_min', self.load_factor_min, dims=['year', 'scenario'] + f'{base}|load_factor_min', self.load_factor_min, dims=['year', 'scenario'] ) if self.on_off_parameters is not None: - self.on_off_parameters.transform_data(flow_system, self.label_full) + self.on_off_parameters.transform_data(flow_system, base) if isinstance(self.size, InvestParameters): - self.size.transform_data(flow_system, self.label_full) + self.size.transform_data(flow_system, base) else: - self.size = flow_system.fit_to_model_coords(f'{self.label_full}|size', self.size, dims=['year', 'scenario']) + self.size = flow_system.fit_to_model_coords(f'{base}|size', self.size, dims=['year', 'scenario']) def _plausibility_checks(self) -> None: # TODO: Incorporate into Variable? (Lower_bound can not be greater than upper bound diff --git a/flixopt/structure.py b/flixopt/structure.py index 85c6621d6..01529aba0 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -222,7 +222,7 @@ class Interface: transform_data(flow_system): Transform data to match FlowSystem dimensions """ - def transform_data(self, flow_system: FlowSystem, name_prefix: str = ''): + def transform_data(self, flow_system: FlowSystem, name_prefix: str = '') -> None: """Transform the data of the interface to match the FlowSystem's dimensions. Args: From ff461473aa3221bb1de2773748c0fc1f4d201288 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 24 Sep 2025 11:59:54 +0200 Subject: [PATCH 334/336] Ensure IO of years_of_last_year --- flixopt/flow_system.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index 9d82598f4..5a91dc36c 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -82,6 +82,7 @@ def __init__( timesteps, hours_of_previous_timesteps ) + self.years_of_last_year = years_of_last_year if years is None: self.years, self.years_per_year = None, None else: @@ -279,6 +280,7 @@ def from_dataset(cls, ds: xr.Dataset) -> FlowSystem: timesteps=ds.indexes['time'], years=ds.indexes.get('year'), scenarios=ds.indexes.get('scenario'), + years_of_last_year=reference_structure.get('years_of_last_year'), weights=cls._resolve_dataarray_reference(reference_structure['weights'], arrays_dict) if 'weights' in reference_structure else None, From 95fa7c28fd68148f29d79946c523ceb16a9a0e67 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 24 Sep 2025 12:00:04 +0200 Subject: [PATCH 335/336] Typo --- flixopt/modeling.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flixopt/modeling.py b/flixopt/modeling.py index d2a192f68..4e368d7ae 100644 --- a/flixopt/modeling.py +++ b/flixopt/modeling.py @@ -627,7 +627,7 @@ def continuous_transition_bounds( Tuple of constraints: (transition_upper, transition_lower, initial_upper, initial_lower) """ if not isinstance(model, Submodel): - raise ValueError('BoundingPatterns.continuous_transition_bounds() can only be used with a Submodel') + raise ValueError('ModelingPrimitives.continuous_transition_bounds() can only be used with a Submodel') # Transition constraints for t > 0: continuous variable can only change when switches are active transition_upper = model.add_constraints( From a9d8deb54efb63e4724787089cc5660b1952ee07 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 24 Sep 2025 12:41:30 +0200 Subject: [PATCH 336/336] Typo --- flixopt/features.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/flixopt/features.py b/flixopt/features.py index 24dd3e844..51a4832b7 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -255,9 +255,9 @@ def _add_effects(self): # Properties access variables from Submodel's tracking system @property - def total_on_hours(self) -> linopy.Variable | None: + def on_hours_total(self) -> linopy.Variable: """Total on hours variable""" - return self['total_on_hours'] + return self['on_hours_total'] @property def off(self) -> linopy.Variable | None: