diff --git a/.github/workflows/continuous-integration-workflow.yaml b/.github/workflows/continuous-integration-workflow.yaml index 774d8a3b..a2a31086 100644 --- a/.github/workflows/continuous-integration-workflow.yaml +++ b/.github/workflows/continuous-integration-workflow.yaml @@ -22,13 +22,13 @@ jobs: run: | python -m pip install --upgrade pip pip install -e ".[develop]" - pip install git+https://github.com/NREL/electrolyzer.git@638d890 + pip install git+https://github.com/NREL/electrolyzer.git pip install https://github.com/NREL/SEAS/blob/v1/SEAS.tar.gz?raw=true # - uses: pre-commit/action@v3.0.0 - name: Run ruff run: | ruff check . -# ruff format + ruff format --check - name: Run tests and collect coverage run: | # -rA displays the captured output for all tests after they're run diff --git a/.gitignore b/.gitignore index 9a179f39..65d20905 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ data.db *.python-version *.DS_Store *.conda +*.sqlite slices # macOS files diff --git a/CITATION.cff b/CITATION.cff index 2fe1998e..033cb181 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -2,10 +2,10 @@ cff-version: 1.3.0 message: "If you use this software, please cite it as below." authors: - - family-names: NREL - given-names: + - family-names: NLR + given-names: title: "HERCULES" version: 2 url: https://github.com/NREL/hercules -date-released: 2025-09-30 +date-released: 2025-12-31 diff --git a/LICENSE.txt b/LICENSE.txt index 364b74c6..70abd942 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -1,6 +1,6 @@ BSD 3-Clause License -Copyright (c) 2025, Alliance for Sustainable Energy LLC, All rights reserved. +Copyright (c) 2025, Alliance for Energy Innovation LLC, All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: diff --git a/README.md b/README.md index b5ece037..f9381ea7 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # README # hercules -Hercules is an open-source tool for wind-based hybrid plant simulation in real time. Hercules is emulated a wind farm co-simulated other generation to form a hybrid plant that can include solar, storage and electrolyzers. The entire hybrid plant can be controlled using the [Wind Hybrid Open Controller (WHOC)](https://github.com/nrel/wind-hybrid-open-controller). +Hercules is an open-source tool for hybrid plant simulation in real time. Hercules co-simulates multiple technologies to form a hybrid plant that can include wind, solar, storage and electrolyzers. The entire hybrid plant can be controlled using [Hycon](https://github.com/nrel/hycon). ## Part of the WETO Stack diff --git a/docs/_config.yml b/docs/_config.yml index 0b271dd1..3489aaf5 100644 --- a/docs/_config.yml +++ b/docs/_config.yml @@ -2,10 +2,12 @@ # Learn more at https://jupyterbook.org/customize/config.html title: HERCULES -author: National Renewable Energy Laboratory +author: National Laboratory of the Rockies logo: herc.png copyright: '2025' only_build_toc_files: false +exclude_patterns: + - not_used/** # Force re-execution of notebooks on each build. # See https://jupyterbook.org/content/execute.html @@ -33,7 +35,8 @@ html: use_issues_button: true use_repository_button: true use_edit_page_button: true - google_analytics_id: G-3V1BDK8KEJ + analytics: + google_analytics_id: G-3V1BDK8KEJ diff --git a/docs/_toc.yml b/docs/_toc.yml index 5314c323..993017ea 100644 --- a/docs/_toc.yml +++ b/docs/_toc.yml @@ -10,6 +10,7 @@ parts: - caption: Running Hercules chapters: - file: running_hercules + - file: order_of_op - caption: Key Concepts chapters: - file: key_concepts @@ -17,27 +18,27 @@ parts: - file: timing - file: h_dict - file: hybrid_plant - - file: emulator + - file: hercules_model - file: output_files + - caption: Plant Components + chapters: + - file: wind + - file: solar_pv + - file: battery + - file: electrolyzer - caption: Inputs chapters: - file: hercules_input + - file: gridstatus_download + - file: resource_downloading - caption: Examples chapters: - file: examples_overview - - caption: HybridPlant - chapters: - - file: wind - - file: battery - - file: solar_pv - - caption: Usage - chapters: - - file: order_of_op - - - - - - - - + - file: examples/00_wind_farm_only + - file: examples/01_wind_farm_dof1_model + - file: examples/02_wind_farm_realistic_inflow + - file: examples/02b_wind_farm_realistic_inflow_precom_floris + - file: examples/03_wind_and_solar + - file: examples/04_wind_and_storage + - file: examples/05_wind_and_storage_with_lmp + - file: examples/06_wind_and_hydrogen diff --git a/docs/battery.md b/docs/battery.md index befa564a..761790e2 100644 --- a/docs/battery.md +++ b/docs/battery.md @@ -7,12 +7,12 @@ There are two battery models currently implemented in Hercules: `BatterySimple` It is important to note that within the battery modules, the convention that positive power is charging the battery is followed for consistency with battery standards. However, at the level of the `HybridPlant` this is inverted, such that positive power implies power delivery (and thus the battery is discharging) -for consistency with other components. This inversions applies to power_setpoint and also occurs within +for consistency with other components. This inversion applies to power_setpoint and also occurs within `HybridPlant`. ### Parameters -Battery parameters are defined in the hercules input yaml file used to initialize `emulator`. +Battery parameters are defined in the hercules input yaml file used to initialize `HerculesModel`. #### Required Parameters - `component_type`: `"BatterySimple"` or `"BatteryLithiumIon"` @@ -32,6 +32,7 @@ Battery parameters are defined in the hercules input yaml file used to initializ - `usage_calc_interval`: Interval for usage calculations in seconds (BatterySimple only) - `usage_lifetime`: Battery lifetime in years for time-based degradation (BatterySimple only) - `usage_cycles`: Number of cycles until replacement for cycle-based degradation (BatterySimple only) +- `log_channels`: List of output channels to log (see [Logging Configuration](battery-logging-configuration) below) Once initialized, the battery is only interacted with using the `step` method. @@ -55,12 +56,42 @@ Outputs are returned as a dict containing the following values: - `power`: Actual battery power in kW - `reject`: Rejected power due to constraints in kW (positive when power cannot be absorbed, negative when required power unavailable) - `soc`: Battery state of charge (0-1) +- `power_setpoint`: Requested power setpoint in kW #### Additional Outputs (BatterySimple only when track_usage=True) - `usage_in_time`: Time-based usage percentage - `usage_in_cycles`: Cycle-based usage percentage - `total_cycles`: Total equivalent cycles completed +(battery-logging-configuration)= +### Logging Configuration + +The `log_channels` parameter controls which outputs are written to the HDF5 output file. This is a list of channel names. The `power` channel is always logged, even if not explicitly specified. + +**Available Channels:** +- `power`: Actual battery power output in kW (always logged) +- `soc`: State of charge (0-1) +- `power_setpoint`: Requested power setpoint in kW + +**Example:** +```yaml +battery: + component_type: BatterySimple + energy_capacity: 100.0 # kWh + charge_rate: 50.0 # kW + discharge_rate: 50.0 # kW + max_SOC: 0.9 + min_SOC: 0.1 + log_channels: + - power + - soc + - power_setpoint + initial_conditions: + SOC: 0.5 +``` + +If `log_channels` is not specified, only `power` will be logged. + ## `BatterySimple` diff --git a/docs/electrolyzer.md b/docs/electrolyzer.md new file mode 100644 index 00000000..991089f9 --- /dev/null +++ b/docs/electrolyzer.md @@ -0,0 +1,117 @@ +# Electrolyzer Plant + +The hydrogen electrolyzer modules use the [electrolyzer](https://github.com/NREL/electrolyzer) package developed by the National Laboratory of the Rockies to predict hydrogen output of hydrogen electrolyzer plants. This repo contains models for PEM and Alkaline electrolyzer cell types. + +To create a hydrogen electrolyzer plant, set `component_type` = `ElectrolyzerPlant` in the input dictionary (.yaml file). + + +## Inputs + +#### Required Parameters +The parameters listed below are required unless otherwise specified as *Optional*. + +- `general`: General simulation parameters. +- `initial_conditions`: Initial conditions for the simulation including: + - `power_available_kw`: Initial power available to the electrolyzer [kW] +- `electrolyzer`: Electrolyzer plant specific parameters including: + - `initialize`: boolean. Whether to initialize the electrolyzer. + - `initial_power_kW`: Initial power input to the electrolyzer [kW]. + - `supervisor`: + - `system_rating_MW`: Total system rating in MW. + - `n_stacks`: Number of electrolyzer stacks in the plant. + - `stack`: Electrolyzer stack parameters including: + - `cell_type`: Type of electrolyzer cell (e.g., PEM, Alkaline). + - `max_current`: Maximum current of the stack [A]. + - `temperature`: Stack operating temperature [degC]. + - `n_cells`: Number of cells per stack. + - `min_power`: Minimum power for electrolyzer operation [kW]. + - `stack_rating_kW`: Stack rated power [kW]. + - `include_degradation_penalty`: *Optional* Whether to include degradation penalty. + - `hydrogen_degradation_penalty`: *Optional* boolean, whether degradation is applied to hydrogen (True) or power (False) + - `cell_params`: Electrolyzer cell parameters including: + - `cell_area`: Area of individual cells in the stack [cm^2]. + - `turndown_ratio`: Minimum turndown ratio for stack operation [between 0 and 1]. + - `max current_density`: Maximum current density [A/cm^2]. + - `p_anode`: Anode operating pressure [bar]. + - `p_cathode`: Cathode operating pressure [bar]. + - `alpha_a`: anode charge transfer coefficient. + - `alpha_c`: cathode charge transfer coefficient. + - `i_0_a`: anode exchange current density [A/cm^2]. + - `i_0_c`: cathode exchange current density [A/cm^2]. + - `e_m`: membrane thickness [cm]. + - `R_ohmic_elec`: electrolyte resistance [A*cm^2]. + - `f_1`: Faradaic coefficient [mA^2/cm^4]. + - `f_2`: Faradaic coefficient [mA^2/cm^4]. + - `degradation`: Electrolyzer degradation parameters including: + - `eol_eff_percent_loss`: End of life efficiency percent loss [%]. + - `PEM_params` or `ALK_params`: Degradation parameters specific to PEM or Alkaline cells: + - `rate_steady`: Rate of voltage degradation under steady operation alone + - `rate_fatigue`: Rate of voltage degradation under variable operation alone + - `rate_onoff`: Rate of voltage degradation per on/off cycle + - `controller`: Electrolyzer control parameters including: + - `control_type`: Controller type for electrolyzer plant operation. + - `costs`: *Optional* Cost parameters for the electrolyzer plant including: + - `plant_params`: + - `plant_life`: integer, Plant life in years + - `pem_location`: Location of the PEM electrolyzer. Options are + [onshore, offshore, in-turbine] + - `grid_connected`: boolean, Whether the plant is connected to the grid or not + - `feedstock`: Parameters related to the feedstock including: + - `water_feedstock_cost`: Cost of water per kg of water + - `water_per_kgH2`: Amount of water required per kg of hydrogen produced + - `opex`: Operational expenditure parameters including: + - `var_OM`: Variable operation and maintenance cost per kW + - `fixed_OM`: Fixed operation and maintenance cost per kW-year + - `stack_replacement`: Parameters related to stack replacement costs including: + - `d_eol`: End of life cell voltage value [V] + - `stack_replacement_percent`: Stack replacement cost as a percentage of CapEx [0,1] + - `capex`: Capital expenditure parameters including: + - `capex_learning_rate`: Capital expenditure learning rate. + - `ref_cost_bop`: Reference cost of balance of plant per kW. + - `ref_size_bop`: Reference size of balance of plant in kW. + - `ref_cost_pem`: Reference cost of PEM electrolyzer stack per kW. + - `ref_size_pem`: Reference size of PEM electrolyzer stack in kW. + - `finances`: Financial parameters including: + - `discount_rate`: Discount rate for financial calculations [%]. + - `install_factor`: Installation factor for capital expenditure [0,1]. +- `log_channels`: List of output channels to log (see [Logging Configuration](elec-logging-configuration) below) + + +## Outputs +(elec-logging-configuration)= +**Logging Configuration** + +The `log_channels` parameter controls which outputs are written to the HDF5 output file. This is a list of channel names. The `power` channel is always logged, even if not explicitly specified. + + +### Available Channels + +**Scalar Channels:** +- `H2_output`: Total hydrogen produced during the last timestep +- `H2_mfr`: Mass flow rate of the hydrogen production in kg/s +- `power`: Power that the electrolyzer plant used to create hydrogen (kW). This follows the convention that power consumed is negative power. +- `Power_input_kw`: Power allocated to the electrolyzer plant to use (kW) +- `stacks_on`: Total number of stacks producing hydrogen + +**Array Channels:** +- `stacks_waiting`: Boolean list of the stacks that are waiting to start producing hydrogen (True for stacks waiting, False for stacks not waiting) + + +**Example:** +```yaml +ele: + component_type: ElectrolyzerPlant + log_channels: + - power + - H2_output + - H2_mfr + initial_conditions: + - power_available_kw: 3000 + electrolyzer: + # ... other parameters +``` + +If `log_channels` is not specified, only `power` will be logged. + +## References +1. Z. Tully, G. Starke, K. Johnson and J. King, "An Investigation of Heuristic Control Strategies for Multi-Electrolyzer Wind-Hydrogen Systems Considering Degradation," 2023 IEEE Conference on Control Technology and Applications (CCTA), Bridgetown, Barbados, 2023, pp. 817-822, doi: 10.1109/CCTA54093.2023.10252187. diff --git a/docs/emulator.md b/docs/emulator.md deleted file mode 100644 index fe1fbf93..00000000 --- a/docs/emulator.md +++ /dev/null @@ -1,41 +0,0 @@ -# Emulator - -The `Emulator` class orchestrates the entire Hercules simulation, managing the main execution loop and coordinating between the controller, Python simulators, and output logging. - -## Overview - -The emulator serves as the central coordinator that drives the simulation forward step-by-step, handling data logging, performance monitoring, and output file generation. - -## Simulation Flow - -For each time step: -1. Update external signals from interpolated data -2. Execute controller step (compute control actions) -3. Execute hybrid plant step (update component states) -4. Log current state to output file -5. Advance simulation time - -## Configuration Options - -### Logging Configuration - -The emulator supports configurable logging frequency through the `log_every_n` parameter: - -- **`log_every_n`** (int, optional): Controls how often simulation data is logged to the output file. - - Default: 1 (log every simulation step) - - Example: `log_every_n: 5` logs data every 5 simulation steps - - This reduces output file size and improves performance for long simulations - -### Output File Generation - -The emulator generates HDF5 output files containing comprehensive simulation data for analysis and visualization. - -The output file includes metadata with: -- `dt_sim`: Simulation time step (seconds) -- `dt_log`: Logging time step (seconds) = `dt_sim * log_every_n` -- `log_every_n`: Logging stride value -- `start_clock_time` and `end_clock_time`: Wall clock timing information - - -For detailed information about the output file format and reading utilities, see the [Output Files](output_files.md) documentation. - diff --git a/docs/examples_overview.md b/docs/examples_overview.md index 2994d72a..8f670b42 100644 --- a/docs/examples_overview.md +++ b/docs/examples_overview.md @@ -5,17 +5,19 @@ Hercules includes several example cases that demonstrate different simulation co ## Available Examples - [00: Wind Farm Only](../examples/00_wind_farm_only/) - Simple wind farm simulation with generated wind data -- [01: Wind Farm DOF1 Model](../examples/01_wind_farm_dof1_model/) - 1-DOF long-duration wind simulation +- [01: Wind Farm DOF1 Model](../examples/01_wind_farm_dof1_model/) - 1-DOF long-duration wind simulation - [02: Wind Farm Realistic Inflow](../examples/02_wind_farm_realistic_inflow/) - Large-scale wind farm with longer running wind data - [02b: Wind Farm Realistic Inflow (Precomputed FLORIS)](../examples/02b_wind_farm_realistic_inflow_precom_floris/) - Optimized version using precomputed wake deficits - [03: Wind and Solar](../examples/03_wind_and_solar/) - Hybrid wind and solar plant with interconnect limits - [04: Wind and Storage](../examples/04_wind_and_storage/) - Wind farm with battery storage system +- [05: Wind and Storage with LMP](../examples/05_wind_and_storage_with_lmp/) - Battery control based on electricity pricing with selective external data logging +- [06: Wind and Hydrogen](../examples/06_wind_and_hydrogen/) - Wind farm with electrolyzer for hydrogen production ## Input Data Management All examples use centralized input files located in `examples/inputs/`: - Wind data files (`.ftr` format) -- Solar data files (`.ftr` format) +- Solar data files (`.ftr` format) - FLORIS configuration files (`.yaml`) - Turbine model files (`.yaml`) - PV system configuration files (`.json`) @@ -36,3 +38,12 @@ python hercules_runscript.py ``` No manual setup is required - all necessary input files will be automatically generated on first run. + +## Additional Resource Downloading and Upsampling Examples + +Examples are provided in the `examples/inputs/` folder demonstrating how to download wind and solar data using the `hercules.resource.wind_solar_resource_downloader` module and upsample wind data using the `hercules.resource.upsample_wind_data` module to create inputs for Hercules simulations. + +- [03: Download NSRDB and WIND Toolkit Solar and Wind Data](../examples/inputs/03_download_small_nsrdb_wtk_solar_wind_example.py) - Downloads a subset of solar and wind data for a small grid of locations for a single year from the NSRDB and WIND Toolkit datasets, respectively +- [04: Download and Upsample WIND Toolkit Wind Data](../examples/inputs/04_download_and_upsample_wtk_wind_example.py) - Downloads wind speed and direction for a small grid of locations for a single year from the WIND Toolkit dataset, then spatially interpolates the data at specific wind turbine locations and temporally upsamples the times series with added turbulence +- [05: Download Open-Meteo Solar and Wind Data](../examples/inputs/05_download_small_openmeteo_solar_wind_example.py) - Downloads a subset of solar and wind data for a small grid of locations for a single year using the Open-Meteo API +- [06: Download and Upsample Open-Meteo Wind Data](../examples/inputs/06_download_and_upsample_openmeteo_wind_example.py) - Downloads wind speed and direction for a small grid of locations for a single year using the Open-Meteo API, then spatially interpolates the data at specific wind turbine locations and temporally upsamples the times series with added turbulence diff --git a/docs/gridstatus_download.md b/docs/gridstatus_download.md new file mode 100644 index 00000000..eac43eef --- /dev/null +++ b/docs/gridstatus_download.md @@ -0,0 +1,100 @@ +# Grid Status Data Download + +This page describes how to download LMP (Locational Marginal Pricing) data from [Grid Status](https://www.gridstatus.io/) for use in Hercules simulations. + +## Basic Workflow + +Refer to `examples/grid/grid_status_download_example.py` for an example of how to download LMP data from Grid Status. The script saves data in a lightly modified feather format that can be used directly in Hercules. + +**If you need to combine real-time and day-ahead data for Hycon**, see the section on `generate_locational_marginal_price_dataframe_from_gridstatus()` at the end of this page. + +## What is Grid Status? + +[Grid Status](https://www.gridstatus.io/) is a platform that provides electricity market data including: +- Real-time and historical LMP data +- Market operations data +- Grid status information +- Power system reliability data + +## API Key Setup + +To use this script, you'll need an API key from Grid Status: + +1. Visit [Grid Status API Settings](https://www.gridstatus.io/settings/api) +2. Sign up for an account if you don't have one +3. Generate an API key +4. Set the API key as an environment variable or configure it in your Grid Status client, this could done either by a shell command `export GRIDSTATUS_API_KEY=your_api_key` or by adding `export GRIDSTATUS_API_KEY="your_api_key"` to your `.bashrc` or `.zshrc` file + +## Downloading LMP Data with `grid_status_download.py` + +The `grid_status_download.py` example script downloads LMP data from Grid Status and saves it in a lightly modified feather format. The modifications are minimal: +- Removes columns not used by Hercules (`interval_end_utc`, `location`, `location_type`, `pnode`) +- Keeps the original time resolution and market type +- Saves as a feather file for efficient storage + +The downloaded feather files can be used directly in Hercules for applications that need only real-time or only day-ahead data. + +### Why uvx? + +We recommend running the script with [uvx](https://docs.astral.sh/uv/guides/tools/) to run in an isolated environment because the `gridstatusio` package requires a different version of numpy than the rest of Hercules. Using uvx prevents dependency conflicts between the Grid Status client and Hercules' requirements. + +See https://docs.astral.sh/uv/getting-started/installation/ for information in installing uv. + +### Usage + +To modify the example script to download data for your own use, you can: + +1. Copy the script to your project folder +2. Update the parameters in the script: + - `dataset`: Set to `"spp_lmp_real_time_5_min"` for real-time data, `"spp_lmp_day_ahead_hourly"` for day-ahead data, or any other Grid Status dataset + - `start` and `end`: Date range for the data + - `filter_column` and `filter_value`: These are used to select the node of interest +3. Run the script with uvx: + ```bash + uvx --with gridstatusio --with pyarrow python grid_status_download.py + ``` + +If you need multiple datasets (e.g., both real-time and day-ahead), update the `dataset` parameter and run the script again. + +See `examples/grid/grid_status_download_example.py` for an example that downloads both real-time and day-ahead datasets. + +### Parameters + +- **dataset**: The Grid Status dataset identifier (e.g., "spp_lmp_real_time_5_min", "spp_lmp_day_ahead_hourly"; see https://www.gridstatus.io/datasets for a complete list) +- **start**: Start date in YYYY-MM-DD format +- **end**: End date in YYYY-MM-DD format +- **filter_column**: Column to filter on (usually "location" to select a node) +- **filter_value**: Value to filter by (should be the name of a node of interest, e.g., "OKGE.FRONTIER"; all nodes listed here: https://www.gridstatus.io/nodes) +- **QUERY_LIMIT**: Maximum number of rows to download (default: 20,000). Included to avoid accidentally using too much of account limit. + +### Output + +The script saves the downloaded data as a feather file (`.ftr`) with: +- Filename format: `gs_{dataset}_{start}_{filter_value}.ftr` +- Original time resolution (5-minute intervals for real-time, hourly for day-ahead) +- Original market type identifier preserved in the `market` column + +## Combining Real-Time and Day-Ahead Data for Hycon + +If you need to combine both real-time and day-ahead LMP data for use with Hycon, use the `generate_locational_marginal_price_dataframe_from_gridstatus()` function located in `hercules/grid/grid_utilities.py`. + +**Note:** This step is only necessary if you need both real-time and day-ahead data combined. If you only need one type of data, you can use the feather files from `grid_status_download.py` directly. + +The `generate_locational_marginal_price_dataframe_from_griddstatus()` function combines the real-time and day-ahead LMP data into a format optimized for Hycon: + +- Merges real-time and day-ahead data at the base interval of the real-time data +- Creates hourly day-ahead LMP columns (`lmp_da_00` through `lmp_da_23`) for each hour of the day +- Forward-fills any missing values +- Generates "end" rows for each time interval for Hercules interpolation + +See `examples/grid/process_grid_status_results.py` for a complete example. + +### Output Format + +The resulting DataFrame contains: +- `time_utc`: UTC timestamp +- ``: Real-time LMP at the base time interval +- `lmp_da`: Day-ahead LMP (forward-filled to the base interval) +- `lmp_da_00` through `lmp_da_23`: Day-ahead LMP for each hour of the day + +This format is optimized for use with Hycon in Hercules simulations. diff --git a/docs/h_dict.md b/docs/h_dict.md index b712683e..58e8e778 100644 --- a/docs/h_dict.md +++ b/docs/h_dict.md @@ -23,20 +23,21 @@ The `h_dict` is a Python dictionary that contains all the configurations for eac | `verbose` | bool | Enable verbose logging | False | | `name` | str | Simulation name | - | | `description` | str | Simulation description | - | -| `output_file` | str | Output CSV file path | "outputs/hercules_output.csv" | -| `time_log_interval` | int | Logging interval in steps | - | -| `log_every_n` | int | Log every N simulation steps (default: 1) | 1 | -| `external_data_file` | str | External data file path | - | +| `output_file` | str | Output HDF5 file path | "outputs/hercules_output.h5" | +| `log_every_n` | int | Log every N simulation steps to output log (default: 1) | 1 | +| `external_data` | dict | External data configuration | - | +| `external_data_file` | str | External data file path (deprecated, use `external_data` instead) | - | | `controller` | dict | Controller configuration | - | | **Hybrid Plant Components** | ### Wind Farm (`wind_farm`) -| `component_type` | str | Must be "Wind_MesoToPower" | +| `component_type` | str | Must be "WindFarm" or "WindFarmSCADAPower" | | `floris_input_file` | str | FLORIS input file path | | `wind_input_filename` | str | Wind data input file | | `turbine_file_name` | str | Turbine configuration file | | `log_file_name` | str | Wind farm log file path | -| `logging_option` | str | Logging level: "base", "turb_subset", or "all" | +| `log_channels` | list | List of channels to log (e.g., ["power", "wind_speed_mean_background", "turbine_powers"]) | +| `floris_update_time_s` | float | How often to update FLORIS wake calculations in seconds | ### Solar Farm (`solar_farm`) | `component_type` | str | "SolarPySAMPVWatts" | @@ -48,6 +49,7 @@ The `h_dict` is a Python dictionary that contains all the configurations for eac | `lat` | float | Latitude | | `lon` | float | Longitude | | `elev` | float | Elevation in meters | +| `log_channels` | list | List of channels to log (e.g., ["power", "dni", "poa", "aoi"]) | | `initial_conditions` | dict | Initial power, DNI, POA | ### Battery (`battery`) @@ -61,6 +63,7 @@ The `h_dict` is a Python dictionary that contains all the configurations for eac | `min_SOC` | float | Minimum state of charge (0-1) | Required | | `initial_conditions` | dict | Contains initial SOC | Required | | `allow_grid_power_consumption` | bool | Allow grid power consumption | False | +| `log_channels` | list | List of channels to log (e.g., ["power", "soc", "power_setpoint"]) | ["power"] | | `roundtrip_efficiency` | float | Roundtrip efficiency (BatterySimple only) | 1.0 | | `self_discharge_time_constant` | float | Self-discharge time constant in seconds (BatterySimple only) | inf | | `track_usage` | bool | Enable usage tracking (BatterySimple only) | False | @@ -80,3 +83,17 @@ The `h_dict` is a Python dictionary that contains all the configurations for eac | `cell_params` | dict | Cell parameters | | `degradation` | dict | Degradation parameters | +### External Data (`external_data`) +| Key | Type | Description | Default | +|-----|------|-------------|---------| +| `external_data_file` | str | Path to CSV file with external time series data | Optional (if not specified, `external_data` is ignored) | +| `log_channels` | list | List of channels to log to HDF5 output | None (log all) | + +**Logging behavior:** +- `log_channels` **not specified**: All channels are logged (default) +- `log_channels: []` (empty list): No channels are logged +- `log_channels: [channel1, channel2]`: Only listed channels are logged + +**Note**: All channels from the external data file are always available to the controller via `h_dict["external_signals"]`, regardless of the `log_channels` setting. The `log_channels` parameter only controls which channels are written to the HDF5 output file. + +**Old format** (deprecated): Setting `external_data_file` at the top level is still supported but shows a deprecation warning. Use the `external_data` dict format instead. diff --git a/docs/hercules_input.md b/docs/hercules_input.md index 8b03e4c8..0ad7416c 100644 --- a/docs/hercules_input.md +++ b/docs/hercules_input.md @@ -10,9 +10,10 @@ Input files use YAML format for readability and flexibility. The `Loader` class The input file structure mirrors the `h_dict` structure documented in the [h_dict page](h_dict.md). Key sections include: -- **Top level parameters**: `dt`, `starttime`, `endtime` (see [timing](timing.md) for details) +- **Top level parameters**: `dt`, `starttime_utc`, `endtime_utc` (see [timing](timing.md) for details) - **Plant configuration**: `interconnect_limit` - **Hybrid plant configurations**: `wind_farm`, `solar_farm`, `battery`, `electrolyzer` +- **External data**: `external_data` for external time series data (e.g., LMP prices, weather forecasts) - **Optional settings**: `verbose`, `name`, `description`, `output_file` @@ -21,10 +22,12 @@ The input file structure mirrors the `h_dict` structure documented in the [h_dic The `load_hercules_input()` function in `utilities.py` performs comprehensive validation: 1. Loads the YAML file using the custom `Loader` class -2. Validates required keys (`dt`, `starttime`, `endtime`, `plant`) -3. Ensures `plant.interconnect_limit` is present and numeric -4. Validates component configurations and types -5. Sets defaults for optional parameters (e.g., `verbose: False`) +2. Validates required keys (`dt`, `starttime_utc`, `endtime_utc`, `plant`) +3. Parses and validates UTC datetime strings for `starttime_utc` and `endtime_utc` +4. Computes derived values: `starttime` (always 0.0) and `endtime` (duration in seconds) +5. Ensures `plant.interconnect_limit` is present and numeric +6. Validates component configurations and types +7. Sets defaults for optional parameters (e.g., `verbose: False`) ## Example @@ -35,20 +38,25 @@ name: example_simulation description: Wind and Solar Farm Simulation dt: 1.0 -starttime: 0.0 -endtime: 950.0 +starttime_utc: "2020-01-01T00:00:00Z" # Simulation start in UTC +endtime_utc: "2020-01-01T00:15:50Z" # Simulation end (15 min 50 sec later) verbose: False plant: interconnect_limit: 30000 # kW wind_farm: - component_type: Wind_MesoToPower + component_type: WindFarm + wake_method: dynamic floris_input_file: inputs/floris_input.yaml wind_input_filename: inputs/wind_input.csv turbine_file_name: inputs/turbine_filter_model.yaml log_file_name: outputs/log_wind_sim.log - logging_option: all + log_channels: + - power + - wind_speed_mean_background + - wind_speed_mean_withwakes + - wind_direction_mean floris_update_time_s: 30.0 solar_farm: @@ -59,6 +67,11 @@ solar_farm: elev: 1829 system_capacity: 10000 # kW (10 MW) tilt: 0 # degrees + log_channels: + - power + - dni + - poa + - aoi initial_conditions: power: 2000 # kW dni: 1000 @@ -71,6 +84,10 @@ battery: discharge_rate: 50.0 # MW max_SOC: 0.95 min_SOC: 0.05 + log_channels: + - power + - soc + - power_setpoint initial_conditions: SOC: 0.5 @@ -81,6 +98,106 @@ output_file: outputs/hercules_output.h5 log_every_n: 1 ``` +## External Data Configuration + +Hercules supports loading external time series data from CSV files (e.g., electricity prices, weather forecasts, or other external signals). This data becomes available to controllers through `h_dict["external_signals"]`. + +### New Format (Preferred) + +```yaml +external_data: + external_data_file: path/to/data.csv + log_channels: + - lmp_rt + - wind_forecast +``` + +**Key features:** +- `external_data_file`: Path to CSV file with `time_utc` column and data columns +- `log_channels`: Optional list of channels to log to HDF5 output + - If **omitted**: all channels are logged (default behavior) + - If **empty list** (`log_channels: []`): no channels are logged + - If **non-empty list**: only listed channels are written to HDF5 + - **Important**: All channels are always available to the controller via `h_dict["external_signals"]`, regardless of `log_channels` + +### Old Format (Deprecated) + +```yaml +external_data_file: path/to/data.csv # Logs all channels, shows deprecation warning +``` + +The old format is still supported for backward compatibility but will show a deprecation warning. It automatically logs all external data channels to the output file. + +### External Data File Format + +The CSV file must contain: +- A `time_utc` column with UTC timestamps in ISO 8601 format +- One or more data columns with external signals. Note that the names of the other columns are arbitrary; any column names will be carried forward and interpolated. However, the values must be floats. Additionally, some controllers and plotting utilities that work on external signals may require specific column names like `lmp_rt`, `lmp_da`, `wind_forecast`, etc. + +Example `lmp_data.csv`: +```csv +time_utc,lmp_rt,lmp_da,wind_forecast +2024-06-24T16:59:08Z,25.5,20.0,12.3 +2024-06-24T17:04:08Z,26.1,20.0,12.5 +2024-06-24T17:09:08Z,27.3,20.0,12.8 +... +``` + +Hercules automatically interpolates external data to match the simulation time step. + +### Usage in Controllers + +All external data channels are accessible in the controller through `h_dict["external_signals"]`: + +```python +class MyController: + def step(self, h_dict): + # Access external signals (all channels available) + lmp_rt = h_dict["external_signals"]["lmp_rt"] + lmp_da = h_dict["external_signals"]["lmp_da"] + wind_forecast = h_dict["external_signals"]["wind_forecast"] + + # Use signals for control logic + if lmp_rt < 15: + h_dict["battery"]["power_setpoint"] = -10000 # charge + elif lmp_rt > 35: + h_dict["battery"]["power_setpoint"] = 10000 # discharge + else: + h_dict["battery"]["power_setpoint"] = 0 + + return h_dict +``` + +Even if `log_channels` only specifies `["lmp_rt"]`, the controller can still access all channels. The `log_channels` setting only controls what gets written to the HDF5 output file. + +### Selective Logging Examples + +**Example 1: Log all channels (default)** +```yaml +external_data: + external_data_file: data.csv + # log_channels not specified → logs all channels +``` + +**Example 2: Log specific channels** +```yaml +external_data: + external_data_file: data.csv + log_channels: + - lmp_rt + - wind_forecast + # Only logs lmp_rt and wind_forecast (but all channels available to controller) +``` + +**Example 3: Log no channels** +```yaml +external_data: + external_data_file: data.csv + log_channels: [] # Empty list → logs nothing (but all channels available to controller) +``` + +This is useful when you want external data available for control decisions but don't need it saved in the output file. + ## Output Configuration Options Hercules supports several output configuration options to optimize file size and write performance: @@ -100,17 +217,13 @@ Controls HDF5 compression (default: True). Disable for faster writes if storage ### output_buffer_size Controls the memory buffer size for writing data (default: 50000 rows). Larger buffers improve performance but use more memory. - - - - ### Example with Output Configuration ```yaml # Advanced output configuration example dt: 1.0 -starttime: 0.0 -endtime: 3600.0 +starttime_utc: "2020-06-15T12:00:00Z" +endtime_utc: "2020-06-15T13:00:00Z" # 1 hour simulation # Log every 60 seconds (1 minute) to reduce file size log_every_n: 60 @@ -122,11 +235,16 @@ plant: interconnect_limit: 5000 wind_farm: - component_type: Wind_MesoToPower + component_type: WindFarm + wake_method: dynamic floris_input_file: inputs/floris_input.yaml wind_input_filename: inputs/wind_input.csv turbine_file_name: inputs/turbine_filter_model.yaml - logging_option: all + log_channels: + - power + - wind_speed_mean_background + - wind_speed_mean_withwakes + - wind_direction_mean floris_update_time_s: 30.0 controller: @@ -136,11 +254,15 @@ controller: The `load_hercules_input()` function performs strict validation on input files to catch configuration errors early. This includes checking for: -- Required keys at the top level (`dt`, `starttime`, `endtime`, `plant`) +- Required keys at the top level (`dt`, `starttime_utc`, `endtime_utc`, `plant`) +- Valid UTC datetime strings (ISO 8601 format) for `starttime_utc` and `endtime_utc` + - Accepts: strings ending with "Z" (explicit UTC) or naive strings (no timezone) + - Rejects: strings with timezone offsets (e.g., `+05:00`, `-08:00`) since the field must be UTC +- Logical time ordering (`endtime_utc` must be after `starttime_utc`) - Valid component types and configurations - Numeric validation for timing and power parameters - File existence checks for referenced input files - Output configuration validation (`log_every_n` must be a positive integer) -- Component-specific validation (e.g., wind farm `logging_option` must be "base", "turb_subset", or "all") +- Component-specific validation (e.g., `log_channels` must be a list of valid channel names) -Invalid configurations will raise descriptive `ValueError` exceptions to help with debugging. \ No newline at end of file +Invalid configurations will raise descriptive `ValueError` exceptions to help with debugging. diff --git a/docs/hercules_model.md b/docs/hercules_model.md new file mode 100644 index 00000000..bfd004ea --- /dev/null +++ b/docs/hercules_model.md @@ -0,0 +1,90 @@ +# HerculesModel + +The `HerculesModel` class orchestrates the entire Hercules simulation, managing the main execution loop and coordinating between the controller, HybridPlant, and output logging. + +## Overview + +The HerculesModel serves as the central coordinator that drives the simulation forward step-by-step, handling data logging, performance monitoring, and output file generation. + +## Usage + +HerculesModel is initialized with an input file, and then a controller is assigned separately. The simplest case is a pass-through controller: + +```python +from hercules.hercules_model import HerculesModel + +# Initialize the Hercules model +hmodel = HerculesModel("hercules_input.yaml") + +# Define your controller class +class MyController: + def __init__(self, h_dict): + # Initialize with prepared h_dict + pass + + def step(self, h_dict): + # Implement control logic + return h_dict + +# Assign the controller to the model +hmodel.assign_controller(MyController(hmodel.h_dict)) + +# Run the simulation +hmodel.run() +``` + +The HerculesModel handles all initialization automatically: +- Sets up logging +- Loads and validates the input file +- Initializes the hybrid plant +- Adds plant metadata to h_dict + +The controller is then assigned using the `assign_controller()` method, which: +- Takes a controller instance (not the class) +- The controller instance is initialized with the prepared h_dict from the model + +## Simulation Flow + +For each time step: +1. Update external signals from interpolated data +2. Execute controller step (compute control actions) +3. Execute hybrid plant step (update component states) +4. Log current state to output file +5. Advance simulation time + +## Configuration Options + +### Logging Configuration + +The HerculesModel supports configurable logging frequency through the `log_every_n` parameter: + +- **`log_every_n`** (int, optional): Controls how often simulation data is logged to the output file. + - Default: 1 (log every simulation step) + - Example: `log_every_n: 5` logs data every 5 simulation steps + - This reduces output file size and improves performance for long simulations + +### Output File Generation + +The HerculesModel generates HDF5 output files containing comprehensive simulation data for analysis and visualization. + +The output file includes metadata with: +- `dt_sim`: Simulation time step (seconds) +- `dt_log`: Logging time step (seconds) = `dt_sim * log_every_n` +- `log_every_n`: Logging stride value +- `start_clock_time` and `end_clock_time`: Wall clock timing information + + +For detailed information about the output file format and reading utilities, see the [Output Files](output_files.md) documentation. + +## Logging Configuration + +Hercules provides a unified `setup_logging()` function in the `hercules.utilities` module that handles all logging setup across the framework. This function is used internally by `HerculesModel` and all component classes, but can also be used directly for custom applications. + +### Internal Usage + +The `setup_logging()` function is called automatically by: +- `HerculesModel` during initialization (creates hercules logger) +- All component classes through `ComponentBase` (creates component-specific loggers) + +Each component gets its own logger with an appropriate console prefix for easy identification of log messages. + diff --git a/docs/hybrid_plant.md b/docs/hybrid_plant.md index 8b7a7958..b764dc6d 100644 --- a/docs/hybrid_plant.md +++ b/docs/hybrid_plant.md @@ -10,8 +10,8 @@ HybridPlant automatically detects and initializes components based on the [h_dic | Component | Component Type | Description | |-----------|----------------|-------------| -| `wind_farm` | `Wind_MesoToPower` | FLORIS-based wind farm simulation | -| `wind_farm` | `Wind_MesoToPowerPrecomFloris` | Precomputed FLORIS-based wind farm simulation | +| `wind_farm` | `WindFarm` | FLORIS-based wind farm simulation | +| `wind_farm` | `WindFarmSCADAPower` | Pass through wind farm SCADA | | `solar_farm` | `SolarPySAMPVWatts` | PySAM-based simplified solar simulation | | `battery` | `BatterySimple` | Basic battery storage model | | `battery` | `BatteryLithiumIon` | Detailed lithium-ion battery model | diff --git a/docs/index.md b/docs/index.md index 3fc1f5a8..75d33457 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,3 +1,3 @@ # Hercules -Hercules is an open-source tool for wind-based hybrid plant simulation in real time (or faster). Hercules emulates a wind farm co-simulated other generation to form a hybrid plant that can include solar, storage and electrolyzers. The entire hybrid plant can be controlled using the [Wind Hybrid Open Controller (WHOC)](https://github.com/nrel/wind-hybrid-open-controller). \ No newline at end of file +Hercules is an open-source tool for wind-based hybrid plant simulation in real time (or faster). Hercules emulates a wind farm co-simulated with other generation to form a hybrid plant that can include solar, storage and electrolyzers. The entire hybrid plant can be controlled using [Hycon](https://github.com/nrel/hycon). \ No newline at end of file diff --git a/docs/install.md b/docs/install.md index 90953b08..dc78c9b9 100644 --- a/docs/install.md +++ b/docs/install.md @@ -5,7 +5,7 @@ ## Root Directory -It is recommended to install Hercules into a root directory. This root directory can also contain other projects that are often used with Hercules such as the Wind Hybrid Open Controller (WHOC) and the Electrolyzer. +It is recommended to install Hercules into a root directory. This root directory can also contain other projects that are often used with Hercules such as Hycon and the Electrolyzer. ```bash mkdir -p hercules_root @@ -40,16 +40,21 @@ uv venv source .venv/bin/activate ``` +See https://docs.astral.sh/uv/getting-started/installation/ for information in installing uv. + +Note, `uvx` is used for in running the gridstatus_download.py script. So you will need to install if using the gridstatus_download.py script. +See (Grid Status Data Download)[gridstatus_download.md] for more information. + ## PIP Install Install Hercules in editible mode into the active virtual environment. -#### Just Hercules +### Just Hercules ```bash pip install -e . ``` -#### With Developer and Documentation Dependencies +### With Developer and Documentation Dependencies ```bash pip install -e .[develop,docs] @@ -64,9 +69,9 @@ git fetch --all git switch v2 ``` -## Wind Hybrid Open Controller (WHOC) +## Hycon -NREL's Wind Hybrid Open Controller (WHOC) software is used to implement controllers in the Hercules platform. This package is not essential to run Hercules by itself, but is needed to implement any controls in the platform. +NLR's Hycon software is used to implement controllers in the Hercules platform. This package is not essential to run Hercules by itself, but is needed to implement any controls in the platform. To install: @@ -74,15 +79,15 @@ Go back to root ```bash cd .. -git clone git@github.com:NREL/wind-hybrid-open-controller.git -cd wind-hybrid-open-controller +git clone git@github.com:NREL/hycon.git +cd hycon git fetch --all pip install -e . ``` ## Electrolyzer -A python electrolyzer model is also required for hercules. To install +A python electrolyzer model is also required for hercules. To install the electrolyzer, use ```bash @@ -92,7 +97,3 @@ cd electrolyzer git fetch --all git switch main ``` - - - - diff --git a/docs/key_concepts.md b/docs/key_concepts.md index 0400902d..c3b961e4 100644 --- a/docs/key_concepts.md +++ b/docs/key_concepts.md @@ -10,5 +10,5 @@ The central data structure that contains all simulation parameters, component co ### [Hybrid Plant Components](hybrid_plant.md) Manages individual components like wind farms, solar panels, batteries, and electrolyzers. -### [Emulator](emulator.md) -The central orchestrator that drives the simulation forward step-by-step. The emulator manages the main execution loop, coordinates between components, and handles output generation. +### [HerculesModel](hercules_model.md) +The central orchestrator that drives the simulation forward step-by-step. HerculesModel manages the main execution loop, coordinates between components, and handles output generation. diff --git a/docs/not_used/install_on_kestrel.md b/docs/not_used/install_on_kestrel.md index 78b85790..0c8e2ba4 100644 --- a/docs/not_used/install_on_kestrel.md +++ b/docs/not_used/install_on_kestrel.md @@ -1,6 +1,6 @@ # Installation on Kestrel -This document outlines the process for install hercules onto NREL's Kestrel +This document outlines the process for install hercules onto NLR's Kestrel computer. The initial steps for running on Kestrel are the same as those outlined in **Local installation instructions**. Once those steps are complete, @@ -105,18 +105,18 @@ If you run hercules and get an error that `pyyaml` is missing, you may also need conda install -c conda-forge pyyaml ``` -### Install the NREL Wind Hybrid Open Controller (WHOC) +### Install Hycon This module is used to implement controllers in the Hercules platform. Example 06 has an example of how this is used to control a battery based on wind farm power output. -Note: if you want the newest updates to the WHOC repository, you can checkout the develop branch instead of the main branch. +Note: if you want the newest updates to the Hycon repository, you can checkout the develop branch instead of the main branch. Installation instructions: Go back to herc_root ``` -git clone git@github.com:NREL/wind-hybrid-open-controller.git -cd wind-hybrid-open-controller +git clone git@github.com:NREL/hycon.git +cd hycon git fetch --all pip install -e . ``` diff --git a/docs/not_used/install_spack.md b/docs/not_used/install_spack.md index 39ffe48b..5bcee40f 100644 --- a/docs/not_used/install_spack.md +++ b/docs/not_used/install_spack.md @@ -1,5 +1,5 @@ # Spack installation of AMR Wind code -This document outlines the process for installing the high-fidelity wind portion of Hercules onto NREL's Kestrel HPC system using spack package manager. For more information on Spack, see (https://spack.io/) +This document outlines the process for installing the high-fidelity wind portion of Hercules onto NLR's Kestrel HPC system using spack package manager. For more information on Spack, see (https://spack.io/) ## Initial steps 1. Log into Kestrel diff --git a/docs/order_of_op.md b/docs/order_of_op.md index 94736fdb..2cd5bd9c 100644 --- a/docs/order_of_op.md +++ b/docs/order_of_op.md @@ -2,12 +2,13 @@ ## Initialization -1. Load configuration from YAML input file into `h_dict` -2. Initialize controller +1. Initialize HerculesModel with YAML input file +2. Load and validate configuration into `h_dict` 3. Initialize hybrid plant components based on `h_dict` configuration -4. Initialize emulator with controller, hybrid plant, and configuration -5. Add plant component metadata to `h_dict` -6. Load external data files if specified +4. Add plant component metadata to `h_dict` +5. Load external data files if specified +6. Initialize controller instance with the prepared `h_dict` +7. Assign controller to HerculesModel using `assign_controller()` method ## Main Simulation Loop diff --git a/docs/output_files.md b/docs/output_files.md index 84f8c951..f56b03f9 100644 --- a/docs/output_files.md +++ b/docs/output_files.md @@ -1,6 +1,6 @@ # Output Files -Hercules generates HDF5 output files containing simulation data for analysis and visualization. This page describes the file format, available utilities for reading the data, and how the emulator generates these files. +Hercules generates HDF5 output files containing simulation data for analysis and visualization. This page describes the file format, available utilities for reading the data, and how HerculesModel generates these files. ## File Format @@ -18,10 +18,18 @@ hercules_output.h5 │ ├── plant_power # Total plant power output │ ├── plant_locally_generated_power # Locally generated power │ ├── components/ -│ │ ├── wind_farm.power # Wind farm power output -│ │ ├── wind_farm.wind_speed # Wind speed at hub height -│ │ ├── solar_farm.power # Solar farm power output -│ │ └── ... # Other component outputs +│ │ ├── wind_farm.power # Wind farm power output +│ │ ├── wind_farm.wind_speed_mean_background # Farm-average background wind speed +│ │ ├── wind_farm.wind_speed_mean_withwakes # Farm-average with-wakes wind speed +│ │ ├── wind_farm.wind_direction_mean # Farm-average wind direction +│ │ ├── wind_farm.turbine_powers.000 # Turbine 0 power (if logged) +│ │ ├── wind_farm.turbine_powers.001 # Turbine 1 power (if logged) +│ │ ├── solar_farm.power # Solar farm power output +│ │ ├── solar_farm.dni # Direct normal irradiance (if logged) +│ │ ├── solar_farm.poa # Plane-of-array irradiance (if logged) +│ │ ├── battery.power # Battery power (if present) +│ │ ├── battery.soc # Battery state of charge (if logged) +│ │ └── ... # Other component outputs │ └── external_signals/ │ └── ... # Other external signals └── metadata/ @@ -29,10 +37,12 @@ hercules_output.h5 ├── dt_sim # Simulation time step (seconds) ├── dt_log # Logging time step (seconds) ├── log_every_n # Logging stride value + ├── starttime # Simulation start time (always 0.0 seconds) + ├── endtime # Simulation end time (duration in seconds) ├── start_clock_time # Simulation start wall clock time ├── end_clock_time # Simulation end wall clock time - ├── start_time_utc # Simulation start UTC time (if any component data contains time_utc) - ├── zero_time_utc # Simulation zero UTC time (if any component data contains time_utc) + ├── total_time_wall # Total wall clock time for simulation + ├── starttime_utc # Simulation start UTC time (Unix timestamp) └── ... # Other metadata attributes ``` @@ -60,7 +70,7 @@ from hercules.utilities import read_hercules_hdf5_subset # Read specific columns df_subset = read_hercules_hdf5_subset( "outputs/hercules_output.h5", - columns=["wind_farm.power", "solar_farm.power", "external_signals.wind_speed"] + columns=["wind_farm.power", "wind_farm.wind_speed_mean_background", "solar_farm.power"] ) # Read specific time range (seconds) @@ -85,7 +95,7 @@ from hercules.utilities import get_hercules_metadata # Get simulation metadata metadata = get_hercules_metadata("outputs/hercules_output.h5") print(f"Simulation configuration: {metadata['h_dict']}") -print(f"Start time: {metadata.get('start_time_utc')}") +print(f"Start time: {metadata.get('starttime_utc')}") ``` ### Convenience Class @@ -118,7 +128,7 @@ The `HerculesOutput` class provides a convenient interface while still allowing ### Time UTC Reconstruction -If any component input data contains `time_utc` columns, the utilities can reconstruct UTC timestamps for each simulation step: +The `read_hercules_hdf5()` function automatically reconstructs UTC timestamps for each simulation step using the stored `starttime_utc` metadata: ```python from hercules.utilities import read_hercules_hdf5 @@ -128,3 +138,19 @@ df = read_hercules_hdf5("outputs/hercules_output.h5") if "time_utc" in df.columns: print(f"UTC timestamps available: {df['time_utc'].head()}") ``` + +The reconstruction formula is: +```python +time_utc = starttime_utc + timedelta(seconds=time) +``` + +## Backward Compatibility + +Hercules maintains backward compatibility with output files created before the timing model changes (prior to version 2.0). Old output files may contain: + +- `zero_time_utc` instead of `starttime_utc` +- Different metadata field names + +The `HerculesOutput` class and reading utilities automatically handle both old and new formats, so you can read any Hercules output file regardless of when it was created. + +``` diff --git a/docs/resource_downloading.md b/docs/resource_downloading.md new file mode 100644 index 00000000..4d0017dd --- /dev/null +++ b/docs/resource_downloading.md @@ -0,0 +1,135 @@ +# Solar and Wind Resource Downloading and Upsampling + +Functions are provided in the `hercules.resource.wind_solar_resource_downloading` module for downloading solar and wind time series data so they can be used as inputs to Hercules simulations. The `hercules.resource.upsample_wind_data` module is used to spatially interpolate downloaded wind data at specific wind turbine locations and temporally upsample the data. + +## Overview + +The `hercules.resource.wind_solar_resource_downloading` module contains functions for downloading solar data from the [National Solar Radiation Database (NSRDB)](https://nsrdb.nrel.gov), wind data from the [Wind Integration National Dataset (WIND) Toolkit](https://www.nrel.gov/grid/wind-toolkit), and solar and wind data from [Open-Meteo](https://open-meteo.com). + +For downloaded wind data, the `hercules.resource.upsample_wind_data` module can be used to spatially interpolate the data at specific wind turbine locations and temporally upsample the data to represent realistic turbulent wind speeds. The downloaded and upsampled data can be saved as `.feather` files and used as inputs to Hercules simulations. + +## Solar and Wind Resource Downloading Module + +This section describes the functions for downloading solar and wind resource data in the `hercules.resource.wind_solar_resource_downloading` module. + +### API Key + +The functions for downloading NSRDB and WIND Toolkit data require an NLR API key, which can be obtained by visiting https://developer.nrel.gov/signup/. After receiving your API key, you must make a configuration file at ~/.hscfg containing the following: +``` +hs_endpoint = https://developer.nrel.gov/api/hsds +hs_api_key = YOUR_API_KEY_GOES_HERE +``` + +More information can be found at: https://github.com/NREL/hsds-examples. An API key is not needed for downloading Open-Meteo data. + +### Output Format + +For each resource dataset, the downloaded data are returned as a dictionary of pandas DataFrames with `.feather` files saved for each DataFrame. + +The dictionary key `coordinates` and a corresponding `.feather` file contain a DataFrame with columns `index`, `lat`, and `lon` describing the lat/lon coordinates of each grid location index. + +For each variable downloaded, the dictionary key given by the variable name and a corresponding `.feather` file contain a DataFrame with a `time_index` column, containing the time in UTC, and columns corresponding to each grid location index containing the time series data for the variable. + +### NSRDB Solar Data + +The function `download_nsrdb_data` is used to download historical global horizontal irradiance (GHI), direct normal irradiance (DNI), and diffuse horizontal irradiance (DHI) time series data at a grid of coordinates from the National Solar Radiation Database (NSRDB). By default, data are downloaded from the [GOES Conus PSM v4](https://developer.nrel.gov/docs/solar/nsrdb/nsrdb-GOES-conus-v4-0-0-download/) dataset, which includes data for the continental US for 2018 to the present and has a spatial resolution of 2 km and temporal resolution of 5 minutes. However, [other NSRDB datasets](https://developer.nrel.gov/docs/solar/nsrdb/) can be used as well. + +Arguments to the `download_nsrdb_data` function used to specify the data to download are as follows: +- `target_lat`: The latitude of the center of the grid of points for which data are requested. +- `target_lon`: The longitude of the center of the grid of points for which data are requested. +- `year`: The year for which data are requested. +- `start_date`: If `year` is not used, the specific start date for which data are requested. +- `end_date`: If `year` is not used, the specific end date for which data are requested. +- `variables`: List of variables to download. Defaults to ["ghi", "dni", "dhi", "wind_speed", "air_temperature"]. +- `nsrdb_dataset_path`: Path name of NSRDB dataset. Available datasets are described [here](https://developer.nrel.gov/docs/solar/nsrdb/) and path names can be identified [here](https://data.openei.org/s3_viewer?bucket=nrel-pds-nsrdb). Defaults to the GOES Conus v4.0.0 dataset: "/nrel/nsrdb/GOES/conus/v4.0.0". +- `nsrdb_filename_prefix`: File name prefix for the NSRDB HDF5 files in the format "{nsrdb_filename_prefix}_{year}.h5". Information about file names can be found [here](https://data.openei.org/s3_viewer?bucket=nrel-pds-nsrdb). Defaults to "nsrdb_conus". +- `coord_delta`: Coordinate delta for bounding box defining grid of points for which data are requested. Bounding box is defined as target_lat +/- coord_delta and target_lon +/- coord_delta. Defaults to 0.1 degrees. + +### WIND Toolkit Wind Data + +The function `download_wtk_data` is used to download historical wind data from the [WIND Toolkit Long-term Ensemble Dataset (WTK-LED)](https://www.nrel.gov/grid/wind-toolkit). WTK-LED data are available at US offshore and land-based locations and are provided at a spatial resolution of 2 km and a temporal resolution of 5 minutes for years 2018 through 2020. Available variables include wind speed, wind direction, and turbulent kinetic energy at multiple heights. The full list of available variables can be found [here](https://developer.nrel.gov/docs/wind/wind-toolkit/wtk-conus-5min-v2-0-0-download/). + +Arguments to the `download_wtk_data` function used to specify the data to download are as follows: +- `target_lat`: The latitude of the center of the grid of points for which data are requested. +- `target_lon`: The longitude of the center of the grid of points for which data are requested. +- `year`: The year for which data are requested. +- `start_date`: If `year` is not used, the specific start date for which data are requested. +- `end_date`: If `year` is not used, the specific end date for which data are requested. +- `variables`: List of variables to download. Defaults to ["windspeed_100m", "winddirection_100m"]. +- `coord_delta`: Coordinate delta for bounding box defining grid of points for which data are requested. Bounding box is defined as target_lat +/- coord_delta and target_lon +/- coord_delta. Defaults to 0.1 degrees. + +### Open-Meteo Solar and Wind Data + +The function `download_openmeteo_data` is used to download historical solar and wind data from the [Open-Meteo Historical Forecast API](https://open-meteo.com/en/docs/historical-forecast-api). Data are available at a temporal resolution of 15 minutes for the year 2016 through 15 days from the present (i.e., a 15-day forecast). The spatial resolution varies with latitude, but at ~35 degrees latitude, the grid cell resolution is approximately 0.027 degrees latitude (~2.4 km in the N-S direction) and 0.0333 degrees longitude (~3.7km in the E-W direction). Available variables include wind speed and wind direction at a height of 80 m, temperature at 2 m, instant shortwave radiation, instant diffuse radiation, and instant direct normal irradiance. More information can be found [here](https://open-meteo.com/en/docs/historical-forecast-api). + +Note that in contrast to the NSRDB and WIND Toolkit downloading functions, the specific coordinates at which data are requested must be provided instead of specifying the area of interest as a bounding box. Data will be returned for the nearest weather grid points to the requested coordinates. + +Arguments to the `download_openmeteo_data` function used to specify the data to download are as follows: +- `target_lat`: The latitude or list of latitudes for which data are requested. +- `target_lon`: The longitude or list of longitudes for which data are requested. +- `year`: The year for which data are requested. +- `start_date`: If `year` is not used, the specific start date for which data are requested. +- `end_date`: If `year` is not used, the specific end date for which data are requested. +- `variables`: List of variables to download. Defaults to ["wind_speed_80m", "wind_direction_80m", "temperature_2m", "shortwave_radiation_instant", "diffuse_radiation_instant", "direct_normal_irradiance_instant"]. +- `remove_duplicate_coords`: Whether to remove duplicate coordinates when returning the requested data. Duplicate coordinates can arise if the same weather grid point is the nearest point to multiple requested coordinates. Defaults to `True`. + +## Wind Data Upsampling Module + +After downloading wind data from WIND Toolkit or Open-Meteo, the `hercules.resource.upsample_wind_data` module can be used to spatially interpolate wind speeds and directions from the grid of downloaded points to specific wind turbine locations. The spatially interpolated wind speeds and directions are then upsampled to the desired temporal resolution and realistic turbulence is added to the wind speed time series. The upsampled data are then saved in the format used for wind inputs to Hercules simulations. + +### Spatial Interpolation Overview + +Both wind speed and direction at the specified wind turbine locations are spatially interpolated from the grid of locations for which data are downloaded using the [Clough-Tocher interpolation method](https://docs.scipy.org/doc//scipy-1.9.2/reference/generated/scipy.interpolate.CloughTocher2DInterpolator.html). This method produces a smooth, continuous surface that includes the original grid values and is expected to result in more realistic values than simple bilinear interpolation. It can also be applied to irregular grids of downloaded data. + +### Temporal Upsampling Overview + +After spatially interpolating the downloaded wind data, the time series of wind speed and wind direction are upsampled from the original temporal resolution (e.g., 5 minutes for WIND Toolkit and 15 minutes for Open-Meteo data) to the desired resolution (e.g., 1 second). This is accomplished by applying the discrete Fourier transform (DFT) to the original time series, increasing the length of the frequency-domain signal to match the desired temporal resolution by adding zeros above the Nyquist frequency, then applying the inverse DFT. The result is a smooth upsampled time series with no additional frequency content beyond what is present in the original signal. + +### Turbulence Model + +Because high-frequency turbulent fluctuations in wind speed are important to consider when simulating wind turbine power production, zero-mean stochastic turbulence is added to the upsampled wind speeds at each location. Stochastic turbulence is generated using the Kaimal turbulence spectrum, as defined in the IEC 61400-1 design standard. The generated turbulence is then scaled to achieve wind speed-dependent turbulence intensity (TI) values based on the normal turbulence model defined in the IEC 61400-1 standard, in which TI decreases as wind speed increases. Specifically, the upsampled wind speed value at each time sample is used to determine the TI, and the turbulence value corresponding to that time sample is scaled accordingly. + +However, rather than using the specific TI magnitudes defined in the IEC standard, the entire stochastic turbulence signal is scaled to achieve a user-specified TI at the provided reference wind speed. In other words, the user specifies the desired TI at a reference wind speed, but the *relative* change in TI as a function of wind speed is based on the IEC normal turbulence model. + +Note that in the current implementation, independent stochastic turbulence is generated at each upsampled location; spatial coherence is not modeled. + +### Wind Data Upsampling Function + +The function `upsample_wind_data` is used to perform the above-mentioned steps and return the upsampled wind speeds and directions at each upsampled location as a pandas DataFrame, which is also saved as a `.feather` file. + +Arguments to the `upsample_wind_data` function used to specify the upsampling are as follows: +- `ws_data_filepath`: Filepath to the `.feather` file containing raw downloaded wind speed data saved by the `download_wtk_data` or `download_openmeteo_data` functions in the `wind_solar_resource_downloading` module. +- `wd_data_filepath`: Filepath to the `.feather` file containing raw downloaded wind direction data saved by the `download_wtk_data` or `download_openmeteo_data` functions in the `wind_solar_resource_downloading` module. +- `coords_filepath`: Filepath to the `.feather` file containing the coordinates corresponding to the downloaded wind data saved by the `download_wtk_data` or `download_openmeteo_data` functions in the `wind_solar_resource_downloading` module. +- `x_locs_upsample`: The "x" (Easting) locations of the desired upsampled locations (e.g., corresponding to turbine locations) relative to the provided origin coordinates in meters. +- `y_locs_upsample`: The "y" (Northing) locations of the desired upsampled locations (e.g., corresponding to turbine locations) relative to the provided origin coordinates in meters. +- `origin_lat`: The "origin" latitude corresponding to a `y_locs_upsample` location of 0. +- `origin_lon`: The "origin" longitude corresponding to a `x_locs_upsample` location of 0. +- `timestep_upsample`: Time step of upsampled wind time series in seconds. Defaults to 1 second. +- `turbulence_Uhub`: Mean hub-height wind speed to use for the Kaimal turbulence spectrum (m/s). If `None`, the mean wind speed from the interpolated upsample locations will be used. Defaults to `None`. +- `turbulence_L`: The turbulence length scale to use for the Kaimal turbulence spectrum (m). Defaults to 340.2 m, the value specified in the IEC standard. +- `TI_ref`: The reference TI that will be assigned at the reference wind speed `TI_ws_ref`. Defaults to 0.1. +- `TI_ws_ref`: The reference wind speed at which the reference TI `TI_ref` is defined (m/s). Defaults to 8 m/s. +- `save_individual_wds`: If `True`, upsampled wind directions will be saved in the output for each upsampled location. If `False`, only the mean wind direction over all locations will be saved. Defaults to `False`. + +### Output Format + +The function `upsample_wind_data` returns a pandas DataFrame containing the upsampled wind time series and saves the DataFrame as a `.feather` file. This DataFrame is in the format used for Hercules wind plant simulation inputs. An example illustrating the DataFrame columns is shown below for the case where `save_individual_wds` is `True`. Note that the suffixes "000", "001", etc. correspond to the locations specified in `x_locs_upsample` and `y_locs_upsample` (in order), and the `time` column contains the number of seconds from the start of the time series. + +| time | time_utc | ws_000 | wd_000 | ws_001 | wd_001 | ws_002 | ... +|-----|-----|-----|-----|-----|-----|-----|-----| +| 0.0 | 2020-01-01 00:00:00+00:00 | 5.7 | 256.2 | 5.7 | 256.0 | 6.4 | ... | +| 1.0 | 2020-01-01 00:00:01+00:00 | 5.4 | 256.1 | 5.9 | 255.9 | 6.4 | ... | +| 2.0 | 2020-01-01 00:00:02+00:00 | 5.7 | 256.0 | 5.8 | 255.8 | 5.7 | ... | +| 3.0 | 2020-01-01 00:00:03+00:00 | 6.5 | 255.9 | 5.0 | 255.7 | 6.5 | ... | +| ... | ... | ... | ... | ... | ... | ... | ... | + +On the other hand, for the case where `save_individual_wds` is `False`, an example DataFrame is provided below. + +| time | time_utc | wd_mean | ws_000 | ws_001 | ws_002 | ... +|-----|-----|-----|-----|-----|-----|-----| +| 0.0 | 2020-01-01 00:00:00+00:00 | 255.8 | 5.7 | 5.7 | 6.4 | ... | +| 1.0 | 2020-01-01 00:00:01+00:00 | 255.8 | 5.4 | 5.9 | 6.4 | ... | +| 2.0 | 2020-01-01 00:00:02+00:00 | 255.7 | 5.7 | 5.8 | 5.7 | ... | +| 3.0 | 2020-01-01 00:00:03+00:00 | 255.6 | 6.5 | 5.0 | 6.5 | ... | +| ... | ... | ... | ... | ... | ... | ... | ... | ... | diff --git a/docs/running_hercules.md b/docs/running_hercules.md index 4f0da698..b0aee99e 100644 --- a/docs/running_hercules.md +++ b/docs/running_hercules.md @@ -1,7 +1,33 @@ # Running Hercules -It is recommended to run Hercules using a python runscript that sets up the emulation environment and then runs the emulator. See for example the [hercules_runscript.py](../example_case_folders/00_wind_farm_only/hercules_runscript.py) file for an example. +It is recommended to run Hercules using a python runscript. The typical pattern is: + +```python +from hercules.hercules_model import HerculesModel + +# Initialize the Hercules model +hmodel = HerculesModel("hercules_input.yaml") + +# Define your controller class +class MyController: + def __init__(self, h_dict): + # Initialize with the prepared h_dict + pass + + def step(self, h_dict): + # Implement your control logic here + # Set power setpoints, etc. + return h_dict + +# Assign the controller to the Hercules model +hmodel.assign_controller(MyController(hmodel.h_dict)) + +# Run the simulation +hmodel.run() +``` + +See the example runscripts in the `examples/` directory for complete examples. diff --git a/docs/solar_pv.md b/docs/solar_pv.md index 4f08409b..d9a00311 100644 --- a/docs/solar_pv.md +++ b/docs/solar_pv.md @@ -1,6 +1,6 @@ # Solar PV -The solar PV modules use the [PySAM](https://nrel-pysam.readthedocs.io/en/main/overview.html) package for the National Renewable Energy Laboratory's System Advisor Model (SAM) to predict the power output of the solar PV plant. +The solar PV modules use the [PySAM](https://nrel-pysam.readthedocs.io/en/main/overview.html) package for the National Laboratory of the Rockies's System Advisor Model (SAM) to predict the power output of the solar PV plant. Presently only one solar simulator is available @@ -8,10 +8,10 @@ Presently only one solar simulator is available -### Inputs +## Inputs Both models require an input weather file: -1. A CSV file that specifies the weather conditions (e.g. NonAnnualSimulation-sample_data-interpolated-daytime.csv). This file should include: +1. A CSV file that specifies the weather conditions (e.g. NonAnnualSimulation-sample_data-interpolated-daytime.csv). This file should include: - timestamp (see [timing](timing.md) for time format requirements) - direct normal irradiance (DNI) - diffuse horizontal irradiance (DHI) @@ -23,7 +23,7 @@ Both models require an input weather file: The system location (latitude, longitude, and elevation) is specified in the input `yaml` file. -### Outputs +## Outputs The solar module output is the DC power (`power`) in kW of the PV plant at each timestep. Using DC power makes the parameters `inv_eff` and `dc_to_ac_ratio` irrelevant. The `system_capacity` parameter represents the DC system capacity under Standard Test Conditions. @@ -35,18 +35,41 @@ The PVWatts model is configured with the following hardcoded parameters for util The array tilt angle must be specified in the input configuration file. -When `log_extra_outputs` is set to `True` in the input .yaml file, the solar modules also output plane-of-array irradiance (`poa`) in W/m^2, direct normal irradiance (`dni`) in W/m^2, and the angle of incidence (`aoi`) in degrees. +## Logging Configuration -### Efficiency and Loss Parameters +The `log_channels` parameter controls which outputs are written to the HDF5 output file. This is a list of channel names. The `power` channel is always logged, even if not explicitly specified. -ALthough the pysam model `SolarPySAMPVWatts` model, technically includes efficiency terms: +**Available Channels:** +- `power`: DC power output in kW (always logged) +- `poa`: Plane-of-array irradiance in W/m² +- `dni`: Direct normal irradiance in W/m² +- `aoi`: Angle of incidence in degrees + +**Example:** +```yaml +solar_farm: + component_type: SolarPySAMPVWatts + solar_input_filename: inputs/solar_input.csv + log_channels: + - power + - dni + - poa + - aoi + # ... other parameters +``` + +If `log_channels` is not specified, only `power` will be logged. + +## Efficiency and Loss Parameters + +Although the pysam model `SolarPySAMPVWatts` model, technically includes efficiency terms: - **`inv_eff`** - Inverter efficiency as a percentage (0-99.5). (No longer used in Hercules) - **`losses`** - System losses as a percentage (0-100). Default recommended value: `0` (no losses). This parameter affects the DC power generated by the PV panels, before any conversion to AC by the inverter. The example folder `03_wind_and_solar` specifies: - use of the `SolarPySAMPVWatts` model with `component_type: "SolarPySAMPVWatts"` -- weather conditions on May 10, 2018 measured at NREL's Flatirons Campus +- weather conditions on May 10, 2018 measured at NLR's Flatirons Campus - latitude, longitude, and elevation of Golden, CO - system design information for a 100 MW single-axis PV tracking system (with backtracking) - inverter efficiency of 99.5% and system losses of 0% @@ -56,5 +79,5 @@ The system capacity can be changed in the `.yaml` file, but the DC/AC ratio is f For examples using the detailed `SolarPySAMPVSam` model, see the test files in the `tests/` directory. -### References -PySAM. National Renewable Energy Laboratory. Golden, CO. https://github.com/nrel/pysam +## References +PySAM. National Laboratory of the Rockies. Golden, CO. https://github.com/nrel/pysam diff --git a/docs/timing.md b/docs/timing.md index 2b3c4d96..795ebbb4 100644 --- a/docs/timing.md +++ b/docs/timing.md @@ -1,48 +1,224 @@ # Timing -Timing in Hercules is specified by two primary variables: +Hercules uses a simplified, UTC-first time model where all simulations are referenced to coordinated universal time (UTC). -- `time` (float): Time in seconds from `time=0` and the `zero_time_utc` value -- `time_utc` (datetime): Time in UTC (Coordinated Universal Time) +## Core Concepts -The `time` variable is always mandatory, while `time_utc` may be required for certain components (e.g., solar components). +Timing in Hercules is specified using two complementary representations: -## Important Metadata - -- `zero_time_utc`: The UTC time corresponding to `time==0`. This is implied by the input data and doesn't need to be specified explicitly. -- `starttime`: The simulation start time value. Required in the [input file](hercules_input.md). -- `start_time_utc`: The UTC time corresponding to `starttime`. Implied by the data. -- `endtime`: The simulation end time value. Required in the [input file](hercules_input.md). +- `time` (float): Simulation time in seconds, where `time=0` corresponds to `starttime_utc` +- `time_utc` (datetime): Absolute UTC timestamp ## Input Requirements -Both [wind](wind.md) and [solar](solar_pv.md) inputs require a `time` column, while `time_utc` is optional for wind and mandatory for solar. A top-level `time` column is constructed based on the time step (`dt`) specified in the [input file](hercules_input.md) and logged at the top level of `h_dict`. +All Hercules input files must specify start and end times using UTC datetime strings: + +- `starttime_utc`: The UTC datetime when the simulation begins (required in input YAML) +- `endtime_utc`: The UTC datetime when the simulation ends (required in input YAML) + +These are the ONLY time parameters you need to specify in your input file. Example: + +```yaml +dt: 1.0 +starttime_utc: "2020-01-01T00:00:00Z" # ISO 8601 format +endtime_utc: "2020-01-01T01:00:00Z" # 1 hour simulation +``` + +### Datetime String Format (ISO 8601) + +Hercules accepts UTC datetime strings in **ISO 8601** format. The variable names `starttime_utc` and `endtime_utc` indicate that these times must represent UTC (Coordinated Universal Time). + +**Accepted formats:** +- **Explicit UTC with "Z" suffix**: `"2020-01-01T00:00:00Z"` - The "Z" (Zulu time) explicitly marks the time as UTC +- **Naive string (no timezone)**: `"2020-01-01T00:00:00"` - Without timezone info, treated as UTC + +**Rejected formats:** +- **Timezone offsets**: `"2020-01-01T00:00:00+05:00"` or `"2020-01-01T00:00:00-08:00"` - These imply a different timezone, which contradicts the UTC requirement + +**About ISO 8601:** +ISO 8601 is an international standard for representing dates and times. The format used by Hercules is: +- Date: `YYYY-MM-DD` (year-month-day) +- Separator: `T` (separates date and time) +- Time: `HH:MM:SS` (24-hour format) +- UTC marker: `Z` (optional but recommended for clarity) + +Examples: +- `"2020-01-01T00:00:00Z"` - Midnight, January 1, 2020 UTC +- `"2020-06-15T12:30:45Z"` - 12:30:45 PM, June 15, 2020 UTC + +When loading input files, Hercules validates that datetime strings don't contain timezone offsets and will raise a clear error if a non-UTC timezone is detected. + +### Converting Local Time to UTC + +If you only know your local time and need to convert it to UTC (accounting for daylight saving time), Hercules provides a utility function to help: + +```python +from hercules.utilities import local_time_to_utc + +# Midnight Jan 1, 2025 in Mountain Time (MST, UTC-7, no DST) +utc_time_jan = local_time_to_utc("2025-01-01T00:00:00", tz="America/Denver") +# Returns: "2025-01-01T07:00:00Z" + +# Midnight July 1, 2025 in Mountain Time (MDT, UTC-6, DST in effect) +utc_time_july = local_time_to_utc("2025-07-01T00:00:00", tz="America/Denver") +# Returns: "2025-07-01T06:00:00Z" +``` + +**Note:** The `tz` parameter is **required**. You must specify your timezone using IANA timezone names. + +**Available Timezone Names:** + +Common timezone names: +- **US**: `"America/New_York"`, `"America/Chicago"`, `"America/Denver"`, `"America/Los_Angeles"` +- **Europe**: `"Europe/London"`, `"Europe/Paris"`, `"Europe/Berlin"`, `"Europe/Madrid"` +- **Asia**: `"Asia/Tokyo"`, `"Asia/Shanghai"`, `"Asia/Dubai"`, `"Asia/Kolkata"` +- **Pacific**: `"Pacific/Auckland"`, `"Pacific/Honolulu"`, `"Pacific/Sydney"` + +**Complete list of timezones:** + +For a complete list of all available IANA timezone names, see: +- [Wikipedia: List of tz database time zones](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) +- Or in Python: +```python +import zoneinfo +print(sorted(zoneinfo.available_timezones())) +``` + +The function automatically handles daylight saving time conversions based on the date you provide. + +**Example usage in your input YAML:** + +```python +from hercules.utilities import local_time_to_utc + +# If you want midnight local time (Mountain Time) on Jan 1, 2025 +start_utc = local_time_to_utc("2025-01-01T00:00:00", tz="America/Denver") +end_utc = local_time_to_utc("2025-07-01T00:00:00", tz="America/Denver") + +# Use these values in your YAML: +# starttime_utc: "2025-01-01T07:00:00Z" +# endtime_utc: "2025-07-01T06:00:00Z" +``` + +## Computed Time Values + +When Hercules loads your input file, it automatically computes: -## Consistency +- `starttime`: Always 0.0 (seconds) +- `endtime`: Simulation duration in seconds, computed as `(endtime_utc - starttime_utc).total_seconds()` -When both wind and solar inputs contain `time_utc` columns, the `HybridPlant` class ensures their `zero_time_utc` and `start_time_utc` values are consistent and brings them to the top level of `h_dict`. +For the example above, `endtime` would be 3600.0 seconds. -## Logging +## Data File Requirements -To save space, `time_utc` is not logged. However, `time`, `zero_time_utc`, and `start_time_utc` are logged, allowing `time_utc` to be reconstructed during [post-processing](output_files.md). +### Wind and Solar Input Data -## Diagram +Both wind and solar input CSV/Feather/Parquet files must contain a `time_utc` column with UTC timestamps: + +```text +time_utc,wd_mean,ws_000,ws_001,ws_002 +2020-01-01T00:00:00Z,270.0,8.0,8.1,8.2 +2020-01-01T00:00:01Z,270.5,8.1,8.2,8.3 +... +``` + +The `time` column (numeric seconds from t=0) is computed internally by Hercules components and should NOT be included in your input files. + +## Time Coordinate System ``` Timeline Visualization: -time (seconds): 0 -------- starttime -------- endtime - | | | - | | | -time_utc: | | | - | | | - v v v - zero_time_utc start_time_utc end_time_utc - (datetime) (datetime) (datetime) +time (seconds): 0.0 ----------- duration (endtime) -----------> + | | + | | +time_utc: | | + v v + starttime_utc endtime_utc + (datetime) (datetime) Key Points: -• time=0 corresponds to zero_time_utc -• starttime corresponds to start_time_utc -• time_utc can be calculated as: zero_time_utc + timedelta(seconds=time) +• time=0 corresponds to starttime_utc +• time is always relative to starttime_utc +• All times advance together: time_utc = starttime_utc + timedelta(seconds=time) +``` + +## Output Files + +Hercules output HDF5 files store: + +- `time` array: Simulation time points (seconds from t=0) +- `step` array: Simulation step numbers +- `starttime_utc` metadata: Starting UTC timestamp (Unix timestamp format) +- `time_utc` column: Reconstructed UTC timestamps for each time point + +The `time_utc` column in output data is reconstructed during read using: + +```python +time_utc = starttime_utc + timedelta(seconds=time) ``` +## Backward Compatibility + +**Note for users with old output files:** Hercules maintains backward compatibility with output files created before this timing model change. Old files may contain `zero_time_utc` metadata instead of `starttime_utc`. The output reader automatically handles both formats. + +## Consistency Validation + +When multiple plant components (wind, solar) provide time-series data: + +1. All input data files must contain `time_utc` columns +2. The `HybridPlant` class validates that all components' `starttime_utc` values match +3. A single `starttime_utc` value is promoted to the top level of `h_dict` + +This ensures temporal consistency across all simulation components. + +## Best Practices + +1. **Always use UTC timestamps** in your input files to avoid timezone confusion +2. **Use ISO 8601 format** for datetime strings: `"YYYY-MM-DDTHH:MM:SSZ"` +3. **Ensure data coverage**: Your input data files must cover the full range from `starttime_utc` to `endtime_utc` +4. **Don't include `time` columns** in your input CSV files - Hercules computes these internally +5. **Match your dt**: Ensure your input data's temporal resolution is compatible with your simulation `dt` + +## Example: Complete Timing Setup + +```yaml +# hercules_input.yaml +name: example_simulation +dt: 1.0 # seconds + +# Specify UTC times (REQUIRED) +starttime_utc: "2020-06-15T12:00:00Z" +endtime_utc: "2020-06-15T13:00:00Z" # 1 hour simulation + +plant: + interconnect_limit: 50000 # kW + +wind_farm: + component_type: WindFarm + wind_input_filename: inputs/wind_data.ftr + # wind_data.ftr must have time_utc column covering the simulation period + ... +``` + +Your `wind_data.ftr` file should contain: + +``` +time_utc | wd_mean | ws_000 | ws_001 | ... +-------------------------|---------|--------|--------|---- +2020-06-15T12:00:00Z | 270.0 | 8.0 | 8.1 | ... +2020-06-15T12:00:01Z | 270.1 | 8.0 | 8.1 | ... +... +2020-06-15T13:00:00Z | 271.5 | 8.2 | 8.3 | ... +``` + +Hercules will automatically compute the `time` column internally: + +``` +time_utc | time | ... +-------------------------|------|---- +2020-06-15T12:00:00Z | 0.0 | ... +2020-06-15T12:00:01Z | 1.0 | ... +... +2020-06-15T13:00:00Z | 3600.0 | ... +``` diff --git a/docs/wind.md b/docs/wind.md index 5f4c7bde..559cba2f 100644 --- a/docs/wind.md +++ b/docs/wind.md @@ -1,28 +1,39 @@ # Wind Farm Components -## Wind_MesoToPower +Hercules provides four wind farm simulation components that differ in their approach to wake modeling and data sources. The first three components support both simple filter-based turbine models and 1-degree-of-freedom (1-DOF) turbine dynamics, while the fourth component uses SCADA power data directly. -Wind_MesoToPower is a comprehensive wind farm simulator that focuses on meso-scale phenomena by applying a separate wind speed time signal to each turbine model derived from data. It combines FLORIS wake modeling with detailed turbine dynamics for long-term wind farm performance analysis. -## Wind_MesoToPowerPrecomFloris +## Overview -Wind_MesoToPowerPrecomFloris is an optimized variant of Wind_MesoToPower that pre-computes FLORIS wake deficits for improved simulation performance. This approach trades some accuracy for significant speed improvements in specific operating scenarios. +The `WindFarm` component applies wind speed time signals to turbine models to simulate wind farm behavior over extended periods. This is available with different methods for how wakes are applies, as described below. The `WindFarmSCADAPower` component uses a fundamentally different approach by using actual SCADA power measurements as input. -## Overview +## WindFarm (with Dynamic wake method) + +`WindFarm` is a comprehensive wind farm simulator. When `wake_method="dynamic"` (the default), `WindFarm` computes wake effects dynamically at each time step (or at intervals specified by `floris_update_time_s`). It focuses on meso-scale phenomena by applying a separate wind speed time signal to each turbine model derived from data. This model combines FLORIS wake modeling with detailed turbine dynamics for long-term wind farm performance analysis. + +**Use this model when:** +- Turbines have individual power setpoints or non-uniform operation +- Precise wake modeling is required for each control action +- Turbines may be partially derated or individually controlled + +## WindFarm (with Precomputed wake method) + +`WindFarm` with `wake_method="precomputed"` is an optimized variant that pre-computes all FLORIS wake deficits at initialization for improved simulation speed. This approach provides significant speed improvements while conservatively assuming wakes are always based on nominal operation. -Both wind farm components integrate FLORIS for wake effects with individual turbine models to simulate wind farm behavior over extended periods. They support both simple filter-based turbine models and 1-degree-of-freedom (1-DOF) turbine dynamics. +**Use this model when:** +- Not investigating wakes of derated turbines or wake losses can be conservatively estimated. -### Precomputed FLORIS Approach -Wind_MesoToPowerPrecomFloris pre-computes wake deficits using a fixed cadence determined by `floris_update_time_s`. At initialization, FLORIS is evaluated at that cadence using right-aligned time-window averages of wind speed, wind direction, and turbulence intensity. The resulting wake deficits are then held constant between evaluations and applied to the per-turbine inflow time series. +## WindFarm (with No Added Wakes method) -This approach is valid when the wind farm operates under these conditions: +Using `WindFarm` with `wake_method="no_added_wakes"` assumes that wake effects are already included in the input wind data and performs no wake modeling during simulation. This model is appropriate for using SCADA data of operational farm since wake losses already included in data. -- All turbines operating normally -- All turbines off -- Following a wind-farm wide derating level -Important: This model is not appropriate when turbines are partially derated below the curtailment level or not uniformly curtailed. In such cases, use the standard Wind_MesoToPower class instead. +## WindFarmSCADAPower (SCADA Power Data) + +`WindFarmSCADAPower` uses SCADA power measurements directly rather than computing power from wind speeds and turbine models. This component applies a filter to the SCADA power data to simulate turbine response dynamics and respects power setpoint constraints. + +_This model is a beta feature and is not yet fully tested._ ## Configuration @@ -31,67 +42,129 @@ Important: This model is not appropriate when turbines are partially derated bel Required parameters for both components in [h_dict](h_dict.md) (see [timing](timing.md) for time-related parameters): - `floris_input_file`: FLORIS farm configuration - `wind_input_filename`: Wind resource data file + +### WindFarm Specific Parameters + +Required parameters for WindFarm: +- `wake_method`: One of `"dynamic"`, `"precomputed"`, or `"no_added_wakes"` (defaults to `"dynamic"`) +- `floris_update_time_s`: How often to update FLORIS (the last `floris_update_time_s` seconds are averaged as input). Required for `"dynamic"` and `"precomputed"` wake methods; for `"no_added_wakes"`, this parameter is not required and ignored if provided. - `turbine_file_name`: Turbine model configuration +- `log_channels`: List of output channels to log. See [Logging Configuration](wind-logging-configuration) section below for details. + +### WindFarmSCADAPower Specific Parameters -### Wind_MesoToPower Specific Parameters +Required parameters for WindFarmSCADAPower: +- `scada_filename`: Path to SCADA data file (CSV, pickle, or feather format) +- `turbine_file_name`: Turbine model configuration (for filter parameters) +- `log_channels`: List of output channels to log. See [Logging Configuration](#logging-configuration) section below for details. -Required parameters for Wind_MesoToPower: -- `floris_update_time_s`: How often to update FLORIS (the last `floris_update_time_s` seconds are averaged as input) +**SCADA File Format:** -Required parameters for Wind_MesoToPower: -- `logging_option`: Logging level. Options are: - - `"base"`: Log basic outputs (power, wind_speed, wind_direction, wind_speed_waked) - - `"turb_subset"`: Base outputs plus 3 random turbines' waked_velocities, turbine_powers, and turbine_power_setpoints - - `"all"`: All available outputs including floris_wind_speed, floris_wind_direction, floris_ti, unwaked_velocities, waked_velocities, turbine_powers, turbine_power_setpoints +The SCADA file must contain the following columns: +- `time_utc`: Timestamps in UTC (ISO 8601 format or parseable datetime strings) +- `wd_mean`: Mean wind direction in degrees +- `pow_###`: Power output for each turbine (e.g., `pow_000`, `pow_001`, `pow_002`) -### Wind_MesoToPowerPrecomFloris Specific Parameters +Optional columns: +- `ws_###`: Wind speed for each turbine (e.g., `ws_000`, `ws_001`, `ws_002`) +- `ws_mean`: Mean wind speed (used if individual turbine speeds not provided) +- `ti_###`: Turbulence intensity for each turbine (defaults to 0.08 if not provided) -Required parameters for Wind_MesoToPowerPrecomFloris: -- `floris_update_time_s`: Determines the cadence of wake precomputation. At each cadence tick, the last `floris_update_time_s` seconds are averaged and used to evaluate FLORIS. The computed wake deficits are then applied until the next cadence tick. -- `logging_option`: Logging level. Options are: - - `"base"`: Log basic outputs (power, wind_speed, wind_direction, wind_speed_waked) - - `"turb_subset"`: Base outputs plus 3 random turbines' waked_velocities, turbine_powers, and turbine_power_setpoints - - `"all"`: All available outputs including floris_wind_speed, floris_wind_direction, floris_ti, unwaked_velocities, waked_velocities, turbine_powers, turbine_power_setpoints +The number of turbines and rated power are automatically inferred from the SCADA data. ## Turbine Models +**Note:** WindFarmSCADAPower does not use a filter model as power values come directly from SCADA data rather than being computed from wind speedes. + ### Filter Model Simple first-order filter for power output smoothing with configurable time constants. ### 1-DOF Model -Advanced model with rotor dynamics, pitch control, and generator torque control. +Advanced model with rotor dynamics, pitch control, and generator torque control. Not applicable to WindFarmSCADAPower. ## Outputs ### Common Outputs -Both components provide these outputs: -- `power`: Total wind farm power -- `turbine_powers`: Individual turbine power outputs -- `turbine_power_setpoints`: Current power setpoint values -- `wind_speed`, `wind_direction`: Farm-level wind conditions - -### Logging Options - -The logging behavior depends on the `logging_option` setting: - -#### Base Logging (`logging_option: "base"`) -- `power`: Total wind farm power -- `wind_speed`, `wind_direction`: Farm-level wind conditions -- `wind_speed_waked`: Average waked wind speed across the farm - -#### Turbine Subset Logging (`logging_option: "turb_subset"`) -Includes all base outputs plus: -- `waked_velocities_turb_XXX`: Waked velocities for 3 randomly selected turbines -- `turbine_powers_turb_XXX`: Power outputs for 3 randomly selected turbines -- `turbine_power_setpoints_turb_XXX`: Power setpoints for 3 randomly selected turbines - -#### Full Logging (`logging_option: "all"`) -Includes all base outputs plus: -- `turbine_powers`: Individual turbine power outputs -- `turbine_power_setpoints`: Current power setpoint values -- `floris_wind_speed`: Wind speed used in FLORIS calculations -- `floris_wind_direction`: Wind direction used in FLORIS calculations -- `floris_ti`: Turbulence intensity values -- `unwaked_velocities`: Wind speeds without wake effects -- `waked_velocities`: Wind speeds with wake effects applied \ No newline at end of file +All four components provide these outputs in the h_dict at each simulation step: +- `power`: Total wind farm power (kW) +- `turbine_powers`: Individual turbine power outputs (array, kW) +- `turbine_power_setpoints`: Current power setpoint values (array, kW) +- `wind_speed_mean_background`: Farm-average background wind speed (m/s) +- `wind_speed_mean_withwakes`: Farm-average with-wakes wind speed (m/s) +- `wind_direction_mean`: Farm-average wind direction (degrees) +- `wind_speeds_background`: Per-turbine background wind speeds (array, m/s) +- `wind_speeds_withwakes`: Per-turbine with-wakes wind speeds (array, m/s) + + +**Note for WindFarm with no_added_wakes and WindFarmSCADAPower:** In these models (no wake modeling), `wind_speeds_withwakes` equals `wind_speeds_background` and `wind_speed_mean_withwakes` equals `wind_speed_mean_background`. + +(wind-logging-configuration)= +## Logging Configuration + +The `log_channels` parameter controls which outputs are written to the HDF5 output file. This is a list of channel names. The `power` channel is always logged, even if not explicitly specified. + +### Available Channels + +**Scalar Channels:** +- `power`: Total wind farm power output (kW) +- `wind_speed_mean_background`: Farm-average background wind speed (m/s) +- `wind_speed_mean_withwakes`: Farm-average with-wakes wind speed (m/s) +- `wind_direction_mean`: Farm-average wind direction (degrees) + +**Array Channels:** +- `turbine_powers`: Power output for all turbines (creates datasets like `wind_farm.turbine_powers.000`, `wind_farm.turbine_powers.001`, etc.) +- `turbine_power_setpoints`: Power setpoints for all turbines +- `wind_speeds_background`: Background wind speeds for all turbines +- `wind_speeds_withwakes`: With-wakes wind speeds for all turbines + +### Selective Array Element Logging + +For large wind farms, logging all turbine data can significantly increase file size and slow down the simulation. You can log specific turbine indices by appending a 3-digit turbine index to the channel name: + +```yaml +# Log only turbines 0, 5, and 10 +log_channels: + - power + - wind_speed_mean_background + - wind_speed_mean_withwakes + - wind_direction_mean + - turbine_powers.000 + - turbine_powers.005 + - turbine_powers.010 +``` + +### Example Configurations + +**Minimal Logging:** +```yaml +log_channels: + - power + - wind_speed_mean_background + - wind_speed_mean_withwakes + - wind_direction_mean +``` + +**Detailed Logging (all turbines):** +```yaml +log_channels: + - power + - wind_speed_mean_background + - wind_speed_mean_withwakes + - wind_direction_mean + - turbine_powers + - wind_speeds_withwakes +``` + +**Selected Turbine Logging:** +```yaml +# Log first 3 turbines only +log_channels: + - power + - wind_speed_mean_background + - wind_speed_mean_withwakes + - wind_direction_mean + - turbine_powers.000 + - turbine_powers.001 + - turbine_powers.002 +``` \ No newline at end of file diff --git a/examples/00_wind_farm_only/hercules_input.yaml b/examples/00_wind_farm_only/hercules_input.yaml index d874fe15..3dfa1963 100644 --- a/examples/00_wind_farm_only/hercules_input.yaml +++ b/examples/00_wind_farm_only/hercules_input.yaml @@ -4,25 +4,32 @@ name: example_00 ### -# Describe this emulator setup -description: Wind Farm Only +# Describe this simulation setup +description: Wind Farm Only, Logging All Turbine Data dt: 1.0 -starttime: 0.0 -endtime: 950.0 +starttime_utc: "2020-01-01T00:00:00Z" # Midnight Jan 1, 2020 UTC (Zulu time) +endtime_utc: "2020-01-01T00:15:50Z" # 15 minutes 50 seconds later verbose: False plant: interconnect_limit: 15000 # kW wind_farm: - - component_type: Wind_MesoToPower + component_type: WindFarm + wake_method: dynamic floris_input_file: ../inputs/floris_input_small.yaml wind_input_filename: ../inputs/wind_input_small.ftr turbine_file_name: ../inputs/turbine_filter_model.yaml log_file_name: outputs/log_wind_sim.log - logging_option: all + log_channels: + - power + - wind_speed_mean_background + - wind_speed_mean_withwakes + - wind_direction_mean + - turbine_powers + - wind_speeds_withwakes + - wind_speeds_background floris_update_time_s: 30.0 # Update wakes every 30 seconds diff --git a/examples/00_wind_farm_only/hercules_runscript.py b/examples/00_wind_farm_only/hercules_runscript.py index fe3fb26a..708f90ef 100644 --- a/examples/00_wind_farm_only/hercules_runscript.py +++ b/examples/00_wind_farm_only/hercules_runscript.py @@ -1,42 +1,14 @@ -import os -import shutil -import sys - import numpy as np -from hercules.emulator import Emulator -from hercules.hybrid_plant import HybridPlant -from hercules.utilities import load_hercules_input, setup_logging -from hercules.utilities_examples import ensure_example_inputs_exist +from hercules.hercules_model import HerculesModel +from hercules.utilities_examples import ensure_example_inputs_exist, prepare_output_directory -# If the output folder exists, delete it -if os.path.exists("outputs"): - shutil.rmtree("outputs") -os.makedirs("outputs") +prepare_output_directory() # Ensure example inputs exist ensure_example_inputs_exist() -# Get the logger -logger = setup_logging() - -# If more than one argument is provided raise and error -if len(sys.argv) > 2: - raise Exception( - "Usage: python hercules_runscript.py [hercules_input_file] or python hercules_runscript.py" - ) - -# If one argument is provided, use it as the input file -if len(sys.argv) == 2: - input_file = sys.argv[1] -# If no arguments are provided, use the default input file -else: - input_file = "hercules_input.yaml" - -# Initialize logging -logger.info(f"Starting with input file: {input_file}") - -# Load the input file -h_dict = load_hercules_input(input_file) +# Initialize the Hercules model +hmodel = HerculesModel("hercules_input.yaml") # Define a simple controller that sets all deratings to full rating @@ -77,16 +49,10 @@ def step(self, h_dict): return h_dict -# Initialize the controller -controller = ControllerToggleTurbine000(h_dict) - -# Initialize the hybrid plant -hybrid_plant = HybridPlant(h_dict) - -# Initialize the emulator -emulator = Emulator(controller, hybrid_plant, h_dict, logger) +# Instantiate the controller and assign to the Hercules model +hmodel.assign_controller(ControllerToggleTurbine000(hmodel.h_dict)) -# Run the emulator -emulator.enter_execution(function_targets=[], function_arguments=[[]]) +# Run the simulation +hmodel.run() -logger.info("Process completed successfully") +hmodel.logger.info("Process completed successfully") diff --git a/examples/00_wind_farm_only/plot_outputs.py b/examples/00_wind_farm_only/plot_outputs.py index 82a8880d..56cced26 100644 --- a/examples/00_wind_farm_only/plot_outputs.py +++ b/examples/00_wind_farm_only/plot_outputs.py @@ -25,33 +25,42 @@ # Plot the wind speeds ax = axarr[0] for t_idx in range(3): - if f"wind_farm.unwaked_velocities.{t_idx:03}" in df.columns: + if f"wind_farm.wind_speeds_background.{t_idx:03}" in df.columns: ax.plot( df["time"], - df[f"wind_farm.unwaked_velocities.{t_idx:03}"], - label=f"Unwaked {t_idx}", + df[f"wind_farm.wind_speeds_background.{t_idx:03}"], + label=f"Background {t_idx}", color=colors[t_idx], ) for t_idx in range(3): - if f"wind_farm.waked_velocities.{t_idx:03}" in df.columns: + if f"wind_farm.wind_speeds_withwakes.{t_idx:03}" in df.columns: ax.plot( df["time"], - df[f"wind_farm.waked_velocities.{t_idx:03}"], - label=f"Waked {t_idx}", + df[f"wind_farm.wind_speeds_withwakes.{t_idx:03}"], + label=f"With wakes {t_idx}", linestyle="--", color=colors[t_idx], ) # Plot the FLORIS wind speed if available -if "wind_farm.floris_wind_speed" in df.columns: +if "wind_farm.wind_speed_mean_background" in df.columns: ax.plot( df["time"], - df["wind_farm.floris_wind_speed"], - label="FLORIS", + df["wind_farm.wind_speed_mean_background"], + label="Mean Background Wind Speed", color="black", lw=2, ) +if "wind_farm.wind_speed_mean_withwakes" in df.columns: + ax.plot( + df["time"], + df["wind_farm.wind_speed_mean_withwakes"], + label="Mean With-Wakes Wind Speed", + color="red", + lw=2, + ) + ax.grid(True) ax.legend() ax.set_ylabel("Wind Speed [m/s]") diff --git a/examples/01_wind_farm_dof1_model/README.md b/examples/01_wind_farm_dof1_model/README.md index 97264833..f2a3e55f 100644 --- a/examples/01_wind_farm_dof1_model/README.md +++ b/examples/01_wind_farm_dof1_model/README.md @@ -2,11 +2,11 @@ ## Description -This example runs the 1-DOF long-duration wind simulation. +This example demonstrates a simple wind farm simulation using generated wind data, using a higher fidelity turbine model. The simulation uses a small wind farm configuration with basic turbine control. -## Pre setup +## Setup -Make sure to first generate the wind input file using generate_wind_history.ipynb +No manual setup is required. The example automatically generates the necessary input files (wind data, FLORIS configuration, and turbine model) in the centralized `examples/inputs/` folder when first run. ## Running diff --git a/examples/01_wind_farm_dof1_model/hercules_input.yaml b/examples/01_wind_farm_dof1_model/hercules_input.yaml index 518fb181..7d5beacf 100644 --- a/examples/01_wind_farm_dof1_model/hercules_input.yaml +++ b/examples/01_wind_farm_dof1_model/hercules_input.yaml @@ -4,25 +4,33 @@ name: example_01 ### -# Describe this emulator setup +# Describe this simulation setup description: Wind Farm Only - 1 DOF Model dt: 1.0 -starttime: 0.0 -endtime: 950.0 +starttime_utc: "2020-01-01T00:00:00Z" # Midnight Jan 1, 2020 UTC (Zulu time) +endtime_utc: "2020-01-01T00:15:50Z" # 15 minutes 50 seconds later verbose: False plant: interconnect_limit: 15000 # kW wind_farm: - - component_type: Wind_MesoToPower - floris_input_file: inputs/floris_input.yaml - wind_input_filename: inputs/wind_input.csv - turbine_file_name: inputs/turbine_dof_1.yaml + component_type: WindFarm + wake_method: dynamic # If not specified, default is 'dynamic' + floris_input_file: ../inputs/floris_input_small.yaml + wind_input_filename: ../inputs/wind_input_small.ftr + turbine_file_name: ../inputs/turbine_1dof_model.yaml log_file_name: outputs/log_wind_sim.log - logging_option: all + log_channels: + - power + - wind_speed_mean_background + - wind_speed_mean_withwakes + - wind_direction_mean + - turbine_powers + - wind_speeds_withwakes + - wind_speeds_background + - turbine_power_setpoints floris_update_time_s: 30.0 # Update wakes every 30 seconds diff --git a/examples/01_wind_farm_dof1_model/hercules_runscript.py b/examples/01_wind_farm_dof1_model/hercules_runscript.py index 942b34af..708f90ef 100644 --- a/examples/01_wind_farm_dof1_model/hercules_runscript.py +++ b/examples/01_wind_farm_dof1_model/hercules_runscript.py @@ -1,37 +1,14 @@ -import os -import shutil -import sys +import numpy as np +from hercules.hercules_model import HerculesModel +from hercules.utilities_examples import ensure_example_inputs_exist, prepare_output_directory -from hercules.emulator import Emulator -from hercules.hybrid_plant import HybridPlant -from hercules.utilities import load_hercules_input, setup_logging +prepare_output_directory() -# If the output folder exists, delete it -if os.path.exists("outputs"): - shutil.rmtree("outputs") -os.makedirs("outputs") +# Ensure example inputs exist +ensure_example_inputs_exist() -# Get the logger -logger = setup_logging() - -# If more than one argument is provided raise and error -if len(sys.argv) > 2: - raise Exception( - "Usage: python hercules_runscript.py [hercules_input_file] or python hercules_runscript.py" - ) - -# If one argument is provided, use it as the input file -if len(sys.argv) == 2: - input_file = sys.argv[1] -# If no arguments are provided, use the default input file -else: - input_file = "hercules_input.yaml" - -# Initialize logging -logger.info(f"Starting with input file: {input_file}") - -# Load the input file -h_dict = load_hercules_input(input_file) +# Initialize the Hercules model +hmodel = HerculesModel("hercules_input.yaml") # Define a simple controller that sets all deratings to full rating @@ -61,25 +38,21 @@ def step(self, h_dict): dict: The updated hercules input dictionary. """ # Set deratings to full rating - for t_idx in range(h_dict["wind_farm"]["n_turbines"]): - h_dict["wind_farm"][f"derating_{t_idx:03d}"] = 5000 + h_dict["wind_farm"]["turbine_power_setpoints"] = 5000 * np.ones( + h_dict["wind_farm"]["n_turbines"] + ) - # Lower t0 derating every other 100 seconds + # Lower t0 derating to 500 every other 100 seconds if h_dict["time"] % 200 < 100: - h_dict["wind_farm"]["derating_000"] = 500 - return h_dict - + h_dict["wind_farm"]["turbine_power_setpoints"][0] = 500 -# Initialize the controller -controller = ControllerToggleTurbine000(h_dict) + return h_dict -# Initialize the hybrid plant -hybrid_plant = HybridPlant(h_dict) -# Initialize the emulator -emulator = Emulator(controller, hybrid_plant, h_dict, logger) +# Instantiate the controller and assign to the Hercules model +hmodel.assign_controller(ControllerToggleTurbine000(hmodel.h_dict)) -# Run the emulator -emulator.enter_execution(function_targets=[], function_arguments=[[]]) +# Run the simulation +hmodel.run() -logger.info("Process completed successfully") +hmodel.logger.info("Process completed successfully") diff --git a/examples/01_wind_farm_dof1_model/inputs/floris_input.yaml b/examples/01_wind_farm_dof1_model/inputs/floris_input.yaml deleted file mode 100644 index fc170766..00000000 --- a/examples/01_wind_farm_dof1_model/inputs/floris_input.yaml +++ /dev/null @@ -1,184 +0,0 @@ - -### -# A name for this input file. -# This is not currently only for the user's reference. -name: GCH - -### -# A description of the contents of this input file. -# This is not currently only for the user's reference. -description: Three turbines using Gauss Curl Hybrid model - -### -# The earliest verion of FLORIS this input file supports. -# This is not currently only for the user's reference. -floris_version: v4 - -### -# Configure the logging level and where to show the logs. -logging: - - ### - # Settings for logging to the console (i.e. terminal). - console: - - ### - # Can be "true" or "false". - enable: true - - ### - # Set the severity to show output. Messages at this level or higher will be shown. - # Can be one of "CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG". - level: WARNING - - ### - # Settings for logging to a file. - file: - - ### - # Can be "true" or "false". - enable: false - - ### - # Set the severity to show output. Messages at this level or higher will be shown. - # Can be one of "CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG". - level: WARNING - -### -# Configure the solver for the type of simulation. -solver: - - ### - # Select the solver type. - # Can be one of: "turbine_grid", "flow_field_grid", "flow_field_planar_grid". - type: turbine_grid - - ### - # Options for the turbine type selected above. See the solver documentation for available parameters. - turbine_grid_points: 3 - -### -# Configure the turbine types and their placement within the wind farm. -farm: - - ### - # Coordinates for the turbine locations in the x-direction which is typically considered - # to be the streamwise direction (left, right) when the wind is out of the west. - # The order of the coordinates here corresponds to the index of the turbine in the primary - # data structures. - layout_x: - - 0.0 - - 630.0 - - 630.0 - - ### - # Coordinates for the turbine locations in the y-direction which is typically considered - # to be the spanwise direction (up, down) when the wind is out of the west. - # The order of the coordinates here corresponds to the index of the turbine in the primary - # data structures. - layout_y: - - 0.0 - - 0.0 - - 500.0 - - ### - # Listing of turbine types for placement at the x and y coordinates given above. - # The list length must be 1 or the same as ``layout_x`` and ``layout_y``. If it is a - # single value, all turbines are of the same type. Otherwise, the turbine type - # is mapped to the location at the same index in ``layout_x`` and ``layout_y``. - # The types can be either a name included in the turbine_library or - # a full definition of a wind turbine directly. - turbine_type: - - nrel_5MW - -### -# Configure the atmospheric conditions. -flow_field: - - ### - # Air density. - air_density: 1.225 - - ### - # The height to consider the "center" of the vertical wind speed profile - # due to shear. With a shear exponent not 1, the wind speed at this height - # will be the value given in ``wind_speeds``. Above and below this height, - # the wind speed will change according to the shear profile; see - # :py:meth:`.FlowField.initialize_velocity_field`. - # For farms consisting of one wind turbine type, use ``reference_wind_height: -1`` - # to use the hub height of the wind turbine definition. For multiple wind turbine - # types, the reference wind height must be given explicitly. - reference_wind_height: -1 - - ### - # The turbulence intensities to include in the simulation, specified as a decimal. - turbulence_intensities: - - 0.06 - - ### - # The wind directions to include in the simulation. - # 0 is north and 270 is west. - wind_directions: - - 270.0 - - ### - # The exponent used to model the wind shear profile; see - # :py:meth:`.FlowField.initialize_velocity_field`. - wind_shear: 0.12 - - ### - # The wind speeds to include in the simulation. - wind_speeds: - - 8.0 - - ### - # The wind veer as a constant value for all points in the grid. - wind_veer: 0.0 - - ### - # The conditions that are specified for use with the multi-dimensional Cp/Ct capbility. - # These conditions are external to FLORIS and specified by the user. They are used internally - # through a nearest-neighbor selection process to choose the correct Cp/Ct interpolants - # to use. - multidim_conditions: - Tp: 2.5 - Hs: 3.01 - -wake: - model_strings: - combination_model: sosfs - deflection_model: empirical_gauss - turbulence_model: wake_induced_mixing - velocity_model: empirical_gauss - - enable_secondary_steering: false - enable_yaw_added_recovery: true - enable_active_wake_mixing: false - enable_transverse_velocities: false - - wake_deflection_parameters: - empirical_gauss: - horizontal_deflection_gain_D: 3.0 - vertical_deflection_gain_D: -1 - deflection_rate: 22 - mixing_gain_deflection: 0.0 - yaw_added_mixing_gain: 0.0 - - wake_velocity_parameters: - empirical_gauss: - wake_expansion_rates: - - 0.023 - - 0.008 - breakpoints_D: - - 10 - sigma_0_D: 0.28 - smoothing_length_D: 2.0 - mixing_gain_velocity: 2.0 - wake_turbulence_parameters: - crespo_hernandez: - initial: 0.1 - constant: 0.5 - ai: 0.8 - downstream: -0.32 - wake_induced_mixing: - atmospheric_ti_gain: 0.0 diff --git a/examples/01_wind_farm_dof1_model/inputs/turbine_dof_1.yaml b/examples/01_wind_farm_dof1_model/inputs/turbine_dof_1.yaml deleted file mode 100644 index 1fa52990..00000000 --- a/examples/01_wind_farm_dof1_model/inputs/turbine_dof_1.yaml +++ /dev/null @@ -1,23 +0,0 @@ -# Input YAML for turbine model - -# Name -name: NREL 5MW - DOF1 Model - -turbine_model_type: dof1_model # Can be filter_model or dof1_model - -dof1_model: - rho: 1.225 - rotor_inertia: 38677040.613 # [Kg m^2] - rated_power: 5000 # [kW] - rated_rotor_speed: 12.1 # RPM - filterfreq_rotor_speed: 1.57 # rad/sec - max_torque_rate: 400000 # [Nm/s] - max_pitch_rate: 0.1745 # [rad/s] - - gearbox_ratio: 97 - cq_table_file: inputs/Cp_Ct_Cq_NREL5MW.txt # Copied from ROSCO examples - initial_rpm: 10 # [RPM] - controller: - r2_k_torque: 21944 #19710 - r3_torque: 43,093 - ki_pitch: 0.008068634 \ No newline at end of file diff --git a/examples/01_wind_farm_dof1_model/inputs/turbine_filter_model.yaml b/examples/01_wind_farm_dof1_model/inputs/turbine_filter_model.yaml deleted file mode 100644 index 57bc0835..00000000 --- a/examples/01_wind_farm_dof1_model/inputs/turbine_filter_model.yaml +++ /dev/null @@ -1,12 +0,0 @@ -# Input YAML for turbine model - -# Name -name: NREL 5MW - Filter Model - - -turbine_model_type: filter_model # Can be filter_model or dof1_model - - -filter_model: - - time_constant: 12 # [s] diff --git a/examples/01_wind_farm_dof1_model/plot_outputs.py b/examples/01_wind_farm_dof1_model/plot_outputs.py index 8ce5fdae..01065b94 100644 --- a/examples/01_wind_farm_dof1_model/plot_outputs.py +++ b/examples/01_wind_farm_dof1_model/plot_outputs.py @@ -27,28 +27,19 @@ for t_idx in range(3): ax.plot( df["time"], - df[f"wind_farm.unwaked_velocities.{t_idx:03}"], + df[f"wind_farm.wind_speeds_background.{t_idx:03}"], label=f"Unwaked {t_idx}", color=colors[t_idx], ) for t_idx in range(3): ax.plot( df["time"], - df[f"wind_farm.waked_velocities.{t_idx:03}"], + df[f"wind_farm.wind_speeds_withwakes.{t_idx:03}"], label=f"Waked {t_idx}", linestyle="--", color=colors[t_idx], ) -# Plot the FLORIS wind speed -ax.plot( - df["time"], - df["wind_farm.floris_wind_speed"], - label="FLORIS", - color="black", - lw=2, -) - ax.grid(True) ax.legend() ax.set_ylabel("Wind Speed [m/s]") @@ -68,7 +59,7 @@ for t_idx in range(3): ax.plot( df["time"], - df[f"wind_farm.turbine_deratings.{t_idx:03}"], + df[f"wind_farm.turbine_power_setpoints.{t_idx:03}"], label=f"Derating {t_idx}", linestyle="--", color=colors[t_idx], diff --git a/examples/02_wind_farm_realistic_inflow/hercules_input.yaml b/examples/02_wind_farm_realistic_inflow/hercules_input.yaml deleted file mode 100644 index d442e973..00000000 --- a/examples/02_wind_farm_realistic_inflow/hercules_input.yaml +++ /dev/null @@ -1,34 +0,0 @@ -# Input YAML for hercules - -# Name -name: example_02 - -### -# Describe this emulator setup -description: Wind Only Realistic Inflow - -dt: 1.0 -starttime: 0.0 -endtime: 172800 # 2 Days -verbose: False - -plant: - interconnect_limit: 45000 # kW - -wind_farm: - - component_type: Wind_MesoToPower - floris_input_file: ../inputs/floris_input_large.yaml - wind_input_filename: ../inputs/wind_input_large.ftr - turbine_file_name: ../inputs/turbine_filter_model.yaml - log_file_name: outputs/log_wind_sim.log - logging_option: all - floris_update_time_s: 300.0 # Update wakes every 5 minutes - - -controller: - - - - - diff --git a/examples/02_wind_farm_realistic_inflow/README.md b/examples/02a_wind_farm_realistic_inflow/README.md similarity index 100% rename from examples/02_wind_farm_realistic_inflow/README.md rename to examples/02a_wind_farm_realistic_inflow/README.md diff --git a/examples/02a_wind_farm_realistic_inflow/hercules_input.yaml b/examples/02a_wind_farm_realistic_inflow/hercules_input.yaml new file mode 100644 index 00000000..dac663b8 --- /dev/null +++ b/examples/02a_wind_farm_realistic_inflow/hercules_input.yaml @@ -0,0 +1,42 @@ +# Input YAML for hercules + +# Name +name: example_02 + +### +# Describe this simulation setup +description: Wind Only Realistic Inflow + +dt: 1.0 +starttime_utc: "2024-06-24T16:59:08Z" # Jun 24, 2024 16:59:08 UTC (Zulu time) +endtime_utc: "2024-06-26T16:59:00Z" # ≈48 hours later (Jun 26, 2024 16:59:00 UTC) +verbose: False + +plant: + interconnect_limit: 45000 # kW + +wind_farm: + component_type: WindFarm + wake_method: dynamic + floris_input_file: ../inputs/floris_input_large.yaml + wind_input_filename: ../inputs/wind_input_large.ftr + turbine_file_name: ../inputs/turbine_filter_model.yaml + log_file_name: outputs/log_wind_sim.log + log_channels: + - power + - wind_speed_mean_background + - wind_speed_mean_withwakes + - wind_direction_mean + - turbine_powers + - wind_speeds_withwakes + - wind_speeds_background + - turbine_power_setpoints + floris_update_time_s: 300.0 # Update wakes every 5 minutes + + +controller: + + + + + diff --git a/examples/02_wind_farm_realistic_inflow/hercules_runscript.py b/examples/02a_wind_farm_realistic_inflow/hercules_runscript.py similarity index 50% rename from examples/02_wind_farm_realistic_inflow/hercules_runscript.py rename to examples/02a_wind_farm_realistic_inflow/hercules_runscript.py index fe3fb26a..708f90ef 100644 --- a/examples/02_wind_farm_realistic_inflow/hercules_runscript.py +++ b/examples/02a_wind_farm_realistic_inflow/hercules_runscript.py @@ -1,42 +1,14 @@ -import os -import shutil -import sys - import numpy as np -from hercules.emulator import Emulator -from hercules.hybrid_plant import HybridPlant -from hercules.utilities import load_hercules_input, setup_logging -from hercules.utilities_examples import ensure_example_inputs_exist +from hercules.hercules_model import HerculesModel +from hercules.utilities_examples import ensure_example_inputs_exist, prepare_output_directory -# If the output folder exists, delete it -if os.path.exists("outputs"): - shutil.rmtree("outputs") -os.makedirs("outputs") +prepare_output_directory() # Ensure example inputs exist ensure_example_inputs_exist() -# Get the logger -logger = setup_logging() - -# If more than one argument is provided raise and error -if len(sys.argv) > 2: - raise Exception( - "Usage: python hercules_runscript.py [hercules_input_file] or python hercules_runscript.py" - ) - -# If one argument is provided, use it as the input file -if len(sys.argv) == 2: - input_file = sys.argv[1] -# If no arguments are provided, use the default input file -else: - input_file = "hercules_input.yaml" - -# Initialize logging -logger.info(f"Starting with input file: {input_file}") - -# Load the input file -h_dict = load_hercules_input(input_file) +# Initialize the Hercules model +hmodel = HerculesModel("hercules_input.yaml") # Define a simple controller that sets all deratings to full rating @@ -77,16 +49,10 @@ def step(self, h_dict): return h_dict -# Initialize the controller -controller = ControllerToggleTurbine000(h_dict) - -# Initialize the hybrid plant -hybrid_plant = HybridPlant(h_dict) - -# Initialize the emulator -emulator = Emulator(controller, hybrid_plant, h_dict, logger) +# Instantiate the controller and assign to the Hercules model +hmodel.assign_controller(ControllerToggleTurbine000(hmodel.h_dict)) -# Run the emulator -emulator.enter_execution(function_targets=[], function_arguments=[[]]) +# Run the simulation +hmodel.run() -logger.info("Process completed successfully") +hmodel.logger.info("Process completed successfully") diff --git a/examples/02_wind_farm_realistic_inflow/plot_outputs.py b/examples/02a_wind_farm_realistic_inflow/plot_outputs.py similarity index 85% rename from examples/02_wind_farm_realistic_inflow/plot_outputs.py rename to examples/02a_wind_farm_realistic_inflow/plot_outputs.py index a9db1248..90f3d136 100644 --- a/examples/02_wind_farm_realistic_inflow/plot_outputs.py +++ b/examples/02a_wind_farm_realistic_inflow/plot_outputs.py @@ -40,27 +40,19 @@ for t_idx in turbines_to_plot: ax.plot( df["time_utc"], - df[f"wind_farm.unwaked_velocities.{t_idx:03}"], + df[f"wind_farm.wind_speeds_background.{t_idx:03}"], label=f"Unwaked {t_idx}", color=colors[t_idx], ) for t_idx in turbines_to_plot: ax.plot( df["time_utc"], - df[f"wind_farm.waked_velocities.{t_idx:03}"], + df[f"wind_farm.wind_speeds_withwakes.{t_idx:03}"], label=f"Waked {t_idx}", linestyle="--", color=colors[t_idx], ) -# # Plot the FLORIS wind speed -# ax.plot( -# df["time"], -# df["wind_farm.floris_wind_speed"], -# label="FLORIS", -# color="black", -# lw=2, -# ) ax.grid(True) ax.legend() diff --git a/examples/02b_wind_farm_realistic_inflow_precom_floris/README.md b/examples/02b_wind_farm_realistic_inflow_precom_floris/README.md index 13b17567..20531969 100644 --- a/examples/02b_wind_farm_realistic_inflow_precom_floris/README.md +++ b/examples/02b_wind_farm_realistic_inflow_precom_floris/README.md @@ -2,13 +2,12 @@ ## Description -This example is identical to `02_wind_farm_realistic_inflow` with the exception that the `Wind_MesoToPowerPrecomFloris` -class is used to speed up the simulation. This example automatically generates the necessary input files in the centralized `examples/inputs/` folder when first run. +This example is identical to `02_wind_farm_realistic_inflow` with the exception that the `precomputed` wake method of the `WindFarm` class is used to speed up the simulation. This example automatically generates the necessary input files in the centralized `examples/inputs/` folder when first run. Note the caveats to using this class from the docs: -> In contrast to the Wind_MesoToPower class, this class pre-computes the FLORIS wake +> In contrast to `wake_method="dynamic"`, this class pre-computes the FLORIS wake deficits for all possible wind speeds and power setpoints. This is done by running for all wind speeds and wind directions (but not over all power setpoints). This is valid for cases where the wind farm is operating: @@ -19,9 +18,8 @@ Note the caveats to using this class from the docs: It is in practice conservative with respect to the wake deficits, but it is more efficient than running FLORIS for each condition. In cases where turbines are: - partially derated below the curtailment level - - not uniformly curtailed or some turbines are off - - This is not an appropriate model and the more general Wind_MesoToPower class should be used. + - not uniformly curtailed or some turbines are off + this is not an appropriate model and the more general `wake_method="dynamic"` version should be used. diff --git a/examples/02b_wind_farm_realistic_inflow_precom_floris/hercules_input.yaml b/examples/02b_wind_farm_realistic_inflow_precom_floris/hercules_input.yaml index 1e4c0669..943d26e4 100644 --- a/examples/02b_wind_farm_realistic_inflow_precom_floris/hercules_input.yaml +++ b/examples/02b_wind_farm_realistic_inflow_precom_floris/hercules_input.yaml @@ -4,25 +4,33 @@ name: example_02 ### -# Describe this emulator setup +# Describe this simulation setup description: Wind Only Realistic Inflow dt: 1.0 -starttime: 0.0 -endtime: 172800 # 2 Days +starttime_utc: "2024-06-24T16:59:08Z" # Jun 24, 2024 16:59:08 UTC (Zulu time) +endtime_utc: "2024-06-26T16:59:00Z" # ≈48 hours later (Jun 26, 2024 16:59:00 UTC) verbose: False plant: interconnect_limit: 45000 # kW wind_farm: - - component_type: Wind_MesoToPowerPrecomFloris + component_type: WindFarm + wake_method: precomputed floris_input_file: ../inputs/floris_input_large.yaml wind_input_filename: ../inputs/wind_input_large.ftr turbine_file_name: ../inputs/turbine_filter_model.yaml log_file_name: outputs/log_wind_sim.log - logging_option: all + log_channels: + - power + - wind_speed_mean_background + - wind_speed_mean_withwakes + - wind_direction_mean + - turbine_powers + - wind_speeds_withwakes + - wind_speeds_background + - turbine_power_setpoints floris_update_time_s: 300.0 # Update wakes every 5 minutes diff --git a/examples/02b_wind_farm_realistic_inflow_precom_floris/hercules_runscript.py b/examples/02b_wind_farm_realistic_inflow_precom_floris/hercules_runscript.py index fe3fb26a..e9c2b4a2 100644 --- a/examples/02b_wind_farm_realistic_inflow_precom_floris/hercules_runscript.py +++ b/examples/02b_wind_farm_realistic_inflow_precom_floris/hercules_runscript.py @@ -1,42 +1,14 @@ -import os -import shutil -import sys - import numpy as np -from hercules.emulator import Emulator -from hercules.hybrid_plant import HybridPlant -from hercules.utilities import load_hercules_input, setup_logging -from hercules.utilities_examples import ensure_example_inputs_exist +from hercules.hercules_model import HerculesModel +from hercules.utilities_examples import ensure_example_inputs_exist, prepare_output_directory -# If the output folder exists, delete it -if os.path.exists("outputs"): - shutil.rmtree("outputs") -os.makedirs("outputs") +prepare_output_directory() # Ensure example inputs exist ensure_example_inputs_exist() -# Get the logger -logger = setup_logging() - -# If more than one argument is provided raise and error -if len(sys.argv) > 2: - raise Exception( - "Usage: python hercules_runscript.py [hercules_input_file] or python hercules_runscript.py" - ) - -# If one argument is provided, use it as the input file -if len(sys.argv) == 2: - input_file = sys.argv[1] -# If no arguments are provided, use the default input file -else: - input_file = "hercules_input.yaml" - -# Initialize logging -logger.info(f"Starting with input file: {input_file}") - -# Load the input file -h_dict = load_hercules_input(input_file) +# Initialize the Hercules model +hmodel = HerculesModel("hercules_input.yaml") # Define a simple controller that sets all deratings to full rating @@ -77,16 +49,10 @@ def step(self, h_dict): return h_dict -# Initialize the controller -controller = ControllerToggleTurbine000(h_dict) - -# Initialize the hybrid plant -hybrid_plant = HybridPlant(h_dict) - -# Initialize the emulator -emulator = Emulator(controller, hybrid_plant, h_dict, logger) +# Assign the controller to the Hercules model +hmodel.assign_controller(ControllerToggleTurbine000(hmodel.h_dict)) -# Run the emulator -emulator.enter_execution(function_targets=[], function_arguments=[[]]) +# Run the simulation +hmodel.run() -logger.info("Process completed successfully") +hmodel.logger.info("Process completed successfully") diff --git a/examples/02b_wind_farm_realistic_inflow_precom_floris/plot_outputs.py b/examples/02b_wind_farm_realistic_inflow_precom_floris/plot_outputs.py index a91d017e..dddbd118 100644 --- a/examples/02b_wind_farm_realistic_inflow_precom_floris/plot_outputs.py +++ b/examples/02b_wind_farm_realistic_inflow_precom_floris/plot_outputs.py @@ -40,14 +40,14 @@ for t_idx in turbines_to_plot: ax.plot( df["time"], - df[f"wind_farm.unwaked_velocities.{t_idx:03}"], + df[f"wind_farm.wind_speeds_background.{t_idx:03}"], label=f"Unwaked {t_idx}", color=colors[t_idx], ) for t_idx in turbines_to_plot: ax.plot( df["time"], - df[f"wind_farm.waked_velocities.{t_idx:03}"], + df[f"wind_farm.wind_speeds_withwakes.{t_idx:03}"], label=f"Waked {t_idx}", linestyle="--", color=colors[t_idx], @@ -56,8 +56,8 @@ # Plot the FLORIS wind speed ax.plot( df["time"], - df["wind_farm.floris_wind_speed"], - label="FLORIS", + df["wind_farm.wind_speed_mean_background"], + label="Mean Unwaked Wind Speed", color="black", lw=2, ) diff --git a/examples/02c_wind_farm_realistic_inflow_direct/README.md b/examples/02c_wind_farm_realistic_inflow_direct/README.md new file mode 100644 index 00000000..fda307b8 --- /dev/null +++ b/examples/02c_wind_farm_realistic_inflow_direct/README.md @@ -0,0 +1,31 @@ +# Example 02c: Wind Farm Realistic Inflow (Direct - No Wake Modeling) + +## Description + +This example demonstrates the `"no_added_wakes"` wake method, which assumes that wake effects are already included in the input wind data and performs no additional wake modeling. + +In this example, the `WindFarm` component type uses `wake_method="no_added_wakes"`, which means: +- No FLORIS calculations are performed during the simulation (only at initialization to read turbine properties) +- `wind_speeds_withwakes` equals `wind_speeds_background` at all times +- Wake deficits are always zero +- Turbine dynamics (filter model or DOF1 model) still operate normally + +This example automatically generates the necessary input files in the centralized `examples/inputs/` folder when first run. + +## Running + +To run the example, execute the following command in the terminal: + +```bash +python hercules_runscript.py +``` + +## Outputs + +To plot the outputs run the following command in the terminal: + +```bash +python plot_outputs.py +``` + + diff --git a/examples/02c_wind_farm_realistic_inflow_direct/hercules_input.yaml b/examples/02c_wind_farm_realistic_inflow_direct/hercules_input.yaml new file mode 100644 index 00000000..4c2da5fa --- /dev/null +++ b/examples/02c_wind_farm_realistic_inflow_direct/hercules_input.yaml @@ -0,0 +1,40 @@ +# Input YAML for hercules + +# Name +name: example_02c + +### +# Describe this simulation setup +description: Wind Only Realistic Inflow (Direct - No Wake Modeling) + +dt: 1.0 +starttime_utc: "2024-06-24T16:59:08Z" # Jun 24, 2024 16:59:08 UTC (Zulu time) +endtime_utc: "2024-06-26T16:59:00Z" # ≈48 hours later (Jun 26, 2024 16:59:00 UTC) +verbose: False + +plant: + interconnect_limit: 45000 # kW + +wind_farm: + component_type: WindFarm + wake_method: no_added_wakes + floris_input_file: ../inputs/floris_input_large.yaml + wind_input_filename: ../inputs/wind_input_large.ftr + turbine_file_name: ../inputs/turbine_filter_model.yaml + log_file_name: outputs/log_wind_sim.log + log_channels: + - power + - wind_speed_mean_background + - wind_speed_mean_withwakes + - wind_direction_mean + - turbine_powers + - wind_speeds_withwakes + - wind_speeds_background + - turbine_power_setpoints + +controller: + + + + + diff --git a/examples/02c_wind_farm_realistic_inflow_direct/hercules_runscript.py b/examples/02c_wind_farm_realistic_inflow_direct/hercules_runscript.py new file mode 100644 index 00000000..8e06f8a7 --- /dev/null +++ b/examples/02c_wind_farm_realistic_inflow_direct/hercules_runscript.py @@ -0,0 +1,53 @@ +import numpy as np +from hercules.hercules_model import HerculesModel +from hercules.utilities_examples import ensure_example_inputs_exist, prepare_output_directory + +prepare_output_directory() + +# Ensure example inputs exist +ensure_example_inputs_exist() + +# Initialize the Hercules model +hmodel = HerculesModel("hercules_input.yaml") + + +# Define a simple controller that sets all power setpoints to full rating +class ControllerFullRating: + """A simple controller that sets all turbines to full rating. + + This controller is appropriate for the direct wake model where + wake effects are already included in the input wind data. + """ + + def __init__(self, h_dict): + """Initialize the controller. + + Args: + h_dict (dict): The hercules input dictionary. + """ + pass + + def step(self, h_dict): + """Execute one control step. + + Args: + h_dict (dict): The hercules input dictionary. + + Returns: + dict: The updated hercules input dictionary. + """ + # Set all turbines to full rating + h_dict["wind_farm"]["turbine_power_setpoints"] = 5000 * np.ones( + h_dict["wind_farm"]["n_turbines"] + ) + + return h_dict + + +# Assign the controller to the Hercules model +hmodel.assign_controller(ControllerFullRating(hmodel.h_dict)) + +# Run the simulation +hmodel.run() + +hmodel.logger.info("Process completed successfully") diff --git a/examples/02c_wind_farm_realistic_inflow_direct/plot_outputs.py b/examples/02c_wind_farm_realistic_inflow_direct/plot_outputs.py new file mode 100644 index 00000000..c63bfcd6 --- /dev/null +++ b/examples/02c_wind_farm_realistic_inflow_direct/plot_outputs.py @@ -0,0 +1,79 @@ +# Plot the outputs of the simulation + +import matplotlib.pyplot as plt +from hercules import HerculesOutput + +# Read the Hercules output file using HerculesOutput +ho = HerculesOutput("outputs/hercules_output.h5") + +# Print metadata information +print("Simulation Metadata:") +ho.print_metadata() +print() + +# Create a shortcut to the dataframe +df = ho.df + +# Limit to the first 4 hours +df = df.iloc[: 3600 * 4] + +# Set number of turbines +turbines_to_plot = [0, 8] + +# Define a consistent color map with 9 +colors = [ + "tab:blue", + "tab:orange", + "tab:green", + "tab:red", + "tab:purple", + "tab:brown", + "tab:pink", + "tab:gray", + "tab:olive", +] + +fig, axarr = plt.subplots(2, 1, sharex=True) + +# Plot the wind speeds +ax = axarr[0] +for t_idx in turbines_to_plot: + ax.plot( + df["time"], + df[f"wind_farm.wind_speeds_background.{t_idx:03}"], + label=f"Wind Speed {t_idx}", + color=colors[t_idx], + ) + +# Note: In direct mode, wind_speeds_withwakes == wind_speeds_background + +# Plot the mean wind speed +ax.plot( + df["time"], + df["wind_farm.wind_speed_mean_background"], + label="Mean Wind Speed", + color="black", + lw=2, +) + +ax.grid(True) +ax.legend() +ax.set_ylabel("Wind Speed [m/s]") +ax.set_title("Direct Wake Model (No Wake Modeling)") + + +# Plot the power +ax = axarr[1] +for t_idx in turbines_to_plot: + ax.plot( + df["time"], + df[f"wind_farm.turbine_powers.{t_idx:03}"], + label=f"Turbine {t_idx}", + color=colors[t_idx], + ) + +ax.grid(True) +ax.legend() +ax.set_xlabel("Time [s]") +ax.set_ylabel("Power [kW]") +plt.show() diff --git a/examples/03_wind_and_solar/hercules_input.yaml b/examples/03_wind_and_solar/hercules_input.yaml index 0104bd1c..0946e928 100644 --- a/examples/03_wind_and_solar/hercules_input.yaml +++ b/examples/03_wind_and_solar/hercules_input.yaml @@ -4,12 +4,12 @@ name: example_03 ### -# Describe this emulator setup +# Describe this simulation setup description: Wind and solar dt: 1.0 -starttime: 0.0 -endtime: 28800.0 +starttime_utc: "2024-06-24T16:59:08Z" # Jun 24, 2024 16:59:08 UTC (Zulu time) +endtime_utc: "2024-06-24T17:07:08Z" # 8 minutes later verbose: False @@ -17,13 +17,15 @@ plant: interconnect_limit: 45000 # kW -wind_farm: # The name of the Wind_MesoToPower wind farm - component_type: Wind_MesoToPowerPrecomFloris +wind_farm: + component_type: WindFarm + wake_method: precomputed floris_input_file: ../inputs/floris_input_large.yaml wind_input_filename: ../inputs/wind_input_large.ftr turbine_file_name: ../inputs/turbine_filter_model.yaml log_file_name: outputs/log_wind_sim.log - logging_option: all + log_channels: + - power floris_update_time_s: 300.0 # Update wakes every 5 minutes solar_farm: # The name of component object 1 @@ -35,6 +37,8 @@ solar_farm: # The name of component object 1 system_capacity: 30000 # kW (30 MW) tilt: 0 # degrees losses: 0 + log_channels: + - power initial_conditions: @@ -44,7 +48,3 @@ solar_farm: # The name of component object 1 controller: - - - - diff --git a/examples/03_wind_and_solar/hercules_runscript.py b/examples/03_wind_and_solar/hercules_runscript.py index 2989e9e0..bb177a55 100644 --- a/examples/03_wind_and_solar/hercules_runscript.py +++ b/examples/03_wind_and_solar/hercules_runscript.py @@ -1,42 +1,14 @@ -import os -import shutil -import sys - import numpy as np -from hercules.emulator import Emulator -from hercules.hybrid_plant import HybridPlant -from hercules.utilities import load_hercules_input, setup_logging -from hercules.utilities_examples import ensure_example_inputs_exist +from hercules.hercules_model import HerculesModel +from hercules.utilities_examples import ensure_example_inputs_exist, prepare_output_directory -# If the output folder exists, delete it -if os.path.exists("outputs"): - shutil.rmtree("outputs") -os.makedirs("outputs") +prepare_output_directory() # Ensure example inputs exist ensure_example_inputs_exist() -# Get the logger -logger = setup_logging() - -# If more than one argument is provided raise and error -if len(sys.argv) > 2: - raise Exception( - "Usage: python hercules_runscript.py [hercules_input_file] or python hercules_runscript.py" - ) - -# If one argument is provided, use it as the input file -if len(sys.argv) == 2: - input_file = sys.argv[1] -# If no arguments are provided, use the default input file -else: - input_file = "hercules_input.yaml" - -# Initialize logging -logger.info(f"Starting with input file: {input_file}") - -# Load the input file -h_dict = load_hercules_input(input_file) +# Initialize the Hercules model +hmodel = HerculesModel("hercules_input.yaml") # Define a simple controller that sets all deratings to full rating @@ -79,19 +51,10 @@ def step(self, h_dict): return h_dict -# Initialize the hybrid plant -hybrid_plant = HybridPlant(h_dict) - -# Add initial values and meta data back to the h_dict -h_dict = hybrid_plant.add_plant_metadata_to_h_dict(h_dict) - -# Initialize the controller -controller = ControllerLimitSolar(h_dict) - -# Initialize the emulator -emulator = Emulator(controller, hybrid_plant, h_dict, logger) +# Assign the controller to the Hercules model +hmodel.assign_controller(ControllerLimitSolar(hmodel.h_dict)) -# Run the emulator -emulator.enter_execution(function_targets=[], function_arguments=[[]]) +# Run the simulation +hmodel.run() -logger.info("Process completed successfully") +hmodel.logger.info("Process completed successfully") diff --git a/examples/04_wind_and_storage/hercules_input.yaml b/examples/04_wind_and_storage/hercules_input.yaml index 946ebc78..a313de5c 100644 --- a/examples/04_wind_and_storage/hercules_input.yaml +++ b/examples/04_wind_and_storage/hercules_input.yaml @@ -4,12 +4,12 @@ name: example_03 ### -# Describe this emulator setup +# Describe this simulation setup description: Wind and solar dt: 1.0 -starttime: 0.0 -endtime: 172800.0 +starttime_utc: "2024-06-24T16:59:08Z" # Jun 24, 2024 16:59:08 UTC (Zulu time) +endtime_utc: "2024-06-26T16:59:00Z" # ≈48 hours later (Jun 26, 2024 16:59:00 UTC) verbose: False @@ -17,14 +17,16 @@ plant: interconnect_limit: 45000 # kW -wind_farm: # The name of the Wind_MesoToPower wind farm - component_type: Wind_MesoToPowerPrecomFloris +wind_farm: + component_type: WindFarm + wake_method: precomputed floris_input_file: ../inputs/floris_input_large.yaml wind_input_filename: ../inputs/wind_input_large.ftr turbine_file_name: ../inputs/turbine_filter_model.yaml log_file_name: outputs/log_wind_sim.log + log_channels: + - power floris_update_time_s: 300.0 # Update wakes every 5 minutes - logging_option: all battery: component_type: BatterySimple @@ -34,6 +36,10 @@ battery: discharge_rate: 10000 # discharge rate in kW (10 MW) max_SOC: 0.9 min_SOC: 0.1 + log_channels: + - power + - soc + - power_setpoint allow_grid_power_consumption: False initial_conditions: SOC: 0.5 diff --git a/examples/04_wind_and_storage/hercules_runscript.py b/examples/04_wind_and_storage/hercules_runscript.py index 67f10e0e..3c786711 100644 --- a/examples/04_wind_and_storage/hercules_runscript.py +++ b/examples/04_wind_and_storage/hercules_runscript.py @@ -1,42 +1,14 @@ -import os -import shutil -import sys - import numpy as np -from hercules.emulator import Emulator -from hercules.hybrid_plant import HybridPlant -from hercules.utilities import load_hercules_input, setup_logging -from hercules.utilities_examples import ensure_example_inputs_exist +from hercules.hercules_model import HerculesModel +from hercules.utilities_examples import ensure_example_inputs_exist, prepare_output_directory -# If the output folder exists, delete it -if os.path.exists("outputs"): - shutil.rmtree("outputs") -os.makedirs("outputs") +prepare_output_directory() # Ensure example inputs exist ensure_example_inputs_exist() -# Get the logger -logger = setup_logging() - -# If more than one argument is provided raise and error -if len(sys.argv) > 2: - raise Exception( - "Usage: python hercules_runscript.py [hercules_input_file] or python hercules_runscript.py" - ) - -# If one argument is provided, use it as the input file -if len(sys.argv) == 2: - input_file = sys.argv[1] -# If no arguments are provided, use the default input file -else: - input_file = "hercules_input.yaml" - -# Initialize logging -logger.info(f"Starting with input file: {input_file}") - -# Load the input file -h_dict = load_hercules_input(input_file) +# Initialize the Hercules model +hmodel = HerculesModel("hercules_input.yaml") # Define a simple controller that sets all deratings to full rating @@ -92,19 +64,10 @@ def step(self, h_dict): return h_dict -# Initialize the hybrid plant -hybrid_plant = HybridPlant(h_dict) - -# Add initial values and meta data back to the h_dict -h_dict = hybrid_plant.add_plant_metadata_to_h_dict(h_dict) - -# Initialize the controller -controller = ControllerLimitSolar(h_dict) - -# Initialize the emulator -emulator = Emulator(controller, hybrid_plant, h_dict, logger) +# Assign the controller to the Hercules model +hmodel.assign_controller(ControllerLimitSolar(hmodel.h_dict)) -# Run the emulator -emulator.enter_execution(function_targets=[], function_arguments=[[]]) +# Run the simulation +hmodel.run() -logger.info("Process completed successfully") +hmodel.logger.info("Process completed successfully") diff --git a/examples/05_wind_and_storage_with_lmp/README.md b/examples/05_wind_and_storage_with_lmp/README.md new file mode 100644 index 00000000..2bf9fe85 --- /dev/null +++ b/examples/05_wind_and_storage_with_lmp/README.md @@ -0,0 +1,43 @@ +# Example 05: Wind and storage with LMP-based control + +## Description + +Example of a wind and storage hybrid plant with a controller that responds to Locational Marginal Pricing (LMP) signals. The controller: +- Charges the battery when real-time LMP is below $15/MWh (low prices) +- Discharges the battery when real-time LMP is above $35/MWh (high prices) +- Keeps the battery idle for intermediate prices + +This example also demonstrates the new **selective external data logging** feature. Both `lmp_rt` and `lmp_da` are available to the controller via `h_dict["external_signals"]`, but only `lmp_rt` is logged to the HDF5 output file as specified in `log_channels`. + +## Setup + +No manual setup is required. The example automatically generates the necessary input files (wind data, FLORIS configuration, turbine model, and LMP data) when first run. + +## Running + +To run the example, execute the following command in the terminal: + +```bash +python hercules_runscript.py +``` + +The simulation runs for 4 hours with the following characteristics: +- Real-time LMP ramps from $0 to $50/MWh over 4 hours +- Day-ahead LMP remains constant at $10/MWh +- Only real-time LMP is logged to output (selective logging) + +## Outputs + +To plot the outputs run the following command in the terminal: + +```bash +python plot_outputs.py +``` + +The plot shows: +1. Wind and battery power with interconnect limits +2. Battery state of charge (SOC) +3. Battery power vs setpoint +4. LMP prices and control thresholds + +Note that `lmp_rt` appears in the HDF5 output file, but `lmp_da` does not (as specified in `log_channels`), even though both were available to the controller. diff --git a/examples/05_wind_and_storage_with_lmp/hercules_input.yaml b/examples/05_wind_and_storage_with_lmp/hercules_input.yaml new file mode 100644 index 00000000..3535cf64 --- /dev/null +++ b/examples/05_wind_and_storage_with_lmp/hercules_input.yaml @@ -0,0 +1,56 @@ +# Input YAML for hercules + +# Name +name: example_05 + +### +# Describe this simulation setup +description: Wind farm with battery storage and LMP-based control + +dt: 1.0 +starttime_utc: "2024-06-24T16:59:08Z" # Jun 24, 2024 16:59:08 UTC (Zulu time) +endtime_utc: "2024-06-24T20:59:08Z" # 4 hours later (Jun 24, 2024 20:59:08 UTC) +verbose: False + + +plant: + interconnect_limit: 45000 # kW + + +wind_farm: + component_type: WindFarm + wake_method: precomputed + floris_input_file: ../inputs/floris_input_large.yaml + wind_input_filename: ../inputs/wind_input_large.ftr + turbine_file_name: ../inputs/turbine_filter_model.yaml + log_file_name: outputs/log_wind_sim.log + log_channels: + - power + floris_update_time_s: 300.0 # Update wakes every 5 minutes + +battery: + component_type: BatterySimple + size: 10000 # kW size of the battery (10 MW) + energy_capacity: 10000 # total capacity of the battery in kWh (10 MWh) + charge_rate: 10000 # charge rate in kW (10 MW) + discharge_rate: 10000 # discharge rate in kW (10 MW) + max_SOC: 0.9 + min_SOC: 0.1 + log_channels: + - power + - soc + - power_setpoint + allow_grid_power_consumption: False + initial_conditions: + SOC: 0.5 + +external_data: + external_data_file: external_data_lmp.csv + log_channels: + - lmp_rt + +controller: + + + + diff --git a/examples/05_wind_and_storage_with_lmp/hercules_runscript.py b/examples/05_wind_and_storage_with_lmp/hercules_runscript.py new file mode 100644 index 00000000..59235eac --- /dev/null +++ b/examples/05_wind_and_storage_with_lmp/hercules_runscript.py @@ -0,0 +1,107 @@ +from pathlib import Path + +import numpy as np +import pandas as pd +from hercules.hercules_model import HerculesModel +from hercules.utilities_examples import ensure_example_inputs_exist, prepare_output_directory + +prepare_output_directory() + +# Ensure example inputs exist +ensure_example_inputs_exist() + +# Generate LMP data file if it doesn't exist +lmp_data_path = Path("external_data_lmp.csv") + +print("Generating LMP data file...") +# Create 4 hours of data at 5-minute intervals +# Start time matching the example: 2024-06-24T16:59:08Z +start_time = pd.Timestamp("2024-06-24T16:59:08Z") +# 4 hours = 240 minutes, 5-minute intervals = 49 time points (0, 5, 10, ..., 240) +time_points = pd.date_range(start=start_time, periods=49, freq="5min") + +# Create the dataframe +df = pd.DataFrame( + { + "time_utc": time_points, + "lmp_da": np.full(49, 10.0), # Constant $10 + "lmp_rt": np.linspace(0, 50, 49), # Ramp from $0 to $50 + } +) + +# Save to CSV +df.to_csv(lmp_data_path, index=False) +print(f"Created {lmp_data_path}") + +# Initialize the Hercules model +hmodel = HerculesModel("hercules_input.yaml") + + +# Define an LMP-based battery controller +class ControllerLMPBased: + """Battery controller that charges/discharges based on real-time LMP prices.""" + + def __init__(self, h_dict): + """Initialize the controller. + + Args: + h_dict (dict): The hercules input dictionary. + """ + self.interconnect_limit = h_dict["plant"]["interconnect_limit"] + self.charge_threshold = 15.0 # Charge when lmp_rt < $15 + self.discharge_threshold = 35.0 # Discharge when lmp_rt > $35 + + def step(self, h_dict): + """Execute one control step based on LMP prices. + + Args: + h_dict (dict): The hercules input dictionary. + + Returns: + dict: The updated hercules input dictionary. + """ + # Set wind turbine power setpoints to full rating + h_dict["wind_farm"]["turbine_power_setpoints"] = 5000 * np.ones( + h_dict["wind_farm"]["n_turbines"] + ) + + # Get the total wind farm power + wind_farm_power = h_dict["wind_farm"]["power"] + + # Get the real-time LMP from external signals + lmp_rt = h_dict["external_signals"]["lmp_rt"] + + # Battery control based on LMP + # With hybrid plant sign convention: Positive = discharge, Negative = charge + if lmp_rt < self.charge_threshold: + # Low price: charge the battery + h_dict["battery"]["power_setpoint"] = -10000 + elif lmp_rt > self.discharge_threshold: + # High price: discharge the battery + h_dict["battery"]["power_setpoint"] = 10000 + else: + # Medium price: idle + h_dict["battery"]["power_setpoint"] = 0 + + # Get the limit for battery power + # Battery power + wind power must be less than the interconnect limit + battery_power_upper_limit = self.interconnect_limit - wind_farm_power + battery_power_lower_limit = -1 * self.interconnect_limit - wind_farm_power + + # Clip to respect interconnect limits + h_dict["battery"]["power_setpoint"] = np.clip( + h_dict["battery"]["power_setpoint"], + battery_power_lower_limit, + battery_power_upper_limit, + ) + + return h_dict + + +# Assign the controller to the Hercules model +hmodel.assign_controller(ControllerLMPBased(hmodel.h_dict)) + +# Run the simulation +hmodel.run() + +hmodel.logger.info("Process completed successfully") diff --git a/examples/05_wind_and_storage_with_lmp/plot_outputs.py b/examples/05_wind_and_storage_with_lmp/plot_outputs.py new file mode 100644 index 00000000..01cc2556 --- /dev/null +++ b/examples/05_wind_and_storage_with_lmp/plot_outputs.py @@ -0,0 +1,115 @@ +# Plot the outputs of the simulation for the wind, storage, and LMP example + +import matplotlib.pyplot as plt +from hercules import HerculesOutput + +# Read the Hercules output file using HerculesOutput +ho = HerculesOutput("outputs/hercules_output.h5") + +# Print metadata information +print("Simulation Metadata:") +ho.print_metadata() +print() + +# Create a shortcut to the dataframe +df = ho.df + +# Get the h_dict from metadata +h_dict = ho.h_dict + +fig, axarr = plt.subplots(4, 1, sharex=True, figsize=(10, 10)) + +# Get an index of where battery power is positive or negative +df_battery_positive = df.copy() +df_battery_negative = df.copy() + +# 0 negative power from df_battery_positive and vice versa +df_battery_positive.loc[df_battery_positive["battery.power"] < 0, "battery.power"] = 0 +df_battery_negative.loc[df_battery_negative["battery.power"] > 0, "battery.power"] = 0 + +# Plot the farm power +ax = axarr[0] + +# Plot the hybrid plant power +ax.plot( + df["time"] / 3600, + df["wind_farm.power"], + label="Wind Power", + color="b", + alpha=0.5, +) + +# Only plot battery discharging if there are any positive battery power values + +ax.fill_between( + df_battery_positive["time"] / 3600, + df_battery_positive["wind_farm.power"], + df_battery_positive["wind_farm.power"] + df_battery_positive["battery.power"], + label="Battery Discharging", + color="orange", + alpha=0.5, +) + +# Only plot battery charging if there are any negative battery power values + +ax.fill_between( + df_battery_negative["time"] / 3600, + df_battery_negative["wind_farm.power"], + df_battery_negative["wind_farm.power"] + df_battery_negative["battery.power"], + label="Battery Charging", + color="green", + alpha=0.5, +) + +# Plot total hybrid plant power (wind + battery) +ax.plot( + df["time"] / 3600, + df["wind_farm.power"] + df["battery.power"], + label="Hybrid Plant Total", + color="k", +) +ax.axhline( + h_dict["plant"]["interconnect_limit"], color="r", linestyle="--", label="Interconnect Limit" +) + + +ax.set_ylabel("Power [kW]") +ax.legend() +ax.grid(True) + +# Plot the battery SOC +ax = axarr[1] + +ax.plot(df["time"] / 3600, df["battery.soc"], label="Battery SOC", color="k") +ax.axhline(h_dict["battery"]["max_SOC"], color="r", linestyle="--", label="Max SOC") +ax.axhline(h_dict["battery"]["min_SOC"], color="r", linestyle="--", label="Min SOC") +ax.set_ylabel("SOC") +ax.legend() +ax.grid(True) + +# Plot the battery power and power setpoint +ax = axarr[2] +ax.plot(df["time"] / 3600, df["battery.power"], label="Battery Power", color="k") +ax.plot(df["time"] / 3600, df["battery.power_setpoint"], label="Battery Power Setpoint", color="r") + +ax.set_ylabel("Power [kW]") +ax.legend() +ax.grid(True) + +# Plot the LMP data +ax = axarr[3] +# Plot lmp_rt from HDF5 output (this was logged) +if "external_signals.lmp_rt" in df.columns: + ax.plot(df["time"] / 3600, df["external_signals.lmp_rt"], label="LMP RT (logged)", color="b") + + +ax.axhline(15, color="green", linestyle=":", label="Charge Threshold ($15)") +ax.axhline(35, color="orange", linestyle=":", label="Discharge Threshold ($35)") + +ax.set_xlabel("Time [hr]") +ax.set_ylabel("LMP [$/MWh]") +ax.legend() +ax.grid(True) + +plt.tight_layout() +plt.show() diff --git a/examples/06_wind_and_hydrogen/README.md b/examples/06_wind_and_hydrogen/README.md new file mode 100644 index 00000000..7aca39b7 --- /dev/null +++ b/examples/06_wind_and_hydrogen/README.md @@ -0,0 +1,25 @@ +# Example 06: Wind and hydrogen hybrid plant + +## Description + +Example of a wind and hydrogen hybrid plant where power that the wind farm produces goes directly to hydrogen electrolysis + +## Setup + +No manual setup is required. The example automatically generates the necessary input files (wind data, FLORIS configuration, and turbine model) in the centralized `examples/inputs/` folder when first run. + + +## Running + +To run the example, execute the following command in the terminal: + +```bash +python hercules_runscript.py +``` +## Outputs + +To plot the outputs run the following command in the terminal: + +```bash +python plot_outputs.py +``` \ No newline at end of file diff --git a/examples/06_wind_and_hydrogen/hercules_input.yaml b/examples/06_wind_and_hydrogen/hercules_input.yaml new file mode 100644 index 00000000..25c367cc --- /dev/null +++ b/examples/06_wind_and_hydrogen/hercules_input.yaml @@ -0,0 +1,117 @@ +# Input YAML for hercules + +# Name +name: example_06 + +### +# Describe this simulation setup +description: Wind and hydrogen + +dt: 1.0 +starttime_utc: "2024-06-24T16:59:08Z" # Jun 24, 2024 16:59:08 UTC (Zulu time) +endtime_utc: "2024-06-24T18:59:00Z" # ≈2 hours later (Jun 26, 2024 16:59:00 UTC) +# endtime_utc: "2024-06-26T16:59:00Z" # ≈48 hours later (Jun 26, 2024 16:59:00 UTC) +verbose: False + + +plant: + interconnect_limit: 45000 # kW + + +wind_farm: + component_type: WindFarm + wake_method: precomputed + floris_input_file: ../inputs/floris_input_large.yaml + wind_input_filename: ../inputs/wind_input_large.ftr + turbine_file_name: ../inputs/turbine_filter_model.yaml + log_file_name: outputs/log_wind_sim.log + log_channels: + - power + - wind_speed_mean_background + - wind_speed_mean_withwakes + - wind_direction_mean + - turbine_powers + - wind_speeds_withwakes + - wind_speeds_background + - turbine_power_setpoints + floris_update_time_s: 300.0 # Update wakes every 5 minutes + +# Electrolyzer plant input file +electrolyzer: # The name of the py_sim object + + component_type: ElectrolyzerPlant + general: + verbose: True # default + initial_conditions: + # Initial power input to electrolyzer + power_available_kw: 20000 + electrolyzer: + initialize: True + initial_power_kW: 20000 + supervisor: + n_stacks: 40 + system_rating_MW: 40 + stack: + cell_type: PEM + # Maximum current of Stack (A) + max_current: 2000 + # Stack operating temperature (degC) + temperature: 60 + # Number of Cells per Stack + n_cells: 100 + # Stack rated power + stack_rating_kW: 1000 + # Determines whether degradation is applied to the Stack operation + include_degradation_penalty: False + # hydrogen_degradation_penalty: True + # cell_params: + # cell_type: PEM + # max_current_density: 2 + # PEM_params: + # cell_area: 1000 + # turndown_ratio: 0.1 + # max_current_density: 2.0 + # p_anode: 1.01325 + # p_cathode: 30 + # alpha_a: 2 + # alpha_c: 0.5 + # i_0_a: 2.0e-7 + # i_0_c: 2.0e-3 + # e_m: 0.02 + # R_ohmic_elec: 50.0e-3 + # f_1: 250 + # f_2: 0.996 + # degradation: + # eol_eff_percent_loss: 20 + # PEM_params: + # rate_steady: 1.42e-10 + # rate_fatigue: 3.33e-07 + # rate_onoff: 1.47e-04 + controller: + # Controller type for electrolyzer plant operation + control_type: PowerSharingRotation + # policy: + # eager_on: False + # eager_off: False + # sequential: False + # even_dist: False + # baseline: True + # costs: + log_channels: + - power + - H2_output + - H2_mfr + - power_input_kw + + +controller: + num_turbines: 9 # Should match AMR-Wind! Ideally, would come from AMR-wind + nominal_plant_power_kW: 45000 # Plant power in kW + nominal_hydrogen_rate_kgps: 0.208 # in kg/s [kg per day/24/3600 * stack number] + hydrogen_controller_gain: 1 + +external_data_file: inputs/hydrogen_ref_signal.csv + + + + diff --git a/examples/06_wind_and_hydrogen/hercules_runscript.py b/examples/06_wind_and_hydrogen/hercules_runscript.py new file mode 100644 index 00000000..30758060 --- /dev/null +++ b/examples/06_wind_and_hydrogen/hercules_runscript.py @@ -0,0 +1,94 @@ +import numpy as np +from hercules.hercules_model import HerculesModel +from hercules.utilities_examples import ensure_example_inputs_exist, prepare_output_directory +from hycon.controllers import ( + HydrogenPlantController, + WindFarmPowerTrackingController, +) +from hycon.interfaces import HerculesInterface + +prepare_output_directory() + +# Ensure example inputs exist +ensure_example_inputs_exist() + +# Initialize the Hercules model +hmodel = HerculesModel("hercules_input.yaml") + + +# Define a simple controller that sets all deratings to full rating +# and then sets the derating of turbine 000 to 500, toggling every other 100 seconds. +class ControllerLimitSolar: + """Limits the solar power to keep the total power below the interconnect limit.""" + + def __init__(self, h_dict): + """Initialize the controller. + + Args: + h_dict (dict): The hercules input dictionary. + """ + self.interconnect_limit = h_dict["plant"]["interconnect_limit"] + pass + + def step(self, h_dict): + """Execute one control step. + + Args: + h_dict (dict): The hercules input dictionary. + + Returns: + dict: The updated hercules input dictionary. + """ + # Set wind turbine power setpoints to full rating + h_dict["wind_farm"]["turbine_power_setpoints"] = 5000 * np.ones( + h_dict["wind_farm"]["n_turbines"] + ) + + # Get the total wind farm power + wind_farm_power = h_dict["wind_farm"]["power"] + + # Charge or discharge the battery, reversing every other hour + # With hybrid plant sign convention: Positive = discharge, Negative = charge + if h_dict["time"] % 3600 < 1800: + h_dict["battery"]["power_setpoint"] = 10000 # Discharge the battery + else: + h_dict["battery"]["power_setpoint"] = -10000 # Charge the battery + + # Get the limit for battery power + # Battery power + wind power must be less than the interconnect limit + battery_power_upper_limit = self.interconnect_limit - wind_farm_power + battery_power_lower_limit = -1 * self.interconnect_limit - wind_farm_power + + # Set the solar power limit + h_dict["battery"]["power_setpoint"] = np.clip( + h_dict["battery"]["power_setpoint"], + battery_power_lower_limit, + battery_power_upper_limit, + ) + + return h_dict + + +# Establish controllers based on options +interface = HerculesInterface(hmodel.h_dict) + +print("Setting up controller.") +wind_controller = WindFarmPowerTrackingController(interface, hmodel.h_dict) +# solar_controller = ( +# SolarPassthroughController(interface, hmodel.h_dict) if include_solar +# else None +# ) +# battery_controller = ( +# BatteryPassthroughController(interface, hmodel.h_dict) if include_battery +# else None +# ) +controller = HydrogenPlantController(interface, hmodel.h_dict, generator_controller=wind_controller) + +# Assign the controller to the Hercules model +hmodel.assign_controller(controller) +print("Controller assigned.") + +# Run the simulation +hmodel.run() + +hmodel.logger.info("Process completed successfully") diff --git a/examples/06_wind_and_hydrogen/inputs/hydrogen_ref_signal.csv b/examples/06_wind_and_hydrogen/inputs/hydrogen_ref_signal.csv new file mode 100644 index 00000000..6d2bd7f7 --- /dev/null +++ b/examples/06_wind_and_hydrogen/inputs/hydrogen_ref_signal.csv @@ -0,0 +1,7201 @@ +time,hydrogen_reference,time_utc +1.0,0.05,2024-06-24 16:59:08+00:00 +2.0,0.05,2024-06-24 16:59:09+00:00 +3.0,0.05,2024-06-24 16:59:10+00:00 +4.0,0.05,2024-06-24 16:59:11+00:00 +5.0,0.05,2024-06-24 16:59:12+00:00 +6.0,0.05,2024-06-24 16:59:13+00:00 +7.0,0.05,2024-06-24 16:59:14+00:00 +8.0,0.05,2024-06-24 16:59:15+00:00 +9.0,0.05,2024-06-24 16:59:16+00:00 +10.0,0.05,2024-06-24 16:59:17+00:00 +11.0,0.05,2024-06-24 16:59:18+00:00 +12.0,0.05,2024-06-24 16:59:19+00:00 +13.0,0.05,2024-06-24 16:59:20+00:00 +14.0,0.05,2024-06-24 16:59:21+00:00 +15.0,0.05,2024-06-24 16:59:22+00:00 +16.0,0.05,2024-06-24 16:59:23+00:00 +17.0,0.05,2024-06-24 16:59:24+00:00 +18.0,0.05,2024-06-24 16:59:25+00:00 +19.0,0.05,2024-06-24 16:59:26+00:00 +20.0,0.05,2024-06-24 16:59:27+00:00 +21.0,0.05,2024-06-24 16:59:28+00:00 +22.0,0.05,2024-06-24 16:59:29+00:00 +23.0,0.05,2024-06-24 16:59:30+00:00 +24.0,0.05,2024-06-24 16:59:31+00:00 +25.0,0.05,2024-06-24 16:59:32+00:00 +26.0,0.05,2024-06-24 16:59:33+00:00 +27.0,0.05,2024-06-24 16:59:34+00:00 +28.0,0.05,2024-06-24 16:59:35+00:00 +29.0,0.05,2024-06-24 16:59:36+00:00 +30.0,0.05,2024-06-24 16:59:37+00:00 +31.0,0.05,2024-06-24 16:59:38+00:00 +32.0,0.05,2024-06-24 16:59:39+00:00 +33.0,0.05,2024-06-24 16:59:40+00:00 +34.0,0.05,2024-06-24 16:59:41+00:00 +35.0,0.05,2024-06-24 16:59:42+00:00 +36.0,0.05,2024-06-24 16:59:43+00:00 +37.0,0.05,2024-06-24 16:59:44+00:00 +38.0,0.05,2024-06-24 16:59:45+00:00 +39.0,0.05,2024-06-24 16:59:46+00:00 +40.0,0.05,2024-06-24 16:59:47+00:00 +41.0,0.05,2024-06-24 16:59:48+00:00 +42.0,0.05,2024-06-24 16:59:49+00:00 +43.0,0.05,2024-06-24 16:59:50+00:00 +44.0,0.05,2024-06-24 16:59:51+00:00 +45.0,0.05,2024-06-24 16:59:52+00:00 +46.0,0.05,2024-06-24 16:59:53+00:00 +47.0,0.05,2024-06-24 16:59:54+00:00 +48.0,0.05,2024-06-24 16:59:55+00:00 +49.0,0.05,2024-06-24 16:59:56+00:00 +50.0,0.05,2024-06-24 16:59:57+00:00 +51.0,0.05,2024-06-24 16:59:58+00:00 +52.0,0.05,2024-06-24 16:59:59+00:00 +53.0,0.05,2024-06-24 17:00:00+00:00 +54.0,0.05,2024-06-24 17:00:01+00:00 +55.0,0.05,2024-06-24 17:00:02+00:00 +56.0,0.05,2024-06-24 17:00:03+00:00 +57.0,0.05,2024-06-24 17:00:04+00:00 +58.0,0.05,2024-06-24 17:00:05+00:00 +59.0,0.05,2024-06-24 17:00:06+00:00 +60.0,0.05,2024-06-24 17:00:07+00:00 +61.0,0.05,2024-06-24 17:00:08+00:00 +62.0,0.05,2024-06-24 17:00:09+00:00 +63.0,0.05,2024-06-24 17:00:10+00:00 +64.0,0.05,2024-06-24 17:00:11+00:00 +65.0,0.05,2024-06-24 17:00:12+00:00 +66.0,0.05,2024-06-24 17:00:13+00:00 +67.0,0.05,2024-06-24 17:00:14+00:00 +68.0,0.05,2024-06-24 17:00:15+00:00 +69.0,0.05,2024-06-24 17:00:16+00:00 +70.0,0.05,2024-06-24 17:00:17+00:00 +71.0,0.05,2024-06-24 17:00:18+00:00 +72.0,0.05,2024-06-24 17:00:19+00:00 +73.0,0.05,2024-06-24 17:00:20+00:00 +74.0,0.05,2024-06-24 17:00:21+00:00 +75.0,0.05,2024-06-24 17:00:22+00:00 +76.0,0.05,2024-06-24 17:00:23+00:00 +77.0,0.05,2024-06-24 17:00:24+00:00 +78.0,0.05,2024-06-24 17:00:25+00:00 +79.0,0.05,2024-06-24 17:00:26+00:00 +80.0,0.05,2024-06-24 17:00:27+00:00 +81.0,0.05,2024-06-24 17:00:28+00:00 +82.0,0.05,2024-06-24 17:00:29+00:00 +83.0,0.05,2024-06-24 17:00:30+00:00 +84.0,0.05,2024-06-24 17:00:31+00:00 +85.0,0.05,2024-06-24 17:00:32+00:00 +86.0,0.05,2024-06-24 17:00:33+00:00 +87.0,0.05,2024-06-24 17:00:34+00:00 +88.0,0.05,2024-06-24 17:00:35+00:00 +89.0,0.05,2024-06-24 17:00:36+00:00 +90.0,0.05,2024-06-24 17:00:37+00:00 +91.0,0.05,2024-06-24 17:00:38+00:00 +92.0,0.05,2024-06-24 17:00:39+00:00 +93.0,0.05,2024-06-24 17:00:40+00:00 +94.0,0.05,2024-06-24 17:00:41+00:00 +95.0,0.05,2024-06-24 17:00:42+00:00 +96.0,0.05,2024-06-24 17:00:43+00:00 +97.0,0.05,2024-06-24 17:00:44+00:00 +98.0,0.05,2024-06-24 17:00:45+00:00 +99.0,0.05,2024-06-24 17:00:46+00:00 +100.0,0.05,2024-06-24 17:00:47+00:00 +101.0,0.05,2024-06-24 17:00:48+00:00 +102.0,0.05,2024-06-24 17:00:49+00:00 +103.0,0.05,2024-06-24 17:00:50+00:00 +104.0,0.05,2024-06-24 17:00:51+00:00 +105.0,0.05,2024-06-24 17:00:52+00:00 +106.0,0.05,2024-06-24 17:00:53+00:00 +107.0,0.05,2024-06-24 17:00:54+00:00 +108.0,0.05,2024-06-24 17:00:55+00:00 +109.0,0.05,2024-06-24 17:00:56+00:00 +110.0,0.05,2024-06-24 17:00:57+00:00 +111.0,0.05,2024-06-24 17:00:58+00:00 +112.0,0.05,2024-06-24 17:00:59+00:00 +113.0,0.05,2024-06-24 17:01:00+00:00 +114.0,0.05,2024-06-24 17:01:01+00:00 +115.0,0.05,2024-06-24 17:01:02+00:00 +116.0,0.05,2024-06-24 17:01:03+00:00 +117.0,0.05,2024-06-24 17:01:04+00:00 +118.0,0.05,2024-06-24 17:01:05+00:00 +119.0,0.05,2024-06-24 17:01:06+00:00 +120.0,0.05,2024-06-24 17:01:07+00:00 +121.0,0.05,2024-06-24 17:01:08+00:00 +122.0,0.05,2024-06-24 17:01:09+00:00 +123.0,0.05,2024-06-24 17:01:10+00:00 +124.0,0.05,2024-06-24 17:01:11+00:00 +125.0,0.05,2024-06-24 17:01:12+00:00 +126.0,0.05,2024-06-24 17:01:13+00:00 +127.0,0.05,2024-06-24 17:01:14+00:00 +128.0,0.05,2024-06-24 17:01:15+00:00 +129.0,0.05,2024-06-24 17:01:16+00:00 +130.0,0.05,2024-06-24 17:01:17+00:00 +131.0,0.05,2024-06-24 17:01:18+00:00 +132.0,0.05,2024-06-24 17:01:19+00:00 +133.0,0.05,2024-06-24 17:01:20+00:00 +134.0,0.05,2024-06-24 17:01:21+00:00 +135.0,0.05,2024-06-24 17:01:22+00:00 +136.0,0.05,2024-06-24 17:01:23+00:00 +137.0,0.05,2024-06-24 17:01:24+00:00 +138.0,0.05,2024-06-24 17:01:25+00:00 +139.0,0.05,2024-06-24 17:01:26+00:00 +140.0,0.05,2024-06-24 17:01:27+00:00 +141.0,0.05,2024-06-24 17:01:28+00:00 +142.0,0.05,2024-06-24 17:01:29+00:00 +143.0,0.05,2024-06-24 17:01:30+00:00 +144.0,0.05,2024-06-24 17:01:31+00:00 +145.0,0.05,2024-06-24 17:01:32+00:00 +146.0,0.05,2024-06-24 17:01:33+00:00 +147.0,0.05,2024-06-24 17:01:34+00:00 +148.0,0.05,2024-06-24 17:01:35+00:00 +149.0,0.05,2024-06-24 17:01:36+00:00 +150.0,0.05,2024-06-24 17:01:37+00:00 +151.0,0.05,2024-06-24 17:01:38+00:00 +152.0,0.05,2024-06-24 17:01:39+00:00 +153.0,0.05,2024-06-24 17:01:40+00:00 +154.0,0.05,2024-06-24 17:01:41+00:00 +155.0,0.05,2024-06-24 17:01:42+00:00 +156.0,0.05,2024-06-24 17:01:43+00:00 +157.0,0.05,2024-06-24 17:01:44+00:00 +158.0,0.05,2024-06-24 17:01:45+00:00 +159.0,0.05,2024-06-24 17:01:46+00:00 +160.0,0.05,2024-06-24 17:01:47+00:00 +161.0,0.05,2024-06-24 17:01:48+00:00 +162.0,0.05,2024-06-24 17:01:49+00:00 +163.0,0.05,2024-06-24 17:01:50+00:00 +164.0,0.05,2024-06-24 17:01:51+00:00 +165.0,0.05,2024-06-24 17:01:52+00:00 +166.0,0.05,2024-06-24 17:01:53+00:00 +167.0,0.05,2024-06-24 17:01:54+00:00 +168.0,0.05,2024-06-24 17:01:55+00:00 +169.0,0.05,2024-06-24 17:01:56+00:00 +170.0,0.05,2024-06-24 17:01:57+00:00 +171.0,0.05,2024-06-24 17:01:58+00:00 +172.0,0.05,2024-06-24 17:01:59+00:00 +173.0,0.05,2024-06-24 17:02:00+00:00 +174.0,0.05,2024-06-24 17:02:01+00:00 +175.0,0.05,2024-06-24 17:02:02+00:00 +176.0,0.05,2024-06-24 17:02:03+00:00 +177.0,0.05,2024-06-24 17:02:04+00:00 +178.0,0.05,2024-06-24 17:02:05+00:00 +179.0,0.05,2024-06-24 17:02:06+00:00 +180.0,0.05,2024-06-24 17:02:07+00:00 +181.0,0.05,2024-06-24 17:02:08+00:00 +182.0,0.05,2024-06-24 17:02:09+00:00 +183.0,0.05,2024-06-24 17:02:10+00:00 +184.0,0.05,2024-06-24 17:02:11+00:00 +185.0,0.05,2024-06-24 17:02:12+00:00 +186.0,0.05,2024-06-24 17:02:13+00:00 +187.0,0.05,2024-06-24 17:02:14+00:00 +188.0,0.05,2024-06-24 17:02:15+00:00 +189.0,0.05,2024-06-24 17:02:16+00:00 +190.0,0.05,2024-06-24 17:02:17+00:00 +191.0,0.05,2024-06-24 17:02:18+00:00 +192.0,0.05,2024-06-24 17:02:19+00:00 +193.0,0.05,2024-06-24 17:02:20+00:00 +194.0,0.05,2024-06-24 17:02:21+00:00 +195.0,0.05,2024-06-24 17:02:22+00:00 +196.0,0.05,2024-06-24 17:02:23+00:00 +197.0,0.05,2024-06-24 17:02:24+00:00 +198.0,0.05,2024-06-24 17:02:25+00:00 +199.0,0.05,2024-06-24 17:02:26+00:00 +200.0,0.05,2024-06-24 17:02:27+00:00 +201.0,0.05,2024-06-24 17:02:28+00:00 +202.0,0.05,2024-06-24 17:02:29+00:00 +203.0,0.05,2024-06-24 17:02:30+00:00 +204.0,0.05,2024-06-24 17:02:31+00:00 +205.0,0.05,2024-06-24 17:02:32+00:00 +206.0,0.05,2024-06-24 17:02:33+00:00 +207.0,0.05,2024-06-24 17:02:34+00:00 +208.0,0.05,2024-06-24 17:02:35+00:00 +209.0,0.05,2024-06-24 17:02:36+00:00 +210.0,0.05,2024-06-24 17:02:37+00:00 +211.0,0.05,2024-06-24 17:02:38+00:00 +212.0,0.05,2024-06-24 17:02:39+00:00 +213.0,0.05,2024-06-24 17:02:40+00:00 +214.0,0.05,2024-06-24 17:02:41+00:00 +215.0,0.05,2024-06-24 17:02:42+00:00 +216.0,0.05,2024-06-24 17:02:43+00:00 +217.0,0.05,2024-06-24 17:02:44+00:00 +218.0,0.05,2024-06-24 17:02:45+00:00 +219.0,0.05,2024-06-24 17:02:46+00:00 +220.0,0.05,2024-06-24 17:02:47+00:00 +221.0,0.05,2024-06-24 17:02:48+00:00 +222.0,0.05,2024-06-24 17:02:49+00:00 +223.0,0.05,2024-06-24 17:02:50+00:00 +224.0,0.05,2024-06-24 17:02:51+00:00 +225.0,0.05,2024-06-24 17:02:52+00:00 +226.0,0.05,2024-06-24 17:02:53+00:00 +227.0,0.05,2024-06-24 17:02:54+00:00 +228.0,0.05,2024-06-24 17:02:55+00:00 +229.0,0.05,2024-06-24 17:02:56+00:00 +230.0,0.05,2024-06-24 17:02:57+00:00 +231.0,0.05,2024-06-24 17:02:58+00:00 +232.0,0.05,2024-06-24 17:02:59+00:00 +233.0,0.05,2024-06-24 17:03:00+00:00 +234.0,0.05,2024-06-24 17:03:01+00:00 +235.0,0.05,2024-06-24 17:03:02+00:00 +236.0,0.05,2024-06-24 17:03:03+00:00 +237.0,0.05,2024-06-24 17:03:04+00:00 +238.0,0.05,2024-06-24 17:03:05+00:00 +239.0,0.05,2024-06-24 17:03:06+00:00 +240.0,0.05,2024-06-24 17:03:07+00:00 +241.0,0.05,2024-06-24 17:03:08+00:00 +242.0,0.05,2024-06-24 17:03:09+00:00 +243.0,0.05,2024-06-24 17:03:10+00:00 +244.0,0.05,2024-06-24 17:03:11+00:00 +245.0,0.05,2024-06-24 17:03:12+00:00 +246.0,0.05,2024-06-24 17:03:13+00:00 +247.0,0.05,2024-06-24 17:03:14+00:00 +248.0,0.05,2024-06-24 17:03:15+00:00 +249.0,0.05,2024-06-24 17:03:16+00:00 +250.0,0.05,2024-06-24 17:03:17+00:00 +251.0,0.05,2024-06-24 17:03:18+00:00 +252.0,0.05,2024-06-24 17:03:19+00:00 +253.0,0.05,2024-06-24 17:03:20+00:00 +254.0,0.05,2024-06-24 17:03:21+00:00 +255.0,0.05,2024-06-24 17:03:22+00:00 +256.0,0.05,2024-06-24 17:03:23+00:00 +257.0,0.05,2024-06-24 17:03:24+00:00 +258.0,0.05,2024-06-24 17:03:25+00:00 +259.0,0.05,2024-06-24 17:03:26+00:00 +260.0,0.05,2024-06-24 17:03:27+00:00 +261.0,0.05,2024-06-24 17:03:28+00:00 +262.0,0.05,2024-06-24 17:03:29+00:00 +263.0,0.05,2024-06-24 17:03:30+00:00 +264.0,0.05,2024-06-24 17:03:31+00:00 +265.0,0.05,2024-06-24 17:03:32+00:00 +266.0,0.05,2024-06-24 17:03:33+00:00 +267.0,0.05,2024-06-24 17:03:34+00:00 +268.0,0.05,2024-06-24 17:03:35+00:00 +269.0,0.05,2024-06-24 17:03:36+00:00 +270.0,0.05,2024-06-24 17:03:37+00:00 +271.0,0.05,2024-06-24 17:03:38+00:00 +272.0,0.05,2024-06-24 17:03:39+00:00 +273.0,0.05,2024-06-24 17:03:40+00:00 +274.0,0.05,2024-06-24 17:03:41+00:00 +275.0,0.05,2024-06-24 17:03:42+00:00 +276.0,0.05,2024-06-24 17:03:43+00:00 +277.0,0.05,2024-06-24 17:03:44+00:00 +278.0,0.05,2024-06-24 17:03:45+00:00 +279.0,0.05,2024-06-24 17:03:46+00:00 +280.0,0.05,2024-06-24 17:03:47+00:00 +281.0,0.05,2024-06-24 17:03:48+00:00 +282.0,0.05,2024-06-24 17:03:49+00:00 +283.0,0.05,2024-06-24 17:03:50+00:00 +284.0,0.05,2024-06-24 17:03:51+00:00 +285.0,0.05,2024-06-24 17:03:52+00:00 +286.0,0.05,2024-06-24 17:03:53+00:00 +287.0,0.05,2024-06-24 17:03:54+00:00 +288.0,0.05,2024-06-24 17:03:55+00:00 +289.0,0.05,2024-06-24 17:03:56+00:00 +290.0,0.05,2024-06-24 17:03:57+00:00 +291.0,0.05,2024-06-24 17:03:58+00:00 +292.0,0.05,2024-06-24 17:03:59+00:00 +293.0,0.05,2024-06-24 17:04:00+00:00 +294.0,0.05,2024-06-24 17:04:01+00:00 +295.0,0.05,2024-06-24 17:04:02+00:00 +296.0,0.05,2024-06-24 17:04:03+00:00 +297.0,0.05,2024-06-24 17:04:04+00:00 +298.0,0.05,2024-06-24 17:04:05+00:00 +299.0,0.05,2024-06-24 17:04:06+00:00 +300.0,0.05,2024-06-24 17:04:07+00:00 +301.0,0.05,2024-06-24 17:04:08+00:00 +302.0,0.05,2024-06-24 17:04:09+00:00 +303.0,0.05,2024-06-24 17:04:10+00:00 +304.0,0.05,2024-06-24 17:04:11+00:00 +305.0,0.05,2024-06-24 17:04:12+00:00 +306.0,0.05,2024-06-24 17:04:13+00:00 +307.0,0.05,2024-06-24 17:04:14+00:00 +308.0,0.05,2024-06-24 17:04:15+00:00 +309.0,0.05,2024-06-24 17:04:16+00:00 +310.0,0.05,2024-06-24 17:04:17+00:00 +311.0,0.05,2024-06-24 17:04:18+00:00 +312.0,0.05,2024-06-24 17:04:19+00:00 +313.0,0.05,2024-06-24 17:04:20+00:00 +314.0,0.05,2024-06-24 17:04:21+00:00 +315.0,0.05,2024-06-24 17:04:22+00:00 +316.0,0.05,2024-06-24 17:04:23+00:00 +317.0,0.05,2024-06-24 17:04:24+00:00 +318.0,0.05,2024-06-24 17:04:25+00:00 +319.0,0.05,2024-06-24 17:04:26+00:00 +320.0,0.05,2024-06-24 17:04:27+00:00 +321.0,0.05,2024-06-24 17:04:28+00:00 +322.0,0.05,2024-06-24 17:04:29+00:00 +323.0,0.05,2024-06-24 17:04:30+00:00 +324.0,0.05,2024-06-24 17:04:31+00:00 +325.0,0.05,2024-06-24 17:04:32+00:00 +326.0,0.05,2024-06-24 17:04:33+00:00 +327.0,0.05,2024-06-24 17:04:34+00:00 +328.0,0.05,2024-06-24 17:04:35+00:00 +329.0,0.05,2024-06-24 17:04:36+00:00 +330.0,0.05,2024-06-24 17:04:37+00:00 +331.0,0.05,2024-06-24 17:04:38+00:00 +332.0,0.05,2024-06-24 17:04:39+00:00 +333.0,0.05,2024-06-24 17:04:40+00:00 +334.0,0.05,2024-06-24 17:04:41+00:00 +335.0,0.05,2024-06-24 17:04:42+00:00 +336.0,0.05,2024-06-24 17:04:43+00:00 +337.0,0.05,2024-06-24 17:04:44+00:00 +338.0,0.05,2024-06-24 17:04:45+00:00 +339.0,0.05,2024-06-24 17:04:46+00:00 +340.0,0.05,2024-06-24 17:04:47+00:00 +341.0,0.05,2024-06-24 17:04:48+00:00 +342.0,0.05,2024-06-24 17:04:49+00:00 +343.0,0.05,2024-06-24 17:04:50+00:00 +344.0,0.05,2024-06-24 17:04:51+00:00 +345.0,0.05,2024-06-24 17:04:52+00:00 +346.0,0.05,2024-06-24 17:04:53+00:00 +347.0,0.05,2024-06-24 17:04:54+00:00 +348.0,0.05,2024-06-24 17:04:55+00:00 +349.0,0.05,2024-06-24 17:04:56+00:00 +350.0,0.05,2024-06-24 17:04:57+00:00 +351.0,0.05,2024-06-24 17:04:58+00:00 +352.0,0.05,2024-06-24 17:04:59+00:00 +353.0,0.05,2024-06-24 17:05:00+00:00 +354.0,0.05,2024-06-24 17:05:01+00:00 +355.0,0.05,2024-06-24 17:05:02+00:00 +356.0,0.05,2024-06-24 17:05:03+00:00 +357.0,0.05,2024-06-24 17:05:04+00:00 +358.0,0.05,2024-06-24 17:05:05+00:00 +359.0,0.05,2024-06-24 17:05:06+00:00 +360.0,0.05,2024-06-24 17:05:07+00:00 +361.0,0.05,2024-06-24 17:05:08+00:00 +362.0,0.05,2024-06-24 17:05:09+00:00 +363.0,0.05,2024-06-24 17:05:10+00:00 +364.0,0.05,2024-06-24 17:05:11+00:00 +365.0,0.05,2024-06-24 17:05:12+00:00 +366.0,0.05,2024-06-24 17:05:13+00:00 +367.0,0.05,2024-06-24 17:05:14+00:00 +368.0,0.05,2024-06-24 17:05:15+00:00 +369.0,0.05,2024-06-24 17:05:16+00:00 +370.0,0.05,2024-06-24 17:05:17+00:00 +371.0,0.05,2024-06-24 17:05:18+00:00 +372.0,0.05,2024-06-24 17:05:19+00:00 +373.0,0.05,2024-06-24 17:05:20+00:00 +374.0,0.05,2024-06-24 17:05:21+00:00 +375.0,0.05,2024-06-24 17:05:22+00:00 +376.0,0.05,2024-06-24 17:05:23+00:00 +377.0,0.05,2024-06-24 17:05:24+00:00 +378.0,0.05,2024-06-24 17:05:25+00:00 +379.0,0.05,2024-06-24 17:05:26+00:00 +380.0,0.05,2024-06-24 17:05:27+00:00 +381.0,0.05,2024-06-24 17:05:28+00:00 +382.0,0.05,2024-06-24 17:05:29+00:00 +383.0,0.05,2024-06-24 17:05:30+00:00 +384.0,0.05,2024-06-24 17:05:31+00:00 +385.0,0.05,2024-06-24 17:05:32+00:00 +386.0,0.05,2024-06-24 17:05:33+00:00 +387.0,0.05,2024-06-24 17:05:34+00:00 +388.0,0.05,2024-06-24 17:05:35+00:00 +389.0,0.05,2024-06-24 17:05:36+00:00 +390.0,0.05,2024-06-24 17:05:37+00:00 +391.0,0.05,2024-06-24 17:05:38+00:00 +392.0,0.05,2024-06-24 17:05:39+00:00 +393.0,0.05,2024-06-24 17:05:40+00:00 +394.0,0.05,2024-06-24 17:05:41+00:00 +395.0,0.05,2024-06-24 17:05:42+00:00 +396.0,0.05,2024-06-24 17:05:43+00:00 +397.0,0.05,2024-06-24 17:05:44+00:00 +398.0,0.05,2024-06-24 17:05:45+00:00 +399.0,0.05,2024-06-24 17:05:46+00:00 +400.0,0.05,2024-06-24 17:05:47+00:00 +401.0,0.05,2024-06-24 17:05:48+00:00 +402.0,0.05,2024-06-24 17:05:49+00:00 +403.0,0.05,2024-06-24 17:05:50+00:00 +404.0,0.05,2024-06-24 17:05:51+00:00 +405.0,0.05,2024-06-24 17:05:52+00:00 +406.0,0.05,2024-06-24 17:05:53+00:00 +407.0,0.05,2024-06-24 17:05:54+00:00 +408.0,0.05,2024-06-24 17:05:55+00:00 +409.0,0.05,2024-06-24 17:05:56+00:00 +410.0,0.05,2024-06-24 17:05:57+00:00 +411.0,0.05,2024-06-24 17:05:58+00:00 +412.0,0.05,2024-06-24 17:05:59+00:00 +413.0,0.05,2024-06-24 17:06:00+00:00 +414.0,0.05,2024-06-24 17:06:01+00:00 +415.0,0.05,2024-06-24 17:06:02+00:00 +416.0,0.05,2024-06-24 17:06:03+00:00 +417.0,0.05,2024-06-24 17:06:04+00:00 +418.0,0.05,2024-06-24 17:06:05+00:00 +419.0,0.05,2024-06-24 17:06:06+00:00 +420.0,0.05,2024-06-24 17:06:07+00:00 +421.0,0.05,2024-06-24 17:06:08+00:00 +422.0,0.05,2024-06-24 17:06:09+00:00 +423.0,0.05,2024-06-24 17:06:10+00:00 +424.0,0.05,2024-06-24 17:06:11+00:00 +425.0,0.05,2024-06-24 17:06:12+00:00 +426.0,0.05,2024-06-24 17:06:13+00:00 +427.0,0.05,2024-06-24 17:06:14+00:00 +428.0,0.05,2024-06-24 17:06:15+00:00 +429.0,0.05,2024-06-24 17:06:16+00:00 +430.0,0.05,2024-06-24 17:06:17+00:00 +431.0,0.05,2024-06-24 17:06:18+00:00 +432.0,0.05,2024-06-24 17:06:19+00:00 +433.0,0.05,2024-06-24 17:06:20+00:00 +434.0,0.05,2024-06-24 17:06:21+00:00 +435.0,0.05,2024-06-24 17:06:22+00:00 +436.0,0.05,2024-06-24 17:06:23+00:00 +437.0,0.05,2024-06-24 17:06:24+00:00 +438.0,0.05,2024-06-24 17:06:25+00:00 +439.0,0.05,2024-06-24 17:06:26+00:00 +440.0,0.05,2024-06-24 17:06:27+00:00 +441.0,0.05,2024-06-24 17:06:28+00:00 +442.0,0.05,2024-06-24 17:06:29+00:00 +443.0,0.05,2024-06-24 17:06:30+00:00 +444.0,0.05,2024-06-24 17:06:31+00:00 +445.0,0.05,2024-06-24 17:06:32+00:00 +446.0,0.05,2024-06-24 17:06:33+00:00 +447.0,0.05,2024-06-24 17:06:34+00:00 +448.0,0.05,2024-06-24 17:06:35+00:00 +449.0,0.05,2024-06-24 17:06:36+00:00 +450.0,0.05,2024-06-24 17:06:37+00:00 +451.0,0.05,2024-06-24 17:06:38+00:00 +452.0,0.05,2024-06-24 17:06:39+00:00 +453.0,0.05,2024-06-24 17:06:40+00:00 +454.0,0.05,2024-06-24 17:06:41+00:00 +455.0,0.05,2024-06-24 17:06:42+00:00 +456.0,0.05,2024-06-24 17:06:43+00:00 +457.0,0.05,2024-06-24 17:06:44+00:00 +458.0,0.05,2024-06-24 17:06:45+00:00 +459.0,0.05,2024-06-24 17:06:46+00:00 +460.0,0.05,2024-06-24 17:06:47+00:00 +461.0,0.05,2024-06-24 17:06:48+00:00 +462.0,0.05,2024-06-24 17:06:49+00:00 +463.0,0.05,2024-06-24 17:06:50+00:00 +464.0,0.05,2024-06-24 17:06:51+00:00 +465.0,0.05,2024-06-24 17:06:52+00:00 +466.0,0.05,2024-06-24 17:06:53+00:00 +467.0,0.05,2024-06-24 17:06:54+00:00 +468.0,0.05,2024-06-24 17:06:55+00:00 +469.0,0.05,2024-06-24 17:06:56+00:00 +470.0,0.05,2024-06-24 17:06:57+00:00 +471.0,0.05,2024-06-24 17:06:58+00:00 +472.0,0.05,2024-06-24 17:06:59+00:00 +473.0,0.05,2024-06-24 17:07:00+00:00 +474.0,0.05,2024-06-24 17:07:01+00:00 +475.0,0.05,2024-06-24 17:07:02+00:00 +476.0,0.05,2024-06-24 17:07:03+00:00 +477.0,0.05,2024-06-24 17:07:04+00:00 +478.0,0.05,2024-06-24 17:07:05+00:00 +479.0,0.05,2024-06-24 17:07:06+00:00 +480.0,0.05,2024-06-24 17:07:07+00:00 +481.0,0.05,2024-06-24 17:07:08+00:00 +482.0,0.05,2024-06-24 17:07:09+00:00 +483.0,0.05,2024-06-24 17:07:10+00:00 +484.0,0.05,2024-06-24 17:07:11+00:00 +485.0,0.05,2024-06-24 17:07:12+00:00 +486.0,0.05,2024-06-24 17:07:13+00:00 +487.0,0.05,2024-06-24 17:07:14+00:00 +488.0,0.05,2024-06-24 17:07:15+00:00 +489.0,0.05,2024-06-24 17:07:16+00:00 +490.0,0.05,2024-06-24 17:07:17+00:00 +491.0,0.05,2024-06-24 17:07:18+00:00 +492.0,0.05,2024-06-24 17:07:19+00:00 +493.0,0.05,2024-06-24 17:07:20+00:00 +494.0,0.05,2024-06-24 17:07:21+00:00 +495.0,0.05,2024-06-24 17:07:22+00:00 +496.0,0.05,2024-06-24 17:07:23+00:00 +497.0,0.05,2024-06-24 17:07:24+00:00 +498.0,0.05,2024-06-24 17:07:25+00:00 +499.0,0.05,2024-06-24 17:07:26+00:00 +500.0,0.05,2024-06-24 17:07:27+00:00 +501.0,0.05,2024-06-24 17:07:28+00:00 +502.0,0.05,2024-06-24 17:07:29+00:00 +503.0,0.05,2024-06-24 17:07:30+00:00 +504.0,0.05,2024-06-24 17:07:31+00:00 +505.0,0.05,2024-06-24 17:07:32+00:00 +506.0,0.05,2024-06-24 17:07:33+00:00 +507.0,0.05,2024-06-24 17:07:34+00:00 +508.0,0.05,2024-06-24 17:07:35+00:00 +509.0,0.05,2024-06-24 17:07:36+00:00 +510.0,0.05,2024-06-24 17:07:37+00:00 +511.0,0.05,2024-06-24 17:07:38+00:00 +512.0,0.05,2024-06-24 17:07:39+00:00 +513.0,0.05,2024-06-24 17:07:40+00:00 +514.0,0.05,2024-06-24 17:07:41+00:00 +515.0,0.05,2024-06-24 17:07:42+00:00 +516.0,0.05,2024-06-24 17:07:43+00:00 +517.0,0.05,2024-06-24 17:07:44+00:00 +518.0,0.05,2024-06-24 17:07:45+00:00 +519.0,0.05,2024-06-24 17:07:46+00:00 +520.0,0.05,2024-06-24 17:07:47+00:00 +521.0,0.05,2024-06-24 17:07:48+00:00 +522.0,0.05,2024-06-24 17:07:49+00:00 +523.0,0.05,2024-06-24 17:07:50+00:00 +524.0,0.05,2024-06-24 17:07:51+00:00 +525.0,0.05,2024-06-24 17:07:52+00:00 +526.0,0.05,2024-06-24 17:07:53+00:00 +527.0,0.05,2024-06-24 17:07:54+00:00 +528.0,0.05,2024-06-24 17:07:55+00:00 +529.0,0.05,2024-06-24 17:07:56+00:00 +530.0,0.05,2024-06-24 17:07:57+00:00 +531.0,0.05,2024-06-24 17:07:58+00:00 +532.0,0.05,2024-06-24 17:07:59+00:00 +533.0,0.05,2024-06-24 17:08:00+00:00 +534.0,0.05,2024-06-24 17:08:01+00:00 +535.0,0.05,2024-06-24 17:08:02+00:00 +536.0,0.05,2024-06-24 17:08:03+00:00 +537.0,0.05,2024-06-24 17:08:04+00:00 +538.0,0.05,2024-06-24 17:08:05+00:00 +539.0,0.05,2024-06-24 17:08:06+00:00 +540.0,0.05,2024-06-24 17:08:07+00:00 +541.0,0.05,2024-06-24 17:08:08+00:00 +542.0,0.05,2024-06-24 17:08:09+00:00 +543.0,0.05,2024-06-24 17:08:10+00:00 +544.0,0.05,2024-06-24 17:08:11+00:00 +545.0,0.05,2024-06-24 17:08:12+00:00 +546.0,0.05,2024-06-24 17:08:13+00:00 +547.0,0.05,2024-06-24 17:08:14+00:00 +548.0,0.05,2024-06-24 17:08:15+00:00 +549.0,0.05,2024-06-24 17:08:16+00:00 +550.0,0.05,2024-06-24 17:08:17+00:00 +551.0,0.05,2024-06-24 17:08:18+00:00 +552.0,0.05,2024-06-24 17:08:19+00:00 +553.0,0.05,2024-06-24 17:08:20+00:00 +554.0,0.05,2024-06-24 17:08:21+00:00 +555.0,0.05,2024-06-24 17:08:22+00:00 +556.0,0.05,2024-06-24 17:08:23+00:00 +557.0,0.05,2024-06-24 17:08:24+00:00 +558.0,0.05,2024-06-24 17:08:25+00:00 +559.0,0.05,2024-06-24 17:08:26+00:00 +560.0,0.05,2024-06-24 17:08:27+00:00 +561.0,0.05,2024-06-24 17:08:28+00:00 +562.0,0.05,2024-06-24 17:08:29+00:00 +563.0,0.05,2024-06-24 17:08:30+00:00 +564.0,0.05,2024-06-24 17:08:31+00:00 +565.0,0.05,2024-06-24 17:08:32+00:00 +566.0,0.05,2024-06-24 17:08:33+00:00 +567.0,0.05,2024-06-24 17:08:34+00:00 +568.0,0.05,2024-06-24 17:08:35+00:00 +569.0,0.05,2024-06-24 17:08:36+00:00 +570.0,0.05,2024-06-24 17:08:37+00:00 +571.0,0.05,2024-06-24 17:08:38+00:00 +572.0,0.05,2024-06-24 17:08:39+00:00 +573.0,0.05,2024-06-24 17:08:40+00:00 +574.0,0.05,2024-06-24 17:08:41+00:00 +575.0,0.05,2024-06-24 17:08:42+00:00 +576.0,0.05,2024-06-24 17:08:43+00:00 +577.0,0.05,2024-06-24 17:08:44+00:00 +578.0,0.05,2024-06-24 17:08:45+00:00 +579.0,0.05,2024-06-24 17:08:46+00:00 +580.0,0.05,2024-06-24 17:08:47+00:00 +581.0,0.05,2024-06-24 17:08:48+00:00 +582.0,0.05,2024-06-24 17:08:49+00:00 +583.0,0.05,2024-06-24 17:08:50+00:00 +584.0,0.05,2024-06-24 17:08:51+00:00 +585.0,0.05,2024-06-24 17:08:52+00:00 +586.0,0.05,2024-06-24 17:08:53+00:00 +587.0,0.05,2024-06-24 17:08:54+00:00 +588.0,0.05,2024-06-24 17:08:55+00:00 +589.0,0.05,2024-06-24 17:08:56+00:00 +590.0,0.05,2024-06-24 17:08:57+00:00 +591.0,0.05,2024-06-24 17:08:58+00:00 +592.0,0.05,2024-06-24 17:08:59+00:00 +593.0,0.05,2024-06-24 17:09:00+00:00 +594.0,0.05,2024-06-24 17:09:01+00:00 +595.0,0.05,2024-06-24 17:09:02+00:00 +596.0,0.05,2024-06-24 17:09:03+00:00 +597.0,0.05,2024-06-24 17:09:04+00:00 +598.0,0.05,2024-06-24 17:09:05+00:00 +599.0,0.05,2024-06-24 17:09:06+00:00 +600.0,0.05,2024-06-24 17:09:07+00:00 +601.0,0.05,2024-06-24 17:09:08+00:00 +602.0,0.05,2024-06-24 17:09:09+00:00 +603.0,0.05,2024-06-24 17:09:10+00:00 +604.0,0.05,2024-06-24 17:09:11+00:00 +605.0,0.05,2024-06-24 17:09:12+00:00 +606.0,0.05,2024-06-24 17:09:13+00:00 +607.0,0.05,2024-06-24 17:09:14+00:00 +608.0,0.05,2024-06-24 17:09:15+00:00 +609.0,0.05,2024-06-24 17:09:16+00:00 +610.0,0.05,2024-06-24 17:09:17+00:00 +611.0,0.05,2024-06-24 17:09:18+00:00 +612.0,0.05,2024-06-24 17:09:19+00:00 +613.0,0.05,2024-06-24 17:09:20+00:00 +614.0,0.05,2024-06-24 17:09:21+00:00 +615.0,0.05,2024-06-24 17:09:22+00:00 +616.0,0.05,2024-06-24 17:09:23+00:00 +617.0,0.05,2024-06-24 17:09:24+00:00 +618.0,0.05,2024-06-24 17:09:25+00:00 +619.0,0.05,2024-06-24 17:09:26+00:00 +620.0,0.05,2024-06-24 17:09:27+00:00 +621.0,0.05,2024-06-24 17:09:28+00:00 +622.0,0.05,2024-06-24 17:09:29+00:00 +623.0,0.05,2024-06-24 17:09:30+00:00 +624.0,0.05,2024-06-24 17:09:31+00:00 +625.0,0.05,2024-06-24 17:09:32+00:00 +626.0,0.05,2024-06-24 17:09:33+00:00 +627.0,0.05,2024-06-24 17:09:34+00:00 +628.0,0.05,2024-06-24 17:09:35+00:00 +629.0,0.05,2024-06-24 17:09:36+00:00 +630.0,0.05,2024-06-24 17:09:37+00:00 +631.0,0.05,2024-06-24 17:09:38+00:00 +632.0,0.05,2024-06-24 17:09:39+00:00 +633.0,0.05,2024-06-24 17:09:40+00:00 +634.0,0.05,2024-06-24 17:09:41+00:00 +635.0,0.05,2024-06-24 17:09:42+00:00 +636.0,0.05,2024-06-24 17:09:43+00:00 +637.0,0.05,2024-06-24 17:09:44+00:00 +638.0,0.05,2024-06-24 17:09:45+00:00 +639.0,0.05,2024-06-24 17:09:46+00:00 +640.0,0.05,2024-06-24 17:09:47+00:00 +641.0,0.05,2024-06-24 17:09:48+00:00 +642.0,0.05,2024-06-24 17:09:49+00:00 +643.0,0.05,2024-06-24 17:09:50+00:00 +644.0,0.05,2024-06-24 17:09:51+00:00 +645.0,0.05,2024-06-24 17:09:52+00:00 +646.0,0.05,2024-06-24 17:09:53+00:00 +647.0,0.05,2024-06-24 17:09:54+00:00 +648.0,0.05,2024-06-24 17:09:55+00:00 +649.0,0.05,2024-06-24 17:09:56+00:00 +650.0,0.05,2024-06-24 17:09:57+00:00 +651.0,0.05,2024-06-24 17:09:58+00:00 +652.0,0.05,2024-06-24 17:09:59+00:00 +653.0,0.05,2024-06-24 17:10:00+00:00 +654.0,0.05,2024-06-24 17:10:01+00:00 +655.0,0.05,2024-06-24 17:10:02+00:00 +656.0,0.05,2024-06-24 17:10:03+00:00 +657.0,0.05,2024-06-24 17:10:04+00:00 +658.0,0.05,2024-06-24 17:10:05+00:00 +659.0,0.05,2024-06-24 17:10:06+00:00 +660.0,0.05,2024-06-24 17:10:07+00:00 +661.0,0.05,2024-06-24 17:10:08+00:00 +662.0,0.05,2024-06-24 17:10:09+00:00 +663.0,0.05,2024-06-24 17:10:10+00:00 +664.0,0.05,2024-06-24 17:10:11+00:00 +665.0,0.05,2024-06-24 17:10:12+00:00 +666.0,0.05,2024-06-24 17:10:13+00:00 +667.0,0.05,2024-06-24 17:10:14+00:00 +668.0,0.05,2024-06-24 17:10:15+00:00 +669.0,0.05,2024-06-24 17:10:16+00:00 +670.0,0.05,2024-06-24 17:10:17+00:00 +671.0,0.05,2024-06-24 17:10:18+00:00 +672.0,0.05,2024-06-24 17:10:19+00:00 +673.0,0.05,2024-06-24 17:10:20+00:00 +674.0,0.05,2024-06-24 17:10:21+00:00 +675.0,0.05,2024-06-24 17:10:22+00:00 +676.0,0.05,2024-06-24 17:10:23+00:00 +677.0,0.05,2024-06-24 17:10:24+00:00 +678.0,0.05,2024-06-24 17:10:25+00:00 +679.0,0.05,2024-06-24 17:10:26+00:00 +680.0,0.05,2024-06-24 17:10:27+00:00 +681.0,0.05,2024-06-24 17:10:28+00:00 +682.0,0.05,2024-06-24 17:10:29+00:00 +683.0,0.05,2024-06-24 17:10:30+00:00 +684.0,0.05,2024-06-24 17:10:31+00:00 +685.0,0.05,2024-06-24 17:10:32+00:00 +686.0,0.05,2024-06-24 17:10:33+00:00 +687.0,0.05,2024-06-24 17:10:34+00:00 +688.0,0.05,2024-06-24 17:10:35+00:00 +689.0,0.05,2024-06-24 17:10:36+00:00 +690.0,0.05,2024-06-24 17:10:37+00:00 +691.0,0.05,2024-06-24 17:10:38+00:00 +692.0,0.05,2024-06-24 17:10:39+00:00 +693.0,0.05,2024-06-24 17:10:40+00:00 +694.0,0.05,2024-06-24 17:10:41+00:00 +695.0,0.05,2024-06-24 17:10:42+00:00 +696.0,0.05,2024-06-24 17:10:43+00:00 +697.0,0.05,2024-06-24 17:10:44+00:00 +698.0,0.05,2024-06-24 17:10:45+00:00 +699.0,0.05,2024-06-24 17:10:46+00:00 +700.0,0.05,2024-06-24 17:10:47+00:00 +701.0,0.05,2024-06-24 17:10:48+00:00 +702.0,0.05,2024-06-24 17:10:49+00:00 +703.0,0.05,2024-06-24 17:10:50+00:00 +704.0,0.05,2024-06-24 17:10:51+00:00 +705.0,0.05,2024-06-24 17:10:52+00:00 +706.0,0.05,2024-06-24 17:10:53+00:00 +707.0,0.05,2024-06-24 17:10:54+00:00 +708.0,0.05,2024-06-24 17:10:55+00:00 +709.0,0.05,2024-06-24 17:10:56+00:00 +710.0,0.05,2024-06-24 17:10:57+00:00 +711.0,0.05,2024-06-24 17:10:58+00:00 +712.0,0.05,2024-06-24 17:10:59+00:00 +713.0,0.05,2024-06-24 17:11:00+00:00 +714.0,0.05,2024-06-24 17:11:01+00:00 +715.0,0.05,2024-06-24 17:11:02+00:00 +716.0,0.05,2024-06-24 17:11:03+00:00 +717.0,0.05,2024-06-24 17:11:04+00:00 +718.0,0.05,2024-06-24 17:11:05+00:00 +719.0,0.05,2024-06-24 17:11:06+00:00 +720.0,0.05,2024-06-24 17:11:07+00:00 +721.0,0.05,2024-06-24 17:11:08+00:00 +722.0,0.05,2024-06-24 17:11:09+00:00 +723.0,0.05,2024-06-24 17:11:10+00:00 +724.0,0.05,2024-06-24 17:11:11+00:00 +725.0,0.05,2024-06-24 17:11:12+00:00 +726.0,0.05,2024-06-24 17:11:13+00:00 +727.0,0.05,2024-06-24 17:11:14+00:00 +728.0,0.05,2024-06-24 17:11:15+00:00 +729.0,0.05,2024-06-24 17:11:16+00:00 +730.0,0.05,2024-06-24 17:11:17+00:00 +731.0,0.05,2024-06-24 17:11:18+00:00 +732.0,0.05,2024-06-24 17:11:19+00:00 +733.0,0.05,2024-06-24 17:11:20+00:00 +734.0,0.05,2024-06-24 17:11:21+00:00 +735.0,0.05,2024-06-24 17:11:22+00:00 +736.0,0.05,2024-06-24 17:11:23+00:00 +737.0,0.05,2024-06-24 17:11:24+00:00 +738.0,0.05,2024-06-24 17:11:25+00:00 +739.0,0.05,2024-06-24 17:11:26+00:00 +740.0,0.05,2024-06-24 17:11:27+00:00 +741.0,0.05,2024-06-24 17:11:28+00:00 +742.0,0.05,2024-06-24 17:11:29+00:00 +743.0,0.05,2024-06-24 17:11:30+00:00 +744.0,0.05,2024-06-24 17:11:31+00:00 +745.0,0.05,2024-06-24 17:11:32+00:00 +746.0,0.05,2024-06-24 17:11:33+00:00 +747.0,0.05,2024-06-24 17:11:34+00:00 +748.0,0.05,2024-06-24 17:11:35+00:00 +749.0,0.05,2024-06-24 17:11:36+00:00 +750.0,0.05,2024-06-24 17:11:37+00:00 +751.0,0.05,2024-06-24 17:11:38+00:00 +752.0,0.05,2024-06-24 17:11:39+00:00 +753.0,0.05,2024-06-24 17:11:40+00:00 +754.0,0.05,2024-06-24 17:11:41+00:00 +755.0,0.05,2024-06-24 17:11:42+00:00 +756.0,0.05,2024-06-24 17:11:43+00:00 +757.0,0.05,2024-06-24 17:11:44+00:00 +758.0,0.05,2024-06-24 17:11:45+00:00 +759.0,0.05,2024-06-24 17:11:46+00:00 +760.0,0.05,2024-06-24 17:11:47+00:00 +761.0,0.05,2024-06-24 17:11:48+00:00 +762.0,0.05,2024-06-24 17:11:49+00:00 +763.0,0.05,2024-06-24 17:11:50+00:00 +764.0,0.05,2024-06-24 17:11:51+00:00 +765.0,0.05,2024-06-24 17:11:52+00:00 +766.0,0.05,2024-06-24 17:11:53+00:00 +767.0,0.05,2024-06-24 17:11:54+00:00 +768.0,0.05,2024-06-24 17:11:55+00:00 +769.0,0.05,2024-06-24 17:11:56+00:00 +770.0,0.05,2024-06-24 17:11:57+00:00 +771.0,0.05,2024-06-24 17:11:58+00:00 +772.0,0.05,2024-06-24 17:11:59+00:00 +773.0,0.05,2024-06-24 17:12:00+00:00 +774.0,0.05,2024-06-24 17:12:01+00:00 +775.0,0.05,2024-06-24 17:12:02+00:00 +776.0,0.05,2024-06-24 17:12:03+00:00 +777.0,0.05,2024-06-24 17:12:04+00:00 +778.0,0.05,2024-06-24 17:12:05+00:00 +779.0,0.05,2024-06-24 17:12:06+00:00 +780.0,0.05,2024-06-24 17:12:07+00:00 +781.0,0.05,2024-06-24 17:12:08+00:00 +782.0,0.05,2024-06-24 17:12:09+00:00 +783.0,0.05,2024-06-24 17:12:10+00:00 +784.0,0.05,2024-06-24 17:12:11+00:00 +785.0,0.05,2024-06-24 17:12:12+00:00 +786.0,0.05,2024-06-24 17:12:13+00:00 +787.0,0.05,2024-06-24 17:12:14+00:00 +788.0,0.05,2024-06-24 17:12:15+00:00 +789.0,0.05,2024-06-24 17:12:16+00:00 +790.0,0.05,2024-06-24 17:12:17+00:00 +791.0,0.05,2024-06-24 17:12:18+00:00 +792.0,0.05,2024-06-24 17:12:19+00:00 +793.0,0.05,2024-06-24 17:12:20+00:00 +794.0,0.05,2024-06-24 17:12:21+00:00 +795.0,0.05,2024-06-24 17:12:22+00:00 +796.0,0.05,2024-06-24 17:12:23+00:00 +797.0,0.05,2024-06-24 17:12:24+00:00 +798.0,0.05,2024-06-24 17:12:25+00:00 +799.0,0.05,2024-06-24 17:12:26+00:00 +800.0,0.05,2024-06-24 17:12:27+00:00 +801.0,0.05,2024-06-24 17:12:28+00:00 +802.0,0.05,2024-06-24 17:12:29+00:00 +803.0,0.05,2024-06-24 17:12:30+00:00 +804.0,0.05,2024-06-24 17:12:31+00:00 +805.0,0.05,2024-06-24 17:12:32+00:00 +806.0,0.05,2024-06-24 17:12:33+00:00 +807.0,0.05,2024-06-24 17:12:34+00:00 +808.0,0.05,2024-06-24 17:12:35+00:00 +809.0,0.05,2024-06-24 17:12:36+00:00 +810.0,0.05,2024-06-24 17:12:37+00:00 +811.0,0.05,2024-06-24 17:12:38+00:00 +812.0,0.05,2024-06-24 17:12:39+00:00 +813.0,0.05,2024-06-24 17:12:40+00:00 +814.0,0.05,2024-06-24 17:12:41+00:00 +815.0,0.05,2024-06-24 17:12:42+00:00 +816.0,0.05,2024-06-24 17:12:43+00:00 +817.0,0.05,2024-06-24 17:12:44+00:00 +818.0,0.05,2024-06-24 17:12:45+00:00 +819.0,0.05,2024-06-24 17:12:46+00:00 +820.0,0.05,2024-06-24 17:12:47+00:00 +821.0,0.05,2024-06-24 17:12:48+00:00 +822.0,0.05,2024-06-24 17:12:49+00:00 +823.0,0.05,2024-06-24 17:12:50+00:00 +824.0,0.05,2024-06-24 17:12:51+00:00 +825.0,0.05,2024-06-24 17:12:52+00:00 +826.0,0.05,2024-06-24 17:12:53+00:00 +827.0,0.05,2024-06-24 17:12:54+00:00 +828.0,0.05,2024-06-24 17:12:55+00:00 +829.0,0.05,2024-06-24 17:12:56+00:00 +830.0,0.05,2024-06-24 17:12:57+00:00 +831.0,0.05,2024-06-24 17:12:58+00:00 +832.0,0.05,2024-06-24 17:12:59+00:00 +833.0,0.05,2024-06-24 17:13:00+00:00 +834.0,0.05,2024-06-24 17:13:01+00:00 +835.0,0.05,2024-06-24 17:13:02+00:00 +836.0,0.05,2024-06-24 17:13:03+00:00 +837.0,0.05,2024-06-24 17:13:04+00:00 +838.0,0.05,2024-06-24 17:13:05+00:00 +839.0,0.05,2024-06-24 17:13:06+00:00 +840.0,0.05,2024-06-24 17:13:07+00:00 +841.0,0.05,2024-06-24 17:13:08+00:00 +842.0,0.05,2024-06-24 17:13:09+00:00 +843.0,0.05,2024-06-24 17:13:10+00:00 +844.0,0.05,2024-06-24 17:13:11+00:00 +845.0,0.05,2024-06-24 17:13:12+00:00 +846.0,0.05,2024-06-24 17:13:13+00:00 +847.0,0.05,2024-06-24 17:13:14+00:00 +848.0,0.05,2024-06-24 17:13:15+00:00 +849.0,0.05,2024-06-24 17:13:16+00:00 +850.0,0.05,2024-06-24 17:13:17+00:00 +851.0,0.05,2024-06-24 17:13:18+00:00 +852.0,0.05,2024-06-24 17:13:19+00:00 +853.0,0.05,2024-06-24 17:13:20+00:00 +854.0,0.05,2024-06-24 17:13:21+00:00 +855.0,0.05,2024-06-24 17:13:22+00:00 +856.0,0.05,2024-06-24 17:13:23+00:00 +857.0,0.05,2024-06-24 17:13:24+00:00 +858.0,0.05,2024-06-24 17:13:25+00:00 +859.0,0.05,2024-06-24 17:13:26+00:00 +860.0,0.05,2024-06-24 17:13:27+00:00 +861.0,0.05,2024-06-24 17:13:28+00:00 +862.0,0.05,2024-06-24 17:13:29+00:00 +863.0,0.05,2024-06-24 17:13:30+00:00 +864.0,0.05,2024-06-24 17:13:31+00:00 +865.0,0.05,2024-06-24 17:13:32+00:00 +866.0,0.05,2024-06-24 17:13:33+00:00 +867.0,0.05,2024-06-24 17:13:34+00:00 +868.0,0.05,2024-06-24 17:13:35+00:00 +869.0,0.05,2024-06-24 17:13:36+00:00 +870.0,0.05,2024-06-24 17:13:37+00:00 +871.0,0.05,2024-06-24 17:13:38+00:00 +872.0,0.05,2024-06-24 17:13:39+00:00 +873.0,0.05,2024-06-24 17:13:40+00:00 +874.0,0.05,2024-06-24 17:13:41+00:00 +875.0,0.05,2024-06-24 17:13:42+00:00 +876.0,0.05,2024-06-24 17:13:43+00:00 +877.0,0.05,2024-06-24 17:13:44+00:00 +878.0,0.05,2024-06-24 17:13:45+00:00 +879.0,0.05,2024-06-24 17:13:46+00:00 +880.0,0.05,2024-06-24 17:13:47+00:00 +881.0,0.05,2024-06-24 17:13:48+00:00 +882.0,0.05,2024-06-24 17:13:49+00:00 +883.0,0.05,2024-06-24 17:13:50+00:00 +884.0,0.05,2024-06-24 17:13:51+00:00 +885.0,0.05,2024-06-24 17:13:52+00:00 +886.0,0.05,2024-06-24 17:13:53+00:00 +887.0,0.05,2024-06-24 17:13:54+00:00 +888.0,0.05,2024-06-24 17:13:55+00:00 +889.0,0.05,2024-06-24 17:13:56+00:00 +890.0,0.05,2024-06-24 17:13:57+00:00 +891.0,0.05,2024-06-24 17:13:58+00:00 +892.0,0.05,2024-06-24 17:13:59+00:00 +893.0,0.05,2024-06-24 17:14:00+00:00 +894.0,0.05,2024-06-24 17:14:01+00:00 +895.0,0.05,2024-06-24 17:14:02+00:00 +896.0,0.05,2024-06-24 17:14:03+00:00 +897.0,0.05,2024-06-24 17:14:04+00:00 +898.0,0.05,2024-06-24 17:14:05+00:00 +899.0,0.05,2024-06-24 17:14:06+00:00 +900.0,0.05,2024-06-24 17:14:07+00:00 +901.0,0.05,2024-06-24 17:14:08+00:00 +902.0,0.05,2024-06-24 17:14:09+00:00 +903.0,0.05,2024-06-24 17:14:10+00:00 +904.0,0.05,2024-06-24 17:14:11+00:00 +905.0,0.05,2024-06-24 17:14:12+00:00 +906.0,0.05,2024-06-24 17:14:13+00:00 +907.0,0.05,2024-06-24 17:14:14+00:00 +908.0,0.05,2024-06-24 17:14:15+00:00 +909.0,0.05,2024-06-24 17:14:16+00:00 +910.0,0.05,2024-06-24 17:14:17+00:00 +911.0,0.05,2024-06-24 17:14:18+00:00 +912.0,0.05,2024-06-24 17:14:19+00:00 +913.0,0.05,2024-06-24 17:14:20+00:00 +914.0,0.05,2024-06-24 17:14:21+00:00 +915.0,0.05,2024-06-24 17:14:22+00:00 +916.0,0.05,2024-06-24 17:14:23+00:00 +917.0,0.05,2024-06-24 17:14:24+00:00 +918.0,0.05,2024-06-24 17:14:25+00:00 +919.0,0.05,2024-06-24 17:14:26+00:00 +920.0,0.05,2024-06-24 17:14:27+00:00 +921.0,0.05,2024-06-24 17:14:28+00:00 +922.0,0.05,2024-06-24 17:14:29+00:00 +923.0,0.05,2024-06-24 17:14:30+00:00 +924.0,0.05,2024-06-24 17:14:31+00:00 +925.0,0.05,2024-06-24 17:14:32+00:00 +926.0,0.05,2024-06-24 17:14:33+00:00 +927.0,0.05,2024-06-24 17:14:34+00:00 +928.0,0.05,2024-06-24 17:14:35+00:00 +929.0,0.05,2024-06-24 17:14:36+00:00 +930.0,0.05,2024-06-24 17:14:37+00:00 +931.0,0.05,2024-06-24 17:14:38+00:00 +932.0,0.05,2024-06-24 17:14:39+00:00 +933.0,0.05,2024-06-24 17:14:40+00:00 +934.0,0.05,2024-06-24 17:14:41+00:00 +935.0,0.05,2024-06-24 17:14:42+00:00 +936.0,0.05,2024-06-24 17:14:43+00:00 +937.0,0.05,2024-06-24 17:14:44+00:00 +938.0,0.05,2024-06-24 17:14:45+00:00 +939.0,0.05,2024-06-24 17:14:46+00:00 +940.0,0.05,2024-06-24 17:14:47+00:00 +941.0,0.05,2024-06-24 17:14:48+00:00 +942.0,0.05,2024-06-24 17:14:49+00:00 +943.0,0.05,2024-06-24 17:14:50+00:00 +944.0,0.05,2024-06-24 17:14:51+00:00 +945.0,0.05,2024-06-24 17:14:52+00:00 +946.0,0.05,2024-06-24 17:14:53+00:00 +947.0,0.05,2024-06-24 17:14:54+00:00 +948.0,0.05,2024-06-24 17:14:55+00:00 +949.0,0.05,2024-06-24 17:14:56+00:00 +950.0,0.05,2024-06-24 17:14:57+00:00 +951.0,0.05,2024-06-24 17:14:58+00:00 +952.0,0.05,2024-06-24 17:14:59+00:00 +953.0,0.05,2024-06-24 17:15:00+00:00 +954.0,0.05,2024-06-24 17:15:01+00:00 +955.0,0.05,2024-06-24 17:15:02+00:00 +956.0,0.05,2024-06-24 17:15:03+00:00 +957.0,0.05,2024-06-24 17:15:04+00:00 +958.0,0.05,2024-06-24 17:15:05+00:00 +959.0,0.05,2024-06-24 17:15:06+00:00 +960.0,0.05,2024-06-24 17:15:07+00:00 +961.0,0.05,2024-06-24 17:15:08+00:00 +962.0,0.05,2024-06-24 17:15:09+00:00 +963.0,0.05,2024-06-24 17:15:10+00:00 +964.0,0.05,2024-06-24 17:15:11+00:00 +965.0,0.05,2024-06-24 17:15:12+00:00 +966.0,0.05,2024-06-24 17:15:13+00:00 +967.0,0.05,2024-06-24 17:15:14+00:00 +968.0,0.05,2024-06-24 17:15:15+00:00 +969.0,0.05,2024-06-24 17:15:16+00:00 +970.0,0.05,2024-06-24 17:15:17+00:00 +971.0,0.05,2024-06-24 17:15:18+00:00 +972.0,0.05,2024-06-24 17:15:19+00:00 +973.0,0.05,2024-06-24 17:15:20+00:00 +974.0,0.05,2024-06-24 17:15:21+00:00 +975.0,0.05,2024-06-24 17:15:22+00:00 +976.0,0.05,2024-06-24 17:15:23+00:00 +977.0,0.05,2024-06-24 17:15:24+00:00 +978.0,0.05,2024-06-24 17:15:25+00:00 +979.0,0.05,2024-06-24 17:15:26+00:00 +980.0,0.05,2024-06-24 17:15:27+00:00 +981.0,0.05,2024-06-24 17:15:28+00:00 +982.0,0.05,2024-06-24 17:15:29+00:00 +983.0,0.05,2024-06-24 17:15:30+00:00 +984.0,0.05,2024-06-24 17:15:31+00:00 +985.0,0.05,2024-06-24 17:15:32+00:00 +986.0,0.05,2024-06-24 17:15:33+00:00 +987.0,0.05,2024-06-24 17:15:34+00:00 +988.0,0.05,2024-06-24 17:15:35+00:00 +989.0,0.05,2024-06-24 17:15:36+00:00 +990.0,0.05,2024-06-24 17:15:37+00:00 +991.0,0.05,2024-06-24 17:15:38+00:00 +992.0,0.05,2024-06-24 17:15:39+00:00 +993.0,0.05,2024-06-24 17:15:40+00:00 +994.0,0.05,2024-06-24 17:15:41+00:00 +995.0,0.05,2024-06-24 17:15:42+00:00 +996.0,0.05,2024-06-24 17:15:43+00:00 +997.0,0.05,2024-06-24 17:15:44+00:00 +998.0,0.05,2024-06-24 17:15:45+00:00 +999.0,0.05,2024-06-24 17:15:46+00:00 +1000.0,0.05,2024-06-24 17:15:47+00:00 +1001.0,0.05,2024-06-24 17:15:48+00:00 +1002.0,0.05,2024-06-24 17:15:49+00:00 +1003.0,0.05,2024-06-24 17:15:50+00:00 +1004.0,0.05,2024-06-24 17:15:51+00:00 +1005.0,0.05,2024-06-24 17:15:52+00:00 +1006.0,0.05,2024-06-24 17:15:53+00:00 +1007.0,0.05,2024-06-24 17:15:54+00:00 +1008.0,0.05,2024-06-24 17:15:55+00:00 +1009.0,0.05,2024-06-24 17:15:56+00:00 +1010.0,0.05,2024-06-24 17:15:57+00:00 +1011.0,0.05,2024-06-24 17:15:58+00:00 +1012.0,0.05,2024-06-24 17:15:59+00:00 +1013.0,0.05,2024-06-24 17:16:00+00:00 +1014.0,0.05,2024-06-24 17:16:01+00:00 +1015.0,0.05,2024-06-24 17:16:02+00:00 +1016.0,0.05,2024-06-24 17:16:03+00:00 +1017.0,0.05,2024-06-24 17:16:04+00:00 +1018.0,0.05,2024-06-24 17:16:05+00:00 +1019.0,0.05,2024-06-24 17:16:06+00:00 +1020.0,0.05,2024-06-24 17:16:07+00:00 +1021.0,0.05,2024-06-24 17:16:08+00:00 +1022.0,0.05,2024-06-24 17:16:09+00:00 +1023.0,0.05,2024-06-24 17:16:10+00:00 +1024.0,0.05,2024-06-24 17:16:11+00:00 +1025.0,0.05,2024-06-24 17:16:12+00:00 +1026.0,0.05,2024-06-24 17:16:13+00:00 +1027.0,0.05,2024-06-24 17:16:14+00:00 +1028.0,0.05,2024-06-24 17:16:15+00:00 +1029.0,0.05,2024-06-24 17:16:16+00:00 +1030.0,0.05,2024-06-24 17:16:17+00:00 +1031.0,0.05,2024-06-24 17:16:18+00:00 +1032.0,0.05,2024-06-24 17:16:19+00:00 +1033.0,0.05,2024-06-24 17:16:20+00:00 +1034.0,0.05,2024-06-24 17:16:21+00:00 +1035.0,0.05,2024-06-24 17:16:22+00:00 +1036.0,0.05,2024-06-24 17:16:23+00:00 +1037.0,0.05,2024-06-24 17:16:24+00:00 +1038.0,0.05,2024-06-24 17:16:25+00:00 +1039.0,0.05,2024-06-24 17:16:26+00:00 +1040.0,0.05,2024-06-24 17:16:27+00:00 +1041.0,0.05,2024-06-24 17:16:28+00:00 +1042.0,0.05,2024-06-24 17:16:29+00:00 +1043.0,0.05,2024-06-24 17:16:30+00:00 +1044.0,0.05,2024-06-24 17:16:31+00:00 +1045.0,0.05,2024-06-24 17:16:32+00:00 +1046.0,0.05,2024-06-24 17:16:33+00:00 +1047.0,0.05,2024-06-24 17:16:34+00:00 +1048.0,0.05,2024-06-24 17:16:35+00:00 +1049.0,0.05,2024-06-24 17:16:36+00:00 +1050.0,0.05,2024-06-24 17:16:37+00:00 +1051.0,0.05,2024-06-24 17:16:38+00:00 +1052.0,0.05,2024-06-24 17:16:39+00:00 +1053.0,0.05,2024-06-24 17:16:40+00:00 +1054.0,0.05,2024-06-24 17:16:41+00:00 +1055.0,0.05,2024-06-24 17:16:42+00:00 +1056.0,0.05,2024-06-24 17:16:43+00:00 +1057.0,0.05,2024-06-24 17:16:44+00:00 +1058.0,0.05,2024-06-24 17:16:45+00:00 +1059.0,0.05,2024-06-24 17:16:46+00:00 +1060.0,0.05,2024-06-24 17:16:47+00:00 +1061.0,0.05,2024-06-24 17:16:48+00:00 +1062.0,0.05,2024-06-24 17:16:49+00:00 +1063.0,0.05,2024-06-24 17:16:50+00:00 +1064.0,0.05,2024-06-24 17:16:51+00:00 +1065.0,0.05,2024-06-24 17:16:52+00:00 +1066.0,0.05,2024-06-24 17:16:53+00:00 +1067.0,0.05,2024-06-24 17:16:54+00:00 +1068.0,0.05,2024-06-24 17:16:55+00:00 +1069.0,0.05,2024-06-24 17:16:56+00:00 +1070.0,0.05,2024-06-24 17:16:57+00:00 +1071.0,0.05,2024-06-24 17:16:58+00:00 +1072.0,0.05,2024-06-24 17:16:59+00:00 +1073.0,0.05,2024-06-24 17:17:00+00:00 +1074.0,0.05,2024-06-24 17:17:01+00:00 +1075.0,0.05,2024-06-24 17:17:02+00:00 +1076.0,0.05,2024-06-24 17:17:03+00:00 +1077.0,0.05,2024-06-24 17:17:04+00:00 +1078.0,0.05,2024-06-24 17:17:05+00:00 +1079.0,0.05,2024-06-24 17:17:06+00:00 +1080.0,0.05,2024-06-24 17:17:07+00:00 +1081.0,0.05,2024-06-24 17:17:08+00:00 +1082.0,0.05,2024-06-24 17:17:09+00:00 +1083.0,0.05,2024-06-24 17:17:10+00:00 +1084.0,0.05,2024-06-24 17:17:11+00:00 +1085.0,0.05,2024-06-24 17:17:12+00:00 +1086.0,0.05,2024-06-24 17:17:13+00:00 +1087.0,0.05,2024-06-24 17:17:14+00:00 +1088.0,0.05,2024-06-24 17:17:15+00:00 +1089.0,0.05,2024-06-24 17:17:16+00:00 +1090.0,0.05,2024-06-24 17:17:17+00:00 +1091.0,0.05,2024-06-24 17:17:18+00:00 +1092.0,0.05,2024-06-24 17:17:19+00:00 +1093.0,0.05,2024-06-24 17:17:20+00:00 +1094.0,0.05,2024-06-24 17:17:21+00:00 +1095.0,0.05,2024-06-24 17:17:22+00:00 +1096.0,0.05,2024-06-24 17:17:23+00:00 +1097.0,0.05,2024-06-24 17:17:24+00:00 +1098.0,0.05,2024-06-24 17:17:25+00:00 +1099.0,0.05,2024-06-24 17:17:26+00:00 +1100.0,0.05,2024-06-24 17:17:27+00:00 +1101.0,0.05,2024-06-24 17:17:28+00:00 +1102.0,0.05,2024-06-24 17:17:29+00:00 +1103.0,0.05,2024-06-24 17:17:30+00:00 +1104.0,0.05,2024-06-24 17:17:31+00:00 +1105.0,0.05,2024-06-24 17:17:32+00:00 +1106.0,0.05,2024-06-24 17:17:33+00:00 +1107.0,0.05,2024-06-24 17:17:34+00:00 +1108.0,0.05,2024-06-24 17:17:35+00:00 +1109.0,0.05,2024-06-24 17:17:36+00:00 +1110.0,0.05,2024-06-24 17:17:37+00:00 +1111.0,0.05,2024-06-24 17:17:38+00:00 +1112.0,0.05,2024-06-24 17:17:39+00:00 +1113.0,0.05,2024-06-24 17:17:40+00:00 +1114.0,0.05,2024-06-24 17:17:41+00:00 +1115.0,0.05,2024-06-24 17:17:42+00:00 +1116.0,0.05,2024-06-24 17:17:43+00:00 +1117.0,0.05,2024-06-24 17:17:44+00:00 +1118.0,0.05,2024-06-24 17:17:45+00:00 +1119.0,0.05,2024-06-24 17:17:46+00:00 +1120.0,0.05,2024-06-24 17:17:47+00:00 +1121.0,0.05,2024-06-24 17:17:48+00:00 +1122.0,0.05,2024-06-24 17:17:49+00:00 +1123.0,0.05,2024-06-24 17:17:50+00:00 +1124.0,0.05,2024-06-24 17:17:51+00:00 +1125.0,0.05,2024-06-24 17:17:52+00:00 +1126.0,0.05,2024-06-24 17:17:53+00:00 +1127.0,0.05,2024-06-24 17:17:54+00:00 +1128.0,0.05,2024-06-24 17:17:55+00:00 +1129.0,0.05,2024-06-24 17:17:56+00:00 +1130.0,0.05,2024-06-24 17:17:57+00:00 +1131.0,0.05,2024-06-24 17:17:58+00:00 +1132.0,0.05,2024-06-24 17:17:59+00:00 +1133.0,0.05,2024-06-24 17:18:00+00:00 +1134.0,0.05,2024-06-24 17:18:01+00:00 +1135.0,0.05,2024-06-24 17:18:02+00:00 +1136.0,0.05,2024-06-24 17:18:03+00:00 +1137.0,0.05,2024-06-24 17:18:04+00:00 +1138.0,0.05,2024-06-24 17:18:05+00:00 +1139.0,0.05,2024-06-24 17:18:06+00:00 +1140.0,0.05,2024-06-24 17:18:07+00:00 +1141.0,0.05,2024-06-24 17:18:08+00:00 +1142.0,0.05,2024-06-24 17:18:09+00:00 +1143.0,0.05,2024-06-24 17:18:10+00:00 +1144.0,0.05,2024-06-24 17:18:11+00:00 +1145.0,0.05,2024-06-24 17:18:12+00:00 +1146.0,0.05,2024-06-24 17:18:13+00:00 +1147.0,0.05,2024-06-24 17:18:14+00:00 +1148.0,0.05,2024-06-24 17:18:15+00:00 +1149.0,0.05,2024-06-24 17:18:16+00:00 +1150.0,0.05,2024-06-24 17:18:17+00:00 +1151.0,0.05,2024-06-24 17:18:18+00:00 +1152.0,0.05,2024-06-24 17:18:19+00:00 +1153.0,0.05,2024-06-24 17:18:20+00:00 +1154.0,0.05,2024-06-24 17:18:21+00:00 +1155.0,0.05,2024-06-24 17:18:22+00:00 +1156.0,0.05,2024-06-24 17:18:23+00:00 +1157.0,0.05,2024-06-24 17:18:24+00:00 +1158.0,0.05,2024-06-24 17:18:25+00:00 +1159.0,0.05,2024-06-24 17:18:26+00:00 +1160.0,0.05,2024-06-24 17:18:27+00:00 +1161.0,0.05,2024-06-24 17:18:28+00:00 +1162.0,0.05,2024-06-24 17:18:29+00:00 +1163.0,0.05,2024-06-24 17:18:30+00:00 +1164.0,0.05,2024-06-24 17:18:31+00:00 +1165.0,0.05,2024-06-24 17:18:32+00:00 +1166.0,0.05,2024-06-24 17:18:33+00:00 +1167.0,0.05,2024-06-24 17:18:34+00:00 +1168.0,0.05,2024-06-24 17:18:35+00:00 +1169.0,0.05,2024-06-24 17:18:36+00:00 +1170.0,0.05,2024-06-24 17:18:37+00:00 +1171.0,0.05,2024-06-24 17:18:38+00:00 +1172.0,0.05,2024-06-24 17:18:39+00:00 +1173.0,0.05,2024-06-24 17:18:40+00:00 +1174.0,0.05,2024-06-24 17:18:41+00:00 +1175.0,0.05,2024-06-24 17:18:42+00:00 +1176.0,0.05,2024-06-24 17:18:43+00:00 +1177.0,0.05,2024-06-24 17:18:44+00:00 +1178.0,0.05,2024-06-24 17:18:45+00:00 +1179.0,0.05,2024-06-24 17:18:46+00:00 +1180.0,0.05,2024-06-24 17:18:47+00:00 +1181.0,0.05,2024-06-24 17:18:48+00:00 +1182.0,0.05,2024-06-24 17:18:49+00:00 +1183.0,0.05,2024-06-24 17:18:50+00:00 +1184.0,0.05,2024-06-24 17:18:51+00:00 +1185.0,0.05,2024-06-24 17:18:52+00:00 +1186.0,0.05,2024-06-24 17:18:53+00:00 +1187.0,0.05,2024-06-24 17:18:54+00:00 +1188.0,0.05,2024-06-24 17:18:55+00:00 +1189.0,0.05,2024-06-24 17:18:56+00:00 +1190.0,0.05,2024-06-24 17:18:57+00:00 +1191.0,0.05,2024-06-24 17:18:58+00:00 +1192.0,0.05,2024-06-24 17:18:59+00:00 +1193.0,0.05,2024-06-24 17:19:00+00:00 +1194.0,0.05,2024-06-24 17:19:01+00:00 +1195.0,0.05,2024-06-24 17:19:02+00:00 +1196.0,0.05,2024-06-24 17:19:03+00:00 +1197.0,0.05,2024-06-24 17:19:04+00:00 +1198.0,0.05,2024-06-24 17:19:05+00:00 +1199.0,0.05,2024-06-24 17:19:06+00:00 +1200.0,0.05,2024-06-24 17:19:07+00:00 +1201.0,0.05,2024-06-24 17:19:08+00:00 +1202.0,0.05,2024-06-24 17:19:09+00:00 +1203.0,0.05,2024-06-24 17:19:10+00:00 +1204.0,0.05,2024-06-24 17:19:11+00:00 +1205.0,0.05,2024-06-24 17:19:12+00:00 +1206.0,0.05,2024-06-24 17:19:13+00:00 +1207.0,0.05,2024-06-24 17:19:14+00:00 +1208.0,0.05,2024-06-24 17:19:15+00:00 +1209.0,0.05,2024-06-24 17:19:16+00:00 +1210.0,0.05,2024-06-24 17:19:17+00:00 +1211.0,0.05,2024-06-24 17:19:18+00:00 +1212.0,0.05,2024-06-24 17:19:19+00:00 +1213.0,0.05,2024-06-24 17:19:20+00:00 +1214.0,0.05,2024-06-24 17:19:21+00:00 +1215.0,0.05,2024-06-24 17:19:22+00:00 +1216.0,0.05,2024-06-24 17:19:23+00:00 +1217.0,0.05,2024-06-24 17:19:24+00:00 +1218.0,0.05,2024-06-24 17:19:25+00:00 +1219.0,0.05,2024-06-24 17:19:26+00:00 +1220.0,0.05,2024-06-24 17:19:27+00:00 +1221.0,0.05,2024-06-24 17:19:28+00:00 +1222.0,0.05,2024-06-24 17:19:29+00:00 +1223.0,0.05,2024-06-24 17:19:30+00:00 +1224.0,0.05,2024-06-24 17:19:31+00:00 +1225.0,0.05,2024-06-24 17:19:32+00:00 +1226.0,0.05,2024-06-24 17:19:33+00:00 +1227.0,0.05,2024-06-24 17:19:34+00:00 +1228.0,0.05,2024-06-24 17:19:35+00:00 +1229.0,0.05,2024-06-24 17:19:36+00:00 +1230.0,0.05,2024-06-24 17:19:37+00:00 +1231.0,0.05,2024-06-24 17:19:38+00:00 +1232.0,0.05,2024-06-24 17:19:39+00:00 +1233.0,0.05,2024-06-24 17:19:40+00:00 +1234.0,0.05,2024-06-24 17:19:41+00:00 +1235.0,0.05,2024-06-24 17:19:42+00:00 +1236.0,0.05,2024-06-24 17:19:43+00:00 +1237.0,0.05,2024-06-24 17:19:44+00:00 +1238.0,0.05,2024-06-24 17:19:45+00:00 +1239.0,0.05,2024-06-24 17:19:46+00:00 +1240.0,0.05,2024-06-24 17:19:47+00:00 +1241.0,0.05,2024-06-24 17:19:48+00:00 +1242.0,0.05,2024-06-24 17:19:49+00:00 +1243.0,0.05,2024-06-24 17:19:50+00:00 +1244.0,0.05,2024-06-24 17:19:51+00:00 +1245.0,0.05,2024-06-24 17:19:52+00:00 +1246.0,0.05,2024-06-24 17:19:53+00:00 +1247.0,0.05,2024-06-24 17:19:54+00:00 +1248.0,0.05,2024-06-24 17:19:55+00:00 +1249.0,0.05,2024-06-24 17:19:56+00:00 +1250.0,0.05,2024-06-24 17:19:57+00:00 +1251.0,0.05,2024-06-24 17:19:58+00:00 +1252.0,0.05,2024-06-24 17:19:59+00:00 +1253.0,0.05,2024-06-24 17:20:00+00:00 +1254.0,0.05,2024-06-24 17:20:01+00:00 +1255.0,0.05,2024-06-24 17:20:02+00:00 +1256.0,0.05,2024-06-24 17:20:03+00:00 +1257.0,0.05,2024-06-24 17:20:04+00:00 +1258.0,0.05,2024-06-24 17:20:05+00:00 +1259.0,0.05,2024-06-24 17:20:06+00:00 +1260.0,0.05,2024-06-24 17:20:07+00:00 +1261.0,0.05,2024-06-24 17:20:08+00:00 +1262.0,0.05,2024-06-24 17:20:09+00:00 +1263.0,0.05,2024-06-24 17:20:10+00:00 +1264.0,0.05,2024-06-24 17:20:11+00:00 +1265.0,0.05,2024-06-24 17:20:12+00:00 +1266.0,0.05,2024-06-24 17:20:13+00:00 +1267.0,0.05,2024-06-24 17:20:14+00:00 +1268.0,0.05,2024-06-24 17:20:15+00:00 +1269.0,0.05,2024-06-24 17:20:16+00:00 +1270.0,0.05,2024-06-24 17:20:17+00:00 +1271.0,0.05,2024-06-24 17:20:18+00:00 +1272.0,0.05,2024-06-24 17:20:19+00:00 +1273.0,0.05,2024-06-24 17:20:20+00:00 +1274.0,0.05,2024-06-24 17:20:21+00:00 +1275.0,0.05,2024-06-24 17:20:22+00:00 +1276.0,0.05,2024-06-24 17:20:23+00:00 +1277.0,0.05,2024-06-24 17:20:24+00:00 +1278.0,0.05,2024-06-24 17:20:25+00:00 +1279.0,0.05,2024-06-24 17:20:26+00:00 +1280.0,0.05,2024-06-24 17:20:27+00:00 +1281.0,0.05,2024-06-24 17:20:28+00:00 +1282.0,0.05,2024-06-24 17:20:29+00:00 +1283.0,0.05,2024-06-24 17:20:30+00:00 +1284.0,0.05,2024-06-24 17:20:31+00:00 +1285.0,0.05,2024-06-24 17:20:32+00:00 +1286.0,0.05,2024-06-24 17:20:33+00:00 +1287.0,0.05,2024-06-24 17:20:34+00:00 +1288.0,0.05,2024-06-24 17:20:35+00:00 +1289.0,0.05,2024-06-24 17:20:36+00:00 +1290.0,0.05,2024-06-24 17:20:37+00:00 +1291.0,0.05,2024-06-24 17:20:38+00:00 +1292.0,0.05,2024-06-24 17:20:39+00:00 +1293.0,0.05,2024-06-24 17:20:40+00:00 +1294.0,0.05,2024-06-24 17:20:41+00:00 +1295.0,0.05,2024-06-24 17:20:42+00:00 +1296.0,0.05,2024-06-24 17:20:43+00:00 +1297.0,0.05,2024-06-24 17:20:44+00:00 +1298.0,0.05,2024-06-24 17:20:45+00:00 +1299.0,0.05,2024-06-24 17:20:46+00:00 +1300.0,0.05,2024-06-24 17:20:47+00:00 +1301.0,0.05,2024-06-24 17:20:48+00:00 +1302.0,0.05,2024-06-24 17:20:49+00:00 +1303.0,0.05,2024-06-24 17:20:50+00:00 +1304.0,0.05,2024-06-24 17:20:51+00:00 +1305.0,0.05,2024-06-24 17:20:52+00:00 +1306.0,0.05,2024-06-24 17:20:53+00:00 +1307.0,0.05,2024-06-24 17:20:54+00:00 +1308.0,0.05,2024-06-24 17:20:55+00:00 +1309.0,0.05,2024-06-24 17:20:56+00:00 +1310.0,0.05,2024-06-24 17:20:57+00:00 +1311.0,0.05,2024-06-24 17:20:58+00:00 +1312.0,0.05,2024-06-24 17:20:59+00:00 +1313.0,0.05,2024-06-24 17:21:00+00:00 +1314.0,0.05,2024-06-24 17:21:01+00:00 +1315.0,0.05,2024-06-24 17:21:02+00:00 +1316.0,0.05,2024-06-24 17:21:03+00:00 +1317.0,0.05,2024-06-24 17:21:04+00:00 +1318.0,0.05,2024-06-24 17:21:05+00:00 +1319.0,0.05,2024-06-24 17:21:06+00:00 +1320.0,0.05,2024-06-24 17:21:07+00:00 +1321.0,0.05,2024-06-24 17:21:08+00:00 +1322.0,0.05,2024-06-24 17:21:09+00:00 +1323.0,0.05,2024-06-24 17:21:10+00:00 +1324.0,0.05,2024-06-24 17:21:11+00:00 +1325.0,0.05,2024-06-24 17:21:12+00:00 +1326.0,0.05,2024-06-24 17:21:13+00:00 +1327.0,0.05,2024-06-24 17:21:14+00:00 +1328.0,0.05,2024-06-24 17:21:15+00:00 +1329.0,0.05,2024-06-24 17:21:16+00:00 +1330.0,0.05,2024-06-24 17:21:17+00:00 +1331.0,0.05,2024-06-24 17:21:18+00:00 +1332.0,0.05,2024-06-24 17:21:19+00:00 +1333.0,0.05,2024-06-24 17:21:20+00:00 +1334.0,0.05,2024-06-24 17:21:21+00:00 +1335.0,0.05,2024-06-24 17:21:22+00:00 +1336.0,0.05,2024-06-24 17:21:23+00:00 +1337.0,0.05,2024-06-24 17:21:24+00:00 +1338.0,0.05,2024-06-24 17:21:25+00:00 +1339.0,0.05,2024-06-24 17:21:26+00:00 +1340.0,0.05,2024-06-24 17:21:27+00:00 +1341.0,0.05,2024-06-24 17:21:28+00:00 +1342.0,0.05,2024-06-24 17:21:29+00:00 +1343.0,0.05,2024-06-24 17:21:30+00:00 +1344.0,0.05,2024-06-24 17:21:31+00:00 +1345.0,0.05,2024-06-24 17:21:32+00:00 +1346.0,0.05,2024-06-24 17:21:33+00:00 +1347.0,0.05,2024-06-24 17:21:34+00:00 +1348.0,0.05,2024-06-24 17:21:35+00:00 +1349.0,0.05,2024-06-24 17:21:36+00:00 +1350.0,0.05,2024-06-24 17:21:37+00:00 +1351.0,0.05,2024-06-24 17:21:38+00:00 +1352.0,0.05,2024-06-24 17:21:39+00:00 +1353.0,0.05,2024-06-24 17:21:40+00:00 +1354.0,0.05,2024-06-24 17:21:41+00:00 +1355.0,0.05,2024-06-24 17:21:42+00:00 +1356.0,0.05,2024-06-24 17:21:43+00:00 +1357.0,0.05,2024-06-24 17:21:44+00:00 +1358.0,0.05,2024-06-24 17:21:45+00:00 +1359.0,0.05,2024-06-24 17:21:46+00:00 +1360.0,0.05,2024-06-24 17:21:47+00:00 +1361.0,0.05,2024-06-24 17:21:48+00:00 +1362.0,0.05,2024-06-24 17:21:49+00:00 +1363.0,0.05,2024-06-24 17:21:50+00:00 +1364.0,0.05,2024-06-24 17:21:51+00:00 +1365.0,0.05,2024-06-24 17:21:52+00:00 +1366.0,0.05,2024-06-24 17:21:53+00:00 +1367.0,0.05,2024-06-24 17:21:54+00:00 +1368.0,0.05,2024-06-24 17:21:55+00:00 +1369.0,0.05,2024-06-24 17:21:56+00:00 +1370.0,0.05,2024-06-24 17:21:57+00:00 +1371.0,0.05,2024-06-24 17:21:58+00:00 +1372.0,0.05,2024-06-24 17:21:59+00:00 +1373.0,0.05,2024-06-24 17:22:00+00:00 +1374.0,0.05,2024-06-24 17:22:01+00:00 +1375.0,0.05,2024-06-24 17:22:02+00:00 +1376.0,0.05,2024-06-24 17:22:03+00:00 +1377.0,0.05,2024-06-24 17:22:04+00:00 +1378.0,0.05,2024-06-24 17:22:05+00:00 +1379.0,0.05,2024-06-24 17:22:06+00:00 +1380.0,0.05,2024-06-24 17:22:07+00:00 +1381.0,0.05,2024-06-24 17:22:08+00:00 +1382.0,0.05,2024-06-24 17:22:09+00:00 +1383.0,0.05,2024-06-24 17:22:10+00:00 +1384.0,0.05,2024-06-24 17:22:11+00:00 +1385.0,0.05,2024-06-24 17:22:12+00:00 +1386.0,0.05,2024-06-24 17:22:13+00:00 +1387.0,0.05,2024-06-24 17:22:14+00:00 +1388.0,0.05,2024-06-24 17:22:15+00:00 +1389.0,0.05,2024-06-24 17:22:16+00:00 +1390.0,0.05,2024-06-24 17:22:17+00:00 +1391.0,0.05,2024-06-24 17:22:18+00:00 +1392.0,0.05,2024-06-24 17:22:19+00:00 +1393.0,0.05,2024-06-24 17:22:20+00:00 +1394.0,0.05,2024-06-24 17:22:21+00:00 +1395.0,0.05,2024-06-24 17:22:22+00:00 +1396.0,0.05,2024-06-24 17:22:23+00:00 +1397.0,0.05,2024-06-24 17:22:24+00:00 +1398.0,0.05,2024-06-24 17:22:25+00:00 +1399.0,0.05,2024-06-24 17:22:26+00:00 +1400.0,0.05,2024-06-24 17:22:27+00:00 +1401.0,0.05,2024-06-24 17:22:28+00:00 +1402.0,0.05,2024-06-24 17:22:29+00:00 +1403.0,0.05,2024-06-24 17:22:30+00:00 +1404.0,0.05,2024-06-24 17:22:31+00:00 +1405.0,0.05,2024-06-24 17:22:32+00:00 +1406.0,0.05,2024-06-24 17:22:33+00:00 +1407.0,0.05,2024-06-24 17:22:34+00:00 +1408.0,0.05,2024-06-24 17:22:35+00:00 +1409.0,0.05,2024-06-24 17:22:36+00:00 +1410.0,0.05,2024-06-24 17:22:37+00:00 +1411.0,0.05,2024-06-24 17:22:38+00:00 +1412.0,0.05,2024-06-24 17:22:39+00:00 +1413.0,0.05,2024-06-24 17:22:40+00:00 +1414.0,0.05,2024-06-24 17:22:41+00:00 +1415.0,0.05,2024-06-24 17:22:42+00:00 +1416.0,0.05,2024-06-24 17:22:43+00:00 +1417.0,0.05,2024-06-24 17:22:44+00:00 +1418.0,0.05,2024-06-24 17:22:45+00:00 +1419.0,0.05,2024-06-24 17:22:46+00:00 +1420.0,0.05,2024-06-24 17:22:47+00:00 +1421.0,0.05,2024-06-24 17:22:48+00:00 +1422.0,0.05,2024-06-24 17:22:49+00:00 +1423.0,0.05,2024-06-24 17:22:50+00:00 +1424.0,0.05,2024-06-24 17:22:51+00:00 +1425.0,0.05,2024-06-24 17:22:52+00:00 +1426.0,0.05,2024-06-24 17:22:53+00:00 +1427.0,0.05,2024-06-24 17:22:54+00:00 +1428.0,0.05,2024-06-24 17:22:55+00:00 +1429.0,0.05,2024-06-24 17:22:56+00:00 +1430.0,0.05,2024-06-24 17:22:57+00:00 +1431.0,0.05,2024-06-24 17:22:58+00:00 +1432.0,0.05,2024-06-24 17:22:59+00:00 +1433.0,0.05,2024-06-24 17:23:00+00:00 +1434.0,0.05,2024-06-24 17:23:01+00:00 +1435.0,0.05,2024-06-24 17:23:02+00:00 +1436.0,0.05,2024-06-24 17:23:03+00:00 +1437.0,0.05,2024-06-24 17:23:04+00:00 +1438.0,0.05,2024-06-24 17:23:05+00:00 +1439.0,0.05,2024-06-24 17:23:06+00:00 +1440.0,0.05,2024-06-24 17:23:07+00:00 +1441.0,0.05,2024-06-24 17:23:08+00:00 +1442.0,0.05,2024-06-24 17:23:09+00:00 +1443.0,0.05,2024-06-24 17:23:10+00:00 +1444.0,0.05,2024-06-24 17:23:11+00:00 +1445.0,0.05,2024-06-24 17:23:12+00:00 +1446.0,0.05,2024-06-24 17:23:13+00:00 +1447.0,0.05,2024-06-24 17:23:14+00:00 +1448.0,0.05,2024-06-24 17:23:15+00:00 +1449.0,0.05,2024-06-24 17:23:16+00:00 +1450.0,0.05,2024-06-24 17:23:17+00:00 +1451.0,0.05,2024-06-24 17:23:18+00:00 +1452.0,0.05,2024-06-24 17:23:19+00:00 +1453.0,0.05,2024-06-24 17:23:20+00:00 +1454.0,0.05,2024-06-24 17:23:21+00:00 +1455.0,0.05,2024-06-24 17:23:22+00:00 +1456.0,0.05,2024-06-24 17:23:23+00:00 +1457.0,0.05,2024-06-24 17:23:24+00:00 +1458.0,0.05,2024-06-24 17:23:25+00:00 +1459.0,0.05,2024-06-24 17:23:26+00:00 +1460.0,0.05,2024-06-24 17:23:27+00:00 +1461.0,0.05,2024-06-24 17:23:28+00:00 +1462.0,0.05,2024-06-24 17:23:29+00:00 +1463.0,0.05,2024-06-24 17:23:30+00:00 +1464.0,0.05,2024-06-24 17:23:31+00:00 +1465.0,0.05,2024-06-24 17:23:32+00:00 +1466.0,0.05,2024-06-24 17:23:33+00:00 +1467.0,0.05,2024-06-24 17:23:34+00:00 +1468.0,0.05,2024-06-24 17:23:35+00:00 +1469.0,0.05,2024-06-24 17:23:36+00:00 +1470.0,0.05,2024-06-24 17:23:37+00:00 +1471.0,0.05,2024-06-24 17:23:38+00:00 +1472.0,0.05,2024-06-24 17:23:39+00:00 +1473.0,0.05,2024-06-24 17:23:40+00:00 +1474.0,0.05,2024-06-24 17:23:41+00:00 +1475.0,0.05,2024-06-24 17:23:42+00:00 +1476.0,0.05,2024-06-24 17:23:43+00:00 +1477.0,0.05,2024-06-24 17:23:44+00:00 +1478.0,0.05,2024-06-24 17:23:45+00:00 +1479.0,0.05,2024-06-24 17:23:46+00:00 +1480.0,0.05,2024-06-24 17:23:47+00:00 +1481.0,0.05,2024-06-24 17:23:48+00:00 +1482.0,0.05,2024-06-24 17:23:49+00:00 +1483.0,0.05,2024-06-24 17:23:50+00:00 +1484.0,0.05,2024-06-24 17:23:51+00:00 +1485.0,0.05,2024-06-24 17:23:52+00:00 +1486.0,0.05,2024-06-24 17:23:53+00:00 +1487.0,0.05,2024-06-24 17:23:54+00:00 +1488.0,0.05,2024-06-24 17:23:55+00:00 +1489.0,0.05,2024-06-24 17:23:56+00:00 +1490.0,0.05,2024-06-24 17:23:57+00:00 +1491.0,0.05,2024-06-24 17:23:58+00:00 +1492.0,0.05,2024-06-24 17:23:59+00:00 +1493.0,0.05,2024-06-24 17:24:00+00:00 +1494.0,0.05,2024-06-24 17:24:01+00:00 +1495.0,0.05,2024-06-24 17:24:02+00:00 +1496.0,0.05,2024-06-24 17:24:03+00:00 +1497.0,0.05,2024-06-24 17:24:04+00:00 +1498.0,0.05,2024-06-24 17:24:05+00:00 +1499.0,0.05,2024-06-24 17:24:06+00:00 +1500.0,0.05,2024-06-24 17:24:07+00:00 +1501.0,0.05,2024-06-24 17:24:08+00:00 +1502.0,0.05,2024-06-24 17:24:09+00:00 +1503.0,0.05,2024-06-24 17:24:10+00:00 +1504.0,0.05,2024-06-24 17:24:11+00:00 +1505.0,0.05,2024-06-24 17:24:12+00:00 +1506.0,0.05,2024-06-24 17:24:13+00:00 +1507.0,0.05,2024-06-24 17:24:14+00:00 +1508.0,0.05,2024-06-24 17:24:15+00:00 +1509.0,0.05,2024-06-24 17:24:16+00:00 +1510.0,0.05,2024-06-24 17:24:17+00:00 +1511.0,0.05,2024-06-24 17:24:18+00:00 +1512.0,0.05,2024-06-24 17:24:19+00:00 +1513.0,0.05,2024-06-24 17:24:20+00:00 +1514.0,0.05,2024-06-24 17:24:21+00:00 +1515.0,0.05,2024-06-24 17:24:22+00:00 +1516.0,0.05,2024-06-24 17:24:23+00:00 +1517.0,0.05,2024-06-24 17:24:24+00:00 +1518.0,0.05,2024-06-24 17:24:25+00:00 +1519.0,0.05,2024-06-24 17:24:26+00:00 +1520.0,0.05,2024-06-24 17:24:27+00:00 +1521.0,0.05,2024-06-24 17:24:28+00:00 +1522.0,0.05,2024-06-24 17:24:29+00:00 +1523.0,0.05,2024-06-24 17:24:30+00:00 +1524.0,0.05,2024-06-24 17:24:31+00:00 +1525.0,0.05,2024-06-24 17:24:32+00:00 +1526.0,0.05,2024-06-24 17:24:33+00:00 +1527.0,0.05,2024-06-24 17:24:34+00:00 +1528.0,0.05,2024-06-24 17:24:35+00:00 +1529.0,0.05,2024-06-24 17:24:36+00:00 +1530.0,0.05,2024-06-24 17:24:37+00:00 +1531.0,0.05,2024-06-24 17:24:38+00:00 +1532.0,0.05,2024-06-24 17:24:39+00:00 +1533.0,0.05,2024-06-24 17:24:40+00:00 +1534.0,0.05,2024-06-24 17:24:41+00:00 +1535.0,0.05,2024-06-24 17:24:42+00:00 +1536.0,0.05,2024-06-24 17:24:43+00:00 +1537.0,0.05,2024-06-24 17:24:44+00:00 +1538.0,0.05,2024-06-24 17:24:45+00:00 +1539.0,0.05,2024-06-24 17:24:46+00:00 +1540.0,0.05,2024-06-24 17:24:47+00:00 +1541.0,0.05,2024-06-24 17:24:48+00:00 +1542.0,0.05,2024-06-24 17:24:49+00:00 +1543.0,0.05,2024-06-24 17:24:50+00:00 +1544.0,0.05,2024-06-24 17:24:51+00:00 +1545.0,0.05,2024-06-24 17:24:52+00:00 +1546.0,0.05,2024-06-24 17:24:53+00:00 +1547.0,0.05,2024-06-24 17:24:54+00:00 +1548.0,0.05,2024-06-24 17:24:55+00:00 +1549.0,0.05,2024-06-24 17:24:56+00:00 +1550.0,0.05,2024-06-24 17:24:57+00:00 +1551.0,0.05,2024-06-24 17:24:58+00:00 +1552.0,0.05,2024-06-24 17:24:59+00:00 +1553.0,0.05,2024-06-24 17:25:00+00:00 +1554.0,0.05,2024-06-24 17:25:01+00:00 +1555.0,0.05,2024-06-24 17:25:02+00:00 +1556.0,0.05,2024-06-24 17:25:03+00:00 +1557.0,0.05,2024-06-24 17:25:04+00:00 +1558.0,0.05,2024-06-24 17:25:05+00:00 +1559.0,0.05,2024-06-24 17:25:06+00:00 +1560.0,0.05,2024-06-24 17:25:07+00:00 +1561.0,0.05,2024-06-24 17:25:08+00:00 +1562.0,0.05,2024-06-24 17:25:09+00:00 +1563.0,0.05,2024-06-24 17:25:10+00:00 +1564.0,0.05,2024-06-24 17:25:11+00:00 +1565.0,0.05,2024-06-24 17:25:12+00:00 +1566.0,0.05,2024-06-24 17:25:13+00:00 +1567.0,0.05,2024-06-24 17:25:14+00:00 +1568.0,0.05,2024-06-24 17:25:15+00:00 +1569.0,0.05,2024-06-24 17:25:16+00:00 +1570.0,0.05,2024-06-24 17:25:17+00:00 +1571.0,0.05,2024-06-24 17:25:18+00:00 +1572.0,0.05,2024-06-24 17:25:19+00:00 +1573.0,0.05,2024-06-24 17:25:20+00:00 +1574.0,0.05,2024-06-24 17:25:21+00:00 +1575.0,0.05,2024-06-24 17:25:22+00:00 +1576.0,0.05,2024-06-24 17:25:23+00:00 +1577.0,0.05,2024-06-24 17:25:24+00:00 +1578.0,0.05,2024-06-24 17:25:25+00:00 +1579.0,0.05,2024-06-24 17:25:26+00:00 +1580.0,0.05,2024-06-24 17:25:27+00:00 +1581.0,0.05,2024-06-24 17:25:28+00:00 +1582.0,0.05,2024-06-24 17:25:29+00:00 +1583.0,0.05,2024-06-24 17:25:30+00:00 +1584.0,0.05,2024-06-24 17:25:31+00:00 +1585.0,0.05,2024-06-24 17:25:32+00:00 +1586.0,0.05,2024-06-24 17:25:33+00:00 +1587.0,0.05,2024-06-24 17:25:34+00:00 +1588.0,0.05,2024-06-24 17:25:35+00:00 +1589.0,0.05,2024-06-24 17:25:36+00:00 +1590.0,0.05,2024-06-24 17:25:37+00:00 +1591.0,0.05,2024-06-24 17:25:38+00:00 +1592.0,0.05,2024-06-24 17:25:39+00:00 +1593.0,0.05,2024-06-24 17:25:40+00:00 +1594.0,0.05,2024-06-24 17:25:41+00:00 +1595.0,0.05,2024-06-24 17:25:42+00:00 +1596.0,0.05,2024-06-24 17:25:43+00:00 +1597.0,0.05,2024-06-24 17:25:44+00:00 +1598.0,0.05,2024-06-24 17:25:45+00:00 +1599.0,0.05,2024-06-24 17:25:46+00:00 +1600.0,0.05,2024-06-24 17:25:47+00:00 +1601.0,0.05,2024-06-24 17:25:48+00:00 +1602.0,0.05,2024-06-24 17:25:49+00:00 +1603.0,0.05,2024-06-24 17:25:50+00:00 +1604.0,0.05,2024-06-24 17:25:51+00:00 +1605.0,0.05,2024-06-24 17:25:52+00:00 +1606.0,0.05,2024-06-24 17:25:53+00:00 +1607.0,0.05,2024-06-24 17:25:54+00:00 +1608.0,0.05,2024-06-24 17:25:55+00:00 +1609.0,0.05,2024-06-24 17:25:56+00:00 +1610.0,0.05,2024-06-24 17:25:57+00:00 +1611.0,0.05,2024-06-24 17:25:58+00:00 +1612.0,0.05,2024-06-24 17:25:59+00:00 +1613.0,0.05,2024-06-24 17:26:00+00:00 +1614.0,0.05,2024-06-24 17:26:01+00:00 +1615.0,0.05,2024-06-24 17:26:02+00:00 +1616.0,0.05,2024-06-24 17:26:03+00:00 +1617.0,0.05,2024-06-24 17:26:04+00:00 +1618.0,0.05,2024-06-24 17:26:05+00:00 +1619.0,0.05,2024-06-24 17:26:06+00:00 +1620.0,0.05,2024-06-24 17:26:07+00:00 +1621.0,0.05,2024-06-24 17:26:08+00:00 +1622.0,0.05,2024-06-24 17:26:09+00:00 +1623.0,0.05,2024-06-24 17:26:10+00:00 +1624.0,0.05,2024-06-24 17:26:11+00:00 +1625.0,0.05,2024-06-24 17:26:12+00:00 +1626.0,0.05,2024-06-24 17:26:13+00:00 +1627.0,0.05,2024-06-24 17:26:14+00:00 +1628.0,0.05,2024-06-24 17:26:15+00:00 +1629.0,0.05,2024-06-24 17:26:16+00:00 +1630.0,0.05,2024-06-24 17:26:17+00:00 +1631.0,0.05,2024-06-24 17:26:18+00:00 +1632.0,0.05,2024-06-24 17:26:19+00:00 +1633.0,0.05,2024-06-24 17:26:20+00:00 +1634.0,0.05,2024-06-24 17:26:21+00:00 +1635.0,0.05,2024-06-24 17:26:22+00:00 +1636.0,0.05,2024-06-24 17:26:23+00:00 +1637.0,0.05,2024-06-24 17:26:24+00:00 +1638.0,0.05,2024-06-24 17:26:25+00:00 +1639.0,0.05,2024-06-24 17:26:26+00:00 +1640.0,0.05,2024-06-24 17:26:27+00:00 +1641.0,0.05,2024-06-24 17:26:28+00:00 +1642.0,0.05,2024-06-24 17:26:29+00:00 +1643.0,0.05,2024-06-24 17:26:30+00:00 +1644.0,0.05,2024-06-24 17:26:31+00:00 +1645.0,0.05,2024-06-24 17:26:32+00:00 +1646.0,0.05,2024-06-24 17:26:33+00:00 +1647.0,0.05,2024-06-24 17:26:34+00:00 +1648.0,0.05,2024-06-24 17:26:35+00:00 +1649.0,0.05,2024-06-24 17:26:36+00:00 +1650.0,0.05,2024-06-24 17:26:37+00:00 +1651.0,0.05,2024-06-24 17:26:38+00:00 +1652.0,0.05,2024-06-24 17:26:39+00:00 +1653.0,0.05,2024-06-24 17:26:40+00:00 +1654.0,0.05,2024-06-24 17:26:41+00:00 +1655.0,0.05,2024-06-24 17:26:42+00:00 +1656.0,0.05,2024-06-24 17:26:43+00:00 +1657.0,0.05,2024-06-24 17:26:44+00:00 +1658.0,0.05,2024-06-24 17:26:45+00:00 +1659.0,0.05,2024-06-24 17:26:46+00:00 +1660.0,0.05,2024-06-24 17:26:47+00:00 +1661.0,0.05,2024-06-24 17:26:48+00:00 +1662.0,0.05,2024-06-24 17:26:49+00:00 +1663.0,0.05,2024-06-24 17:26:50+00:00 +1664.0,0.05,2024-06-24 17:26:51+00:00 +1665.0,0.05,2024-06-24 17:26:52+00:00 +1666.0,0.05,2024-06-24 17:26:53+00:00 +1667.0,0.05,2024-06-24 17:26:54+00:00 +1668.0,0.05,2024-06-24 17:26:55+00:00 +1669.0,0.05,2024-06-24 17:26:56+00:00 +1670.0,0.05,2024-06-24 17:26:57+00:00 +1671.0,0.05,2024-06-24 17:26:58+00:00 +1672.0,0.05,2024-06-24 17:26:59+00:00 +1673.0,0.05,2024-06-24 17:27:00+00:00 +1674.0,0.05,2024-06-24 17:27:01+00:00 +1675.0,0.05,2024-06-24 17:27:02+00:00 +1676.0,0.05,2024-06-24 17:27:03+00:00 +1677.0,0.05,2024-06-24 17:27:04+00:00 +1678.0,0.05,2024-06-24 17:27:05+00:00 +1679.0,0.05,2024-06-24 17:27:06+00:00 +1680.0,0.05,2024-06-24 17:27:07+00:00 +1681.0,0.05,2024-06-24 17:27:08+00:00 +1682.0,0.05,2024-06-24 17:27:09+00:00 +1683.0,0.05,2024-06-24 17:27:10+00:00 +1684.0,0.05,2024-06-24 17:27:11+00:00 +1685.0,0.05,2024-06-24 17:27:12+00:00 +1686.0,0.05,2024-06-24 17:27:13+00:00 +1687.0,0.05,2024-06-24 17:27:14+00:00 +1688.0,0.05,2024-06-24 17:27:15+00:00 +1689.0,0.05,2024-06-24 17:27:16+00:00 +1690.0,0.05,2024-06-24 17:27:17+00:00 +1691.0,0.05,2024-06-24 17:27:18+00:00 +1692.0,0.05,2024-06-24 17:27:19+00:00 +1693.0,0.05,2024-06-24 17:27:20+00:00 +1694.0,0.05,2024-06-24 17:27:21+00:00 +1695.0,0.05,2024-06-24 17:27:22+00:00 +1696.0,0.05,2024-06-24 17:27:23+00:00 +1697.0,0.05,2024-06-24 17:27:24+00:00 +1698.0,0.05,2024-06-24 17:27:25+00:00 +1699.0,0.05,2024-06-24 17:27:26+00:00 +1700.0,0.05,2024-06-24 17:27:27+00:00 +1701.0,0.05,2024-06-24 17:27:28+00:00 +1702.0,0.05,2024-06-24 17:27:29+00:00 +1703.0,0.05,2024-06-24 17:27:30+00:00 +1704.0,0.05,2024-06-24 17:27:31+00:00 +1705.0,0.05,2024-06-24 17:27:32+00:00 +1706.0,0.05,2024-06-24 17:27:33+00:00 +1707.0,0.05,2024-06-24 17:27:34+00:00 +1708.0,0.05,2024-06-24 17:27:35+00:00 +1709.0,0.05,2024-06-24 17:27:36+00:00 +1710.0,0.05,2024-06-24 17:27:37+00:00 +1711.0,0.05,2024-06-24 17:27:38+00:00 +1712.0,0.05,2024-06-24 17:27:39+00:00 +1713.0,0.05,2024-06-24 17:27:40+00:00 +1714.0,0.05,2024-06-24 17:27:41+00:00 +1715.0,0.05,2024-06-24 17:27:42+00:00 +1716.0,0.05,2024-06-24 17:27:43+00:00 +1717.0,0.05,2024-06-24 17:27:44+00:00 +1718.0,0.05,2024-06-24 17:27:45+00:00 +1719.0,0.05,2024-06-24 17:27:46+00:00 +1720.0,0.05,2024-06-24 17:27:47+00:00 +1721.0,0.05,2024-06-24 17:27:48+00:00 +1722.0,0.05,2024-06-24 17:27:49+00:00 +1723.0,0.05,2024-06-24 17:27:50+00:00 +1724.0,0.05,2024-06-24 17:27:51+00:00 +1725.0,0.05,2024-06-24 17:27:52+00:00 +1726.0,0.05,2024-06-24 17:27:53+00:00 +1727.0,0.05,2024-06-24 17:27:54+00:00 +1728.0,0.05,2024-06-24 17:27:55+00:00 +1729.0,0.05,2024-06-24 17:27:56+00:00 +1730.0,0.05,2024-06-24 17:27:57+00:00 +1731.0,0.05,2024-06-24 17:27:58+00:00 +1732.0,0.05,2024-06-24 17:27:59+00:00 +1733.0,0.05,2024-06-24 17:28:00+00:00 +1734.0,0.05,2024-06-24 17:28:01+00:00 +1735.0,0.05,2024-06-24 17:28:02+00:00 +1736.0,0.05,2024-06-24 17:28:03+00:00 +1737.0,0.05,2024-06-24 17:28:04+00:00 +1738.0,0.05,2024-06-24 17:28:05+00:00 +1739.0,0.05,2024-06-24 17:28:06+00:00 +1740.0,0.05,2024-06-24 17:28:07+00:00 +1741.0,0.05,2024-06-24 17:28:08+00:00 +1742.0,0.05,2024-06-24 17:28:09+00:00 +1743.0,0.05,2024-06-24 17:28:10+00:00 +1744.0,0.05,2024-06-24 17:28:11+00:00 +1745.0,0.05,2024-06-24 17:28:12+00:00 +1746.0,0.05,2024-06-24 17:28:13+00:00 +1747.0,0.05,2024-06-24 17:28:14+00:00 +1748.0,0.05,2024-06-24 17:28:15+00:00 +1749.0,0.05,2024-06-24 17:28:16+00:00 +1750.0,0.05,2024-06-24 17:28:17+00:00 +1751.0,0.05,2024-06-24 17:28:18+00:00 +1752.0,0.05,2024-06-24 17:28:19+00:00 +1753.0,0.05,2024-06-24 17:28:20+00:00 +1754.0,0.05,2024-06-24 17:28:21+00:00 +1755.0,0.05,2024-06-24 17:28:22+00:00 +1756.0,0.05,2024-06-24 17:28:23+00:00 +1757.0,0.05,2024-06-24 17:28:24+00:00 +1758.0,0.05,2024-06-24 17:28:25+00:00 +1759.0,0.05,2024-06-24 17:28:26+00:00 +1760.0,0.05,2024-06-24 17:28:27+00:00 +1761.0,0.05,2024-06-24 17:28:28+00:00 +1762.0,0.05,2024-06-24 17:28:29+00:00 +1763.0,0.05,2024-06-24 17:28:30+00:00 +1764.0,0.05,2024-06-24 17:28:31+00:00 +1765.0,0.05,2024-06-24 17:28:32+00:00 +1766.0,0.05,2024-06-24 17:28:33+00:00 +1767.0,0.05,2024-06-24 17:28:34+00:00 +1768.0,0.05,2024-06-24 17:28:35+00:00 +1769.0,0.05,2024-06-24 17:28:36+00:00 +1770.0,0.05,2024-06-24 17:28:37+00:00 +1771.0,0.05,2024-06-24 17:28:38+00:00 +1772.0,0.05,2024-06-24 17:28:39+00:00 +1773.0,0.05,2024-06-24 17:28:40+00:00 +1774.0,0.05,2024-06-24 17:28:41+00:00 +1775.0,0.05,2024-06-24 17:28:42+00:00 +1776.0,0.05,2024-06-24 17:28:43+00:00 +1777.0,0.05,2024-06-24 17:28:44+00:00 +1778.0,0.05,2024-06-24 17:28:45+00:00 +1779.0,0.05,2024-06-24 17:28:46+00:00 +1780.0,0.05,2024-06-24 17:28:47+00:00 +1781.0,0.05,2024-06-24 17:28:48+00:00 +1782.0,0.05,2024-06-24 17:28:49+00:00 +1783.0,0.05,2024-06-24 17:28:50+00:00 +1784.0,0.05,2024-06-24 17:28:51+00:00 +1785.0,0.05,2024-06-24 17:28:52+00:00 +1786.0,0.05,2024-06-24 17:28:53+00:00 +1787.0,0.05,2024-06-24 17:28:54+00:00 +1788.0,0.05,2024-06-24 17:28:55+00:00 +1789.0,0.05,2024-06-24 17:28:56+00:00 +1790.0,0.05,2024-06-24 17:28:57+00:00 +1791.0,0.05,2024-06-24 17:28:58+00:00 +1792.0,0.05,2024-06-24 17:28:59+00:00 +1793.0,0.05,2024-06-24 17:29:00+00:00 +1794.0,0.05,2024-06-24 17:29:01+00:00 +1795.0,0.05,2024-06-24 17:29:02+00:00 +1796.0,0.05,2024-06-24 17:29:03+00:00 +1797.0,0.05,2024-06-24 17:29:04+00:00 +1798.0,0.05,2024-06-24 17:29:05+00:00 +1799.0,0.05,2024-06-24 17:29:06+00:00 +1800.0,0.05,2024-06-24 17:29:07+00:00 +1801.0,0.1,2024-06-24 17:29:08+00:00 +1802.0,0.1,2024-06-24 17:29:09+00:00 +1803.0,0.1,2024-06-24 17:29:10+00:00 +1804.0,0.1,2024-06-24 17:29:11+00:00 +1805.0,0.1,2024-06-24 17:29:12+00:00 +1806.0,0.1,2024-06-24 17:29:13+00:00 +1807.0,0.1,2024-06-24 17:29:14+00:00 +1808.0,0.1,2024-06-24 17:29:15+00:00 +1809.0,0.1,2024-06-24 17:29:16+00:00 +1810.0,0.1,2024-06-24 17:29:17+00:00 +1811.0,0.1,2024-06-24 17:29:18+00:00 +1812.0,0.1,2024-06-24 17:29:19+00:00 +1813.0,0.1,2024-06-24 17:29:20+00:00 +1814.0,0.1,2024-06-24 17:29:21+00:00 +1815.0,0.1,2024-06-24 17:29:22+00:00 +1816.0,0.1,2024-06-24 17:29:23+00:00 +1817.0,0.1,2024-06-24 17:29:24+00:00 +1818.0,0.1,2024-06-24 17:29:25+00:00 +1819.0,0.1,2024-06-24 17:29:26+00:00 +1820.0,0.1,2024-06-24 17:29:27+00:00 +1821.0,0.1,2024-06-24 17:29:28+00:00 +1822.0,0.1,2024-06-24 17:29:29+00:00 +1823.0,0.1,2024-06-24 17:29:30+00:00 +1824.0,0.1,2024-06-24 17:29:31+00:00 +1825.0,0.1,2024-06-24 17:29:32+00:00 +1826.0,0.1,2024-06-24 17:29:33+00:00 +1827.0,0.1,2024-06-24 17:29:34+00:00 +1828.0,0.1,2024-06-24 17:29:35+00:00 +1829.0,0.1,2024-06-24 17:29:36+00:00 +1830.0,0.1,2024-06-24 17:29:37+00:00 +1831.0,0.1,2024-06-24 17:29:38+00:00 +1832.0,0.1,2024-06-24 17:29:39+00:00 +1833.0,0.1,2024-06-24 17:29:40+00:00 +1834.0,0.1,2024-06-24 17:29:41+00:00 +1835.0,0.1,2024-06-24 17:29:42+00:00 +1836.0,0.1,2024-06-24 17:29:43+00:00 +1837.0,0.1,2024-06-24 17:29:44+00:00 +1838.0,0.1,2024-06-24 17:29:45+00:00 +1839.0,0.1,2024-06-24 17:29:46+00:00 +1840.0,0.1,2024-06-24 17:29:47+00:00 +1841.0,0.1,2024-06-24 17:29:48+00:00 +1842.0,0.1,2024-06-24 17:29:49+00:00 +1843.0,0.1,2024-06-24 17:29:50+00:00 +1844.0,0.1,2024-06-24 17:29:51+00:00 +1845.0,0.1,2024-06-24 17:29:52+00:00 +1846.0,0.1,2024-06-24 17:29:53+00:00 +1847.0,0.1,2024-06-24 17:29:54+00:00 +1848.0,0.1,2024-06-24 17:29:55+00:00 +1849.0,0.1,2024-06-24 17:29:56+00:00 +1850.0,0.1,2024-06-24 17:29:57+00:00 +1851.0,0.1,2024-06-24 17:29:58+00:00 +1852.0,0.1,2024-06-24 17:29:59+00:00 +1853.0,0.1,2024-06-24 17:30:00+00:00 +1854.0,0.1,2024-06-24 17:30:01+00:00 +1855.0,0.1,2024-06-24 17:30:02+00:00 +1856.0,0.1,2024-06-24 17:30:03+00:00 +1857.0,0.1,2024-06-24 17:30:04+00:00 +1858.0,0.1,2024-06-24 17:30:05+00:00 +1859.0,0.1,2024-06-24 17:30:06+00:00 +1860.0,0.1,2024-06-24 17:30:07+00:00 +1861.0,0.1,2024-06-24 17:30:08+00:00 +1862.0,0.1,2024-06-24 17:30:09+00:00 +1863.0,0.1,2024-06-24 17:30:10+00:00 +1864.0,0.1,2024-06-24 17:30:11+00:00 +1865.0,0.1,2024-06-24 17:30:12+00:00 +1866.0,0.1,2024-06-24 17:30:13+00:00 +1867.0,0.1,2024-06-24 17:30:14+00:00 +1868.0,0.1,2024-06-24 17:30:15+00:00 +1869.0,0.1,2024-06-24 17:30:16+00:00 +1870.0,0.1,2024-06-24 17:30:17+00:00 +1871.0,0.1,2024-06-24 17:30:18+00:00 +1872.0,0.1,2024-06-24 17:30:19+00:00 +1873.0,0.1,2024-06-24 17:30:20+00:00 +1874.0,0.1,2024-06-24 17:30:21+00:00 +1875.0,0.1,2024-06-24 17:30:22+00:00 +1876.0,0.1,2024-06-24 17:30:23+00:00 +1877.0,0.1,2024-06-24 17:30:24+00:00 +1878.0,0.1,2024-06-24 17:30:25+00:00 +1879.0,0.1,2024-06-24 17:30:26+00:00 +1880.0,0.1,2024-06-24 17:30:27+00:00 +1881.0,0.1,2024-06-24 17:30:28+00:00 +1882.0,0.1,2024-06-24 17:30:29+00:00 +1883.0,0.1,2024-06-24 17:30:30+00:00 +1884.0,0.1,2024-06-24 17:30:31+00:00 +1885.0,0.1,2024-06-24 17:30:32+00:00 +1886.0,0.1,2024-06-24 17:30:33+00:00 +1887.0,0.1,2024-06-24 17:30:34+00:00 +1888.0,0.1,2024-06-24 17:30:35+00:00 +1889.0,0.1,2024-06-24 17:30:36+00:00 +1890.0,0.1,2024-06-24 17:30:37+00:00 +1891.0,0.1,2024-06-24 17:30:38+00:00 +1892.0,0.1,2024-06-24 17:30:39+00:00 +1893.0,0.1,2024-06-24 17:30:40+00:00 +1894.0,0.1,2024-06-24 17:30:41+00:00 +1895.0,0.1,2024-06-24 17:30:42+00:00 +1896.0,0.1,2024-06-24 17:30:43+00:00 +1897.0,0.1,2024-06-24 17:30:44+00:00 +1898.0,0.1,2024-06-24 17:30:45+00:00 +1899.0,0.1,2024-06-24 17:30:46+00:00 +1900.0,0.1,2024-06-24 17:30:47+00:00 +1901.0,0.1,2024-06-24 17:30:48+00:00 +1902.0,0.1,2024-06-24 17:30:49+00:00 +1903.0,0.1,2024-06-24 17:30:50+00:00 +1904.0,0.1,2024-06-24 17:30:51+00:00 +1905.0,0.1,2024-06-24 17:30:52+00:00 +1906.0,0.1,2024-06-24 17:30:53+00:00 +1907.0,0.1,2024-06-24 17:30:54+00:00 +1908.0,0.1,2024-06-24 17:30:55+00:00 +1909.0,0.1,2024-06-24 17:30:56+00:00 +1910.0,0.1,2024-06-24 17:30:57+00:00 +1911.0,0.1,2024-06-24 17:30:58+00:00 +1912.0,0.1,2024-06-24 17:30:59+00:00 +1913.0,0.1,2024-06-24 17:31:00+00:00 +1914.0,0.1,2024-06-24 17:31:01+00:00 +1915.0,0.1,2024-06-24 17:31:02+00:00 +1916.0,0.1,2024-06-24 17:31:03+00:00 +1917.0,0.1,2024-06-24 17:31:04+00:00 +1918.0,0.1,2024-06-24 17:31:05+00:00 +1919.0,0.1,2024-06-24 17:31:06+00:00 +1920.0,0.1,2024-06-24 17:31:07+00:00 +1921.0,0.1,2024-06-24 17:31:08+00:00 +1922.0,0.1,2024-06-24 17:31:09+00:00 +1923.0,0.1,2024-06-24 17:31:10+00:00 +1924.0,0.1,2024-06-24 17:31:11+00:00 +1925.0,0.1,2024-06-24 17:31:12+00:00 +1926.0,0.1,2024-06-24 17:31:13+00:00 +1927.0,0.1,2024-06-24 17:31:14+00:00 +1928.0,0.1,2024-06-24 17:31:15+00:00 +1929.0,0.1,2024-06-24 17:31:16+00:00 +1930.0,0.1,2024-06-24 17:31:17+00:00 +1931.0,0.1,2024-06-24 17:31:18+00:00 +1932.0,0.1,2024-06-24 17:31:19+00:00 +1933.0,0.1,2024-06-24 17:31:20+00:00 +1934.0,0.1,2024-06-24 17:31:21+00:00 +1935.0,0.1,2024-06-24 17:31:22+00:00 +1936.0,0.1,2024-06-24 17:31:23+00:00 +1937.0,0.1,2024-06-24 17:31:24+00:00 +1938.0,0.1,2024-06-24 17:31:25+00:00 +1939.0,0.1,2024-06-24 17:31:26+00:00 +1940.0,0.1,2024-06-24 17:31:27+00:00 +1941.0,0.1,2024-06-24 17:31:28+00:00 +1942.0,0.1,2024-06-24 17:31:29+00:00 +1943.0,0.1,2024-06-24 17:31:30+00:00 +1944.0,0.1,2024-06-24 17:31:31+00:00 +1945.0,0.1,2024-06-24 17:31:32+00:00 +1946.0,0.1,2024-06-24 17:31:33+00:00 +1947.0,0.1,2024-06-24 17:31:34+00:00 +1948.0,0.1,2024-06-24 17:31:35+00:00 +1949.0,0.1,2024-06-24 17:31:36+00:00 +1950.0,0.1,2024-06-24 17:31:37+00:00 +1951.0,0.1,2024-06-24 17:31:38+00:00 +1952.0,0.1,2024-06-24 17:31:39+00:00 +1953.0,0.1,2024-06-24 17:31:40+00:00 +1954.0,0.1,2024-06-24 17:31:41+00:00 +1955.0,0.1,2024-06-24 17:31:42+00:00 +1956.0,0.1,2024-06-24 17:31:43+00:00 +1957.0,0.1,2024-06-24 17:31:44+00:00 +1958.0,0.1,2024-06-24 17:31:45+00:00 +1959.0,0.1,2024-06-24 17:31:46+00:00 +1960.0,0.1,2024-06-24 17:31:47+00:00 +1961.0,0.1,2024-06-24 17:31:48+00:00 +1962.0,0.1,2024-06-24 17:31:49+00:00 +1963.0,0.1,2024-06-24 17:31:50+00:00 +1964.0,0.1,2024-06-24 17:31:51+00:00 +1965.0,0.1,2024-06-24 17:31:52+00:00 +1966.0,0.1,2024-06-24 17:31:53+00:00 +1967.0,0.1,2024-06-24 17:31:54+00:00 +1968.0,0.1,2024-06-24 17:31:55+00:00 +1969.0,0.1,2024-06-24 17:31:56+00:00 +1970.0,0.1,2024-06-24 17:31:57+00:00 +1971.0,0.1,2024-06-24 17:31:58+00:00 +1972.0,0.1,2024-06-24 17:31:59+00:00 +1973.0,0.1,2024-06-24 17:32:00+00:00 +1974.0,0.1,2024-06-24 17:32:01+00:00 +1975.0,0.1,2024-06-24 17:32:02+00:00 +1976.0,0.1,2024-06-24 17:32:03+00:00 +1977.0,0.1,2024-06-24 17:32:04+00:00 +1978.0,0.1,2024-06-24 17:32:05+00:00 +1979.0,0.1,2024-06-24 17:32:06+00:00 +1980.0,0.1,2024-06-24 17:32:07+00:00 +1981.0,0.1,2024-06-24 17:32:08+00:00 +1982.0,0.1,2024-06-24 17:32:09+00:00 +1983.0,0.1,2024-06-24 17:32:10+00:00 +1984.0,0.1,2024-06-24 17:32:11+00:00 +1985.0,0.1,2024-06-24 17:32:12+00:00 +1986.0,0.1,2024-06-24 17:32:13+00:00 +1987.0,0.1,2024-06-24 17:32:14+00:00 +1988.0,0.1,2024-06-24 17:32:15+00:00 +1989.0,0.1,2024-06-24 17:32:16+00:00 +1990.0,0.1,2024-06-24 17:32:17+00:00 +1991.0,0.1,2024-06-24 17:32:18+00:00 +1992.0,0.1,2024-06-24 17:32:19+00:00 +1993.0,0.1,2024-06-24 17:32:20+00:00 +1994.0,0.1,2024-06-24 17:32:21+00:00 +1995.0,0.1,2024-06-24 17:32:22+00:00 +1996.0,0.1,2024-06-24 17:32:23+00:00 +1997.0,0.1,2024-06-24 17:32:24+00:00 +1998.0,0.1,2024-06-24 17:32:25+00:00 +1999.0,0.1,2024-06-24 17:32:26+00:00 +2000.0,0.1,2024-06-24 17:32:27+00:00 +2001.0,0.1,2024-06-24 17:32:28+00:00 +2002.0,0.1,2024-06-24 17:32:29+00:00 +2003.0,0.1,2024-06-24 17:32:30+00:00 +2004.0,0.1,2024-06-24 17:32:31+00:00 +2005.0,0.1,2024-06-24 17:32:32+00:00 +2006.0,0.1,2024-06-24 17:32:33+00:00 +2007.0,0.1,2024-06-24 17:32:34+00:00 +2008.0,0.1,2024-06-24 17:32:35+00:00 +2009.0,0.1,2024-06-24 17:32:36+00:00 +2010.0,0.1,2024-06-24 17:32:37+00:00 +2011.0,0.1,2024-06-24 17:32:38+00:00 +2012.0,0.1,2024-06-24 17:32:39+00:00 +2013.0,0.1,2024-06-24 17:32:40+00:00 +2014.0,0.1,2024-06-24 17:32:41+00:00 +2015.0,0.1,2024-06-24 17:32:42+00:00 +2016.0,0.1,2024-06-24 17:32:43+00:00 +2017.0,0.1,2024-06-24 17:32:44+00:00 +2018.0,0.1,2024-06-24 17:32:45+00:00 +2019.0,0.1,2024-06-24 17:32:46+00:00 +2020.0,0.1,2024-06-24 17:32:47+00:00 +2021.0,0.1,2024-06-24 17:32:48+00:00 +2022.0,0.1,2024-06-24 17:32:49+00:00 +2023.0,0.1,2024-06-24 17:32:50+00:00 +2024.0,0.1,2024-06-24 17:32:51+00:00 +2025.0,0.1,2024-06-24 17:32:52+00:00 +2026.0,0.1,2024-06-24 17:32:53+00:00 +2027.0,0.1,2024-06-24 17:32:54+00:00 +2028.0,0.1,2024-06-24 17:32:55+00:00 +2029.0,0.1,2024-06-24 17:32:56+00:00 +2030.0,0.1,2024-06-24 17:32:57+00:00 +2031.0,0.1,2024-06-24 17:32:58+00:00 +2032.0,0.1,2024-06-24 17:32:59+00:00 +2033.0,0.1,2024-06-24 17:33:00+00:00 +2034.0,0.1,2024-06-24 17:33:01+00:00 +2035.0,0.1,2024-06-24 17:33:02+00:00 +2036.0,0.1,2024-06-24 17:33:03+00:00 +2037.0,0.1,2024-06-24 17:33:04+00:00 +2038.0,0.1,2024-06-24 17:33:05+00:00 +2039.0,0.1,2024-06-24 17:33:06+00:00 +2040.0,0.1,2024-06-24 17:33:07+00:00 +2041.0,0.1,2024-06-24 17:33:08+00:00 +2042.0,0.1,2024-06-24 17:33:09+00:00 +2043.0,0.1,2024-06-24 17:33:10+00:00 +2044.0,0.1,2024-06-24 17:33:11+00:00 +2045.0,0.1,2024-06-24 17:33:12+00:00 +2046.0,0.1,2024-06-24 17:33:13+00:00 +2047.0,0.1,2024-06-24 17:33:14+00:00 +2048.0,0.1,2024-06-24 17:33:15+00:00 +2049.0,0.1,2024-06-24 17:33:16+00:00 +2050.0,0.1,2024-06-24 17:33:17+00:00 +2051.0,0.1,2024-06-24 17:33:18+00:00 +2052.0,0.1,2024-06-24 17:33:19+00:00 +2053.0,0.1,2024-06-24 17:33:20+00:00 +2054.0,0.1,2024-06-24 17:33:21+00:00 +2055.0,0.1,2024-06-24 17:33:22+00:00 +2056.0,0.1,2024-06-24 17:33:23+00:00 +2057.0,0.1,2024-06-24 17:33:24+00:00 +2058.0,0.1,2024-06-24 17:33:25+00:00 +2059.0,0.1,2024-06-24 17:33:26+00:00 +2060.0,0.1,2024-06-24 17:33:27+00:00 +2061.0,0.1,2024-06-24 17:33:28+00:00 +2062.0,0.1,2024-06-24 17:33:29+00:00 +2063.0,0.1,2024-06-24 17:33:30+00:00 +2064.0,0.1,2024-06-24 17:33:31+00:00 +2065.0,0.1,2024-06-24 17:33:32+00:00 +2066.0,0.1,2024-06-24 17:33:33+00:00 +2067.0,0.1,2024-06-24 17:33:34+00:00 +2068.0,0.1,2024-06-24 17:33:35+00:00 +2069.0,0.1,2024-06-24 17:33:36+00:00 +2070.0,0.1,2024-06-24 17:33:37+00:00 +2071.0,0.1,2024-06-24 17:33:38+00:00 +2072.0,0.1,2024-06-24 17:33:39+00:00 +2073.0,0.1,2024-06-24 17:33:40+00:00 +2074.0,0.1,2024-06-24 17:33:41+00:00 +2075.0,0.1,2024-06-24 17:33:42+00:00 +2076.0,0.1,2024-06-24 17:33:43+00:00 +2077.0,0.1,2024-06-24 17:33:44+00:00 +2078.0,0.1,2024-06-24 17:33:45+00:00 +2079.0,0.1,2024-06-24 17:33:46+00:00 +2080.0,0.1,2024-06-24 17:33:47+00:00 +2081.0,0.1,2024-06-24 17:33:48+00:00 +2082.0,0.1,2024-06-24 17:33:49+00:00 +2083.0,0.1,2024-06-24 17:33:50+00:00 +2084.0,0.1,2024-06-24 17:33:51+00:00 +2085.0,0.1,2024-06-24 17:33:52+00:00 +2086.0,0.1,2024-06-24 17:33:53+00:00 +2087.0,0.1,2024-06-24 17:33:54+00:00 +2088.0,0.1,2024-06-24 17:33:55+00:00 +2089.0,0.1,2024-06-24 17:33:56+00:00 +2090.0,0.1,2024-06-24 17:33:57+00:00 +2091.0,0.1,2024-06-24 17:33:58+00:00 +2092.0,0.1,2024-06-24 17:33:59+00:00 +2093.0,0.1,2024-06-24 17:34:00+00:00 +2094.0,0.1,2024-06-24 17:34:01+00:00 +2095.0,0.1,2024-06-24 17:34:02+00:00 +2096.0,0.1,2024-06-24 17:34:03+00:00 +2097.0,0.1,2024-06-24 17:34:04+00:00 +2098.0,0.1,2024-06-24 17:34:05+00:00 +2099.0,0.1,2024-06-24 17:34:06+00:00 +2100.0,0.1,2024-06-24 17:34:07+00:00 +2101.0,0.1,2024-06-24 17:34:08+00:00 +2102.0,0.1,2024-06-24 17:34:09+00:00 +2103.0,0.1,2024-06-24 17:34:10+00:00 +2104.0,0.1,2024-06-24 17:34:11+00:00 +2105.0,0.1,2024-06-24 17:34:12+00:00 +2106.0,0.1,2024-06-24 17:34:13+00:00 +2107.0,0.1,2024-06-24 17:34:14+00:00 +2108.0,0.1,2024-06-24 17:34:15+00:00 +2109.0,0.1,2024-06-24 17:34:16+00:00 +2110.0,0.1,2024-06-24 17:34:17+00:00 +2111.0,0.1,2024-06-24 17:34:18+00:00 +2112.0,0.1,2024-06-24 17:34:19+00:00 +2113.0,0.1,2024-06-24 17:34:20+00:00 +2114.0,0.1,2024-06-24 17:34:21+00:00 +2115.0,0.1,2024-06-24 17:34:22+00:00 +2116.0,0.1,2024-06-24 17:34:23+00:00 +2117.0,0.1,2024-06-24 17:34:24+00:00 +2118.0,0.1,2024-06-24 17:34:25+00:00 +2119.0,0.1,2024-06-24 17:34:26+00:00 +2120.0,0.1,2024-06-24 17:34:27+00:00 +2121.0,0.1,2024-06-24 17:34:28+00:00 +2122.0,0.1,2024-06-24 17:34:29+00:00 +2123.0,0.1,2024-06-24 17:34:30+00:00 +2124.0,0.1,2024-06-24 17:34:31+00:00 +2125.0,0.1,2024-06-24 17:34:32+00:00 +2126.0,0.1,2024-06-24 17:34:33+00:00 +2127.0,0.1,2024-06-24 17:34:34+00:00 +2128.0,0.1,2024-06-24 17:34:35+00:00 +2129.0,0.1,2024-06-24 17:34:36+00:00 +2130.0,0.1,2024-06-24 17:34:37+00:00 +2131.0,0.1,2024-06-24 17:34:38+00:00 +2132.0,0.1,2024-06-24 17:34:39+00:00 +2133.0,0.1,2024-06-24 17:34:40+00:00 +2134.0,0.1,2024-06-24 17:34:41+00:00 +2135.0,0.1,2024-06-24 17:34:42+00:00 +2136.0,0.1,2024-06-24 17:34:43+00:00 +2137.0,0.1,2024-06-24 17:34:44+00:00 +2138.0,0.1,2024-06-24 17:34:45+00:00 +2139.0,0.1,2024-06-24 17:34:46+00:00 +2140.0,0.1,2024-06-24 17:34:47+00:00 +2141.0,0.1,2024-06-24 17:34:48+00:00 +2142.0,0.1,2024-06-24 17:34:49+00:00 +2143.0,0.1,2024-06-24 17:34:50+00:00 +2144.0,0.1,2024-06-24 17:34:51+00:00 +2145.0,0.1,2024-06-24 17:34:52+00:00 +2146.0,0.1,2024-06-24 17:34:53+00:00 +2147.0,0.1,2024-06-24 17:34:54+00:00 +2148.0,0.1,2024-06-24 17:34:55+00:00 +2149.0,0.1,2024-06-24 17:34:56+00:00 +2150.0,0.1,2024-06-24 17:34:57+00:00 +2151.0,0.1,2024-06-24 17:34:58+00:00 +2152.0,0.1,2024-06-24 17:34:59+00:00 +2153.0,0.1,2024-06-24 17:35:00+00:00 +2154.0,0.1,2024-06-24 17:35:01+00:00 +2155.0,0.1,2024-06-24 17:35:02+00:00 +2156.0,0.1,2024-06-24 17:35:03+00:00 +2157.0,0.1,2024-06-24 17:35:04+00:00 +2158.0,0.1,2024-06-24 17:35:05+00:00 +2159.0,0.1,2024-06-24 17:35:06+00:00 +2160.0,0.1,2024-06-24 17:35:07+00:00 +2161.0,0.1,2024-06-24 17:35:08+00:00 +2162.0,0.1,2024-06-24 17:35:09+00:00 +2163.0,0.1,2024-06-24 17:35:10+00:00 +2164.0,0.1,2024-06-24 17:35:11+00:00 +2165.0,0.1,2024-06-24 17:35:12+00:00 +2166.0,0.1,2024-06-24 17:35:13+00:00 +2167.0,0.1,2024-06-24 17:35:14+00:00 +2168.0,0.1,2024-06-24 17:35:15+00:00 +2169.0,0.1,2024-06-24 17:35:16+00:00 +2170.0,0.1,2024-06-24 17:35:17+00:00 +2171.0,0.1,2024-06-24 17:35:18+00:00 +2172.0,0.1,2024-06-24 17:35:19+00:00 +2173.0,0.1,2024-06-24 17:35:20+00:00 +2174.0,0.1,2024-06-24 17:35:21+00:00 +2175.0,0.1,2024-06-24 17:35:22+00:00 +2176.0,0.1,2024-06-24 17:35:23+00:00 +2177.0,0.1,2024-06-24 17:35:24+00:00 +2178.0,0.1,2024-06-24 17:35:25+00:00 +2179.0,0.1,2024-06-24 17:35:26+00:00 +2180.0,0.1,2024-06-24 17:35:27+00:00 +2181.0,0.1,2024-06-24 17:35:28+00:00 +2182.0,0.1,2024-06-24 17:35:29+00:00 +2183.0,0.1,2024-06-24 17:35:30+00:00 +2184.0,0.1,2024-06-24 17:35:31+00:00 +2185.0,0.1,2024-06-24 17:35:32+00:00 +2186.0,0.1,2024-06-24 17:35:33+00:00 +2187.0,0.1,2024-06-24 17:35:34+00:00 +2188.0,0.1,2024-06-24 17:35:35+00:00 +2189.0,0.1,2024-06-24 17:35:36+00:00 +2190.0,0.1,2024-06-24 17:35:37+00:00 +2191.0,0.1,2024-06-24 17:35:38+00:00 +2192.0,0.1,2024-06-24 17:35:39+00:00 +2193.0,0.1,2024-06-24 17:35:40+00:00 +2194.0,0.1,2024-06-24 17:35:41+00:00 +2195.0,0.1,2024-06-24 17:35:42+00:00 +2196.0,0.1,2024-06-24 17:35:43+00:00 +2197.0,0.1,2024-06-24 17:35:44+00:00 +2198.0,0.1,2024-06-24 17:35:45+00:00 +2199.0,0.1,2024-06-24 17:35:46+00:00 +2200.0,0.1,2024-06-24 17:35:47+00:00 +2201.0,0.1,2024-06-24 17:35:48+00:00 +2202.0,0.1,2024-06-24 17:35:49+00:00 +2203.0,0.1,2024-06-24 17:35:50+00:00 +2204.0,0.1,2024-06-24 17:35:51+00:00 +2205.0,0.1,2024-06-24 17:35:52+00:00 +2206.0,0.1,2024-06-24 17:35:53+00:00 +2207.0,0.1,2024-06-24 17:35:54+00:00 +2208.0,0.1,2024-06-24 17:35:55+00:00 +2209.0,0.1,2024-06-24 17:35:56+00:00 +2210.0,0.1,2024-06-24 17:35:57+00:00 +2211.0,0.1,2024-06-24 17:35:58+00:00 +2212.0,0.1,2024-06-24 17:35:59+00:00 +2213.0,0.1,2024-06-24 17:36:00+00:00 +2214.0,0.1,2024-06-24 17:36:01+00:00 +2215.0,0.1,2024-06-24 17:36:02+00:00 +2216.0,0.1,2024-06-24 17:36:03+00:00 +2217.0,0.1,2024-06-24 17:36:04+00:00 +2218.0,0.1,2024-06-24 17:36:05+00:00 +2219.0,0.1,2024-06-24 17:36:06+00:00 +2220.0,0.1,2024-06-24 17:36:07+00:00 +2221.0,0.1,2024-06-24 17:36:08+00:00 +2222.0,0.1,2024-06-24 17:36:09+00:00 +2223.0,0.1,2024-06-24 17:36:10+00:00 +2224.0,0.1,2024-06-24 17:36:11+00:00 +2225.0,0.1,2024-06-24 17:36:12+00:00 +2226.0,0.1,2024-06-24 17:36:13+00:00 +2227.0,0.1,2024-06-24 17:36:14+00:00 +2228.0,0.1,2024-06-24 17:36:15+00:00 +2229.0,0.1,2024-06-24 17:36:16+00:00 +2230.0,0.1,2024-06-24 17:36:17+00:00 +2231.0,0.1,2024-06-24 17:36:18+00:00 +2232.0,0.1,2024-06-24 17:36:19+00:00 +2233.0,0.1,2024-06-24 17:36:20+00:00 +2234.0,0.1,2024-06-24 17:36:21+00:00 +2235.0,0.1,2024-06-24 17:36:22+00:00 +2236.0,0.1,2024-06-24 17:36:23+00:00 +2237.0,0.1,2024-06-24 17:36:24+00:00 +2238.0,0.1,2024-06-24 17:36:25+00:00 +2239.0,0.1,2024-06-24 17:36:26+00:00 +2240.0,0.1,2024-06-24 17:36:27+00:00 +2241.0,0.1,2024-06-24 17:36:28+00:00 +2242.0,0.1,2024-06-24 17:36:29+00:00 +2243.0,0.1,2024-06-24 17:36:30+00:00 +2244.0,0.1,2024-06-24 17:36:31+00:00 +2245.0,0.1,2024-06-24 17:36:32+00:00 +2246.0,0.1,2024-06-24 17:36:33+00:00 +2247.0,0.1,2024-06-24 17:36:34+00:00 +2248.0,0.1,2024-06-24 17:36:35+00:00 +2249.0,0.1,2024-06-24 17:36:36+00:00 +2250.0,0.1,2024-06-24 17:36:37+00:00 +2251.0,0.1,2024-06-24 17:36:38+00:00 +2252.0,0.1,2024-06-24 17:36:39+00:00 +2253.0,0.1,2024-06-24 17:36:40+00:00 +2254.0,0.1,2024-06-24 17:36:41+00:00 +2255.0,0.1,2024-06-24 17:36:42+00:00 +2256.0,0.1,2024-06-24 17:36:43+00:00 +2257.0,0.1,2024-06-24 17:36:44+00:00 +2258.0,0.1,2024-06-24 17:36:45+00:00 +2259.0,0.1,2024-06-24 17:36:46+00:00 +2260.0,0.1,2024-06-24 17:36:47+00:00 +2261.0,0.1,2024-06-24 17:36:48+00:00 +2262.0,0.1,2024-06-24 17:36:49+00:00 +2263.0,0.1,2024-06-24 17:36:50+00:00 +2264.0,0.1,2024-06-24 17:36:51+00:00 +2265.0,0.1,2024-06-24 17:36:52+00:00 +2266.0,0.1,2024-06-24 17:36:53+00:00 +2267.0,0.1,2024-06-24 17:36:54+00:00 +2268.0,0.1,2024-06-24 17:36:55+00:00 +2269.0,0.1,2024-06-24 17:36:56+00:00 +2270.0,0.1,2024-06-24 17:36:57+00:00 +2271.0,0.1,2024-06-24 17:36:58+00:00 +2272.0,0.1,2024-06-24 17:36:59+00:00 +2273.0,0.1,2024-06-24 17:37:00+00:00 +2274.0,0.1,2024-06-24 17:37:01+00:00 +2275.0,0.1,2024-06-24 17:37:02+00:00 +2276.0,0.1,2024-06-24 17:37:03+00:00 +2277.0,0.1,2024-06-24 17:37:04+00:00 +2278.0,0.1,2024-06-24 17:37:05+00:00 +2279.0,0.1,2024-06-24 17:37:06+00:00 +2280.0,0.1,2024-06-24 17:37:07+00:00 +2281.0,0.1,2024-06-24 17:37:08+00:00 +2282.0,0.1,2024-06-24 17:37:09+00:00 +2283.0,0.1,2024-06-24 17:37:10+00:00 +2284.0,0.1,2024-06-24 17:37:11+00:00 +2285.0,0.1,2024-06-24 17:37:12+00:00 +2286.0,0.1,2024-06-24 17:37:13+00:00 +2287.0,0.1,2024-06-24 17:37:14+00:00 +2288.0,0.1,2024-06-24 17:37:15+00:00 +2289.0,0.1,2024-06-24 17:37:16+00:00 +2290.0,0.1,2024-06-24 17:37:17+00:00 +2291.0,0.1,2024-06-24 17:37:18+00:00 +2292.0,0.1,2024-06-24 17:37:19+00:00 +2293.0,0.1,2024-06-24 17:37:20+00:00 +2294.0,0.1,2024-06-24 17:37:21+00:00 +2295.0,0.1,2024-06-24 17:37:22+00:00 +2296.0,0.1,2024-06-24 17:37:23+00:00 +2297.0,0.1,2024-06-24 17:37:24+00:00 +2298.0,0.1,2024-06-24 17:37:25+00:00 +2299.0,0.1,2024-06-24 17:37:26+00:00 +2300.0,0.1,2024-06-24 17:37:27+00:00 +2301.0,0.1,2024-06-24 17:37:28+00:00 +2302.0,0.1,2024-06-24 17:37:29+00:00 +2303.0,0.1,2024-06-24 17:37:30+00:00 +2304.0,0.1,2024-06-24 17:37:31+00:00 +2305.0,0.1,2024-06-24 17:37:32+00:00 +2306.0,0.1,2024-06-24 17:37:33+00:00 +2307.0,0.1,2024-06-24 17:37:34+00:00 +2308.0,0.1,2024-06-24 17:37:35+00:00 +2309.0,0.1,2024-06-24 17:37:36+00:00 +2310.0,0.1,2024-06-24 17:37:37+00:00 +2311.0,0.1,2024-06-24 17:37:38+00:00 +2312.0,0.1,2024-06-24 17:37:39+00:00 +2313.0,0.1,2024-06-24 17:37:40+00:00 +2314.0,0.1,2024-06-24 17:37:41+00:00 +2315.0,0.1,2024-06-24 17:37:42+00:00 +2316.0,0.1,2024-06-24 17:37:43+00:00 +2317.0,0.1,2024-06-24 17:37:44+00:00 +2318.0,0.1,2024-06-24 17:37:45+00:00 +2319.0,0.1,2024-06-24 17:37:46+00:00 +2320.0,0.1,2024-06-24 17:37:47+00:00 +2321.0,0.1,2024-06-24 17:37:48+00:00 +2322.0,0.1,2024-06-24 17:37:49+00:00 +2323.0,0.1,2024-06-24 17:37:50+00:00 +2324.0,0.1,2024-06-24 17:37:51+00:00 +2325.0,0.1,2024-06-24 17:37:52+00:00 +2326.0,0.1,2024-06-24 17:37:53+00:00 +2327.0,0.1,2024-06-24 17:37:54+00:00 +2328.0,0.1,2024-06-24 17:37:55+00:00 +2329.0,0.1,2024-06-24 17:37:56+00:00 +2330.0,0.1,2024-06-24 17:37:57+00:00 +2331.0,0.1,2024-06-24 17:37:58+00:00 +2332.0,0.1,2024-06-24 17:37:59+00:00 +2333.0,0.1,2024-06-24 17:38:00+00:00 +2334.0,0.1,2024-06-24 17:38:01+00:00 +2335.0,0.1,2024-06-24 17:38:02+00:00 +2336.0,0.1,2024-06-24 17:38:03+00:00 +2337.0,0.1,2024-06-24 17:38:04+00:00 +2338.0,0.1,2024-06-24 17:38:05+00:00 +2339.0,0.1,2024-06-24 17:38:06+00:00 +2340.0,0.1,2024-06-24 17:38:07+00:00 +2341.0,0.1,2024-06-24 17:38:08+00:00 +2342.0,0.1,2024-06-24 17:38:09+00:00 +2343.0,0.1,2024-06-24 17:38:10+00:00 +2344.0,0.1,2024-06-24 17:38:11+00:00 +2345.0,0.1,2024-06-24 17:38:12+00:00 +2346.0,0.1,2024-06-24 17:38:13+00:00 +2347.0,0.1,2024-06-24 17:38:14+00:00 +2348.0,0.1,2024-06-24 17:38:15+00:00 +2349.0,0.1,2024-06-24 17:38:16+00:00 +2350.0,0.1,2024-06-24 17:38:17+00:00 +2351.0,0.1,2024-06-24 17:38:18+00:00 +2352.0,0.1,2024-06-24 17:38:19+00:00 +2353.0,0.1,2024-06-24 17:38:20+00:00 +2354.0,0.1,2024-06-24 17:38:21+00:00 +2355.0,0.1,2024-06-24 17:38:22+00:00 +2356.0,0.1,2024-06-24 17:38:23+00:00 +2357.0,0.1,2024-06-24 17:38:24+00:00 +2358.0,0.1,2024-06-24 17:38:25+00:00 +2359.0,0.1,2024-06-24 17:38:26+00:00 +2360.0,0.1,2024-06-24 17:38:27+00:00 +2361.0,0.1,2024-06-24 17:38:28+00:00 +2362.0,0.1,2024-06-24 17:38:29+00:00 +2363.0,0.1,2024-06-24 17:38:30+00:00 +2364.0,0.1,2024-06-24 17:38:31+00:00 +2365.0,0.1,2024-06-24 17:38:32+00:00 +2366.0,0.1,2024-06-24 17:38:33+00:00 +2367.0,0.1,2024-06-24 17:38:34+00:00 +2368.0,0.1,2024-06-24 17:38:35+00:00 +2369.0,0.1,2024-06-24 17:38:36+00:00 +2370.0,0.1,2024-06-24 17:38:37+00:00 +2371.0,0.1,2024-06-24 17:38:38+00:00 +2372.0,0.1,2024-06-24 17:38:39+00:00 +2373.0,0.1,2024-06-24 17:38:40+00:00 +2374.0,0.1,2024-06-24 17:38:41+00:00 +2375.0,0.1,2024-06-24 17:38:42+00:00 +2376.0,0.1,2024-06-24 17:38:43+00:00 +2377.0,0.1,2024-06-24 17:38:44+00:00 +2378.0,0.1,2024-06-24 17:38:45+00:00 +2379.0,0.1,2024-06-24 17:38:46+00:00 +2380.0,0.1,2024-06-24 17:38:47+00:00 +2381.0,0.1,2024-06-24 17:38:48+00:00 +2382.0,0.1,2024-06-24 17:38:49+00:00 +2383.0,0.1,2024-06-24 17:38:50+00:00 +2384.0,0.1,2024-06-24 17:38:51+00:00 +2385.0,0.1,2024-06-24 17:38:52+00:00 +2386.0,0.1,2024-06-24 17:38:53+00:00 +2387.0,0.1,2024-06-24 17:38:54+00:00 +2388.0,0.1,2024-06-24 17:38:55+00:00 +2389.0,0.1,2024-06-24 17:38:56+00:00 +2390.0,0.1,2024-06-24 17:38:57+00:00 +2391.0,0.1,2024-06-24 17:38:58+00:00 +2392.0,0.1,2024-06-24 17:38:59+00:00 +2393.0,0.1,2024-06-24 17:39:00+00:00 +2394.0,0.1,2024-06-24 17:39:01+00:00 +2395.0,0.1,2024-06-24 17:39:02+00:00 +2396.0,0.1,2024-06-24 17:39:03+00:00 +2397.0,0.1,2024-06-24 17:39:04+00:00 +2398.0,0.1,2024-06-24 17:39:05+00:00 +2399.0,0.1,2024-06-24 17:39:06+00:00 +2400.0,0.1,2024-06-24 17:39:07+00:00 +2401.0,0.1,2024-06-24 17:39:08+00:00 +2402.0,0.1,2024-06-24 17:39:09+00:00 +2403.0,0.1,2024-06-24 17:39:10+00:00 +2404.0,0.1,2024-06-24 17:39:11+00:00 +2405.0,0.1,2024-06-24 17:39:12+00:00 +2406.0,0.1,2024-06-24 17:39:13+00:00 +2407.0,0.1,2024-06-24 17:39:14+00:00 +2408.0,0.1,2024-06-24 17:39:15+00:00 +2409.0,0.1,2024-06-24 17:39:16+00:00 +2410.0,0.1,2024-06-24 17:39:17+00:00 +2411.0,0.1,2024-06-24 17:39:18+00:00 +2412.0,0.1,2024-06-24 17:39:19+00:00 +2413.0,0.1,2024-06-24 17:39:20+00:00 +2414.0,0.1,2024-06-24 17:39:21+00:00 +2415.0,0.1,2024-06-24 17:39:22+00:00 +2416.0,0.1,2024-06-24 17:39:23+00:00 +2417.0,0.1,2024-06-24 17:39:24+00:00 +2418.0,0.1,2024-06-24 17:39:25+00:00 +2419.0,0.1,2024-06-24 17:39:26+00:00 +2420.0,0.1,2024-06-24 17:39:27+00:00 +2421.0,0.1,2024-06-24 17:39:28+00:00 +2422.0,0.1,2024-06-24 17:39:29+00:00 +2423.0,0.1,2024-06-24 17:39:30+00:00 +2424.0,0.1,2024-06-24 17:39:31+00:00 +2425.0,0.1,2024-06-24 17:39:32+00:00 +2426.0,0.1,2024-06-24 17:39:33+00:00 +2427.0,0.1,2024-06-24 17:39:34+00:00 +2428.0,0.1,2024-06-24 17:39:35+00:00 +2429.0,0.1,2024-06-24 17:39:36+00:00 +2430.0,0.1,2024-06-24 17:39:37+00:00 +2431.0,0.1,2024-06-24 17:39:38+00:00 +2432.0,0.1,2024-06-24 17:39:39+00:00 +2433.0,0.1,2024-06-24 17:39:40+00:00 +2434.0,0.1,2024-06-24 17:39:41+00:00 +2435.0,0.1,2024-06-24 17:39:42+00:00 +2436.0,0.1,2024-06-24 17:39:43+00:00 +2437.0,0.1,2024-06-24 17:39:44+00:00 +2438.0,0.1,2024-06-24 17:39:45+00:00 +2439.0,0.1,2024-06-24 17:39:46+00:00 +2440.0,0.1,2024-06-24 17:39:47+00:00 +2441.0,0.1,2024-06-24 17:39:48+00:00 +2442.0,0.1,2024-06-24 17:39:49+00:00 +2443.0,0.1,2024-06-24 17:39:50+00:00 +2444.0,0.1,2024-06-24 17:39:51+00:00 +2445.0,0.1,2024-06-24 17:39:52+00:00 +2446.0,0.1,2024-06-24 17:39:53+00:00 +2447.0,0.1,2024-06-24 17:39:54+00:00 +2448.0,0.1,2024-06-24 17:39:55+00:00 +2449.0,0.1,2024-06-24 17:39:56+00:00 +2450.0,0.1,2024-06-24 17:39:57+00:00 +2451.0,0.1,2024-06-24 17:39:58+00:00 +2452.0,0.1,2024-06-24 17:39:59+00:00 +2453.0,0.1,2024-06-24 17:40:00+00:00 +2454.0,0.1,2024-06-24 17:40:01+00:00 +2455.0,0.1,2024-06-24 17:40:02+00:00 +2456.0,0.1,2024-06-24 17:40:03+00:00 +2457.0,0.1,2024-06-24 17:40:04+00:00 +2458.0,0.1,2024-06-24 17:40:05+00:00 +2459.0,0.1,2024-06-24 17:40:06+00:00 +2460.0,0.1,2024-06-24 17:40:07+00:00 +2461.0,0.1,2024-06-24 17:40:08+00:00 +2462.0,0.1,2024-06-24 17:40:09+00:00 +2463.0,0.1,2024-06-24 17:40:10+00:00 +2464.0,0.1,2024-06-24 17:40:11+00:00 +2465.0,0.1,2024-06-24 17:40:12+00:00 +2466.0,0.1,2024-06-24 17:40:13+00:00 +2467.0,0.1,2024-06-24 17:40:14+00:00 +2468.0,0.1,2024-06-24 17:40:15+00:00 +2469.0,0.1,2024-06-24 17:40:16+00:00 +2470.0,0.1,2024-06-24 17:40:17+00:00 +2471.0,0.1,2024-06-24 17:40:18+00:00 +2472.0,0.1,2024-06-24 17:40:19+00:00 +2473.0,0.1,2024-06-24 17:40:20+00:00 +2474.0,0.1,2024-06-24 17:40:21+00:00 +2475.0,0.1,2024-06-24 17:40:22+00:00 +2476.0,0.1,2024-06-24 17:40:23+00:00 +2477.0,0.1,2024-06-24 17:40:24+00:00 +2478.0,0.1,2024-06-24 17:40:25+00:00 +2479.0,0.1,2024-06-24 17:40:26+00:00 +2480.0,0.1,2024-06-24 17:40:27+00:00 +2481.0,0.1,2024-06-24 17:40:28+00:00 +2482.0,0.1,2024-06-24 17:40:29+00:00 +2483.0,0.1,2024-06-24 17:40:30+00:00 +2484.0,0.1,2024-06-24 17:40:31+00:00 +2485.0,0.1,2024-06-24 17:40:32+00:00 +2486.0,0.1,2024-06-24 17:40:33+00:00 +2487.0,0.1,2024-06-24 17:40:34+00:00 +2488.0,0.1,2024-06-24 17:40:35+00:00 +2489.0,0.1,2024-06-24 17:40:36+00:00 +2490.0,0.1,2024-06-24 17:40:37+00:00 +2491.0,0.1,2024-06-24 17:40:38+00:00 +2492.0,0.1,2024-06-24 17:40:39+00:00 +2493.0,0.1,2024-06-24 17:40:40+00:00 +2494.0,0.1,2024-06-24 17:40:41+00:00 +2495.0,0.1,2024-06-24 17:40:42+00:00 +2496.0,0.1,2024-06-24 17:40:43+00:00 +2497.0,0.1,2024-06-24 17:40:44+00:00 +2498.0,0.1,2024-06-24 17:40:45+00:00 +2499.0,0.1,2024-06-24 17:40:46+00:00 +2500.0,0.1,2024-06-24 17:40:47+00:00 +2501.0,0.1,2024-06-24 17:40:48+00:00 +2502.0,0.1,2024-06-24 17:40:49+00:00 +2503.0,0.1,2024-06-24 17:40:50+00:00 +2504.0,0.1,2024-06-24 17:40:51+00:00 +2505.0,0.1,2024-06-24 17:40:52+00:00 +2506.0,0.1,2024-06-24 17:40:53+00:00 +2507.0,0.1,2024-06-24 17:40:54+00:00 +2508.0,0.1,2024-06-24 17:40:55+00:00 +2509.0,0.1,2024-06-24 17:40:56+00:00 +2510.0,0.1,2024-06-24 17:40:57+00:00 +2511.0,0.1,2024-06-24 17:40:58+00:00 +2512.0,0.1,2024-06-24 17:40:59+00:00 +2513.0,0.1,2024-06-24 17:41:00+00:00 +2514.0,0.1,2024-06-24 17:41:01+00:00 +2515.0,0.1,2024-06-24 17:41:02+00:00 +2516.0,0.1,2024-06-24 17:41:03+00:00 +2517.0,0.1,2024-06-24 17:41:04+00:00 +2518.0,0.1,2024-06-24 17:41:05+00:00 +2519.0,0.1,2024-06-24 17:41:06+00:00 +2520.0,0.1,2024-06-24 17:41:07+00:00 +2521.0,0.1,2024-06-24 17:41:08+00:00 +2522.0,0.1,2024-06-24 17:41:09+00:00 +2523.0,0.1,2024-06-24 17:41:10+00:00 +2524.0,0.1,2024-06-24 17:41:11+00:00 +2525.0,0.1,2024-06-24 17:41:12+00:00 +2526.0,0.1,2024-06-24 17:41:13+00:00 +2527.0,0.1,2024-06-24 17:41:14+00:00 +2528.0,0.1,2024-06-24 17:41:15+00:00 +2529.0,0.1,2024-06-24 17:41:16+00:00 +2530.0,0.1,2024-06-24 17:41:17+00:00 +2531.0,0.1,2024-06-24 17:41:18+00:00 +2532.0,0.1,2024-06-24 17:41:19+00:00 +2533.0,0.1,2024-06-24 17:41:20+00:00 +2534.0,0.1,2024-06-24 17:41:21+00:00 +2535.0,0.1,2024-06-24 17:41:22+00:00 +2536.0,0.1,2024-06-24 17:41:23+00:00 +2537.0,0.1,2024-06-24 17:41:24+00:00 +2538.0,0.1,2024-06-24 17:41:25+00:00 +2539.0,0.1,2024-06-24 17:41:26+00:00 +2540.0,0.1,2024-06-24 17:41:27+00:00 +2541.0,0.1,2024-06-24 17:41:28+00:00 +2542.0,0.1,2024-06-24 17:41:29+00:00 +2543.0,0.1,2024-06-24 17:41:30+00:00 +2544.0,0.1,2024-06-24 17:41:31+00:00 +2545.0,0.1,2024-06-24 17:41:32+00:00 +2546.0,0.1,2024-06-24 17:41:33+00:00 +2547.0,0.1,2024-06-24 17:41:34+00:00 +2548.0,0.1,2024-06-24 17:41:35+00:00 +2549.0,0.1,2024-06-24 17:41:36+00:00 +2550.0,0.1,2024-06-24 17:41:37+00:00 +2551.0,0.1,2024-06-24 17:41:38+00:00 +2552.0,0.1,2024-06-24 17:41:39+00:00 +2553.0,0.1,2024-06-24 17:41:40+00:00 +2554.0,0.1,2024-06-24 17:41:41+00:00 +2555.0,0.1,2024-06-24 17:41:42+00:00 +2556.0,0.1,2024-06-24 17:41:43+00:00 +2557.0,0.1,2024-06-24 17:41:44+00:00 +2558.0,0.1,2024-06-24 17:41:45+00:00 +2559.0,0.1,2024-06-24 17:41:46+00:00 +2560.0,0.1,2024-06-24 17:41:47+00:00 +2561.0,0.1,2024-06-24 17:41:48+00:00 +2562.0,0.1,2024-06-24 17:41:49+00:00 +2563.0,0.1,2024-06-24 17:41:50+00:00 +2564.0,0.1,2024-06-24 17:41:51+00:00 +2565.0,0.1,2024-06-24 17:41:52+00:00 +2566.0,0.1,2024-06-24 17:41:53+00:00 +2567.0,0.1,2024-06-24 17:41:54+00:00 +2568.0,0.1,2024-06-24 17:41:55+00:00 +2569.0,0.1,2024-06-24 17:41:56+00:00 +2570.0,0.1,2024-06-24 17:41:57+00:00 +2571.0,0.1,2024-06-24 17:41:58+00:00 +2572.0,0.1,2024-06-24 17:41:59+00:00 +2573.0,0.1,2024-06-24 17:42:00+00:00 +2574.0,0.1,2024-06-24 17:42:01+00:00 +2575.0,0.1,2024-06-24 17:42:02+00:00 +2576.0,0.1,2024-06-24 17:42:03+00:00 +2577.0,0.1,2024-06-24 17:42:04+00:00 +2578.0,0.1,2024-06-24 17:42:05+00:00 +2579.0,0.1,2024-06-24 17:42:06+00:00 +2580.0,0.1,2024-06-24 17:42:07+00:00 +2581.0,0.1,2024-06-24 17:42:08+00:00 +2582.0,0.1,2024-06-24 17:42:09+00:00 +2583.0,0.1,2024-06-24 17:42:10+00:00 +2584.0,0.1,2024-06-24 17:42:11+00:00 +2585.0,0.1,2024-06-24 17:42:12+00:00 +2586.0,0.1,2024-06-24 17:42:13+00:00 +2587.0,0.1,2024-06-24 17:42:14+00:00 +2588.0,0.1,2024-06-24 17:42:15+00:00 +2589.0,0.1,2024-06-24 17:42:16+00:00 +2590.0,0.1,2024-06-24 17:42:17+00:00 +2591.0,0.1,2024-06-24 17:42:18+00:00 +2592.0,0.1,2024-06-24 17:42:19+00:00 +2593.0,0.1,2024-06-24 17:42:20+00:00 +2594.0,0.1,2024-06-24 17:42:21+00:00 +2595.0,0.1,2024-06-24 17:42:22+00:00 +2596.0,0.1,2024-06-24 17:42:23+00:00 +2597.0,0.1,2024-06-24 17:42:24+00:00 +2598.0,0.1,2024-06-24 17:42:25+00:00 +2599.0,0.1,2024-06-24 17:42:26+00:00 +2600.0,0.1,2024-06-24 17:42:27+00:00 +2601.0,0.1,2024-06-24 17:42:28+00:00 +2602.0,0.1,2024-06-24 17:42:29+00:00 +2603.0,0.1,2024-06-24 17:42:30+00:00 +2604.0,0.1,2024-06-24 17:42:31+00:00 +2605.0,0.1,2024-06-24 17:42:32+00:00 +2606.0,0.1,2024-06-24 17:42:33+00:00 +2607.0,0.1,2024-06-24 17:42:34+00:00 +2608.0,0.1,2024-06-24 17:42:35+00:00 +2609.0,0.1,2024-06-24 17:42:36+00:00 +2610.0,0.1,2024-06-24 17:42:37+00:00 +2611.0,0.1,2024-06-24 17:42:38+00:00 +2612.0,0.1,2024-06-24 17:42:39+00:00 +2613.0,0.1,2024-06-24 17:42:40+00:00 +2614.0,0.1,2024-06-24 17:42:41+00:00 +2615.0,0.1,2024-06-24 17:42:42+00:00 +2616.0,0.1,2024-06-24 17:42:43+00:00 +2617.0,0.1,2024-06-24 17:42:44+00:00 +2618.0,0.1,2024-06-24 17:42:45+00:00 +2619.0,0.1,2024-06-24 17:42:46+00:00 +2620.0,0.1,2024-06-24 17:42:47+00:00 +2621.0,0.1,2024-06-24 17:42:48+00:00 +2622.0,0.1,2024-06-24 17:42:49+00:00 +2623.0,0.1,2024-06-24 17:42:50+00:00 +2624.0,0.1,2024-06-24 17:42:51+00:00 +2625.0,0.1,2024-06-24 17:42:52+00:00 +2626.0,0.1,2024-06-24 17:42:53+00:00 +2627.0,0.1,2024-06-24 17:42:54+00:00 +2628.0,0.1,2024-06-24 17:42:55+00:00 +2629.0,0.1,2024-06-24 17:42:56+00:00 +2630.0,0.1,2024-06-24 17:42:57+00:00 +2631.0,0.1,2024-06-24 17:42:58+00:00 +2632.0,0.1,2024-06-24 17:42:59+00:00 +2633.0,0.1,2024-06-24 17:43:00+00:00 +2634.0,0.1,2024-06-24 17:43:01+00:00 +2635.0,0.1,2024-06-24 17:43:02+00:00 +2636.0,0.1,2024-06-24 17:43:03+00:00 +2637.0,0.1,2024-06-24 17:43:04+00:00 +2638.0,0.1,2024-06-24 17:43:05+00:00 +2639.0,0.1,2024-06-24 17:43:06+00:00 +2640.0,0.1,2024-06-24 17:43:07+00:00 +2641.0,0.1,2024-06-24 17:43:08+00:00 +2642.0,0.1,2024-06-24 17:43:09+00:00 +2643.0,0.1,2024-06-24 17:43:10+00:00 +2644.0,0.1,2024-06-24 17:43:11+00:00 +2645.0,0.1,2024-06-24 17:43:12+00:00 +2646.0,0.1,2024-06-24 17:43:13+00:00 +2647.0,0.1,2024-06-24 17:43:14+00:00 +2648.0,0.1,2024-06-24 17:43:15+00:00 +2649.0,0.1,2024-06-24 17:43:16+00:00 +2650.0,0.1,2024-06-24 17:43:17+00:00 +2651.0,0.1,2024-06-24 17:43:18+00:00 +2652.0,0.1,2024-06-24 17:43:19+00:00 +2653.0,0.1,2024-06-24 17:43:20+00:00 +2654.0,0.1,2024-06-24 17:43:21+00:00 +2655.0,0.1,2024-06-24 17:43:22+00:00 +2656.0,0.1,2024-06-24 17:43:23+00:00 +2657.0,0.1,2024-06-24 17:43:24+00:00 +2658.0,0.1,2024-06-24 17:43:25+00:00 +2659.0,0.1,2024-06-24 17:43:26+00:00 +2660.0,0.1,2024-06-24 17:43:27+00:00 +2661.0,0.1,2024-06-24 17:43:28+00:00 +2662.0,0.1,2024-06-24 17:43:29+00:00 +2663.0,0.1,2024-06-24 17:43:30+00:00 +2664.0,0.1,2024-06-24 17:43:31+00:00 +2665.0,0.1,2024-06-24 17:43:32+00:00 +2666.0,0.1,2024-06-24 17:43:33+00:00 +2667.0,0.1,2024-06-24 17:43:34+00:00 +2668.0,0.1,2024-06-24 17:43:35+00:00 +2669.0,0.1,2024-06-24 17:43:36+00:00 +2670.0,0.1,2024-06-24 17:43:37+00:00 +2671.0,0.1,2024-06-24 17:43:38+00:00 +2672.0,0.1,2024-06-24 17:43:39+00:00 +2673.0,0.1,2024-06-24 17:43:40+00:00 +2674.0,0.1,2024-06-24 17:43:41+00:00 +2675.0,0.1,2024-06-24 17:43:42+00:00 +2676.0,0.1,2024-06-24 17:43:43+00:00 +2677.0,0.1,2024-06-24 17:43:44+00:00 +2678.0,0.1,2024-06-24 17:43:45+00:00 +2679.0,0.1,2024-06-24 17:43:46+00:00 +2680.0,0.1,2024-06-24 17:43:47+00:00 +2681.0,0.1,2024-06-24 17:43:48+00:00 +2682.0,0.1,2024-06-24 17:43:49+00:00 +2683.0,0.1,2024-06-24 17:43:50+00:00 +2684.0,0.1,2024-06-24 17:43:51+00:00 +2685.0,0.1,2024-06-24 17:43:52+00:00 +2686.0,0.1,2024-06-24 17:43:53+00:00 +2687.0,0.1,2024-06-24 17:43:54+00:00 +2688.0,0.1,2024-06-24 17:43:55+00:00 +2689.0,0.1,2024-06-24 17:43:56+00:00 +2690.0,0.1,2024-06-24 17:43:57+00:00 +2691.0,0.1,2024-06-24 17:43:58+00:00 +2692.0,0.1,2024-06-24 17:43:59+00:00 +2693.0,0.1,2024-06-24 17:44:00+00:00 +2694.0,0.1,2024-06-24 17:44:01+00:00 +2695.0,0.1,2024-06-24 17:44:02+00:00 +2696.0,0.1,2024-06-24 17:44:03+00:00 +2697.0,0.1,2024-06-24 17:44:04+00:00 +2698.0,0.1,2024-06-24 17:44:05+00:00 +2699.0,0.1,2024-06-24 17:44:06+00:00 +2700.0,0.1,2024-06-24 17:44:07+00:00 +2701.0,0.1,2024-06-24 17:44:08+00:00 +2702.0,0.1,2024-06-24 17:44:09+00:00 +2703.0,0.1,2024-06-24 17:44:10+00:00 +2704.0,0.1,2024-06-24 17:44:11+00:00 +2705.0,0.1,2024-06-24 17:44:12+00:00 +2706.0,0.1,2024-06-24 17:44:13+00:00 +2707.0,0.1,2024-06-24 17:44:14+00:00 +2708.0,0.1,2024-06-24 17:44:15+00:00 +2709.0,0.1,2024-06-24 17:44:16+00:00 +2710.0,0.1,2024-06-24 17:44:17+00:00 +2711.0,0.1,2024-06-24 17:44:18+00:00 +2712.0,0.1,2024-06-24 17:44:19+00:00 +2713.0,0.1,2024-06-24 17:44:20+00:00 +2714.0,0.1,2024-06-24 17:44:21+00:00 +2715.0,0.1,2024-06-24 17:44:22+00:00 +2716.0,0.1,2024-06-24 17:44:23+00:00 +2717.0,0.1,2024-06-24 17:44:24+00:00 +2718.0,0.1,2024-06-24 17:44:25+00:00 +2719.0,0.1,2024-06-24 17:44:26+00:00 +2720.0,0.1,2024-06-24 17:44:27+00:00 +2721.0,0.1,2024-06-24 17:44:28+00:00 +2722.0,0.1,2024-06-24 17:44:29+00:00 +2723.0,0.1,2024-06-24 17:44:30+00:00 +2724.0,0.1,2024-06-24 17:44:31+00:00 +2725.0,0.1,2024-06-24 17:44:32+00:00 +2726.0,0.1,2024-06-24 17:44:33+00:00 +2727.0,0.1,2024-06-24 17:44:34+00:00 +2728.0,0.1,2024-06-24 17:44:35+00:00 +2729.0,0.1,2024-06-24 17:44:36+00:00 +2730.0,0.1,2024-06-24 17:44:37+00:00 +2731.0,0.1,2024-06-24 17:44:38+00:00 +2732.0,0.1,2024-06-24 17:44:39+00:00 +2733.0,0.1,2024-06-24 17:44:40+00:00 +2734.0,0.1,2024-06-24 17:44:41+00:00 +2735.0,0.1,2024-06-24 17:44:42+00:00 +2736.0,0.1,2024-06-24 17:44:43+00:00 +2737.0,0.1,2024-06-24 17:44:44+00:00 +2738.0,0.1,2024-06-24 17:44:45+00:00 +2739.0,0.1,2024-06-24 17:44:46+00:00 +2740.0,0.1,2024-06-24 17:44:47+00:00 +2741.0,0.1,2024-06-24 17:44:48+00:00 +2742.0,0.1,2024-06-24 17:44:49+00:00 +2743.0,0.1,2024-06-24 17:44:50+00:00 +2744.0,0.1,2024-06-24 17:44:51+00:00 +2745.0,0.1,2024-06-24 17:44:52+00:00 +2746.0,0.1,2024-06-24 17:44:53+00:00 +2747.0,0.1,2024-06-24 17:44:54+00:00 +2748.0,0.1,2024-06-24 17:44:55+00:00 +2749.0,0.1,2024-06-24 17:44:56+00:00 +2750.0,0.1,2024-06-24 17:44:57+00:00 +2751.0,0.1,2024-06-24 17:44:58+00:00 +2752.0,0.1,2024-06-24 17:44:59+00:00 +2753.0,0.1,2024-06-24 17:45:00+00:00 +2754.0,0.1,2024-06-24 17:45:01+00:00 +2755.0,0.1,2024-06-24 17:45:02+00:00 +2756.0,0.1,2024-06-24 17:45:03+00:00 +2757.0,0.1,2024-06-24 17:45:04+00:00 +2758.0,0.1,2024-06-24 17:45:05+00:00 +2759.0,0.1,2024-06-24 17:45:06+00:00 +2760.0,0.1,2024-06-24 17:45:07+00:00 +2761.0,0.1,2024-06-24 17:45:08+00:00 +2762.0,0.1,2024-06-24 17:45:09+00:00 +2763.0,0.1,2024-06-24 17:45:10+00:00 +2764.0,0.1,2024-06-24 17:45:11+00:00 +2765.0,0.1,2024-06-24 17:45:12+00:00 +2766.0,0.1,2024-06-24 17:45:13+00:00 +2767.0,0.1,2024-06-24 17:45:14+00:00 +2768.0,0.1,2024-06-24 17:45:15+00:00 +2769.0,0.1,2024-06-24 17:45:16+00:00 +2770.0,0.1,2024-06-24 17:45:17+00:00 +2771.0,0.1,2024-06-24 17:45:18+00:00 +2772.0,0.1,2024-06-24 17:45:19+00:00 +2773.0,0.1,2024-06-24 17:45:20+00:00 +2774.0,0.1,2024-06-24 17:45:21+00:00 +2775.0,0.1,2024-06-24 17:45:22+00:00 +2776.0,0.1,2024-06-24 17:45:23+00:00 +2777.0,0.1,2024-06-24 17:45:24+00:00 +2778.0,0.1,2024-06-24 17:45:25+00:00 +2779.0,0.1,2024-06-24 17:45:26+00:00 +2780.0,0.1,2024-06-24 17:45:27+00:00 +2781.0,0.1,2024-06-24 17:45:28+00:00 +2782.0,0.1,2024-06-24 17:45:29+00:00 +2783.0,0.1,2024-06-24 17:45:30+00:00 +2784.0,0.1,2024-06-24 17:45:31+00:00 +2785.0,0.1,2024-06-24 17:45:32+00:00 +2786.0,0.1,2024-06-24 17:45:33+00:00 +2787.0,0.1,2024-06-24 17:45:34+00:00 +2788.0,0.1,2024-06-24 17:45:35+00:00 +2789.0,0.1,2024-06-24 17:45:36+00:00 +2790.0,0.1,2024-06-24 17:45:37+00:00 +2791.0,0.1,2024-06-24 17:45:38+00:00 +2792.0,0.1,2024-06-24 17:45:39+00:00 +2793.0,0.1,2024-06-24 17:45:40+00:00 +2794.0,0.1,2024-06-24 17:45:41+00:00 +2795.0,0.1,2024-06-24 17:45:42+00:00 +2796.0,0.1,2024-06-24 17:45:43+00:00 +2797.0,0.1,2024-06-24 17:45:44+00:00 +2798.0,0.1,2024-06-24 17:45:45+00:00 +2799.0,0.1,2024-06-24 17:45:46+00:00 +2800.0,0.1,2024-06-24 17:45:47+00:00 +2801.0,0.1,2024-06-24 17:45:48+00:00 +2802.0,0.1,2024-06-24 17:45:49+00:00 +2803.0,0.1,2024-06-24 17:45:50+00:00 +2804.0,0.1,2024-06-24 17:45:51+00:00 +2805.0,0.1,2024-06-24 17:45:52+00:00 +2806.0,0.1,2024-06-24 17:45:53+00:00 +2807.0,0.1,2024-06-24 17:45:54+00:00 +2808.0,0.1,2024-06-24 17:45:55+00:00 +2809.0,0.1,2024-06-24 17:45:56+00:00 +2810.0,0.1,2024-06-24 17:45:57+00:00 +2811.0,0.1,2024-06-24 17:45:58+00:00 +2812.0,0.1,2024-06-24 17:45:59+00:00 +2813.0,0.1,2024-06-24 17:46:00+00:00 +2814.0,0.1,2024-06-24 17:46:01+00:00 +2815.0,0.1,2024-06-24 17:46:02+00:00 +2816.0,0.1,2024-06-24 17:46:03+00:00 +2817.0,0.1,2024-06-24 17:46:04+00:00 +2818.0,0.1,2024-06-24 17:46:05+00:00 +2819.0,0.1,2024-06-24 17:46:06+00:00 +2820.0,0.1,2024-06-24 17:46:07+00:00 +2821.0,0.1,2024-06-24 17:46:08+00:00 +2822.0,0.1,2024-06-24 17:46:09+00:00 +2823.0,0.1,2024-06-24 17:46:10+00:00 +2824.0,0.1,2024-06-24 17:46:11+00:00 +2825.0,0.1,2024-06-24 17:46:12+00:00 +2826.0,0.1,2024-06-24 17:46:13+00:00 +2827.0,0.1,2024-06-24 17:46:14+00:00 +2828.0,0.1,2024-06-24 17:46:15+00:00 +2829.0,0.1,2024-06-24 17:46:16+00:00 +2830.0,0.1,2024-06-24 17:46:17+00:00 +2831.0,0.1,2024-06-24 17:46:18+00:00 +2832.0,0.1,2024-06-24 17:46:19+00:00 +2833.0,0.1,2024-06-24 17:46:20+00:00 +2834.0,0.1,2024-06-24 17:46:21+00:00 +2835.0,0.1,2024-06-24 17:46:22+00:00 +2836.0,0.1,2024-06-24 17:46:23+00:00 +2837.0,0.1,2024-06-24 17:46:24+00:00 +2838.0,0.1,2024-06-24 17:46:25+00:00 +2839.0,0.1,2024-06-24 17:46:26+00:00 +2840.0,0.1,2024-06-24 17:46:27+00:00 +2841.0,0.1,2024-06-24 17:46:28+00:00 +2842.0,0.1,2024-06-24 17:46:29+00:00 +2843.0,0.1,2024-06-24 17:46:30+00:00 +2844.0,0.1,2024-06-24 17:46:31+00:00 +2845.0,0.1,2024-06-24 17:46:32+00:00 +2846.0,0.1,2024-06-24 17:46:33+00:00 +2847.0,0.1,2024-06-24 17:46:34+00:00 +2848.0,0.1,2024-06-24 17:46:35+00:00 +2849.0,0.1,2024-06-24 17:46:36+00:00 +2850.0,0.1,2024-06-24 17:46:37+00:00 +2851.0,0.1,2024-06-24 17:46:38+00:00 +2852.0,0.1,2024-06-24 17:46:39+00:00 +2853.0,0.1,2024-06-24 17:46:40+00:00 +2854.0,0.1,2024-06-24 17:46:41+00:00 +2855.0,0.1,2024-06-24 17:46:42+00:00 +2856.0,0.1,2024-06-24 17:46:43+00:00 +2857.0,0.1,2024-06-24 17:46:44+00:00 +2858.0,0.1,2024-06-24 17:46:45+00:00 +2859.0,0.1,2024-06-24 17:46:46+00:00 +2860.0,0.1,2024-06-24 17:46:47+00:00 +2861.0,0.1,2024-06-24 17:46:48+00:00 +2862.0,0.1,2024-06-24 17:46:49+00:00 +2863.0,0.1,2024-06-24 17:46:50+00:00 +2864.0,0.1,2024-06-24 17:46:51+00:00 +2865.0,0.1,2024-06-24 17:46:52+00:00 +2866.0,0.1,2024-06-24 17:46:53+00:00 +2867.0,0.1,2024-06-24 17:46:54+00:00 +2868.0,0.1,2024-06-24 17:46:55+00:00 +2869.0,0.1,2024-06-24 17:46:56+00:00 +2870.0,0.1,2024-06-24 17:46:57+00:00 +2871.0,0.1,2024-06-24 17:46:58+00:00 +2872.0,0.1,2024-06-24 17:46:59+00:00 +2873.0,0.1,2024-06-24 17:47:00+00:00 +2874.0,0.1,2024-06-24 17:47:01+00:00 +2875.0,0.1,2024-06-24 17:47:02+00:00 +2876.0,0.1,2024-06-24 17:47:03+00:00 +2877.0,0.1,2024-06-24 17:47:04+00:00 +2878.0,0.1,2024-06-24 17:47:05+00:00 +2879.0,0.1,2024-06-24 17:47:06+00:00 +2880.0,0.1,2024-06-24 17:47:07+00:00 +2881.0,0.1,2024-06-24 17:47:08+00:00 +2882.0,0.1,2024-06-24 17:47:09+00:00 +2883.0,0.1,2024-06-24 17:47:10+00:00 +2884.0,0.1,2024-06-24 17:47:11+00:00 +2885.0,0.1,2024-06-24 17:47:12+00:00 +2886.0,0.1,2024-06-24 17:47:13+00:00 +2887.0,0.1,2024-06-24 17:47:14+00:00 +2888.0,0.1,2024-06-24 17:47:15+00:00 +2889.0,0.1,2024-06-24 17:47:16+00:00 +2890.0,0.1,2024-06-24 17:47:17+00:00 +2891.0,0.1,2024-06-24 17:47:18+00:00 +2892.0,0.1,2024-06-24 17:47:19+00:00 +2893.0,0.1,2024-06-24 17:47:20+00:00 +2894.0,0.1,2024-06-24 17:47:21+00:00 +2895.0,0.1,2024-06-24 17:47:22+00:00 +2896.0,0.1,2024-06-24 17:47:23+00:00 +2897.0,0.1,2024-06-24 17:47:24+00:00 +2898.0,0.1,2024-06-24 17:47:25+00:00 +2899.0,0.1,2024-06-24 17:47:26+00:00 +2900.0,0.1,2024-06-24 17:47:27+00:00 +2901.0,0.1,2024-06-24 17:47:28+00:00 +2902.0,0.1,2024-06-24 17:47:29+00:00 +2903.0,0.1,2024-06-24 17:47:30+00:00 +2904.0,0.1,2024-06-24 17:47:31+00:00 +2905.0,0.1,2024-06-24 17:47:32+00:00 +2906.0,0.1,2024-06-24 17:47:33+00:00 +2907.0,0.1,2024-06-24 17:47:34+00:00 +2908.0,0.1,2024-06-24 17:47:35+00:00 +2909.0,0.1,2024-06-24 17:47:36+00:00 +2910.0,0.1,2024-06-24 17:47:37+00:00 +2911.0,0.1,2024-06-24 17:47:38+00:00 +2912.0,0.1,2024-06-24 17:47:39+00:00 +2913.0,0.1,2024-06-24 17:47:40+00:00 +2914.0,0.1,2024-06-24 17:47:41+00:00 +2915.0,0.1,2024-06-24 17:47:42+00:00 +2916.0,0.1,2024-06-24 17:47:43+00:00 +2917.0,0.1,2024-06-24 17:47:44+00:00 +2918.0,0.1,2024-06-24 17:47:45+00:00 +2919.0,0.1,2024-06-24 17:47:46+00:00 +2920.0,0.1,2024-06-24 17:47:47+00:00 +2921.0,0.1,2024-06-24 17:47:48+00:00 +2922.0,0.1,2024-06-24 17:47:49+00:00 +2923.0,0.1,2024-06-24 17:47:50+00:00 +2924.0,0.1,2024-06-24 17:47:51+00:00 +2925.0,0.1,2024-06-24 17:47:52+00:00 +2926.0,0.1,2024-06-24 17:47:53+00:00 +2927.0,0.1,2024-06-24 17:47:54+00:00 +2928.0,0.1,2024-06-24 17:47:55+00:00 +2929.0,0.1,2024-06-24 17:47:56+00:00 +2930.0,0.1,2024-06-24 17:47:57+00:00 +2931.0,0.1,2024-06-24 17:47:58+00:00 +2932.0,0.1,2024-06-24 17:47:59+00:00 +2933.0,0.1,2024-06-24 17:48:00+00:00 +2934.0,0.1,2024-06-24 17:48:01+00:00 +2935.0,0.1,2024-06-24 17:48:02+00:00 +2936.0,0.1,2024-06-24 17:48:03+00:00 +2937.0,0.1,2024-06-24 17:48:04+00:00 +2938.0,0.1,2024-06-24 17:48:05+00:00 +2939.0,0.1,2024-06-24 17:48:06+00:00 +2940.0,0.1,2024-06-24 17:48:07+00:00 +2941.0,0.1,2024-06-24 17:48:08+00:00 +2942.0,0.1,2024-06-24 17:48:09+00:00 +2943.0,0.1,2024-06-24 17:48:10+00:00 +2944.0,0.1,2024-06-24 17:48:11+00:00 +2945.0,0.1,2024-06-24 17:48:12+00:00 +2946.0,0.1,2024-06-24 17:48:13+00:00 +2947.0,0.1,2024-06-24 17:48:14+00:00 +2948.0,0.1,2024-06-24 17:48:15+00:00 +2949.0,0.1,2024-06-24 17:48:16+00:00 +2950.0,0.1,2024-06-24 17:48:17+00:00 +2951.0,0.1,2024-06-24 17:48:18+00:00 +2952.0,0.1,2024-06-24 17:48:19+00:00 +2953.0,0.1,2024-06-24 17:48:20+00:00 +2954.0,0.1,2024-06-24 17:48:21+00:00 +2955.0,0.1,2024-06-24 17:48:22+00:00 +2956.0,0.1,2024-06-24 17:48:23+00:00 +2957.0,0.1,2024-06-24 17:48:24+00:00 +2958.0,0.1,2024-06-24 17:48:25+00:00 +2959.0,0.1,2024-06-24 17:48:26+00:00 +2960.0,0.1,2024-06-24 17:48:27+00:00 +2961.0,0.1,2024-06-24 17:48:28+00:00 +2962.0,0.1,2024-06-24 17:48:29+00:00 +2963.0,0.1,2024-06-24 17:48:30+00:00 +2964.0,0.1,2024-06-24 17:48:31+00:00 +2965.0,0.1,2024-06-24 17:48:32+00:00 +2966.0,0.1,2024-06-24 17:48:33+00:00 +2967.0,0.1,2024-06-24 17:48:34+00:00 +2968.0,0.1,2024-06-24 17:48:35+00:00 +2969.0,0.1,2024-06-24 17:48:36+00:00 +2970.0,0.1,2024-06-24 17:48:37+00:00 +2971.0,0.1,2024-06-24 17:48:38+00:00 +2972.0,0.1,2024-06-24 17:48:39+00:00 +2973.0,0.1,2024-06-24 17:48:40+00:00 +2974.0,0.1,2024-06-24 17:48:41+00:00 +2975.0,0.1,2024-06-24 17:48:42+00:00 +2976.0,0.1,2024-06-24 17:48:43+00:00 +2977.0,0.1,2024-06-24 17:48:44+00:00 +2978.0,0.1,2024-06-24 17:48:45+00:00 +2979.0,0.1,2024-06-24 17:48:46+00:00 +2980.0,0.1,2024-06-24 17:48:47+00:00 +2981.0,0.1,2024-06-24 17:48:48+00:00 +2982.0,0.1,2024-06-24 17:48:49+00:00 +2983.0,0.1,2024-06-24 17:48:50+00:00 +2984.0,0.1,2024-06-24 17:48:51+00:00 +2985.0,0.1,2024-06-24 17:48:52+00:00 +2986.0,0.1,2024-06-24 17:48:53+00:00 +2987.0,0.1,2024-06-24 17:48:54+00:00 +2988.0,0.1,2024-06-24 17:48:55+00:00 +2989.0,0.1,2024-06-24 17:48:56+00:00 +2990.0,0.1,2024-06-24 17:48:57+00:00 +2991.0,0.1,2024-06-24 17:48:58+00:00 +2992.0,0.1,2024-06-24 17:48:59+00:00 +2993.0,0.1,2024-06-24 17:49:00+00:00 +2994.0,0.1,2024-06-24 17:49:01+00:00 +2995.0,0.1,2024-06-24 17:49:02+00:00 +2996.0,0.1,2024-06-24 17:49:03+00:00 +2997.0,0.1,2024-06-24 17:49:04+00:00 +2998.0,0.1,2024-06-24 17:49:05+00:00 +2999.0,0.1,2024-06-24 17:49:06+00:00 +3000.0,0.1,2024-06-24 17:49:07+00:00 +3001.0,0.1,2024-06-24 17:49:08+00:00 +3002.0,0.1,2024-06-24 17:49:09+00:00 +3003.0,0.1,2024-06-24 17:49:10+00:00 +3004.0,0.1,2024-06-24 17:49:11+00:00 +3005.0,0.1,2024-06-24 17:49:12+00:00 +3006.0,0.1,2024-06-24 17:49:13+00:00 +3007.0,0.1,2024-06-24 17:49:14+00:00 +3008.0,0.1,2024-06-24 17:49:15+00:00 +3009.0,0.1,2024-06-24 17:49:16+00:00 +3010.0,0.1,2024-06-24 17:49:17+00:00 +3011.0,0.1,2024-06-24 17:49:18+00:00 +3012.0,0.1,2024-06-24 17:49:19+00:00 +3013.0,0.1,2024-06-24 17:49:20+00:00 +3014.0,0.1,2024-06-24 17:49:21+00:00 +3015.0,0.1,2024-06-24 17:49:22+00:00 +3016.0,0.1,2024-06-24 17:49:23+00:00 +3017.0,0.1,2024-06-24 17:49:24+00:00 +3018.0,0.1,2024-06-24 17:49:25+00:00 +3019.0,0.1,2024-06-24 17:49:26+00:00 +3020.0,0.1,2024-06-24 17:49:27+00:00 +3021.0,0.1,2024-06-24 17:49:28+00:00 +3022.0,0.1,2024-06-24 17:49:29+00:00 +3023.0,0.1,2024-06-24 17:49:30+00:00 +3024.0,0.1,2024-06-24 17:49:31+00:00 +3025.0,0.1,2024-06-24 17:49:32+00:00 +3026.0,0.1,2024-06-24 17:49:33+00:00 +3027.0,0.1,2024-06-24 17:49:34+00:00 +3028.0,0.1,2024-06-24 17:49:35+00:00 +3029.0,0.1,2024-06-24 17:49:36+00:00 +3030.0,0.1,2024-06-24 17:49:37+00:00 +3031.0,0.1,2024-06-24 17:49:38+00:00 +3032.0,0.1,2024-06-24 17:49:39+00:00 +3033.0,0.1,2024-06-24 17:49:40+00:00 +3034.0,0.1,2024-06-24 17:49:41+00:00 +3035.0,0.1,2024-06-24 17:49:42+00:00 +3036.0,0.1,2024-06-24 17:49:43+00:00 +3037.0,0.1,2024-06-24 17:49:44+00:00 +3038.0,0.1,2024-06-24 17:49:45+00:00 +3039.0,0.1,2024-06-24 17:49:46+00:00 +3040.0,0.1,2024-06-24 17:49:47+00:00 +3041.0,0.1,2024-06-24 17:49:48+00:00 +3042.0,0.1,2024-06-24 17:49:49+00:00 +3043.0,0.1,2024-06-24 17:49:50+00:00 +3044.0,0.1,2024-06-24 17:49:51+00:00 +3045.0,0.1,2024-06-24 17:49:52+00:00 +3046.0,0.1,2024-06-24 17:49:53+00:00 +3047.0,0.1,2024-06-24 17:49:54+00:00 +3048.0,0.1,2024-06-24 17:49:55+00:00 +3049.0,0.1,2024-06-24 17:49:56+00:00 +3050.0,0.1,2024-06-24 17:49:57+00:00 +3051.0,0.1,2024-06-24 17:49:58+00:00 +3052.0,0.1,2024-06-24 17:49:59+00:00 +3053.0,0.1,2024-06-24 17:50:00+00:00 +3054.0,0.1,2024-06-24 17:50:01+00:00 +3055.0,0.1,2024-06-24 17:50:02+00:00 +3056.0,0.1,2024-06-24 17:50:03+00:00 +3057.0,0.1,2024-06-24 17:50:04+00:00 +3058.0,0.1,2024-06-24 17:50:05+00:00 +3059.0,0.1,2024-06-24 17:50:06+00:00 +3060.0,0.1,2024-06-24 17:50:07+00:00 +3061.0,0.1,2024-06-24 17:50:08+00:00 +3062.0,0.1,2024-06-24 17:50:09+00:00 +3063.0,0.1,2024-06-24 17:50:10+00:00 +3064.0,0.1,2024-06-24 17:50:11+00:00 +3065.0,0.1,2024-06-24 17:50:12+00:00 +3066.0,0.1,2024-06-24 17:50:13+00:00 +3067.0,0.1,2024-06-24 17:50:14+00:00 +3068.0,0.1,2024-06-24 17:50:15+00:00 +3069.0,0.1,2024-06-24 17:50:16+00:00 +3070.0,0.1,2024-06-24 17:50:17+00:00 +3071.0,0.1,2024-06-24 17:50:18+00:00 +3072.0,0.1,2024-06-24 17:50:19+00:00 +3073.0,0.1,2024-06-24 17:50:20+00:00 +3074.0,0.1,2024-06-24 17:50:21+00:00 +3075.0,0.1,2024-06-24 17:50:22+00:00 +3076.0,0.1,2024-06-24 17:50:23+00:00 +3077.0,0.1,2024-06-24 17:50:24+00:00 +3078.0,0.1,2024-06-24 17:50:25+00:00 +3079.0,0.1,2024-06-24 17:50:26+00:00 +3080.0,0.1,2024-06-24 17:50:27+00:00 +3081.0,0.1,2024-06-24 17:50:28+00:00 +3082.0,0.1,2024-06-24 17:50:29+00:00 +3083.0,0.1,2024-06-24 17:50:30+00:00 +3084.0,0.1,2024-06-24 17:50:31+00:00 +3085.0,0.1,2024-06-24 17:50:32+00:00 +3086.0,0.1,2024-06-24 17:50:33+00:00 +3087.0,0.1,2024-06-24 17:50:34+00:00 +3088.0,0.1,2024-06-24 17:50:35+00:00 +3089.0,0.1,2024-06-24 17:50:36+00:00 +3090.0,0.1,2024-06-24 17:50:37+00:00 +3091.0,0.1,2024-06-24 17:50:38+00:00 +3092.0,0.1,2024-06-24 17:50:39+00:00 +3093.0,0.1,2024-06-24 17:50:40+00:00 +3094.0,0.1,2024-06-24 17:50:41+00:00 +3095.0,0.1,2024-06-24 17:50:42+00:00 +3096.0,0.1,2024-06-24 17:50:43+00:00 +3097.0,0.1,2024-06-24 17:50:44+00:00 +3098.0,0.1,2024-06-24 17:50:45+00:00 +3099.0,0.1,2024-06-24 17:50:46+00:00 +3100.0,0.1,2024-06-24 17:50:47+00:00 +3101.0,0.1,2024-06-24 17:50:48+00:00 +3102.0,0.1,2024-06-24 17:50:49+00:00 +3103.0,0.1,2024-06-24 17:50:50+00:00 +3104.0,0.1,2024-06-24 17:50:51+00:00 +3105.0,0.1,2024-06-24 17:50:52+00:00 +3106.0,0.1,2024-06-24 17:50:53+00:00 +3107.0,0.1,2024-06-24 17:50:54+00:00 +3108.0,0.1,2024-06-24 17:50:55+00:00 +3109.0,0.1,2024-06-24 17:50:56+00:00 +3110.0,0.1,2024-06-24 17:50:57+00:00 +3111.0,0.1,2024-06-24 17:50:58+00:00 +3112.0,0.1,2024-06-24 17:50:59+00:00 +3113.0,0.1,2024-06-24 17:51:00+00:00 +3114.0,0.1,2024-06-24 17:51:01+00:00 +3115.0,0.1,2024-06-24 17:51:02+00:00 +3116.0,0.1,2024-06-24 17:51:03+00:00 +3117.0,0.1,2024-06-24 17:51:04+00:00 +3118.0,0.1,2024-06-24 17:51:05+00:00 +3119.0,0.1,2024-06-24 17:51:06+00:00 +3120.0,0.1,2024-06-24 17:51:07+00:00 +3121.0,0.1,2024-06-24 17:51:08+00:00 +3122.0,0.1,2024-06-24 17:51:09+00:00 +3123.0,0.1,2024-06-24 17:51:10+00:00 +3124.0,0.1,2024-06-24 17:51:11+00:00 +3125.0,0.1,2024-06-24 17:51:12+00:00 +3126.0,0.1,2024-06-24 17:51:13+00:00 +3127.0,0.1,2024-06-24 17:51:14+00:00 +3128.0,0.1,2024-06-24 17:51:15+00:00 +3129.0,0.1,2024-06-24 17:51:16+00:00 +3130.0,0.1,2024-06-24 17:51:17+00:00 +3131.0,0.1,2024-06-24 17:51:18+00:00 +3132.0,0.1,2024-06-24 17:51:19+00:00 +3133.0,0.1,2024-06-24 17:51:20+00:00 +3134.0,0.1,2024-06-24 17:51:21+00:00 +3135.0,0.1,2024-06-24 17:51:22+00:00 +3136.0,0.1,2024-06-24 17:51:23+00:00 +3137.0,0.1,2024-06-24 17:51:24+00:00 +3138.0,0.1,2024-06-24 17:51:25+00:00 +3139.0,0.1,2024-06-24 17:51:26+00:00 +3140.0,0.1,2024-06-24 17:51:27+00:00 +3141.0,0.1,2024-06-24 17:51:28+00:00 +3142.0,0.1,2024-06-24 17:51:29+00:00 +3143.0,0.1,2024-06-24 17:51:30+00:00 +3144.0,0.1,2024-06-24 17:51:31+00:00 +3145.0,0.1,2024-06-24 17:51:32+00:00 +3146.0,0.1,2024-06-24 17:51:33+00:00 +3147.0,0.1,2024-06-24 17:51:34+00:00 +3148.0,0.1,2024-06-24 17:51:35+00:00 +3149.0,0.1,2024-06-24 17:51:36+00:00 +3150.0,0.1,2024-06-24 17:51:37+00:00 +3151.0,0.1,2024-06-24 17:51:38+00:00 +3152.0,0.1,2024-06-24 17:51:39+00:00 +3153.0,0.1,2024-06-24 17:51:40+00:00 +3154.0,0.1,2024-06-24 17:51:41+00:00 +3155.0,0.1,2024-06-24 17:51:42+00:00 +3156.0,0.1,2024-06-24 17:51:43+00:00 +3157.0,0.1,2024-06-24 17:51:44+00:00 +3158.0,0.1,2024-06-24 17:51:45+00:00 +3159.0,0.1,2024-06-24 17:51:46+00:00 +3160.0,0.1,2024-06-24 17:51:47+00:00 +3161.0,0.1,2024-06-24 17:51:48+00:00 +3162.0,0.1,2024-06-24 17:51:49+00:00 +3163.0,0.1,2024-06-24 17:51:50+00:00 +3164.0,0.1,2024-06-24 17:51:51+00:00 +3165.0,0.1,2024-06-24 17:51:52+00:00 +3166.0,0.1,2024-06-24 17:51:53+00:00 +3167.0,0.1,2024-06-24 17:51:54+00:00 +3168.0,0.1,2024-06-24 17:51:55+00:00 +3169.0,0.1,2024-06-24 17:51:56+00:00 +3170.0,0.1,2024-06-24 17:51:57+00:00 +3171.0,0.1,2024-06-24 17:51:58+00:00 +3172.0,0.1,2024-06-24 17:51:59+00:00 +3173.0,0.1,2024-06-24 17:52:00+00:00 +3174.0,0.1,2024-06-24 17:52:01+00:00 +3175.0,0.1,2024-06-24 17:52:02+00:00 +3176.0,0.1,2024-06-24 17:52:03+00:00 +3177.0,0.1,2024-06-24 17:52:04+00:00 +3178.0,0.1,2024-06-24 17:52:05+00:00 +3179.0,0.1,2024-06-24 17:52:06+00:00 +3180.0,0.1,2024-06-24 17:52:07+00:00 +3181.0,0.1,2024-06-24 17:52:08+00:00 +3182.0,0.1,2024-06-24 17:52:09+00:00 +3183.0,0.1,2024-06-24 17:52:10+00:00 +3184.0,0.1,2024-06-24 17:52:11+00:00 +3185.0,0.1,2024-06-24 17:52:12+00:00 +3186.0,0.1,2024-06-24 17:52:13+00:00 +3187.0,0.1,2024-06-24 17:52:14+00:00 +3188.0,0.1,2024-06-24 17:52:15+00:00 +3189.0,0.1,2024-06-24 17:52:16+00:00 +3190.0,0.1,2024-06-24 17:52:17+00:00 +3191.0,0.1,2024-06-24 17:52:18+00:00 +3192.0,0.1,2024-06-24 17:52:19+00:00 +3193.0,0.1,2024-06-24 17:52:20+00:00 +3194.0,0.1,2024-06-24 17:52:21+00:00 +3195.0,0.1,2024-06-24 17:52:22+00:00 +3196.0,0.1,2024-06-24 17:52:23+00:00 +3197.0,0.1,2024-06-24 17:52:24+00:00 +3198.0,0.1,2024-06-24 17:52:25+00:00 +3199.0,0.1,2024-06-24 17:52:26+00:00 +3200.0,0.1,2024-06-24 17:52:27+00:00 +3201.0,0.1,2024-06-24 17:52:28+00:00 +3202.0,0.1,2024-06-24 17:52:29+00:00 +3203.0,0.1,2024-06-24 17:52:30+00:00 +3204.0,0.1,2024-06-24 17:52:31+00:00 +3205.0,0.1,2024-06-24 17:52:32+00:00 +3206.0,0.1,2024-06-24 17:52:33+00:00 +3207.0,0.1,2024-06-24 17:52:34+00:00 +3208.0,0.1,2024-06-24 17:52:35+00:00 +3209.0,0.1,2024-06-24 17:52:36+00:00 +3210.0,0.1,2024-06-24 17:52:37+00:00 +3211.0,0.1,2024-06-24 17:52:38+00:00 +3212.0,0.1,2024-06-24 17:52:39+00:00 +3213.0,0.1,2024-06-24 17:52:40+00:00 +3214.0,0.1,2024-06-24 17:52:41+00:00 +3215.0,0.1,2024-06-24 17:52:42+00:00 +3216.0,0.1,2024-06-24 17:52:43+00:00 +3217.0,0.1,2024-06-24 17:52:44+00:00 +3218.0,0.1,2024-06-24 17:52:45+00:00 +3219.0,0.1,2024-06-24 17:52:46+00:00 +3220.0,0.1,2024-06-24 17:52:47+00:00 +3221.0,0.1,2024-06-24 17:52:48+00:00 +3222.0,0.1,2024-06-24 17:52:49+00:00 +3223.0,0.1,2024-06-24 17:52:50+00:00 +3224.0,0.1,2024-06-24 17:52:51+00:00 +3225.0,0.1,2024-06-24 17:52:52+00:00 +3226.0,0.1,2024-06-24 17:52:53+00:00 +3227.0,0.1,2024-06-24 17:52:54+00:00 +3228.0,0.1,2024-06-24 17:52:55+00:00 +3229.0,0.1,2024-06-24 17:52:56+00:00 +3230.0,0.1,2024-06-24 17:52:57+00:00 +3231.0,0.1,2024-06-24 17:52:58+00:00 +3232.0,0.1,2024-06-24 17:52:59+00:00 +3233.0,0.1,2024-06-24 17:53:00+00:00 +3234.0,0.1,2024-06-24 17:53:01+00:00 +3235.0,0.1,2024-06-24 17:53:02+00:00 +3236.0,0.1,2024-06-24 17:53:03+00:00 +3237.0,0.1,2024-06-24 17:53:04+00:00 +3238.0,0.1,2024-06-24 17:53:05+00:00 +3239.0,0.1,2024-06-24 17:53:06+00:00 +3240.0,0.1,2024-06-24 17:53:07+00:00 +3241.0,0.1,2024-06-24 17:53:08+00:00 +3242.0,0.1,2024-06-24 17:53:09+00:00 +3243.0,0.1,2024-06-24 17:53:10+00:00 +3244.0,0.1,2024-06-24 17:53:11+00:00 +3245.0,0.1,2024-06-24 17:53:12+00:00 +3246.0,0.1,2024-06-24 17:53:13+00:00 +3247.0,0.1,2024-06-24 17:53:14+00:00 +3248.0,0.1,2024-06-24 17:53:15+00:00 +3249.0,0.1,2024-06-24 17:53:16+00:00 +3250.0,0.1,2024-06-24 17:53:17+00:00 +3251.0,0.1,2024-06-24 17:53:18+00:00 +3252.0,0.1,2024-06-24 17:53:19+00:00 +3253.0,0.1,2024-06-24 17:53:20+00:00 +3254.0,0.1,2024-06-24 17:53:21+00:00 +3255.0,0.1,2024-06-24 17:53:22+00:00 +3256.0,0.1,2024-06-24 17:53:23+00:00 +3257.0,0.1,2024-06-24 17:53:24+00:00 +3258.0,0.1,2024-06-24 17:53:25+00:00 +3259.0,0.1,2024-06-24 17:53:26+00:00 +3260.0,0.1,2024-06-24 17:53:27+00:00 +3261.0,0.1,2024-06-24 17:53:28+00:00 +3262.0,0.1,2024-06-24 17:53:29+00:00 +3263.0,0.1,2024-06-24 17:53:30+00:00 +3264.0,0.1,2024-06-24 17:53:31+00:00 +3265.0,0.1,2024-06-24 17:53:32+00:00 +3266.0,0.1,2024-06-24 17:53:33+00:00 +3267.0,0.1,2024-06-24 17:53:34+00:00 +3268.0,0.1,2024-06-24 17:53:35+00:00 +3269.0,0.1,2024-06-24 17:53:36+00:00 +3270.0,0.1,2024-06-24 17:53:37+00:00 +3271.0,0.1,2024-06-24 17:53:38+00:00 +3272.0,0.1,2024-06-24 17:53:39+00:00 +3273.0,0.1,2024-06-24 17:53:40+00:00 +3274.0,0.1,2024-06-24 17:53:41+00:00 +3275.0,0.1,2024-06-24 17:53:42+00:00 +3276.0,0.1,2024-06-24 17:53:43+00:00 +3277.0,0.1,2024-06-24 17:53:44+00:00 +3278.0,0.1,2024-06-24 17:53:45+00:00 +3279.0,0.1,2024-06-24 17:53:46+00:00 +3280.0,0.1,2024-06-24 17:53:47+00:00 +3281.0,0.1,2024-06-24 17:53:48+00:00 +3282.0,0.1,2024-06-24 17:53:49+00:00 +3283.0,0.1,2024-06-24 17:53:50+00:00 +3284.0,0.1,2024-06-24 17:53:51+00:00 +3285.0,0.1,2024-06-24 17:53:52+00:00 +3286.0,0.1,2024-06-24 17:53:53+00:00 +3287.0,0.1,2024-06-24 17:53:54+00:00 +3288.0,0.1,2024-06-24 17:53:55+00:00 +3289.0,0.1,2024-06-24 17:53:56+00:00 +3290.0,0.1,2024-06-24 17:53:57+00:00 +3291.0,0.1,2024-06-24 17:53:58+00:00 +3292.0,0.1,2024-06-24 17:53:59+00:00 +3293.0,0.1,2024-06-24 17:54:00+00:00 +3294.0,0.1,2024-06-24 17:54:01+00:00 +3295.0,0.1,2024-06-24 17:54:02+00:00 +3296.0,0.1,2024-06-24 17:54:03+00:00 +3297.0,0.1,2024-06-24 17:54:04+00:00 +3298.0,0.1,2024-06-24 17:54:05+00:00 +3299.0,0.1,2024-06-24 17:54:06+00:00 +3300.0,0.1,2024-06-24 17:54:07+00:00 +3301.0,0.1,2024-06-24 17:54:08+00:00 +3302.0,0.1,2024-06-24 17:54:09+00:00 +3303.0,0.1,2024-06-24 17:54:10+00:00 +3304.0,0.1,2024-06-24 17:54:11+00:00 +3305.0,0.1,2024-06-24 17:54:12+00:00 +3306.0,0.1,2024-06-24 17:54:13+00:00 +3307.0,0.1,2024-06-24 17:54:14+00:00 +3308.0,0.1,2024-06-24 17:54:15+00:00 +3309.0,0.1,2024-06-24 17:54:16+00:00 +3310.0,0.1,2024-06-24 17:54:17+00:00 +3311.0,0.1,2024-06-24 17:54:18+00:00 +3312.0,0.1,2024-06-24 17:54:19+00:00 +3313.0,0.1,2024-06-24 17:54:20+00:00 +3314.0,0.1,2024-06-24 17:54:21+00:00 +3315.0,0.1,2024-06-24 17:54:22+00:00 +3316.0,0.1,2024-06-24 17:54:23+00:00 +3317.0,0.1,2024-06-24 17:54:24+00:00 +3318.0,0.1,2024-06-24 17:54:25+00:00 +3319.0,0.1,2024-06-24 17:54:26+00:00 +3320.0,0.1,2024-06-24 17:54:27+00:00 +3321.0,0.1,2024-06-24 17:54:28+00:00 +3322.0,0.1,2024-06-24 17:54:29+00:00 +3323.0,0.1,2024-06-24 17:54:30+00:00 +3324.0,0.1,2024-06-24 17:54:31+00:00 +3325.0,0.1,2024-06-24 17:54:32+00:00 +3326.0,0.1,2024-06-24 17:54:33+00:00 +3327.0,0.1,2024-06-24 17:54:34+00:00 +3328.0,0.1,2024-06-24 17:54:35+00:00 +3329.0,0.1,2024-06-24 17:54:36+00:00 +3330.0,0.1,2024-06-24 17:54:37+00:00 +3331.0,0.1,2024-06-24 17:54:38+00:00 +3332.0,0.1,2024-06-24 17:54:39+00:00 +3333.0,0.1,2024-06-24 17:54:40+00:00 +3334.0,0.1,2024-06-24 17:54:41+00:00 +3335.0,0.1,2024-06-24 17:54:42+00:00 +3336.0,0.1,2024-06-24 17:54:43+00:00 +3337.0,0.1,2024-06-24 17:54:44+00:00 +3338.0,0.1,2024-06-24 17:54:45+00:00 +3339.0,0.1,2024-06-24 17:54:46+00:00 +3340.0,0.1,2024-06-24 17:54:47+00:00 +3341.0,0.1,2024-06-24 17:54:48+00:00 +3342.0,0.1,2024-06-24 17:54:49+00:00 +3343.0,0.1,2024-06-24 17:54:50+00:00 +3344.0,0.1,2024-06-24 17:54:51+00:00 +3345.0,0.1,2024-06-24 17:54:52+00:00 +3346.0,0.1,2024-06-24 17:54:53+00:00 +3347.0,0.1,2024-06-24 17:54:54+00:00 +3348.0,0.1,2024-06-24 17:54:55+00:00 +3349.0,0.1,2024-06-24 17:54:56+00:00 +3350.0,0.1,2024-06-24 17:54:57+00:00 +3351.0,0.1,2024-06-24 17:54:58+00:00 +3352.0,0.1,2024-06-24 17:54:59+00:00 +3353.0,0.1,2024-06-24 17:55:00+00:00 +3354.0,0.1,2024-06-24 17:55:01+00:00 +3355.0,0.1,2024-06-24 17:55:02+00:00 +3356.0,0.1,2024-06-24 17:55:03+00:00 +3357.0,0.1,2024-06-24 17:55:04+00:00 +3358.0,0.1,2024-06-24 17:55:05+00:00 +3359.0,0.1,2024-06-24 17:55:06+00:00 +3360.0,0.1,2024-06-24 17:55:07+00:00 +3361.0,0.1,2024-06-24 17:55:08+00:00 +3362.0,0.1,2024-06-24 17:55:09+00:00 +3363.0,0.1,2024-06-24 17:55:10+00:00 +3364.0,0.1,2024-06-24 17:55:11+00:00 +3365.0,0.1,2024-06-24 17:55:12+00:00 +3366.0,0.1,2024-06-24 17:55:13+00:00 +3367.0,0.1,2024-06-24 17:55:14+00:00 +3368.0,0.1,2024-06-24 17:55:15+00:00 +3369.0,0.1,2024-06-24 17:55:16+00:00 +3370.0,0.1,2024-06-24 17:55:17+00:00 +3371.0,0.1,2024-06-24 17:55:18+00:00 +3372.0,0.1,2024-06-24 17:55:19+00:00 +3373.0,0.1,2024-06-24 17:55:20+00:00 +3374.0,0.1,2024-06-24 17:55:21+00:00 +3375.0,0.1,2024-06-24 17:55:22+00:00 +3376.0,0.1,2024-06-24 17:55:23+00:00 +3377.0,0.1,2024-06-24 17:55:24+00:00 +3378.0,0.1,2024-06-24 17:55:25+00:00 +3379.0,0.1,2024-06-24 17:55:26+00:00 +3380.0,0.1,2024-06-24 17:55:27+00:00 +3381.0,0.1,2024-06-24 17:55:28+00:00 +3382.0,0.1,2024-06-24 17:55:29+00:00 +3383.0,0.1,2024-06-24 17:55:30+00:00 +3384.0,0.1,2024-06-24 17:55:31+00:00 +3385.0,0.1,2024-06-24 17:55:32+00:00 +3386.0,0.1,2024-06-24 17:55:33+00:00 +3387.0,0.1,2024-06-24 17:55:34+00:00 +3388.0,0.1,2024-06-24 17:55:35+00:00 +3389.0,0.1,2024-06-24 17:55:36+00:00 +3390.0,0.1,2024-06-24 17:55:37+00:00 +3391.0,0.1,2024-06-24 17:55:38+00:00 +3392.0,0.1,2024-06-24 17:55:39+00:00 +3393.0,0.1,2024-06-24 17:55:40+00:00 +3394.0,0.1,2024-06-24 17:55:41+00:00 +3395.0,0.1,2024-06-24 17:55:42+00:00 +3396.0,0.1,2024-06-24 17:55:43+00:00 +3397.0,0.1,2024-06-24 17:55:44+00:00 +3398.0,0.1,2024-06-24 17:55:45+00:00 +3399.0,0.1,2024-06-24 17:55:46+00:00 +3400.0,0.1,2024-06-24 17:55:47+00:00 +3401.0,0.1,2024-06-24 17:55:48+00:00 +3402.0,0.1,2024-06-24 17:55:49+00:00 +3403.0,0.1,2024-06-24 17:55:50+00:00 +3404.0,0.1,2024-06-24 17:55:51+00:00 +3405.0,0.1,2024-06-24 17:55:52+00:00 +3406.0,0.1,2024-06-24 17:55:53+00:00 +3407.0,0.1,2024-06-24 17:55:54+00:00 +3408.0,0.1,2024-06-24 17:55:55+00:00 +3409.0,0.1,2024-06-24 17:55:56+00:00 +3410.0,0.1,2024-06-24 17:55:57+00:00 +3411.0,0.1,2024-06-24 17:55:58+00:00 +3412.0,0.1,2024-06-24 17:55:59+00:00 +3413.0,0.1,2024-06-24 17:56:00+00:00 +3414.0,0.1,2024-06-24 17:56:01+00:00 +3415.0,0.1,2024-06-24 17:56:02+00:00 +3416.0,0.1,2024-06-24 17:56:03+00:00 +3417.0,0.1,2024-06-24 17:56:04+00:00 +3418.0,0.1,2024-06-24 17:56:05+00:00 +3419.0,0.1,2024-06-24 17:56:06+00:00 +3420.0,0.1,2024-06-24 17:56:07+00:00 +3421.0,0.1,2024-06-24 17:56:08+00:00 +3422.0,0.1,2024-06-24 17:56:09+00:00 +3423.0,0.1,2024-06-24 17:56:10+00:00 +3424.0,0.1,2024-06-24 17:56:11+00:00 +3425.0,0.1,2024-06-24 17:56:12+00:00 +3426.0,0.1,2024-06-24 17:56:13+00:00 +3427.0,0.1,2024-06-24 17:56:14+00:00 +3428.0,0.1,2024-06-24 17:56:15+00:00 +3429.0,0.1,2024-06-24 17:56:16+00:00 +3430.0,0.1,2024-06-24 17:56:17+00:00 +3431.0,0.1,2024-06-24 17:56:18+00:00 +3432.0,0.1,2024-06-24 17:56:19+00:00 +3433.0,0.1,2024-06-24 17:56:20+00:00 +3434.0,0.1,2024-06-24 17:56:21+00:00 +3435.0,0.1,2024-06-24 17:56:22+00:00 +3436.0,0.1,2024-06-24 17:56:23+00:00 +3437.0,0.1,2024-06-24 17:56:24+00:00 +3438.0,0.1,2024-06-24 17:56:25+00:00 +3439.0,0.1,2024-06-24 17:56:26+00:00 +3440.0,0.1,2024-06-24 17:56:27+00:00 +3441.0,0.1,2024-06-24 17:56:28+00:00 +3442.0,0.1,2024-06-24 17:56:29+00:00 +3443.0,0.1,2024-06-24 17:56:30+00:00 +3444.0,0.1,2024-06-24 17:56:31+00:00 +3445.0,0.1,2024-06-24 17:56:32+00:00 +3446.0,0.1,2024-06-24 17:56:33+00:00 +3447.0,0.1,2024-06-24 17:56:34+00:00 +3448.0,0.1,2024-06-24 17:56:35+00:00 +3449.0,0.1,2024-06-24 17:56:36+00:00 +3450.0,0.1,2024-06-24 17:56:37+00:00 +3451.0,0.1,2024-06-24 17:56:38+00:00 +3452.0,0.1,2024-06-24 17:56:39+00:00 +3453.0,0.1,2024-06-24 17:56:40+00:00 +3454.0,0.1,2024-06-24 17:56:41+00:00 +3455.0,0.1,2024-06-24 17:56:42+00:00 +3456.0,0.1,2024-06-24 17:56:43+00:00 +3457.0,0.1,2024-06-24 17:56:44+00:00 +3458.0,0.1,2024-06-24 17:56:45+00:00 +3459.0,0.1,2024-06-24 17:56:46+00:00 +3460.0,0.1,2024-06-24 17:56:47+00:00 +3461.0,0.1,2024-06-24 17:56:48+00:00 +3462.0,0.1,2024-06-24 17:56:49+00:00 +3463.0,0.1,2024-06-24 17:56:50+00:00 +3464.0,0.1,2024-06-24 17:56:51+00:00 +3465.0,0.1,2024-06-24 17:56:52+00:00 +3466.0,0.1,2024-06-24 17:56:53+00:00 +3467.0,0.1,2024-06-24 17:56:54+00:00 +3468.0,0.1,2024-06-24 17:56:55+00:00 +3469.0,0.1,2024-06-24 17:56:56+00:00 +3470.0,0.1,2024-06-24 17:56:57+00:00 +3471.0,0.1,2024-06-24 17:56:58+00:00 +3472.0,0.1,2024-06-24 17:56:59+00:00 +3473.0,0.1,2024-06-24 17:57:00+00:00 +3474.0,0.1,2024-06-24 17:57:01+00:00 +3475.0,0.1,2024-06-24 17:57:02+00:00 +3476.0,0.1,2024-06-24 17:57:03+00:00 +3477.0,0.1,2024-06-24 17:57:04+00:00 +3478.0,0.1,2024-06-24 17:57:05+00:00 +3479.0,0.1,2024-06-24 17:57:06+00:00 +3480.0,0.1,2024-06-24 17:57:07+00:00 +3481.0,0.1,2024-06-24 17:57:08+00:00 +3482.0,0.1,2024-06-24 17:57:09+00:00 +3483.0,0.1,2024-06-24 17:57:10+00:00 +3484.0,0.1,2024-06-24 17:57:11+00:00 +3485.0,0.1,2024-06-24 17:57:12+00:00 +3486.0,0.1,2024-06-24 17:57:13+00:00 +3487.0,0.1,2024-06-24 17:57:14+00:00 +3488.0,0.1,2024-06-24 17:57:15+00:00 +3489.0,0.1,2024-06-24 17:57:16+00:00 +3490.0,0.1,2024-06-24 17:57:17+00:00 +3491.0,0.1,2024-06-24 17:57:18+00:00 +3492.0,0.1,2024-06-24 17:57:19+00:00 +3493.0,0.1,2024-06-24 17:57:20+00:00 +3494.0,0.1,2024-06-24 17:57:21+00:00 +3495.0,0.1,2024-06-24 17:57:22+00:00 +3496.0,0.1,2024-06-24 17:57:23+00:00 +3497.0,0.1,2024-06-24 17:57:24+00:00 +3498.0,0.1,2024-06-24 17:57:25+00:00 +3499.0,0.1,2024-06-24 17:57:26+00:00 +3500.0,0.1,2024-06-24 17:57:27+00:00 +3501.0,0.1,2024-06-24 17:57:28+00:00 +3502.0,0.1,2024-06-24 17:57:29+00:00 +3503.0,0.1,2024-06-24 17:57:30+00:00 +3504.0,0.1,2024-06-24 17:57:31+00:00 +3505.0,0.1,2024-06-24 17:57:32+00:00 +3506.0,0.1,2024-06-24 17:57:33+00:00 +3507.0,0.1,2024-06-24 17:57:34+00:00 +3508.0,0.1,2024-06-24 17:57:35+00:00 +3509.0,0.1,2024-06-24 17:57:36+00:00 +3510.0,0.1,2024-06-24 17:57:37+00:00 +3511.0,0.1,2024-06-24 17:57:38+00:00 +3512.0,0.1,2024-06-24 17:57:39+00:00 +3513.0,0.1,2024-06-24 17:57:40+00:00 +3514.0,0.1,2024-06-24 17:57:41+00:00 +3515.0,0.1,2024-06-24 17:57:42+00:00 +3516.0,0.1,2024-06-24 17:57:43+00:00 +3517.0,0.1,2024-06-24 17:57:44+00:00 +3518.0,0.1,2024-06-24 17:57:45+00:00 +3519.0,0.1,2024-06-24 17:57:46+00:00 +3520.0,0.1,2024-06-24 17:57:47+00:00 +3521.0,0.1,2024-06-24 17:57:48+00:00 +3522.0,0.1,2024-06-24 17:57:49+00:00 +3523.0,0.1,2024-06-24 17:57:50+00:00 +3524.0,0.1,2024-06-24 17:57:51+00:00 +3525.0,0.1,2024-06-24 17:57:52+00:00 +3526.0,0.1,2024-06-24 17:57:53+00:00 +3527.0,0.1,2024-06-24 17:57:54+00:00 +3528.0,0.1,2024-06-24 17:57:55+00:00 +3529.0,0.1,2024-06-24 17:57:56+00:00 +3530.0,0.1,2024-06-24 17:57:57+00:00 +3531.0,0.1,2024-06-24 17:57:58+00:00 +3532.0,0.1,2024-06-24 17:57:59+00:00 +3533.0,0.1,2024-06-24 17:58:00+00:00 +3534.0,0.1,2024-06-24 17:58:01+00:00 +3535.0,0.1,2024-06-24 17:58:02+00:00 +3536.0,0.1,2024-06-24 17:58:03+00:00 +3537.0,0.1,2024-06-24 17:58:04+00:00 +3538.0,0.1,2024-06-24 17:58:05+00:00 +3539.0,0.1,2024-06-24 17:58:06+00:00 +3540.0,0.1,2024-06-24 17:58:07+00:00 +3541.0,0.1,2024-06-24 17:58:08+00:00 +3542.0,0.1,2024-06-24 17:58:09+00:00 +3543.0,0.1,2024-06-24 17:58:10+00:00 +3544.0,0.1,2024-06-24 17:58:11+00:00 +3545.0,0.1,2024-06-24 17:58:12+00:00 +3546.0,0.1,2024-06-24 17:58:13+00:00 +3547.0,0.1,2024-06-24 17:58:14+00:00 +3548.0,0.1,2024-06-24 17:58:15+00:00 +3549.0,0.1,2024-06-24 17:58:16+00:00 +3550.0,0.1,2024-06-24 17:58:17+00:00 +3551.0,0.1,2024-06-24 17:58:18+00:00 +3552.0,0.1,2024-06-24 17:58:19+00:00 +3553.0,0.1,2024-06-24 17:58:20+00:00 +3554.0,0.1,2024-06-24 17:58:21+00:00 +3555.0,0.1,2024-06-24 17:58:22+00:00 +3556.0,0.1,2024-06-24 17:58:23+00:00 +3557.0,0.1,2024-06-24 17:58:24+00:00 +3558.0,0.1,2024-06-24 17:58:25+00:00 +3559.0,0.1,2024-06-24 17:58:26+00:00 +3560.0,0.1,2024-06-24 17:58:27+00:00 +3561.0,0.1,2024-06-24 17:58:28+00:00 +3562.0,0.1,2024-06-24 17:58:29+00:00 +3563.0,0.1,2024-06-24 17:58:30+00:00 +3564.0,0.1,2024-06-24 17:58:31+00:00 +3565.0,0.1,2024-06-24 17:58:32+00:00 +3566.0,0.1,2024-06-24 17:58:33+00:00 +3567.0,0.1,2024-06-24 17:58:34+00:00 +3568.0,0.1,2024-06-24 17:58:35+00:00 +3569.0,0.1,2024-06-24 17:58:36+00:00 +3570.0,0.1,2024-06-24 17:58:37+00:00 +3571.0,0.1,2024-06-24 17:58:38+00:00 +3572.0,0.1,2024-06-24 17:58:39+00:00 +3573.0,0.1,2024-06-24 17:58:40+00:00 +3574.0,0.1,2024-06-24 17:58:41+00:00 +3575.0,0.1,2024-06-24 17:58:42+00:00 +3576.0,0.1,2024-06-24 17:58:43+00:00 +3577.0,0.1,2024-06-24 17:58:44+00:00 +3578.0,0.1,2024-06-24 17:58:45+00:00 +3579.0,0.1,2024-06-24 17:58:46+00:00 +3580.0,0.1,2024-06-24 17:58:47+00:00 +3581.0,0.1,2024-06-24 17:58:48+00:00 +3582.0,0.1,2024-06-24 17:58:49+00:00 +3583.0,0.1,2024-06-24 17:58:50+00:00 +3584.0,0.1,2024-06-24 17:58:51+00:00 +3585.0,0.1,2024-06-24 17:58:52+00:00 +3586.0,0.1,2024-06-24 17:58:53+00:00 +3587.0,0.1,2024-06-24 17:58:54+00:00 +3588.0,0.1,2024-06-24 17:58:55+00:00 +3589.0,0.1,2024-06-24 17:58:56+00:00 +3590.0,0.1,2024-06-24 17:58:57+00:00 +3591.0,0.1,2024-06-24 17:58:58+00:00 +3592.0,0.1,2024-06-24 17:58:59+00:00 +3593.0,0.1,2024-06-24 17:59:00+00:00 +3594.0,0.1,2024-06-24 17:59:01+00:00 +3595.0,0.1,2024-06-24 17:59:02+00:00 +3596.0,0.1,2024-06-24 17:59:03+00:00 +3597.0,0.1,2024-06-24 17:59:04+00:00 +3598.0,0.1,2024-06-24 17:59:05+00:00 +3599.0,0.1,2024-06-24 17:59:06+00:00 +3600.0,0.1,2024-06-24 17:59:07+00:00 +3601.0,0.15,2024-06-24 17:59:08+00:00 +3602.0,0.15,2024-06-24 17:59:09+00:00 +3603.0,0.15,2024-06-24 17:59:10+00:00 +3604.0,0.15,2024-06-24 17:59:11+00:00 +3605.0,0.15,2024-06-24 17:59:12+00:00 +3606.0,0.15,2024-06-24 17:59:13+00:00 +3607.0,0.15,2024-06-24 17:59:14+00:00 +3608.0,0.15,2024-06-24 17:59:15+00:00 +3609.0,0.15,2024-06-24 17:59:16+00:00 +3610.0,0.15,2024-06-24 17:59:17+00:00 +3611.0,0.15,2024-06-24 17:59:18+00:00 +3612.0,0.15,2024-06-24 17:59:19+00:00 +3613.0,0.15,2024-06-24 17:59:20+00:00 +3614.0,0.15,2024-06-24 17:59:21+00:00 +3615.0,0.15,2024-06-24 17:59:22+00:00 +3616.0,0.15,2024-06-24 17:59:23+00:00 +3617.0,0.15,2024-06-24 17:59:24+00:00 +3618.0,0.15,2024-06-24 17:59:25+00:00 +3619.0,0.15,2024-06-24 17:59:26+00:00 +3620.0,0.15,2024-06-24 17:59:27+00:00 +3621.0,0.15,2024-06-24 17:59:28+00:00 +3622.0,0.15,2024-06-24 17:59:29+00:00 +3623.0,0.15,2024-06-24 17:59:30+00:00 +3624.0,0.15,2024-06-24 17:59:31+00:00 +3625.0,0.15,2024-06-24 17:59:32+00:00 +3626.0,0.15,2024-06-24 17:59:33+00:00 +3627.0,0.15,2024-06-24 17:59:34+00:00 +3628.0,0.15,2024-06-24 17:59:35+00:00 +3629.0,0.15,2024-06-24 17:59:36+00:00 +3630.0,0.15,2024-06-24 17:59:37+00:00 +3631.0,0.15,2024-06-24 17:59:38+00:00 +3632.0,0.15,2024-06-24 17:59:39+00:00 +3633.0,0.15,2024-06-24 17:59:40+00:00 +3634.0,0.15,2024-06-24 17:59:41+00:00 +3635.0,0.15,2024-06-24 17:59:42+00:00 +3636.0,0.15,2024-06-24 17:59:43+00:00 +3637.0,0.15,2024-06-24 17:59:44+00:00 +3638.0,0.15,2024-06-24 17:59:45+00:00 +3639.0,0.15,2024-06-24 17:59:46+00:00 +3640.0,0.15,2024-06-24 17:59:47+00:00 +3641.0,0.15,2024-06-24 17:59:48+00:00 +3642.0,0.15,2024-06-24 17:59:49+00:00 +3643.0,0.15,2024-06-24 17:59:50+00:00 +3644.0,0.15,2024-06-24 17:59:51+00:00 +3645.0,0.15,2024-06-24 17:59:52+00:00 +3646.0,0.15,2024-06-24 17:59:53+00:00 +3647.0,0.15,2024-06-24 17:59:54+00:00 +3648.0,0.15,2024-06-24 17:59:55+00:00 +3649.0,0.15,2024-06-24 17:59:56+00:00 +3650.0,0.15,2024-06-24 17:59:57+00:00 +3651.0,0.15,2024-06-24 17:59:58+00:00 +3652.0,0.15,2024-06-24 17:59:59+00:00 +3653.0,0.15,2024-06-24 18:00:00+00:00 +3654.0,0.15,2024-06-24 18:00:01+00:00 +3655.0,0.15,2024-06-24 18:00:02+00:00 +3656.0,0.15,2024-06-24 18:00:03+00:00 +3657.0,0.15,2024-06-24 18:00:04+00:00 +3658.0,0.15,2024-06-24 18:00:05+00:00 +3659.0,0.15,2024-06-24 18:00:06+00:00 +3660.0,0.15,2024-06-24 18:00:07+00:00 +3661.0,0.15,2024-06-24 18:00:08+00:00 +3662.0,0.15,2024-06-24 18:00:09+00:00 +3663.0,0.15,2024-06-24 18:00:10+00:00 +3664.0,0.15,2024-06-24 18:00:11+00:00 +3665.0,0.15,2024-06-24 18:00:12+00:00 +3666.0,0.15,2024-06-24 18:00:13+00:00 +3667.0,0.15,2024-06-24 18:00:14+00:00 +3668.0,0.15,2024-06-24 18:00:15+00:00 +3669.0,0.15,2024-06-24 18:00:16+00:00 +3670.0,0.15,2024-06-24 18:00:17+00:00 +3671.0,0.15,2024-06-24 18:00:18+00:00 +3672.0,0.15,2024-06-24 18:00:19+00:00 +3673.0,0.15,2024-06-24 18:00:20+00:00 +3674.0,0.15,2024-06-24 18:00:21+00:00 +3675.0,0.15,2024-06-24 18:00:22+00:00 +3676.0,0.15,2024-06-24 18:00:23+00:00 +3677.0,0.15,2024-06-24 18:00:24+00:00 +3678.0,0.15,2024-06-24 18:00:25+00:00 +3679.0,0.15,2024-06-24 18:00:26+00:00 +3680.0,0.15,2024-06-24 18:00:27+00:00 +3681.0,0.15,2024-06-24 18:00:28+00:00 +3682.0,0.15,2024-06-24 18:00:29+00:00 +3683.0,0.15,2024-06-24 18:00:30+00:00 +3684.0,0.15,2024-06-24 18:00:31+00:00 +3685.0,0.15,2024-06-24 18:00:32+00:00 +3686.0,0.15,2024-06-24 18:00:33+00:00 +3687.0,0.15,2024-06-24 18:00:34+00:00 +3688.0,0.15,2024-06-24 18:00:35+00:00 +3689.0,0.15,2024-06-24 18:00:36+00:00 +3690.0,0.15,2024-06-24 18:00:37+00:00 +3691.0,0.15,2024-06-24 18:00:38+00:00 +3692.0,0.15,2024-06-24 18:00:39+00:00 +3693.0,0.15,2024-06-24 18:00:40+00:00 +3694.0,0.15,2024-06-24 18:00:41+00:00 +3695.0,0.15,2024-06-24 18:00:42+00:00 +3696.0,0.15,2024-06-24 18:00:43+00:00 +3697.0,0.15,2024-06-24 18:00:44+00:00 +3698.0,0.15,2024-06-24 18:00:45+00:00 +3699.0,0.15,2024-06-24 18:00:46+00:00 +3700.0,0.15,2024-06-24 18:00:47+00:00 +3701.0,0.15,2024-06-24 18:00:48+00:00 +3702.0,0.15,2024-06-24 18:00:49+00:00 +3703.0,0.15,2024-06-24 18:00:50+00:00 +3704.0,0.15,2024-06-24 18:00:51+00:00 +3705.0,0.15,2024-06-24 18:00:52+00:00 +3706.0,0.15,2024-06-24 18:00:53+00:00 +3707.0,0.15,2024-06-24 18:00:54+00:00 +3708.0,0.15,2024-06-24 18:00:55+00:00 +3709.0,0.15,2024-06-24 18:00:56+00:00 +3710.0,0.15,2024-06-24 18:00:57+00:00 +3711.0,0.15,2024-06-24 18:00:58+00:00 +3712.0,0.15,2024-06-24 18:00:59+00:00 +3713.0,0.15,2024-06-24 18:01:00+00:00 +3714.0,0.15,2024-06-24 18:01:01+00:00 +3715.0,0.15,2024-06-24 18:01:02+00:00 +3716.0,0.15,2024-06-24 18:01:03+00:00 +3717.0,0.15,2024-06-24 18:01:04+00:00 +3718.0,0.15,2024-06-24 18:01:05+00:00 +3719.0,0.15,2024-06-24 18:01:06+00:00 +3720.0,0.15,2024-06-24 18:01:07+00:00 +3721.0,0.15,2024-06-24 18:01:08+00:00 +3722.0,0.15,2024-06-24 18:01:09+00:00 +3723.0,0.15,2024-06-24 18:01:10+00:00 +3724.0,0.15,2024-06-24 18:01:11+00:00 +3725.0,0.15,2024-06-24 18:01:12+00:00 +3726.0,0.15,2024-06-24 18:01:13+00:00 +3727.0,0.15,2024-06-24 18:01:14+00:00 +3728.0,0.15,2024-06-24 18:01:15+00:00 +3729.0,0.15,2024-06-24 18:01:16+00:00 +3730.0,0.15,2024-06-24 18:01:17+00:00 +3731.0,0.15,2024-06-24 18:01:18+00:00 +3732.0,0.15,2024-06-24 18:01:19+00:00 +3733.0,0.15,2024-06-24 18:01:20+00:00 +3734.0,0.15,2024-06-24 18:01:21+00:00 +3735.0,0.15,2024-06-24 18:01:22+00:00 +3736.0,0.15,2024-06-24 18:01:23+00:00 +3737.0,0.15,2024-06-24 18:01:24+00:00 +3738.0,0.15,2024-06-24 18:01:25+00:00 +3739.0,0.15,2024-06-24 18:01:26+00:00 +3740.0,0.15,2024-06-24 18:01:27+00:00 +3741.0,0.15,2024-06-24 18:01:28+00:00 +3742.0,0.15,2024-06-24 18:01:29+00:00 +3743.0,0.15,2024-06-24 18:01:30+00:00 +3744.0,0.15,2024-06-24 18:01:31+00:00 +3745.0,0.15,2024-06-24 18:01:32+00:00 +3746.0,0.15,2024-06-24 18:01:33+00:00 +3747.0,0.15,2024-06-24 18:01:34+00:00 +3748.0,0.15,2024-06-24 18:01:35+00:00 +3749.0,0.15,2024-06-24 18:01:36+00:00 +3750.0,0.15,2024-06-24 18:01:37+00:00 +3751.0,0.15,2024-06-24 18:01:38+00:00 +3752.0,0.15,2024-06-24 18:01:39+00:00 +3753.0,0.15,2024-06-24 18:01:40+00:00 +3754.0,0.15,2024-06-24 18:01:41+00:00 +3755.0,0.15,2024-06-24 18:01:42+00:00 +3756.0,0.15,2024-06-24 18:01:43+00:00 +3757.0,0.15,2024-06-24 18:01:44+00:00 +3758.0,0.15,2024-06-24 18:01:45+00:00 +3759.0,0.15,2024-06-24 18:01:46+00:00 +3760.0,0.15,2024-06-24 18:01:47+00:00 +3761.0,0.15,2024-06-24 18:01:48+00:00 +3762.0,0.15,2024-06-24 18:01:49+00:00 +3763.0,0.15,2024-06-24 18:01:50+00:00 +3764.0,0.15,2024-06-24 18:01:51+00:00 +3765.0,0.15,2024-06-24 18:01:52+00:00 +3766.0,0.15,2024-06-24 18:01:53+00:00 +3767.0,0.15,2024-06-24 18:01:54+00:00 +3768.0,0.15,2024-06-24 18:01:55+00:00 +3769.0,0.15,2024-06-24 18:01:56+00:00 +3770.0,0.15,2024-06-24 18:01:57+00:00 +3771.0,0.15,2024-06-24 18:01:58+00:00 +3772.0,0.15,2024-06-24 18:01:59+00:00 +3773.0,0.15,2024-06-24 18:02:00+00:00 +3774.0,0.15,2024-06-24 18:02:01+00:00 +3775.0,0.15,2024-06-24 18:02:02+00:00 +3776.0,0.15,2024-06-24 18:02:03+00:00 +3777.0,0.15,2024-06-24 18:02:04+00:00 +3778.0,0.15,2024-06-24 18:02:05+00:00 +3779.0,0.15,2024-06-24 18:02:06+00:00 +3780.0,0.15,2024-06-24 18:02:07+00:00 +3781.0,0.15,2024-06-24 18:02:08+00:00 +3782.0,0.15,2024-06-24 18:02:09+00:00 +3783.0,0.15,2024-06-24 18:02:10+00:00 +3784.0,0.15,2024-06-24 18:02:11+00:00 +3785.0,0.15,2024-06-24 18:02:12+00:00 +3786.0,0.15,2024-06-24 18:02:13+00:00 +3787.0,0.15,2024-06-24 18:02:14+00:00 +3788.0,0.15,2024-06-24 18:02:15+00:00 +3789.0,0.15,2024-06-24 18:02:16+00:00 +3790.0,0.15,2024-06-24 18:02:17+00:00 +3791.0,0.15,2024-06-24 18:02:18+00:00 +3792.0,0.15,2024-06-24 18:02:19+00:00 +3793.0,0.15,2024-06-24 18:02:20+00:00 +3794.0,0.15,2024-06-24 18:02:21+00:00 +3795.0,0.15,2024-06-24 18:02:22+00:00 +3796.0,0.15,2024-06-24 18:02:23+00:00 +3797.0,0.15,2024-06-24 18:02:24+00:00 +3798.0,0.15,2024-06-24 18:02:25+00:00 +3799.0,0.15,2024-06-24 18:02:26+00:00 +3800.0,0.15,2024-06-24 18:02:27+00:00 +3801.0,0.15,2024-06-24 18:02:28+00:00 +3802.0,0.15,2024-06-24 18:02:29+00:00 +3803.0,0.15,2024-06-24 18:02:30+00:00 +3804.0,0.15,2024-06-24 18:02:31+00:00 +3805.0,0.15,2024-06-24 18:02:32+00:00 +3806.0,0.15,2024-06-24 18:02:33+00:00 +3807.0,0.15,2024-06-24 18:02:34+00:00 +3808.0,0.15,2024-06-24 18:02:35+00:00 +3809.0,0.15,2024-06-24 18:02:36+00:00 +3810.0,0.15,2024-06-24 18:02:37+00:00 +3811.0,0.15,2024-06-24 18:02:38+00:00 +3812.0,0.15,2024-06-24 18:02:39+00:00 +3813.0,0.15,2024-06-24 18:02:40+00:00 +3814.0,0.15,2024-06-24 18:02:41+00:00 +3815.0,0.15,2024-06-24 18:02:42+00:00 +3816.0,0.15,2024-06-24 18:02:43+00:00 +3817.0,0.15,2024-06-24 18:02:44+00:00 +3818.0,0.15,2024-06-24 18:02:45+00:00 +3819.0,0.15,2024-06-24 18:02:46+00:00 +3820.0,0.15,2024-06-24 18:02:47+00:00 +3821.0,0.15,2024-06-24 18:02:48+00:00 +3822.0,0.15,2024-06-24 18:02:49+00:00 +3823.0,0.15,2024-06-24 18:02:50+00:00 +3824.0,0.15,2024-06-24 18:02:51+00:00 +3825.0,0.15,2024-06-24 18:02:52+00:00 +3826.0,0.15,2024-06-24 18:02:53+00:00 +3827.0,0.15,2024-06-24 18:02:54+00:00 +3828.0,0.15,2024-06-24 18:02:55+00:00 +3829.0,0.15,2024-06-24 18:02:56+00:00 +3830.0,0.15,2024-06-24 18:02:57+00:00 +3831.0,0.15,2024-06-24 18:02:58+00:00 +3832.0,0.15,2024-06-24 18:02:59+00:00 +3833.0,0.15,2024-06-24 18:03:00+00:00 +3834.0,0.15,2024-06-24 18:03:01+00:00 +3835.0,0.15,2024-06-24 18:03:02+00:00 +3836.0,0.15,2024-06-24 18:03:03+00:00 +3837.0,0.15,2024-06-24 18:03:04+00:00 +3838.0,0.15,2024-06-24 18:03:05+00:00 +3839.0,0.15,2024-06-24 18:03:06+00:00 +3840.0,0.15,2024-06-24 18:03:07+00:00 +3841.0,0.15,2024-06-24 18:03:08+00:00 +3842.0,0.15,2024-06-24 18:03:09+00:00 +3843.0,0.15,2024-06-24 18:03:10+00:00 +3844.0,0.15,2024-06-24 18:03:11+00:00 +3845.0,0.15,2024-06-24 18:03:12+00:00 +3846.0,0.15,2024-06-24 18:03:13+00:00 +3847.0,0.15,2024-06-24 18:03:14+00:00 +3848.0,0.15,2024-06-24 18:03:15+00:00 +3849.0,0.15,2024-06-24 18:03:16+00:00 +3850.0,0.15,2024-06-24 18:03:17+00:00 +3851.0,0.15,2024-06-24 18:03:18+00:00 +3852.0,0.15,2024-06-24 18:03:19+00:00 +3853.0,0.15,2024-06-24 18:03:20+00:00 +3854.0,0.15,2024-06-24 18:03:21+00:00 +3855.0,0.15,2024-06-24 18:03:22+00:00 +3856.0,0.15,2024-06-24 18:03:23+00:00 +3857.0,0.15,2024-06-24 18:03:24+00:00 +3858.0,0.15,2024-06-24 18:03:25+00:00 +3859.0,0.15,2024-06-24 18:03:26+00:00 +3860.0,0.15,2024-06-24 18:03:27+00:00 +3861.0,0.15,2024-06-24 18:03:28+00:00 +3862.0,0.15,2024-06-24 18:03:29+00:00 +3863.0,0.15,2024-06-24 18:03:30+00:00 +3864.0,0.15,2024-06-24 18:03:31+00:00 +3865.0,0.15,2024-06-24 18:03:32+00:00 +3866.0,0.15,2024-06-24 18:03:33+00:00 +3867.0,0.15,2024-06-24 18:03:34+00:00 +3868.0,0.15,2024-06-24 18:03:35+00:00 +3869.0,0.15,2024-06-24 18:03:36+00:00 +3870.0,0.15,2024-06-24 18:03:37+00:00 +3871.0,0.15,2024-06-24 18:03:38+00:00 +3872.0,0.15,2024-06-24 18:03:39+00:00 +3873.0,0.15,2024-06-24 18:03:40+00:00 +3874.0,0.15,2024-06-24 18:03:41+00:00 +3875.0,0.15,2024-06-24 18:03:42+00:00 +3876.0,0.15,2024-06-24 18:03:43+00:00 +3877.0,0.15,2024-06-24 18:03:44+00:00 +3878.0,0.15,2024-06-24 18:03:45+00:00 +3879.0,0.15,2024-06-24 18:03:46+00:00 +3880.0,0.15,2024-06-24 18:03:47+00:00 +3881.0,0.15,2024-06-24 18:03:48+00:00 +3882.0,0.15,2024-06-24 18:03:49+00:00 +3883.0,0.15,2024-06-24 18:03:50+00:00 +3884.0,0.15,2024-06-24 18:03:51+00:00 +3885.0,0.15,2024-06-24 18:03:52+00:00 +3886.0,0.15,2024-06-24 18:03:53+00:00 +3887.0,0.15,2024-06-24 18:03:54+00:00 +3888.0,0.15,2024-06-24 18:03:55+00:00 +3889.0,0.15,2024-06-24 18:03:56+00:00 +3890.0,0.15,2024-06-24 18:03:57+00:00 +3891.0,0.15,2024-06-24 18:03:58+00:00 +3892.0,0.15,2024-06-24 18:03:59+00:00 +3893.0,0.15,2024-06-24 18:04:00+00:00 +3894.0,0.15,2024-06-24 18:04:01+00:00 +3895.0,0.15,2024-06-24 18:04:02+00:00 +3896.0,0.15,2024-06-24 18:04:03+00:00 +3897.0,0.15,2024-06-24 18:04:04+00:00 +3898.0,0.15,2024-06-24 18:04:05+00:00 +3899.0,0.15,2024-06-24 18:04:06+00:00 +3900.0,0.15,2024-06-24 18:04:07+00:00 +3901.0,0.15,2024-06-24 18:04:08+00:00 +3902.0,0.15,2024-06-24 18:04:09+00:00 +3903.0,0.15,2024-06-24 18:04:10+00:00 +3904.0,0.15,2024-06-24 18:04:11+00:00 +3905.0,0.15,2024-06-24 18:04:12+00:00 +3906.0,0.15,2024-06-24 18:04:13+00:00 +3907.0,0.15,2024-06-24 18:04:14+00:00 +3908.0,0.15,2024-06-24 18:04:15+00:00 +3909.0,0.15,2024-06-24 18:04:16+00:00 +3910.0,0.15,2024-06-24 18:04:17+00:00 +3911.0,0.15,2024-06-24 18:04:18+00:00 +3912.0,0.15,2024-06-24 18:04:19+00:00 +3913.0,0.15,2024-06-24 18:04:20+00:00 +3914.0,0.15,2024-06-24 18:04:21+00:00 +3915.0,0.15,2024-06-24 18:04:22+00:00 +3916.0,0.15,2024-06-24 18:04:23+00:00 +3917.0,0.15,2024-06-24 18:04:24+00:00 +3918.0,0.15,2024-06-24 18:04:25+00:00 +3919.0,0.15,2024-06-24 18:04:26+00:00 +3920.0,0.15,2024-06-24 18:04:27+00:00 +3921.0,0.15,2024-06-24 18:04:28+00:00 +3922.0,0.15,2024-06-24 18:04:29+00:00 +3923.0,0.15,2024-06-24 18:04:30+00:00 +3924.0,0.15,2024-06-24 18:04:31+00:00 +3925.0,0.15,2024-06-24 18:04:32+00:00 +3926.0,0.15,2024-06-24 18:04:33+00:00 +3927.0,0.15,2024-06-24 18:04:34+00:00 +3928.0,0.15,2024-06-24 18:04:35+00:00 +3929.0,0.15,2024-06-24 18:04:36+00:00 +3930.0,0.15,2024-06-24 18:04:37+00:00 +3931.0,0.15,2024-06-24 18:04:38+00:00 +3932.0,0.15,2024-06-24 18:04:39+00:00 +3933.0,0.15,2024-06-24 18:04:40+00:00 +3934.0,0.15,2024-06-24 18:04:41+00:00 +3935.0,0.15,2024-06-24 18:04:42+00:00 +3936.0,0.15,2024-06-24 18:04:43+00:00 +3937.0,0.15,2024-06-24 18:04:44+00:00 +3938.0,0.15,2024-06-24 18:04:45+00:00 +3939.0,0.15,2024-06-24 18:04:46+00:00 +3940.0,0.15,2024-06-24 18:04:47+00:00 +3941.0,0.15,2024-06-24 18:04:48+00:00 +3942.0,0.15,2024-06-24 18:04:49+00:00 +3943.0,0.15,2024-06-24 18:04:50+00:00 +3944.0,0.15,2024-06-24 18:04:51+00:00 +3945.0,0.15,2024-06-24 18:04:52+00:00 +3946.0,0.15,2024-06-24 18:04:53+00:00 +3947.0,0.15,2024-06-24 18:04:54+00:00 +3948.0,0.15,2024-06-24 18:04:55+00:00 +3949.0,0.15,2024-06-24 18:04:56+00:00 +3950.0,0.15,2024-06-24 18:04:57+00:00 +3951.0,0.15,2024-06-24 18:04:58+00:00 +3952.0,0.15,2024-06-24 18:04:59+00:00 +3953.0,0.15,2024-06-24 18:05:00+00:00 +3954.0,0.15,2024-06-24 18:05:01+00:00 +3955.0,0.15,2024-06-24 18:05:02+00:00 +3956.0,0.15,2024-06-24 18:05:03+00:00 +3957.0,0.15,2024-06-24 18:05:04+00:00 +3958.0,0.15,2024-06-24 18:05:05+00:00 +3959.0,0.15,2024-06-24 18:05:06+00:00 +3960.0,0.15,2024-06-24 18:05:07+00:00 +3961.0,0.15,2024-06-24 18:05:08+00:00 +3962.0,0.15,2024-06-24 18:05:09+00:00 +3963.0,0.15,2024-06-24 18:05:10+00:00 +3964.0,0.15,2024-06-24 18:05:11+00:00 +3965.0,0.15,2024-06-24 18:05:12+00:00 +3966.0,0.15,2024-06-24 18:05:13+00:00 +3967.0,0.15,2024-06-24 18:05:14+00:00 +3968.0,0.15,2024-06-24 18:05:15+00:00 +3969.0,0.15,2024-06-24 18:05:16+00:00 +3970.0,0.15,2024-06-24 18:05:17+00:00 +3971.0,0.15,2024-06-24 18:05:18+00:00 +3972.0,0.15,2024-06-24 18:05:19+00:00 +3973.0,0.15,2024-06-24 18:05:20+00:00 +3974.0,0.15,2024-06-24 18:05:21+00:00 +3975.0,0.15,2024-06-24 18:05:22+00:00 +3976.0,0.15,2024-06-24 18:05:23+00:00 +3977.0,0.15,2024-06-24 18:05:24+00:00 +3978.0,0.15,2024-06-24 18:05:25+00:00 +3979.0,0.15,2024-06-24 18:05:26+00:00 +3980.0,0.15,2024-06-24 18:05:27+00:00 +3981.0,0.15,2024-06-24 18:05:28+00:00 +3982.0,0.15,2024-06-24 18:05:29+00:00 +3983.0,0.15,2024-06-24 18:05:30+00:00 +3984.0,0.15,2024-06-24 18:05:31+00:00 +3985.0,0.15,2024-06-24 18:05:32+00:00 +3986.0,0.15,2024-06-24 18:05:33+00:00 +3987.0,0.15,2024-06-24 18:05:34+00:00 +3988.0,0.15,2024-06-24 18:05:35+00:00 +3989.0,0.15,2024-06-24 18:05:36+00:00 +3990.0,0.15,2024-06-24 18:05:37+00:00 +3991.0,0.15,2024-06-24 18:05:38+00:00 +3992.0,0.15,2024-06-24 18:05:39+00:00 +3993.0,0.15,2024-06-24 18:05:40+00:00 +3994.0,0.15,2024-06-24 18:05:41+00:00 +3995.0,0.15,2024-06-24 18:05:42+00:00 +3996.0,0.15,2024-06-24 18:05:43+00:00 +3997.0,0.15,2024-06-24 18:05:44+00:00 +3998.0,0.15,2024-06-24 18:05:45+00:00 +3999.0,0.15,2024-06-24 18:05:46+00:00 +4000.0,0.15,2024-06-24 18:05:47+00:00 +4001.0,0.15,2024-06-24 18:05:48+00:00 +4002.0,0.15,2024-06-24 18:05:49+00:00 +4003.0,0.15,2024-06-24 18:05:50+00:00 +4004.0,0.15,2024-06-24 18:05:51+00:00 +4005.0,0.15,2024-06-24 18:05:52+00:00 +4006.0,0.15,2024-06-24 18:05:53+00:00 +4007.0,0.15,2024-06-24 18:05:54+00:00 +4008.0,0.15,2024-06-24 18:05:55+00:00 +4009.0,0.15,2024-06-24 18:05:56+00:00 +4010.0,0.15,2024-06-24 18:05:57+00:00 +4011.0,0.15,2024-06-24 18:05:58+00:00 +4012.0,0.15,2024-06-24 18:05:59+00:00 +4013.0,0.15,2024-06-24 18:06:00+00:00 +4014.0,0.15,2024-06-24 18:06:01+00:00 +4015.0,0.15,2024-06-24 18:06:02+00:00 +4016.0,0.15,2024-06-24 18:06:03+00:00 +4017.0,0.15,2024-06-24 18:06:04+00:00 +4018.0,0.15,2024-06-24 18:06:05+00:00 +4019.0,0.15,2024-06-24 18:06:06+00:00 +4020.0,0.15,2024-06-24 18:06:07+00:00 +4021.0,0.15,2024-06-24 18:06:08+00:00 +4022.0,0.15,2024-06-24 18:06:09+00:00 +4023.0,0.15,2024-06-24 18:06:10+00:00 +4024.0,0.15,2024-06-24 18:06:11+00:00 +4025.0,0.15,2024-06-24 18:06:12+00:00 +4026.0,0.15,2024-06-24 18:06:13+00:00 +4027.0,0.15,2024-06-24 18:06:14+00:00 +4028.0,0.15,2024-06-24 18:06:15+00:00 +4029.0,0.15,2024-06-24 18:06:16+00:00 +4030.0,0.15,2024-06-24 18:06:17+00:00 +4031.0,0.15,2024-06-24 18:06:18+00:00 +4032.0,0.15,2024-06-24 18:06:19+00:00 +4033.0,0.15,2024-06-24 18:06:20+00:00 +4034.0,0.15,2024-06-24 18:06:21+00:00 +4035.0,0.15,2024-06-24 18:06:22+00:00 +4036.0,0.15,2024-06-24 18:06:23+00:00 +4037.0,0.15,2024-06-24 18:06:24+00:00 +4038.0,0.15,2024-06-24 18:06:25+00:00 +4039.0,0.15,2024-06-24 18:06:26+00:00 +4040.0,0.15,2024-06-24 18:06:27+00:00 +4041.0,0.15,2024-06-24 18:06:28+00:00 +4042.0,0.15,2024-06-24 18:06:29+00:00 +4043.0,0.15,2024-06-24 18:06:30+00:00 +4044.0,0.15,2024-06-24 18:06:31+00:00 +4045.0,0.15,2024-06-24 18:06:32+00:00 +4046.0,0.15,2024-06-24 18:06:33+00:00 +4047.0,0.15,2024-06-24 18:06:34+00:00 +4048.0,0.15,2024-06-24 18:06:35+00:00 +4049.0,0.15,2024-06-24 18:06:36+00:00 +4050.0,0.15,2024-06-24 18:06:37+00:00 +4051.0,0.15,2024-06-24 18:06:38+00:00 +4052.0,0.15,2024-06-24 18:06:39+00:00 +4053.0,0.15,2024-06-24 18:06:40+00:00 +4054.0,0.15,2024-06-24 18:06:41+00:00 +4055.0,0.15,2024-06-24 18:06:42+00:00 +4056.0,0.15,2024-06-24 18:06:43+00:00 +4057.0,0.15,2024-06-24 18:06:44+00:00 +4058.0,0.15,2024-06-24 18:06:45+00:00 +4059.0,0.15,2024-06-24 18:06:46+00:00 +4060.0,0.15,2024-06-24 18:06:47+00:00 +4061.0,0.15,2024-06-24 18:06:48+00:00 +4062.0,0.15,2024-06-24 18:06:49+00:00 +4063.0,0.15,2024-06-24 18:06:50+00:00 +4064.0,0.15,2024-06-24 18:06:51+00:00 +4065.0,0.15,2024-06-24 18:06:52+00:00 +4066.0,0.15,2024-06-24 18:06:53+00:00 +4067.0,0.15,2024-06-24 18:06:54+00:00 +4068.0,0.15,2024-06-24 18:06:55+00:00 +4069.0,0.15,2024-06-24 18:06:56+00:00 +4070.0,0.15,2024-06-24 18:06:57+00:00 +4071.0,0.15,2024-06-24 18:06:58+00:00 +4072.0,0.15,2024-06-24 18:06:59+00:00 +4073.0,0.15,2024-06-24 18:07:00+00:00 +4074.0,0.15,2024-06-24 18:07:01+00:00 +4075.0,0.15,2024-06-24 18:07:02+00:00 +4076.0,0.15,2024-06-24 18:07:03+00:00 +4077.0,0.15,2024-06-24 18:07:04+00:00 +4078.0,0.15,2024-06-24 18:07:05+00:00 +4079.0,0.15,2024-06-24 18:07:06+00:00 +4080.0,0.15,2024-06-24 18:07:07+00:00 +4081.0,0.15,2024-06-24 18:07:08+00:00 +4082.0,0.15,2024-06-24 18:07:09+00:00 +4083.0,0.15,2024-06-24 18:07:10+00:00 +4084.0,0.15,2024-06-24 18:07:11+00:00 +4085.0,0.15,2024-06-24 18:07:12+00:00 +4086.0,0.15,2024-06-24 18:07:13+00:00 +4087.0,0.15,2024-06-24 18:07:14+00:00 +4088.0,0.15,2024-06-24 18:07:15+00:00 +4089.0,0.15,2024-06-24 18:07:16+00:00 +4090.0,0.15,2024-06-24 18:07:17+00:00 +4091.0,0.15,2024-06-24 18:07:18+00:00 +4092.0,0.15,2024-06-24 18:07:19+00:00 +4093.0,0.15,2024-06-24 18:07:20+00:00 +4094.0,0.15,2024-06-24 18:07:21+00:00 +4095.0,0.15,2024-06-24 18:07:22+00:00 +4096.0,0.15,2024-06-24 18:07:23+00:00 +4097.0,0.15,2024-06-24 18:07:24+00:00 +4098.0,0.15,2024-06-24 18:07:25+00:00 +4099.0,0.15,2024-06-24 18:07:26+00:00 +4100.0,0.15,2024-06-24 18:07:27+00:00 +4101.0,0.15,2024-06-24 18:07:28+00:00 +4102.0,0.15,2024-06-24 18:07:29+00:00 +4103.0,0.15,2024-06-24 18:07:30+00:00 +4104.0,0.15,2024-06-24 18:07:31+00:00 +4105.0,0.15,2024-06-24 18:07:32+00:00 +4106.0,0.15,2024-06-24 18:07:33+00:00 +4107.0,0.15,2024-06-24 18:07:34+00:00 +4108.0,0.15,2024-06-24 18:07:35+00:00 +4109.0,0.15,2024-06-24 18:07:36+00:00 +4110.0,0.15,2024-06-24 18:07:37+00:00 +4111.0,0.15,2024-06-24 18:07:38+00:00 +4112.0,0.15,2024-06-24 18:07:39+00:00 +4113.0,0.15,2024-06-24 18:07:40+00:00 +4114.0,0.15,2024-06-24 18:07:41+00:00 +4115.0,0.15,2024-06-24 18:07:42+00:00 +4116.0,0.15,2024-06-24 18:07:43+00:00 +4117.0,0.15,2024-06-24 18:07:44+00:00 +4118.0,0.15,2024-06-24 18:07:45+00:00 +4119.0,0.15,2024-06-24 18:07:46+00:00 +4120.0,0.15,2024-06-24 18:07:47+00:00 +4121.0,0.15,2024-06-24 18:07:48+00:00 +4122.0,0.15,2024-06-24 18:07:49+00:00 +4123.0,0.15,2024-06-24 18:07:50+00:00 +4124.0,0.15,2024-06-24 18:07:51+00:00 +4125.0,0.15,2024-06-24 18:07:52+00:00 +4126.0,0.15,2024-06-24 18:07:53+00:00 +4127.0,0.15,2024-06-24 18:07:54+00:00 +4128.0,0.15,2024-06-24 18:07:55+00:00 +4129.0,0.15,2024-06-24 18:07:56+00:00 +4130.0,0.15,2024-06-24 18:07:57+00:00 +4131.0,0.15,2024-06-24 18:07:58+00:00 +4132.0,0.15,2024-06-24 18:07:59+00:00 +4133.0,0.15,2024-06-24 18:08:00+00:00 +4134.0,0.15,2024-06-24 18:08:01+00:00 +4135.0,0.15,2024-06-24 18:08:02+00:00 +4136.0,0.15,2024-06-24 18:08:03+00:00 +4137.0,0.15,2024-06-24 18:08:04+00:00 +4138.0,0.15,2024-06-24 18:08:05+00:00 +4139.0,0.15,2024-06-24 18:08:06+00:00 +4140.0,0.15,2024-06-24 18:08:07+00:00 +4141.0,0.15,2024-06-24 18:08:08+00:00 +4142.0,0.15,2024-06-24 18:08:09+00:00 +4143.0,0.15,2024-06-24 18:08:10+00:00 +4144.0,0.15,2024-06-24 18:08:11+00:00 +4145.0,0.15,2024-06-24 18:08:12+00:00 +4146.0,0.15,2024-06-24 18:08:13+00:00 +4147.0,0.15,2024-06-24 18:08:14+00:00 +4148.0,0.15,2024-06-24 18:08:15+00:00 +4149.0,0.15,2024-06-24 18:08:16+00:00 +4150.0,0.15,2024-06-24 18:08:17+00:00 +4151.0,0.15,2024-06-24 18:08:18+00:00 +4152.0,0.15,2024-06-24 18:08:19+00:00 +4153.0,0.15,2024-06-24 18:08:20+00:00 +4154.0,0.15,2024-06-24 18:08:21+00:00 +4155.0,0.15,2024-06-24 18:08:22+00:00 +4156.0,0.15,2024-06-24 18:08:23+00:00 +4157.0,0.15,2024-06-24 18:08:24+00:00 +4158.0,0.15,2024-06-24 18:08:25+00:00 +4159.0,0.15,2024-06-24 18:08:26+00:00 +4160.0,0.15,2024-06-24 18:08:27+00:00 +4161.0,0.15,2024-06-24 18:08:28+00:00 +4162.0,0.15,2024-06-24 18:08:29+00:00 +4163.0,0.15,2024-06-24 18:08:30+00:00 +4164.0,0.15,2024-06-24 18:08:31+00:00 +4165.0,0.15,2024-06-24 18:08:32+00:00 +4166.0,0.15,2024-06-24 18:08:33+00:00 +4167.0,0.15,2024-06-24 18:08:34+00:00 +4168.0,0.15,2024-06-24 18:08:35+00:00 +4169.0,0.15,2024-06-24 18:08:36+00:00 +4170.0,0.15,2024-06-24 18:08:37+00:00 +4171.0,0.15,2024-06-24 18:08:38+00:00 +4172.0,0.15,2024-06-24 18:08:39+00:00 +4173.0,0.15,2024-06-24 18:08:40+00:00 +4174.0,0.15,2024-06-24 18:08:41+00:00 +4175.0,0.15,2024-06-24 18:08:42+00:00 +4176.0,0.15,2024-06-24 18:08:43+00:00 +4177.0,0.15,2024-06-24 18:08:44+00:00 +4178.0,0.15,2024-06-24 18:08:45+00:00 +4179.0,0.15,2024-06-24 18:08:46+00:00 +4180.0,0.15,2024-06-24 18:08:47+00:00 +4181.0,0.15,2024-06-24 18:08:48+00:00 +4182.0,0.15,2024-06-24 18:08:49+00:00 +4183.0,0.15,2024-06-24 18:08:50+00:00 +4184.0,0.15,2024-06-24 18:08:51+00:00 +4185.0,0.15,2024-06-24 18:08:52+00:00 +4186.0,0.15,2024-06-24 18:08:53+00:00 +4187.0,0.15,2024-06-24 18:08:54+00:00 +4188.0,0.15,2024-06-24 18:08:55+00:00 +4189.0,0.15,2024-06-24 18:08:56+00:00 +4190.0,0.15,2024-06-24 18:08:57+00:00 +4191.0,0.15,2024-06-24 18:08:58+00:00 +4192.0,0.15,2024-06-24 18:08:59+00:00 +4193.0,0.15,2024-06-24 18:09:00+00:00 +4194.0,0.15,2024-06-24 18:09:01+00:00 +4195.0,0.15,2024-06-24 18:09:02+00:00 +4196.0,0.15,2024-06-24 18:09:03+00:00 +4197.0,0.15,2024-06-24 18:09:04+00:00 +4198.0,0.15,2024-06-24 18:09:05+00:00 +4199.0,0.15,2024-06-24 18:09:06+00:00 +4200.0,0.15,2024-06-24 18:09:07+00:00 +4201.0,0.15,2024-06-24 18:09:08+00:00 +4202.0,0.15,2024-06-24 18:09:09+00:00 +4203.0,0.15,2024-06-24 18:09:10+00:00 +4204.0,0.15,2024-06-24 18:09:11+00:00 +4205.0,0.15,2024-06-24 18:09:12+00:00 +4206.0,0.15,2024-06-24 18:09:13+00:00 +4207.0,0.15,2024-06-24 18:09:14+00:00 +4208.0,0.15,2024-06-24 18:09:15+00:00 +4209.0,0.15,2024-06-24 18:09:16+00:00 +4210.0,0.15,2024-06-24 18:09:17+00:00 +4211.0,0.15,2024-06-24 18:09:18+00:00 +4212.0,0.15,2024-06-24 18:09:19+00:00 +4213.0,0.15,2024-06-24 18:09:20+00:00 +4214.0,0.15,2024-06-24 18:09:21+00:00 +4215.0,0.15,2024-06-24 18:09:22+00:00 +4216.0,0.15,2024-06-24 18:09:23+00:00 +4217.0,0.15,2024-06-24 18:09:24+00:00 +4218.0,0.15,2024-06-24 18:09:25+00:00 +4219.0,0.15,2024-06-24 18:09:26+00:00 +4220.0,0.15,2024-06-24 18:09:27+00:00 +4221.0,0.15,2024-06-24 18:09:28+00:00 +4222.0,0.15,2024-06-24 18:09:29+00:00 +4223.0,0.15,2024-06-24 18:09:30+00:00 +4224.0,0.15,2024-06-24 18:09:31+00:00 +4225.0,0.15,2024-06-24 18:09:32+00:00 +4226.0,0.15,2024-06-24 18:09:33+00:00 +4227.0,0.15,2024-06-24 18:09:34+00:00 +4228.0,0.15,2024-06-24 18:09:35+00:00 +4229.0,0.15,2024-06-24 18:09:36+00:00 +4230.0,0.15,2024-06-24 18:09:37+00:00 +4231.0,0.15,2024-06-24 18:09:38+00:00 +4232.0,0.15,2024-06-24 18:09:39+00:00 +4233.0,0.15,2024-06-24 18:09:40+00:00 +4234.0,0.15,2024-06-24 18:09:41+00:00 +4235.0,0.15,2024-06-24 18:09:42+00:00 +4236.0,0.15,2024-06-24 18:09:43+00:00 +4237.0,0.15,2024-06-24 18:09:44+00:00 +4238.0,0.15,2024-06-24 18:09:45+00:00 +4239.0,0.15,2024-06-24 18:09:46+00:00 +4240.0,0.15,2024-06-24 18:09:47+00:00 +4241.0,0.15,2024-06-24 18:09:48+00:00 +4242.0,0.15,2024-06-24 18:09:49+00:00 +4243.0,0.15,2024-06-24 18:09:50+00:00 +4244.0,0.15,2024-06-24 18:09:51+00:00 +4245.0,0.15,2024-06-24 18:09:52+00:00 +4246.0,0.15,2024-06-24 18:09:53+00:00 +4247.0,0.15,2024-06-24 18:09:54+00:00 +4248.0,0.15,2024-06-24 18:09:55+00:00 +4249.0,0.15,2024-06-24 18:09:56+00:00 +4250.0,0.15,2024-06-24 18:09:57+00:00 +4251.0,0.15,2024-06-24 18:09:58+00:00 +4252.0,0.15,2024-06-24 18:09:59+00:00 +4253.0,0.15,2024-06-24 18:10:00+00:00 +4254.0,0.15,2024-06-24 18:10:01+00:00 +4255.0,0.15,2024-06-24 18:10:02+00:00 +4256.0,0.15,2024-06-24 18:10:03+00:00 +4257.0,0.15,2024-06-24 18:10:04+00:00 +4258.0,0.15,2024-06-24 18:10:05+00:00 +4259.0,0.15,2024-06-24 18:10:06+00:00 +4260.0,0.15,2024-06-24 18:10:07+00:00 +4261.0,0.15,2024-06-24 18:10:08+00:00 +4262.0,0.15,2024-06-24 18:10:09+00:00 +4263.0,0.15,2024-06-24 18:10:10+00:00 +4264.0,0.15,2024-06-24 18:10:11+00:00 +4265.0,0.15,2024-06-24 18:10:12+00:00 +4266.0,0.15,2024-06-24 18:10:13+00:00 +4267.0,0.15,2024-06-24 18:10:14+00:00 +4268.0,0.15,2024-06-24 18:10:15+00:00 +4269.0,0.15,2024-06-24 18:10:16+00:00 +4270.0,0.15,2024-06-24 18:10:17+00:00 +4271.0,0.15,2024-06-24 18:10:18+00:00 +4272.0,0.15,2024-06-24 18:10:19+00:00 +4273.0,0.15,2024-06-24 18:10:20+00:00 +4274.0,0.15,2024-06-24 18:10:21+00:00 +4275.0,0.15,2024-06-24 18:10:22+00:00 +4276.0,0.15,2024-06-24 18:10:23+00:00 +4277.0,0.15,2024-06-24 18:10:24+00:00 +4278.0,0.15,2024-06-24 18:10:25+00:00 +4279.0,0.15,2024-06-24 18:10:26+00:00 +4280.0,0.15,2024-06-24 18:10:27+00:00 +4281.0,0.15,2024-06-24 18:10:28+00:00 +4282.0,0.15,2024-06-24 18:10:29+00:00 +4283.0,0.15,2024-06-24 18:10:30+00:00 +4284.0,0.15,2024-06-24 18:10:31+00:00 +4285.0,0.15,2024-06-24 18:10:32+00:00 +4286.0,0.15,2024-06-24 18:10:33+00:00 +4287.0,0.15,2024-06-24 18:10:34+00:00 +4288.0,0.15,2024-06-24 18:10:35+00:00 +4289.0,0.15,2024-06-24 18:10:36+00:00 +4290.0,0.15,2024-06-24 18:10:37+00:00 +4291.0,0.15,2024-06-24 18:10:38+00:00 +4292.0,0.15,2024-06-24 18:10:39+00:00 +4293.0,0.15,2024-06-24 18:10:40+00:00 +4294.0,0.15,2024-06-24 18:10:41+00:00 +4295.0,0.15,2024-06-24 18:10:42+00:00 +4296.0,0.15,2024-06-24 18:10:43+00:00 +4297.0,0.15,2024-06-24 18:10:44+00:00 +4298.0,0.15,2024-06-24 18:10:45+00:00 +4299.0,0.15,2024-06-24 18:10:46+00:00 +4300.0,0.15,2024-06-24 18:10:47+00:00 +4301.0,0.15,2024-06-24 18:10:48+00:00 +4302.0,0.15,2024-06-24 18:10:49+00:00 +4303.0,0.15,2024-06-24 18:10:50+00:00 +4304.0,0.15,2024-06-24 18:10:51+00:00 +4305.0,0.15,2024-06-24 18:10:52+00:00 +4306.0,0.15,2024-06-24 18:10:53+00:00 +4307.0,0.15,2024-06-24 18:10:54+00:00 +4308.0,0.15,2024-06-24 18:10:55+00:00 +4309.0,0.15,2024-06-24 18:10:56+00:00 +4310.0,0.15,2024-06-24 18:10:57+00:00 +4311.0,0.15,2024-06-24 18:10:58+00:00 +4312.0,0.15,2024-06-24 18:10:59+00:00 +4313.0,0.15,2024-06-24 18:11:00+00:00 +4314.0,0.15,2024-06-24 18:11:01+00:00 +4315.0,0.15,2024-06-24 18:11:02+00:00 +4316.0,0.15,2024-06-24 18:11:03+00:00 +4317.0,0.15,2024-06-24 18:11:04+00:00 +4318.0,0.15,2024-06-24 18:11:05+00:00 +4319.0,0.15,2024-06-24 18:11:06+00:00 +4320.0,0.15,2024-06-24 18:11:07+00:00 +4321.0,0.15,2024-06-24 18:11:08+00:00 +4322.0,0.15,2024-06-24 18:11:09+00:00 +4323.0,0.15,2024-06-24 18:11:10+00:00 +4324.0,0.15,2024-06-24 18:11:11+00:00 +4325.0,0.15,2024-06-24 18:11:12+00:00 +4326.0,0.15,2024-06-24 18:11:13+00:00 +4327.0,0.15,2024-06-24 18:11:14+00:00 +4328.0,0.15,2024-06-24 18:11:15+00:00 +4329.0,0.15,2024-06-24 18:11:16+00:00 +4330.0,0.15,2024-06-24 18:11:17+00:00 +4331.0,0.15,2024-06-24 18:11:18+00:00 +4332.0,0.15,2024-06-24 18:11:19+00:00 +4333.0,0.15,2024-06-24 18:11:20+00:00 +4334.0,0.15,2024-06-24 18:11:21+00:00 +4335.0,0.15,2024-06-24 18:11:22+00:00 +4336.0,0.15,2024-06-24 18:11:23+00:00 +4337.0,0.15,2024-06-24 18:11:24+00:00 +4338.0,0.15,2024-06-24 18:11:25+00:00 +4339.0,0.15,2024-06-24 18:11:26+00:00 +4340.0,0.15,2024-06-24 18:11:27+00:00 +4341.0,0.15,2024-06-24 18:11:28+00:00 +4342.0,0.15,2024-06-24 18:11:29+00:00 +4343.0,0.15,2024-06-24 18:11:30+00:00 +4344.0,0.15,2024-06-24 18:11:31+00:00 +4345.0,0.15,2024-06-24 18:11:32+00:00 +4346.0,0.15,2024-06-24 18:11:33+00:00 +4347.0,0.15,2024-06-24 18:11:34+00:00 +4348.0,0.15,2024-06-24 18:11:35+00:00 +4349.0,0.15,2024-06-24 18:11:36+00:00 +4350.0,0.15,2024-06-24 18:11:37+00:00 +4351.0,0.15,2024-06-24 18:11:38+00:00 +4352.0,0.15,2024-06-24 18:11:39+00:00 +4353.0,0.15,2024-06-24 18:11:40+00:00 +4354.0,0.15,2024-06-24 18:11:41+00:00 +4355.0,0.15,2024-06-24 18:11:42+00:00 +4356.0,0.15,2024-06-24 18:11:43+00:00 +4357.0,0.15,2024-06-24 18:11:44+00:00 +4358.0,0.15,2024-06-24 18:11:45+00:00 +4359.0,0.15,2024-06-24 18:11:46+00:00 +4360.0,0.15,2024-06-24 18:11:47+00:00 +4361.0,0.15,2024-06-24 18:11:48+00:00 +4362.0,0.15,2024-06-24 18:11:49+00:00 +4363.0,0.15,2024-06-24 18:11:50+00:00 +4364.0,0.15,2024-06-24 18:11:51+00:00 +4365.0,0.15,2024-06-24 18:11:52+00:00 +4366.0,0.15,2024-06-24 18:11:53+00:00 +4367.0,0.15,2024-06-24 18:11:54+00:00 +4368.0,0.15,2024-06-24 18:11:55+00:00 +4369.0,0.15,2024-06-24 18:11:56+00:00 +4370.0,0.15,2024-06-24 18:11:57+00:00 +4371.0,0.15,2024-06-24 18:11:58+00:00 +4372.0,0.15,2024-06-24 18:11:59+00:00 +4373.0,0.15,2024-06-24 18:12:00+00:00 +4374.0,0.15,2024-06-24 18:12:01+00:00 +4375.0,0.15,2024-06-24 18:12:02+00:00 +4376.0,0.15,2024-06-24 18:12:03+00:00 +4377.0,0.15,2024-06-24 18:12:04+00:00 +4378.0,0.15,2024-06-24 18:12:05+00:00 +4379.0,0.15,2024-06-24 18:12:06+00:00 +4380.0,0.15,2024-06-24 18:12:07+00:00 +4381.0,0.15,2024-06-24 18:12:08+00:00 +4382.0,0.15,2024-06-24 18:12:09+00:00 +4383.0,0.15,2024-06-24 18:12:10+00:00 +4384.0,0.15,2024-06-24 18:12:11+00:00 +4385.0,0.15,2024-06-24 18:12:12+00:00 +4386.0,0.15,2024-06-24 18:12:13+00:00 +4387.0,0.15,2024-06-24 18:12:14+00:00 +4388.0,0.15,2024-06-24 18:12:15+00:00 +4389.0,0.15,2024-06-24 18:12:16+00:00 +4390.0,0.15,2024-06-24 18:12:17+00:00 +4391.0,0.15,2024-06-24 18:12:18+00:00 +4392.0,0.15,2024-06-24 18:12:19+00:00 +4393.0,0.15,2024-06-24 18:12:20+00:00 +4394.0,0.15,2024-06-24 18:12:21+00:00 +4395.0,0.15,2024-06-24 18:12:22+00:00 +4396.0,0.15,2024-06-24 18:12:23+00:00 +4397.0,0.15,2024-06-24 18:12:24+00:00 +4398.0,0.15,2024-06-24 18:12:25+00:00 +4399.0,0.15,2024-06-24 18:12:26+00:00 +4400.0,0.15,2024-06-24 18:12:27+00:00 +4401.0,0.15,2024-06-24 18:12:28+00:00 +4402.0,0.15,2024-06-24 18:12:29+00:00 +4403.0,0.15,2024-06-24 18:12:30+00:00 +4404.0,0.15,2024-06-24 18:12:31+00:00 +4405.0,0.15,2024-06-24 18:12:32+00:00 +4406.0,0.15,2024-06-24 18:12:33+00:00 +4407.0,0.15,2024-06-24 18:12:34+00:00 +4408.0,0.15,2024-06-24 18:12:35+00:00 +4409.0,0.15,2024-06-24 18:12:36+00:00 +4410.0,0.15,2024-06-24 18:12:37+00:00 +4411.0,0.15,2024-06-24 18:12:38+00:00 +4412.0,0.15,2024-06-24 18:12:39+00:00 +4413.0,0.15,2024-06-24 18:12:40+00:00 +4414.0,0.15,2024-06-24 18:12:41+00:00 +4415.0,0.15,2024-06-24 18:12:42+00:00 +4416.0,0.15,2024-06-24 18:12:43+00:00 +4417.0,0.15,2024-06-24 18:12:44+00:00 +4418.0,0.15,2024-06-24 18:12:45+00:00 +4419.0,0.15,2024-06-24 18:12:46+00:00 +4420.0,0.15,2024-06-24 18:12:47+00:00 +4421.0,0.15,2024-06-24 18:12:48+00:00 +4422.0,0.15,2024-06-24 18:12:49+00:00 +4423.0,0.15,2024-06-24 18:12:50+00:00 +4424.0,0.15,2024-06-24 18:12:51+00:00 +4425.0,0.15,2024-06-24 18:12:52+00:00 +4426.0,0.15,2024-06-24 18:12:53+00:00 +4427.0,0.15,2024-06-24 18:12:54+00:00 +4428.0,0.15,2024-06-24 18:12:55+00:00 +4429.0,0.15,2024-06-24 18:12:56+00:00 +4430.0,0.15,2024-06-24 18:12:57+00:00 +4431.0,0.15,2024-06-24 18:12:58+00:00 +4432.0,0.15,2024-06-24 18:12:59+00:00 +4433.0,0.15,2024-06-24 18:13:00+00:00 +4434.0,0.15,2024-06-24 18:13:01+00:00 +4435.0,0.15,2024-06-24 18:13:02+00:00 +4436.0,0.15,2024-06-24 18:13:03+00:00 +4437.0,0.15,2024-06-24 18:13:04+00:00 +4438.0,0.15,2024-06-24 18:13:05+00:00 +4439.0,0.15,2024-06-24 18:13:06+00:00 +4440.0,0.15,2024-06-24 18:13:07+00:00 +4441.0,0.15,2024-06-24 18:13:08+00:00 +4442.0,0.15,2024-06-24 18:13:09+00:00 +4443.0,0.15,2024-06-24 18:13:10+00:00 +4444.0,0.15,2024-06-24 18:13:11+00:00 +4445.0,0.15,2024-06-24 18:13:12+00:00 +4446.0,0.15,2024-06-24 18:13:13+00:00 +4447.0,0.15,2024-06-24 18:13:14+00:00 +4448.0,0.15,2024-06-24 18:13:15+00:00 +4449.0,0.15,2024-06-24 18:13:16+00:00 +4450.0,0.15,2024-06-24 18:13:17+00:00 +4451.0,0.15,2024-06-24 18:13:18+00:00 +4452.0,0.15,2024-06-24 18:13:19+00:00 +4453.0,0.15,2024-06-24 18:13:20+00:00 +4454.0,0.15,2024-06-24 18:13:21+00:00 +4455.0,0.15,2024-06-24 18:13:22+00:00 +4456.0,0.15,2024-06-24 18:13:23+00:00 +4457.0,0.15,2024-06-24 18:13:24+00:00 +4458.0,0.15,2024-06-24 18:13:25+00:00 +4459.0,0.15,2024-06-24 18:13:26+00:00 +4460.0,0.15,2024-06-24 18:13:27+00:00 +4461.0,0.15,2024-06-24 18:13:28+00:00 +4462.0,0.15,2024-06-24 18:13:29+00:00 +4463.0,0.15,2024-06-24 18:13:30+00:00 +4464.0,0.15,2024-06-24 18:13:31+00:00 +4465.0,0.15,2024-06-24 18:13:32+00:00 +4466.0,0.15,2024-06-24 18:13:33+00:00 +4467.0,0.15,2024-06-24 18:13:34+00:00 +4468.0,0.15,2024-06-24 18:13:35+00:00 +4469.0,0.15,2024-06-24 18:13:36+00:00 +4470.0,0.15,2024-06-24 18:13:37+00:00 +4471.0,0.15,2024-06-24 18:13:38+00:00 +4472.0,0.15,2024-06-24 18:13:39+00:00 +4473.0,0.15,2024-06-24 18:13:40+00:00 +4474.0,0.15,2024-06-24 18:13:41+00:00 +4475.0,0.15,2024-06-24 18:13:42+00:00 +4476.0,0.15,2024-06-24 18:13:43+00:00 +4477.0,0.15,2024-06-24 18:13:44+00:00 +4478.0,0.15,2024-06-24 18:13:45+00:00 +4479.0,0.15,2024-06-24 18:13:46+00:00 +4480.0,0.15,2024-06-24 18:13:47+00:00 +4481.0,0.15,2024-06-24 18:13:48+00:00 +4482.0,0.15,2024-06-24 18:13:49+00:00 +4483.0,0.15,2024-06-24 18:13:50+00:00 +4484.0,0.15,2024-06-24 18:13:51+00:00 +4485.0,0.15,2024-06-24 18:13:52+00:00 +4486.0,0.15,2024-06-24 18:13:53+00:00 +4487.0,0.15,2024-06-24 18:13:54+00:00 +4488.0,0.15,2024-06-24 18:13:55+00:00 +4489.0,0.15,2024-06-24 18:13:56+00:00 +4490.0,0.15,2024-06-24 18:13:57+00:00 +4491.0,0.15,2024-06-24 18:13:58+00:00 +4492.0,0.15,2024-06-24 18:13:59+00:00 +4493.0,0.15,2024-06-24 18:14:00+00:00 +4494.0,0.15,2024-06-24 18:14:01+00:00 +4495.0,0.15,2024-06-24 18:14:02+00:00 +4496.0,0.15,2024-06-24 18:14:03+00:00 +4497.0,0.15,2024-06-24 18:14:04+00:00 +4498.0,0.15,2024-06-24 18:14:05+00:00 +4499.0,0.15,2024-06-24 18:14:06+00:00 +4500.0,0.15,2024-06-24 18:14:07+00:00 +4501.0,0.15,2024-06-24 18:14:08+00:00 +4502.0,0.15,2024-06-24 18:14:09+00:00 +4503.0,0.15,2024-06-24 18:14:10+00:00 +4504.0,0.15,2024-06-24 18:14:11+00:00 +4505.0,0.15,2024-06-24 18:14:12+00:00 +4506.0,0.15,2024-06-24 18:14:13+00:00 +4507.0,0.15,2024-06-24 18:14:14+00:00 +4508.0,0.15,2024-06-24 18:14:15+00:00 +4509.0,0.15,2024-06-24 18:14:16+00:00 +4510.0,0.15,2024-06-24 18:14:17+00:00 +4511.0,0.15,2024-06-24 18:14:18+00:00 +4512.0,0.15,2024-06-24 18:14:19+00:00 +4513.0,0.15,2024-06-24 18:14:20+00:00 +4514.0,0.15,2024-06-24 18:14:21+00:00 +4515.0,0.15,2024-06-24 18:14:22+00:00 +4516.0,0.15,2024-06-24 18:14:23+00:00 +4517.0,0.15,2024-06-24 18:14:24+00:00 +4518.0,0.15,2024-06-24 18:14:25+00:00 +4519.0,0.15,2024-06-24 18:14:26+00:00 +4520.0,0.15,2024-06-24 18:14:27+00:00 +4521.0,0.15,2024-06-24 18:14:28+00:00 +4522.0,0.15,2024-06-24 18:14:29+00:00 +4523.0,0.15,2024-06-24 18:14:30+00:00 +4524.0,0.15,2024-06-24 18:14:31+00:00 +4525.0,0.15,2024-06-24 18:14:32+00:00 +4526.0,0.15,2024-06-24 18:14:33+00:00 +4527.0,0.15,2024-06-24 18:14:34+00:00 +4528.0,0.15,2024-06-24 18:14:35+00:00 +4529.0,0.15,2024-06-24 18:14:36+00:00 +4530.0,0.15,2024-06-24 18:14:37+00:00 +4531.0,0.15,2024-06-24 18:14:38+00:00 +4532.0,0.15,2024-06-24 18:14:39+00:00 +4533.0,0.15,2024-06-24 18:14:40+00:00 +4534.0,0.15,2024-06-24 18:14:41+00:00 +4535.0,0.15,2024-06-24 18:14:42+00:00 +4536.0,0.15,2024-06-24 18:14:43+00:00 +4537.0,0.15,2024-06-24 18:14:44+00:00 +4538.0,0.15,2024-06-24 18:14:45+00:00 +4539.0,0.15,2024-06-24 18:14:46+00:00 +4540.0,0.15,2024-06-24 18:14:47+00:00 +4541.0,0.15,2024-06-24 18:14:48+00:00 +4542.0,0.15,2024-06-24 18:14:49+00:00 +4543.0,0.15,2024-06-24 18:14:50+00:00 +4544.0,0.15,2024-06-24 18:14:51+00:00 +4545.0,0.15,2024-06-24 18:14:52+00:00 +4546.0,0.15,2024-06-24 18:14:53+00:00 +4547.0,0.15,2024-06-24 18:14:54+00:00 +4548.0,0.15,2024-06-24 18:14:55+00:00 +4549.0,0.15,2024-06-24 18:14:56+00:00 +4550.0,0.15,2024-06-24 18:14:57+00:00 +4551.0,0.15,2024-06-24 18:14:58+00:00 +4552.0,0.15,2024-06-24 18:14:59+00:00 +4553.0,0.15,2024-06-24 18:15:00+00:00 +4554.0,0.15,2024-06-24 18:15:01+00:00 +4555.0,0.15,2024-06-24 18:15:02+00:00 +4556.0,0.15,2024-06-24 18:15:03+00:00 +4557.0,0.15,2024-06-24 18:15:04+00:00 +4558.0,0.15,2024-06-24 18:15:05+00:00 +4559.0,0.15,2024-06-24 18:15:06+00:00 +4560.0,0.15,2024-06-24 18:15:07+00:00 +4561.0,0.15,2024-06-24 18:15:08+00:00 +4562.0,0.15,2024-06-24 18:15:09+00:00 +4563.0,0.15,2024-06-24 18:15:10+00:00 +4564.0,0.15,2024-06-24 18:15:11+00:00 +4565.0,0.15,2024-06-24 18:15:12+00:00 +4566.0,0.15,2024-06-24 18:15:13+00:00 +4567.0,0.15,2024-06-24 18:15:14+00:00 +4568.0,0.15,2024-06-24 18:15:15+00:00 +4569.0,0.15,2024-06-24 18:15:16+00:00 +4570.0,0.15,2024-06-24 18:15:17+00:00 +4571.0,0.15,2024-06-24 18:15:18+00:00 +4572.0,0.15,2024-06-24 18:15:19+00:00 +4573.0,0.15,2024-06-24 18:15:20+00:00 +4574.0,0.15,2024-06-24 18:15:21+00:00 +4575.0,0.15,2024-06-24 18:15:22+00:00 +4576.0,0.15,2024-06-24 18:15:23+00:00 +4577.0,0.15,2024-06-24 18:15:24+00:00 +4578.0,0.15,2024-06-24 18:15:25+00:00 +4579.0,0.15,2024-06-24 18:15:26+00:00 +4580.0,0.15,2024-06-24 18:15:27+00:00 +4581.0,0.15,2024-06-24 18:15:28+00:00 +4582.0,0.15,2024-06-24 18:15:29+00:00 +4583.0,0.15,2024-06-24 18:15:30+00:00 +4584.0,0.15,2024-06-24 18:15:31+00:00 +4585.0,0.15,2024-06-24 18:15:32+00:00 +4586.0,0.15,2024-06-24 18:15:33+00:00 +4587.0,0.15,2024-06-24 18:15:34+00:00 +4588.0,0.15,2024-06-24 18:15:35+00:00 +4589.0,0.15,2024-06-24 18:15:36+00:00 +4590.0,0.15,2024-06-24 18:15:37+00:00 +4591.0,0.15,2024-06-24 18:15:38+00:00 +4592.0,0.15,2024-06-24 18:15:39+00:00 +4593.0,0.15,2024-06-24 18:15:40+00:00 +4594.0,0.15,2024-06-24 18:15:41+00:00 +4595.0,0.15,2024-06-24 18:15:42+00:00 +4596.0,0.15,2024-06-24 18:15:43+00:00 +4597.0,0.15,2024-06-24 18:15:44+00:00 +4598.0,0.15,2024-06-24 18:15:45+00:00 +4599.0,0.15,2024-06-24 18:15:46+00:00 +4600.0,0.15,2024-06-24 18:15:47+00:00 +4601.0,0.15,2024-06-24 18:15:48+00:00 +4602.0,0.15,2024-06-24 18:15:49+00:00 +4603.0,0.15,2024-06-24 18:15:50+00:00 +4604.0,0.15,2024-06-24 18:15:51+00:00 +4605.0,0.15,2024-06-24 18:15:52+00:00 +4606.0,0.15,2024-06-24 18:15:53+00:00 +4607.0,0.15,2024-06-24 18:15:54+00:00 +4608.0,0.15,2024-06-24 18:15:55+00:00 +4609.0,0.15,2024-06-24 18:15:56+00:00 +4610.0,0.15,2024-06-24 18:15:57+00:00 +4611.0,0.15,2024-06-24 18:15:58+00:00 +4612.0,0.15,2024-06-24 18:15:59+00:00 +4613.0,0.15,2024-06-24 18:16:00+00:00 +4614.0,0.15,2024-06-24 18:16:01+00:00 +4615.0,0.15,2024-06-24 18:16:02+00:00 +4616.0,0.15,2024-06-24 18:16:03+00:00 +4617.0,0.15,2024-06-24 18:16:04+00:00 +4618.0,0.15,2024-06-24 18:16:05+00:00 +4619.0,0.15,2024-06-24 18:16:06+00:00 +4620.0,0.15,2024-06-24 18:16:07+00:00 +4621.0,0.15,2024-06-24 18:16:08+00:00 +4622.0,0.15,2024-06-24 18:16:09+00:00 +4623.0,0.15,2024-06-24 18:16:10+00:00 +4624.0,0.15,2024-06-24 18:16:11+00:00 +4625.0,0.15,2024-06-24 18:16:12+00:00 +4626.0,0.15,2024-06-24 18:16:13+00:00 +4627.0,0.15,2024-06-24 18:16:14+00:00 +4628.0,0.15,2024-06-24 18:16:15+00:00 +4629.0,0.15,2024-06-24 18:16:16+00:00 +4630.0,0.15,2024-06-24 18:16:17+00:00 +4631.0,0.15,2024-06-24 18:16:18+00:00 +4632.0,0.15,2024-06-24 18:16:19+00:00 +4633.0,0.15,2024-06-24 18:16:20+00:00 +4634.0,0.15,2024-06-24 18:16:21+00:00 +4635.0,0.15,2024-06-24 18:16:22+00:00 +4636.0,0.15,2024-06-24 18:16:23+00:00 +4637.0,0.15,2024-06-24 18:16:24+00:00 +4638.0,0.15,2024-06-24 18:16:25+00:00 +4639.0,0.15,2024-06-24 18:16:26+00:00 +4640.0,0.15,2024-06-24 18:16:27+00:00 +4641.0,0.15,2024-06-24 18:16:28+00:00 +4642.0,0.15,2024-06-24 18:16:29+00:00 +4643.0,0.15,2024-06-24 18:16:30+00:00 +4644.0,0.15,2024-06-24 18:16:31+00:00 +4645.0,0.15,2024-06-24 18:16:32+00:00 +4646.0,0.15,2024-06-24 18:16:33+00:00 +4647.0,0.15,2024-06-24 18:16:34+00:00 +4648.0,0.15,2024-06-24 18:16:35+00:00 +4649.0,0.15,2024-06-24 18:16:36+00:00 +4650.0,0.15,2024-06-24 18:16:37+00:00 +4651.0,0.15,2024-06-24 18:16:38+00:00 +4652.0,0.15,2024-06-24 18:16:39+00:00 +4653.0,0.15,2024-06-24 18:16:40+00:00 +4654.0,0.15,2024-06-24 18:16:41+00:00 +4655.0,0.15,2024-06-24 18:16:42+00:00 +4656.0,0.15,2024-06-24 18:16:43+00:00 +4657.0,0.15,2024-06-24 18:16:44+00:00 +4658.0,0.15,2024-06-24 18:16:45+00:00 +4659.0,0.15,2024-06-24 18:16:46+00:00 +4660.0,0.15,2024-06-24 18:16:47+00:00 +4661.0,0.15,2024-06-24 18:16:48+00:00 +4662.0,0.15,2024-06-24 18:16:49+00:00 +4663.0,0.15,2024-06-24 18:16:50+00:00 +4664.0,0.15,2024-06-24 18:16:51+00:00 +4665.0,0.15,2024-06-24 18:16:52+00:00 +4666.0,0.15,2024-06-24 18:16:53+00:00 +4667.0,0.15,2024-06-24 18:16:54+00:00 +4668.0,0.15,2024-06-24 18:16:55+00:00 +4669.0,0.15,2024-06-24 18:16:56+00:00 +4670.0,0.15,2024-06-24 18:16:57+00:00 +4671.0,0.15,2024-06-24 18:16:58+00:00 +4672.0,0.15,2024-06-24 18:16:59+00:00 +4673.0,0.15,2024-06-24 18:17:00+00:00 +4674.0,0.15,2024-06-24 18:17:01+00:00 +4675.0,0.15,2024-06-24 18:17:02+00:00 +4676.0,0.15,2024-06-24 18:17:03+00:00 +4677.0,0.15,2024-06-24 18:17:04+00:00 +4678.0,0.15,2024-06-24 18:17:05+00:00 +4679.0,0.15,2024-06-24 18:17:06+00:00 +4680.0,0.15,2024-06-24 18:17:07+00:00 +4681.0,0.15,2024-06-24 18:17:08+00:00 +4682.0,0.15,2024-06-24 18:17:09+00:00 +4683.0,0.15,2024-06-24 18:17:10+00:00 +4684.0,0.15,2024-06-24 18:17:11+00:00 +4685.0,0.15,2024-06-24 18:17:12+00:00 +4686.0,0.15,2024-06-24 18:17:13+00:00 +4687.0,0.15,2024-06-24 18:17:14+00:00 +4688.0,0.15,2024-06-24 18:17:15+00:00 +4689.0,0.15,2024-06-24 18:17:16+00:00 +4690.0,0.15,2024-06-24 18:17:17+00:00 +4691.0,0.15,2024-06-24 18:17:18+00:00 +4692.0,0.15,2024-06-24 18:17:19+00:00 +4693.0,0.15,2024-06-24 18:17:20+00:00 +4694.0,0.15,2024-06-24 18:17:21+00:00 +4695.0,0.15,2024-06-24 18:17:22+00:00 +4696.0,0.15,2024-06-24 18:17:23+00:00 +4697.0,0.15,2024-06-24 18:17:24+00:00 +4698.0,0.15,2024-06-24 18:17:25+00:00 +4699.0,0.15,2024-06-24 18:17:26+00:00 +4700.0,0.15,2024-06-24 18:17:27+00:00 +4701.0,0.15,2024-06-24 18:17:28+00:00 +4702.0,0.15,2024-06-24 18:17:29+00:00 +4703.0,0.15,2024-06-24 18:17:30+00:00 +4704.0,0.15,2024-06-24 18:17:31+00:00 +4705.0,0.15,2024-06-24 18:17:32+00:00 +4706.0,0.15,2024-06-24 18:17:33+00:00 +4707.0,0.15,2024-06-24 18:17:34+00:00 +4708.0,0.15,2024-06-24 18:17:35+00:00 +4709.0,0.15,2024-06-24 18:17:36+00:00 +4710.0,0.15,2024-06-24 18:17:37+00:00 +4711.0,0.15,2024-06-24 18:17:38+00:00 +4712.0,0.15,2024-06-24 18:17:39+00:00 +4713.0,0.15,2024-06-24 18:17:40+00:00 +4714.0,0.15,2024-06-24 18:17:41+00:00 +4715.0,0.15,2024-06-24 18:17:42+00:00 +4716.0,0.15,2024-06-24 18:17:43+00:00 +4717.0,0.15,2024-06-24 18:17:44+00:00 +4718.0,0.15,2024-06-24 18:17:45+00:00 +4719.0,0.15,2024-06-24 18:17:46+00:00 +4720.0,0.15,2024-06-24 18:17:47+00:00 +4721.0,0.15,2024-06-24 18:17:48+00:00 +4722.0,0.15,2024-06-24 18:17:49+00:00 +4723.0,0.15,2024-06-24 18:17:50+00:00 +4724.0,0.15,2024-06-24 18:17:51+00:00 +4725.0,0.15,2024-06-24 18:17:52+00:00 +4726.0,0.15,2024-06-24 18:17:53+00:00 +4727.0,0.15,2024-06-24 18:17:54+00:00 +4728.0,0.15,2024-06-24 18:17:55+00:00 +4729.0,0.15,2024-06-24 18:17:56+00:00 +4730.0,0.15,2024-06-24 18:17:57+00:00 +4731.0,0.15,2024-06-24 18:17:58+00:00 +4732.0,0.15,2024-06-24 18:17:59+00:00 +4733.0,0.15,2024-06-24 18:18:00+00:00 +4734.0,0.15,2024-06-24 18:18:01+00:00 +4735.0,0.15,2024-06-24 18:18:02+00:00 +4736.0,0.15,2024-06-24 18:18:03+00:00 +4737.0,0.15,2024-06-24 18:18:04+00:00 +4738.0,0.15,2024-06-24 18:18:05+00:00 +4739.0,0.15,2024-06-24 18:18:06+00:00 +4740.0,0.15,2024-06-24 18:18:07+00:00 +4741.0,0.15,2024-06-24 18:18:08+00:00 +4742.0,0.15,2024-06-24 18:18:09+00:00 +4743.0,0.15,2024-06-24 18:18:10+00:00 +4744.0,0.15,2024-06-24 18:18:11+00:00 +4745.0,0.15,2024-06-24 18:18:12+00:00 +4746.0,0.15,2024-06-24 18:18:13+00:00 +4747.0,0.15,2024-06-24 18:18:14+00:00 +4748.0,0.15,2024-06-24 18:18:15+00:00 +4749.0,0.15,2024-06-24 18:18:16+00:00 +4750.0,0.15,2024-06-24 18:18:17+00:00 +4751.0,0.15,2024-06-24 18:18:18+00:00 +4752.0,0.15,2024-06-24 18:18:19+00:00 +4753.0,0.15,2024-06-24 18:18:20+00:00 +4754.0,0.15,2024-06-24 18:18:21+00:00 +4755.0,0.15,2024-06-24 18:18:22+00:00 +4756.0,0.15,2024-06-24 18:18:23+00:00 +4757.0,0.15,2024-06-24 18:18:24+00:00 +4758.0,0.15,2024-06-24 18:18:25+00:00 +4759.0,0.15,2024-06-24 18:18:26+00:00 +4760.0,0.15,2024-06-24 18:18:27+00:00 +4761.0,0.15,2024-06-24 18:18:28+00:00 +4762.0,0.15,2024-06-24 18:18:29+00:00 +4763.0,0.15,2024-06-24 18:18:30+00:00 +4764.0,0.15,2024-06-24 18:18:31+00:00 +4765.0,0.15,2024-06-24 18:18:32+00:00 +4766.0,0.15,2024-06-24 18:18:33+00:00 +4767.0,0.15,2024-06-24 18:18:34+00:00 +4768.0,0.15,2024-06-24 18:18:35+00:00 +4769.0,0.15,2024-06-24 18:18:36+00:00 +4770.0,0.15,2024-06-24 18:18:37+00:00 +4771.0,0.15,2024-06-24 18:18:38+00:00 +4772.0,0.15,2024-06-24 18:18:39+00:00 +4773.0,0.15,2024-06-24 18:18:40+00:00 +4774.0,0.15,2024-06-24 18:18:41+00:00 +4775.0,0.15,2024-06-24 18:18:42+00:00 +4776.0,0.15,2024-06-24 18:18:43+00:00 +4777.0,0.15,2024-06-24 18:18:44+00:00 +4778.0,0.15,2024-06-24 18:18:45+00:00 +4779.0,0.15,2024-06-24 18:18:46+00:00 +4780.0,0.15,2024-06-24 18:18:47+00:00 +4781.0,0.15,2024-06-24 18:18:48+00:00 +4782.0,0.15,2024-06-24 18:18:49+00:00 +4783.0,0.15,2024-06-24 18:18:50+00:00 +4784.0,0.15,2024-06-24 18:18:51+00:00 +4785.0,0.15,2024-06-24 18:18:52+00:00 +4786.0,0.15,2024-06-24 18:18:53+00:00 +4787.0,0.15,2024-06-24 18:18:54+00:00 +4788.0,0.15,2024-06-24 18:18:55+00:00 +4789.0,0.15,2024-06-24 18:18:56+00:00 +4790.0,0.15,2024-06-24 18:18:57+00:00 +4791.0,0.15,2024-06-24 18:18:58+00:00 +4792.0,0.15,2024-06-24 18:18:59+00:00 +4793.0,0.15,2024-06-24 18:19:00+00:00 +4794.0,0.15,2024-06-24 18:19:01+00:00 +4795.0,0.15,2024-06-24 18:19:02+00:00 +4796.0,0.15,2024-06-24 18:19:03+00:00 +4797.0,0.15,2024-06-24 18:19:04+00:00 +4798.0,0.15,2024-06-24 18:19:05+00:00 +4799.0,0.15,2024-06-24 18:19:06+00:00 +4800.0,0.15,2024-06-24 18:19:07+00:00 +4801.0,0.15,2024-06-24 18:19:08+00:00 +4802.0,0.15,2024-06-24 18:19:09+00:00 +4803.0,0.15,2024-06-24 18:19:10+00:00 +4804.0,0.15,2024-06-24 18:19:11+00:00 +4805.0,0.15,2024-06-24 18:19:12+00:00 +4806.0,0.15,2024-06-24 18:19:13+00:00 +4807.0,0.15,2024-06-24 18:19:14+00:00 +4808.0,0.15,2024-06-24 18:19:15+00:00 +4809.0,0.15,2024-06-24 18:19:16+00:00 +4810.0,0.15,2024-06-24 18:19:17+00:00 +4811.0,0.15,2024-06-24 18:19:18+00:00 +4812.0,0.15,2024-06-24 18:19:19+00:00 +4813.0,0.15,2024-06-24 18:19:20+00:00 +4814.0,0.15,2024-06-24 18:19:21+00:00 +4815.0,0.15,2024-06-24 18:19:22+00:00 +4816.0,0.15,2024-06-24 18:19:23+00:00 +4817.0,0.15,2024-06-24 18:19:24+00:00 +4818.0,0.15,2024-06-24 18:19:25+00:00 +4819.0,0.15,2024-06-24 18:19:26+00:00 +4820.0,0.15,2024-06-24 18:19:27+00:00 +4821.0,0.15,2024-06-24 18:19:28+00:00 +4822.0,0.15,2024-06-24 18:19:29+00:00 +4823.0,0.15,2024-06-24 18:19:30+00:00 +4824.0,0.15,2024-06-24 18:19:31+00:00 +4825.0,0.15,2024-06-24 18:19:32+00:00 +4826.0,0.15,2024-06-24 18:19:33+00:00 +4827.0,0.15,2024-06-24 18:19:34+00:00 +4828.0,0.15,2024-06-24 18:19:35+00:00 +4829.0,0.15,2024-06-24 18:19:36+00:00 +4830.0,0.15,2024-06-24 18:19:37+00:00 +4831.0,0.15,2024-06-24 18:19:38+00:00 +4832.0,0.15,2024-06-24 18:19:39+00:00 +4833.0,0.15,2024-06-24 18:19:40+00:00 +4834.0,0.15,2024-06-24 18:19:41+00:00 +4835.0,0.15,2024-06-24 18:19:42+00:00 +4836.0,0.15,2024-06-24 18:19:43+00:00 +4837.0,0.15,2024-06-24 18:19:44+00:00 +4838.0,0.15,2024-06-24 18:19:45+00:00 +4839.0,0.15,2024-06-24 18:19:46+00:00 +4840.0,0.15,2024-06-24 18:19:47+00:00 +4841.0,0.15,2024-06-24 18:19:48+00:00 +4842.0,0.15,2024-06-24 18:19:49+00:00 +4843.0,0.15,2024-06-24 18:19:50+00:00 +4844.0,0.15,2024-06-24 18:19:51+00:00 +4845.0,0.15,2024-06-24 18:19:52+00:00 +4846.0,0.15,2024-06-24 18:19:53+00:00 +4847.0,0.15,2024-06-24 18:19:54+00:00 +4848.0,0.15,2024-06-24 18:19:55+00:00 +4849.0,0.15,2024-06-24 18:19:56+00:00 +4850.0,0.15,2024-06-24 18:19:57+00:00 +4851.0,0.15,2024-06-24 18:19:58+00:00 +4852.0,0.15,2024-06-24 18:19:59+00:00 +4853.0,0.15,2024-06-24 18:20:00+00:00 +4854.0,0.15,2024-06-24 18:20:01+00:00 +4855.0,0.15,2024-06-24 18:20:02+00:00 +4856.0,0.15,2024-06-24 18:20:03+00:00 +4857.0,0.15,2024-06-24 18:20:04+00:00 +4858.0,0.15,2024-06-24 18:20:05+00:00 +4859.0,0.15,2024-06-24 18:20:06+00:00 +4860.0,0.15,2024-06-24 18:20:07+00:00 +4861.0,0.15,2024-06-24 18:20:08+00:00 +4862.0,0.15,2024-06-24 18:20:09+00:00 +4863.0,0.15,2024-06-24 18:20:10+00:00 +4864.0,0.15,2024-06-24 18:20:11+00:00 +4865.0,0.15,2024-06-24 18:20:12+00:00 +4866.0,0.15,2024-06-24 18:20:13+00:00 +4867.0,0.15,2024-06-24 18:20:14+00:00 +4868.0,0.15,2024-06-24 18:20:15+00:00 +4869.0,0.15,2024-06-24 18:20:16+00:00 +4870.0,0.15,2024-06-24 18:20:17+00:00 +4871.0,0.15,2024-06-24 18:20:18+00:00 +4872.0,0.15,2024-06-24 18:20:19+00:00 +4873.0,0.15,2024-06-24 18:20:20+00:00 +4874.0,0.15,2024-06-24 18:20:21+00:00 +4875.0,0.15,2024-06-24 18:20:22+00:00 +4876.0,0.15,2024-06-24 18:20:23+00:00 +4877.0,0.15,2024-06-24 18:20:24+00:00 +4878.0,0.15,2024-06-24 18:20:25+00:00 +4879.0,0.15,2024-06-24 18:20:26+00:00 +4880.0,0.15,2024-06-24 18:20:27+00:00 +4881.0,0.15,2024-06-24 18:20:28+00:00 +4882.0,0.15,2024-06-24 18:20:29+00:00 +4883.0,0.15,2024-06-24 18:20:30+00:00 +4884.0,0.15,2024-06-24 18:20:31+00:00 +4885.0,0.15,2024-06-24 18:20:32+00:00 +4886.0,0.15,2024-06-24 18:20:33+00:00 +4887.0,0.15,2024-06-24 18:20:34+00:00 +4888.0,0.15,2024-06-24 18:20:35+00:00 +4889.0,0.15,2024-06-24 18:20:36+00:00 +4890.0,0.15,2024-06-24 18:20:37+00:00 +4891.0,0.15,2024-06-24 18:20:38+00:00 +4892.0,0.15,2024-06-24 18:20:39+00:00 +4893.0,0.15,2024-06-24 18:20:40+00:00 +4894.0,0.15,2024-06-24 18:20:41+00:00 +4895.0,0.15,2024-06-24 18:20:42+00:00 +4896.0,0.15,2024-06-24 18:20:43+00:00 +4897.0,0.15,2024-06-24 18:20:44+00:00 +4898.0,0.15,2024-06-24 18:20:45+00:00 +4899.0,0.15,2024-06-24 18:20:46+00:00 +4900.0,0.15,2024-06-24 18:20:47+00:00 +4901.0,0.15,2024-06-24 18:20:48+00:00 +4902.0,0.15,2024-06-24 18:20:49+00:00 +4903.0,0.15,2024-06-24 18:20:50+00:00 +4904.0,0.15,2024-06-24 18:20:51+00:00 +4905.0,0.15,2024-06-24 18:20:52+00:00 +4906.0,0.15,2024-06-24 18:20:53+00:00 +4907.0,0.15,2024-06-24 18:20:54+00:00 +4908.0,0.15,2024-06-24 18:20:55+00:00 +4909.0,0.15,2024-06-24 18:20:56+00:00 +4910.0,0.15,2024-06-24 18:20:57+00:00 +4911.0,0.15,2024-06-24 18:20:58+00:00 +4912.0,0.15,2024-06-24 18:20:59+00:00 +4913.0,0.15,2024-06-24 18:21:00+00:00 +4914.0,0.15,2024-06-24 18:21:01+00:00 +4915.0,0.15,2024-06-24 18:21:02+00:00 +4916.0,0.15,2024-06-24 18:21:03+00:00 +4917.0,0.15,2024-06-24 18:21:04+00:00 +4918.0,0.15,2024-06-24 18:21:05+00:00 +4919.0,0.15,2024-06-24 18:21:06+00:00 +4920.0,0.15,2024-06-24 18:21:07+00:00 +4921.0,0.15,2024-06-24 18:21:08+00:00 +4922.0,0.15,2024-06-24 18:21:09+00:00 +4923.0,0.15,2024-06-24 18:21:10+00:00 +4924.0,0.15,2024-06-24 18:21:11+00:00 +4925.0,0.15,2024-06-24 18:21:12+00:00 +4926.0,0.15,2024-06-24 18:21:13+00:00 +4927.0,0.15,2024-06-24 18:21:14+00:00 +4928.0,0.15,2024-06-24 18:21:15+00:00 +4929.0,0.15,2024-06-24 18:21:16+00:00 +4930.0,0.15,2024-06-24 18:21:17+00:00 +4931.0,0.15,2024-06-24 18:21:18+00:00 +4932.0,0.15,2024-06-24 18:21:19+00:00 +4933.0,0.15,2024-06-24 18:21:20+00:00 +4934.0,0.15,2024-06-24 18:21:21+00:00 +4935.0,0.15,2024-06-24 18:21:22+00:00 +4936.0,0.15,2024-06-24 18:21:23+00:00 +4937.0,0.15,2024-06-24 18:21:24+00:00 +4938.0,0.15,2024-06-24 18:21:25+00:00 +4939.0,0.15,2024-06-24 18:21:26+00:00 +4940.0,0.15,2024-06-24 18:21:27+00:00 +4941.0,0.15,2024-06-24 18:21:28+00:00 +4942.0,0.15,2024-06-24 18:21:29+00:00 +4943.0,0.15,2024-06-24 18:21:30+00:00 +4944.0,0.15,2024-06-24 18:21:31+00:00 +4945.0,0.15,2024-06-24 18:21:32+00:00 +4946.0,0.15,2024-06-24 18:21:33+00:00 +4947.0,0.15,2024-06-24 18:21:34+00:00 +4948.0,0.15,2024-06-24 18:21:35+00:00 +4949.0,0.15,2024-06-24 18:21:36+00:00 +4950.0,0.15,2024-06-24 18:21:37+00:00 +4951.0,0.15,2024-06-24 18:21:38+00:00 +4952.0,0.15,2024-06-24 18:21:39+00:00 +4953.0,0.15,2024-06-24 18:21:40+00:00 +4954.0,0.15,2024-06-24 18:21:41+00:00 +4955.0,0.15,2024-06-24 18:21:42+00:00 +4956.0,0.15,2024-06-24 18:21:43+00:00 +4957.0,0.15,2024-06-24 18:21:44+00:00 +4958.0,0.15,2024-06-24 18:21:45+00:00 +4959.0,0.15,2024-06-24 18:21:46+00:00 +4960.0,0.15,2024-06-24 18:21:47+00:00 +4961.0,0.15,2024-06-24 18:21:48+00:00 +4962.0,0.15,2024-06-24 18:21:49+00:00 +4963.0,0.15,2024-06-24 18:21:50+00:00 +4964.0,0.15,2024-06-24 18:21:51+00:00 +4965.0,0.15,2024-06-24 18:21:52+00:00 +4966.0,0.15,2024-06-24 18:21:53+00:00 +4967.0,0.15,2024-06-24 18:21:54+00:00 +4968.0,0.15,2024-06-24 18:21:55+00:00 +4969.0,0.15,2024-06-24 18:21:56+00:00 +4970.0,0.15,2024-06-24 18:21:57+00:00 +4971.0,0.15,2024-06-24 18:21:58+00:00 +4972.0,0.15,2024-06-24 18:21:59+00:00 +4973.0,0.15,2024-06-24 18:22:00+00:00 +4974.0,0.15,2024-06-24 18:22:01+00:00 +4975.0,0.15,2024-06-24 18:22:02+00:00 +4976.0,0.15,2024-06-24 18:22:03+00:00 +4977.0,0.15,2024-06-24 18:22:04+00:00 +4978.0,0.15,2024-06-24 18:22:05+00:00 +4979.0,0.15,2024-06-24 18:22:06+00:00 +4980.0,0.15,2024-06-24 18:22:07+00:00 +4981.0,0.15,2024-06-24 18:22:08+00:00 +4982.0,0.15,2024-06-24 18:22:09+00:00 +4983.0,0.15,2024-06-24 18:22:10+00:00 +4984.0,0.15,2024-06-24 18:22:11+00:00 +4985.0,0.15,2024-06-24 18:22:12+00:00 +4986.0,0.15,2024-06-24 18:22:13+00:00 +4987.0,0.15,2024-06-24 18:22:14+00:00 +4988.0,0.15,2024-06-24 18:22:15+00:00 +4989.0,0.15,2024-06-24 18:22:16+00:00 +4990.0,0.15,2024-06-24 18:22:17+00:00 +4991.0,0.15,2024-06-24 18:22:18+00:00 +4992.0,0.15,2024-06-24 18:22:19+00:00 +4993.0,0.15,2024-06-24 18:22:20+00:00 +4994.0,0.15,2024-06-24 18:22:21+00:00 +4995.0,0.15,2024-06-24 18:22:22+00:00 +4996.0,0.15,2024-06-24 18:22:23+00:00 +4997.0,0.15,2024-06-24 18:22:24+00:00 +4998.0,0.15,2024-06-24 18:22:25+00:00 +4999.0,0.15,2024-06-24 18:22:26+00:00 +5000.0,0.15,2024-06-24 18:22:27+00:00 +5001.0,0.15,2024-06-24 18:22:28+00:00 +5002.0,0.15,2024-06-24 18:22:29+00:00 +5003.0,0.15,2024-06-24 18:22:30+00:00 +5004.0,0.15,2024-06-24 18:22:31+00:00 +5005.0,0.15,2024-06-24 18:22:32+00:00 +5006.0,0.15,2024-06-24 18:22:33+00:00 +5007.0,0.15,2024-06-24 18:22:34+00:00 +5008.0,0.15,2024-06-24 18:22:35+00:00 +5009.0,0.15,2024-06-24 18:22:36+00:00 +5010.0,0.15,2024-06-24 18:22:37+00:00 +5011.0,0.15,2024-06-24 18:22:38+00:00 +5012.0,0.15,2024-06-24 18:22:39+00:00 +5013.0,0.15,2024-06-24 18:22:40+00:00 +5014.0,0.15,2024-06-24 18:22:41+00:00 +5015.0,0.15,2024-06-24 18:22:42+00:00 +5016.0,0.15,2024-06-24 18:22:43+00:00 +5017.0,0.15,2024-06-24 18:22:44+00:00 +5018.0,0.15,2024-06-24 18:22:45+00:00 +5019.0,0.15,2024-06-24 18:22:46+00:00 +5020.0,0.15,2024-06-24 18:22:47+00:00 +5021.0,0.15,2024-06-24 18:22:48+00:00 +5022.0,0.15,2024-06-24 18:22:49+00:00 +5023.0,0.15,2024-06-24 18:22:50+00:00 +5024.0,0.15,2024-06-24 18:22:51+00:00 +5025.0,0.15,2024-06-24 18:22:52+00:00 +5026.0,0.15,2024-06-24 18:22:53+00:00 +5027.0,0.15,2024-06-24 18:22:54+00:00 +5028.0,0.15,2024-06-24 18:22:55+00:00 +5029.0,0.15,2024-06-24 18:22:56+00:00 +5030.0,0.15,2024-06-24 18:22:57+00:00 +5031.0,0.15,2024-06-24 18:22:58+00:00 +5032.0,0.15,2024-06-24 18:22:59+00:00 +5033.0,0.15,2024-06-24 18:23:00+00:00 +5034.0,0.15,2024-06-24 18:23:01+00:00 +5035.0,0.15,2024-06-24 18:23:02+00:00 +5036.0,0.15,2024-06-24 18:23:03+00:00 +5037.0,0.15,2024-06-24 18:23:04+00:00 +5038.0,0.15,2024-06-24 18:23:05+00:00 +5039.0,0.15,2024-06-24 18:23:06+00:00 +5040.0,0.15,2024-06-24 18:23:07+00:00 +5041.0,0.15,2024-06-24 18:23:08+00:00 +5042.0,0.15,2024-06-24 18:23:09+00:00 +5043.0,0.15,2024-06-24 18:23:10+00:00 +5044.0,0.15,2024-06-24 18:23:11+00:00 +5045.0,0.15,2024-06-24 18:23:12+00:00 +5046.0,0.15,2024-06-24 18:23:13+00:00 +5047.0,0.15,2024-06-24 18:23:14+00:00 +5048.0,0.15,2024-06-24 18:23:15+00:00 +5049.0,0.15,2024-06-24 18:23:16+00:00 +5050.0,0.15,2024-06-24 18:23:17+00:00 +5051.0,0.15,2024-06-24 18:23:18+00:00 +5052.0,0.15,2024-06-24 18:23:19+00:00 +5053.0,0.15,2024-06-24 18:23:20+00:00 +5054.0,0.15,2024-06-24 18:23:21+00:00 +5055.0,0.15,2024-06-24 18:23:22+00:00 +5056.0,0.15,2024-06-24 18:23:23+00:00 +5057.0,0.15,2024-06-24 18:23:24+00:00 +5058.0,0.15,2024-06-24 18:23:25+00:00 +5059.0,0.15,2024-06-24 18:23:26+00:00 +5060.0,0.15,2024-06-24 18:23:27+00:00 +5061.0,0.15,2024-06-24 18:23:28+00:00 +5062.0,0.15,2024-06-24 18:23:29+00:00 +5063.0,0.15,2024-06-24 18:23:30+00:00 +5064.0,0.15,2024-06-24 18:23:31+00:00 +5065.0,0.15,2024-06-24 18:23:32+00:00 +5066.0,0.15,2024-06-24 18:23:33+00:00 +5067.0,0.15,2024-06-24 18:23:34+00:00 +5068.0,0.15,2024-06-24 18:23:35+00:00 +5069.0,0.15,2024-06-24 18:23:36+00:00 +5070.0,0.15,2024-06-24 18:23:37+00:00 +5071.0,0.15,2024-06-24 18:23:38+00:00 +5072.0,0.15,2024-06-24 18:23:39+00:00 +5073.0,0.15,2024-06-24 18:23:40+00:00 +5074.0,0.15,2024-06-24 18:23:41+00:00 +5075.0,0.15,2024-06-24 18:23:42+00:00 +5076.0,0.15,2024-06-24 18:23:43+00:00 +5077.0,0.15,2024-06-24 18:23:44+00:00 +5078.0,0.15,2024-06-24 18:23:45+00:00 +5079.0,0.15,2024-06-24 18:23:46+00:00 +5080.0,0.15,2024-06-24 18:23:47+00:00 +5081.0,0.15,2024-06-24 18:23:48+00:00 +5082.0,0.15,2024-06-24 18:23:49+00:00 +5083.0,0.15,2024-06-24 18:23:50+00:00 +5084.0,0.15,2024-06-24 18:23:51+00:00 +5085.0,0.15,2024-06-24 18:23:52+00:00 +5086.0,0.15,2024-06-24 18:23:53+00:00 +5087.0,0.15,2024-06-24 18:23:54+00:00 +5088.0,0.15,2024-06-24 18:23:55+00:00 +5089.0,0.15,2024-06-24 18:23:56+00:00 +5090.0,0.15,2024-06-24 18:23:57+00:00 +5091.0,0.15,2024-06-24 18:23:58+00:00 +5092.0,0.15,2024-06-24 18:23:59+00:00 +5093.0,0.15,2024-06-24 18:24:00+00:00 +5094.0,0.15,2024-06-24 18:24:01+00:00 +5095.0,0.15,2024-06-24 18:24:02+00:00 +5096.0,0.15,2024-06-24 18:24:03+00:00 +5097.0,0.15,2024-06-24 18:24:04+00:00 +5098.0,0.15,2024-06-24 18:24:05+00:00 +5099.0,0.15,2024-06-24 18:24:06+00:00 +5100.0,0.15,2024-06-24 18:24:07+00:00 +5101.0,0.15,2024-06-24 18:24:08+00:00 +5102.0,0.15,2024-06-24 18:24:09+00:00 +5103.0,0.15,2024-06-24 18:24:10+00:00 +5104.0,0.15,2024-06-24 18:24:11+00:00 +5105.0,0.15,2024-06-24 18:24:12+00:00 +5106.0,0.15,2024-06-24 18:24:13+00:00 +5107.0,0.15,2024-06-24 18:24:14+00:00 +5108.0,0.15,2024-06-24 18:24:15+00:00 +5109.0,0.15,2024-06-24 18:24:16+00:00 +5110.0,0.15,2024-06-24 18:24:17+00:00 +5111.0,0.15,2024-06-24 18:24:18+00:00 +5112.0,0.15,2024-06-24 18:24:19+00:00 +5113.0,0.15,2024-06-24 18:24:20+00:00 +5114.0,0.15,2024-06-24 18:24:21+00:00 +5115.0,0.15,2024-06-24 18:24:22+00:00 +5116.0,0.15,2024-06-24 18:24:23+00:00 +5117.0,0.15,2024-06-24 18:24:24+00:00 +5118.0,0.15,2024-06-24 18:24:25+00:00 +5119.0,0.15,2024-06-24 18:24:26+00:00 +5120.0,0.15,2024-06-24 18:24:27+00:00 +5121.0,0.15,2024-06-24 18:24:28+00:00 +5122.0,0.15,2024-06-24 18:24:29+00:00 +5123.0,0.15,2024-06-24 18:24:30+00:00 +5124.0,0.15,2024-06-24 18:24:31+00:00 +5125.0,0.15,2024-06-24 18:24:32+00:00 +5126.0,0.15,2024-06-24 18:24:33+00:00 +5127.0,0.15,2024-06-24 18:24:34+00:00 +5128.0,0.15,2024-06-24 18:24:35+00:00 +5129.0,0.15,2024-06-24 18:24:36+00:00 +5130.0,0.15,2024-06-24 18:24:37+00:00 +5131.0,0.15,2024-06-24 18:24:38+00:00 +5132.0,0.15,2024-06-24 18:24:39+00:00 +5133.0,0.15,2024-06-24 18:24:40+00:00 +5134.0,0.15,2024-06-24 18:24:41+00:00 +5135.0,0.15,2024-06-24 18:24:42+00:00 +5136.0,0.15,2024-06-24 18:24:43+00:00 +5137.0,0.15,2024-06-24 18:24:44+00:00 +5138.0,0.15,2024-06-24 18:24:45+00:00 +5139.0,0.15,2024-06-24 18:24:46+00:00 +5140.0,0.15,2024-06-24 18:24:47+00:00 +5141.0,0.15,2024-06-24 18:24:48+00:00 +5142.0,0.15,2024-06-24 18:24:49+00:00 +5143.0,0.15,2024-06-24 18:24:50+00:00 +5144.0,0.15,2024-06-24 18:24:51+00:00 +5145.0,0.15,2024-06-24 18:24:52+00:00 +5146.0,0.15,2024-06-24 18:24:53+00:00 +5147.0,0.15,2024-06-24 18:24:54+00:00 +5148.0,0.15,2024-06-24 18:24:55+00:00 +5149.0,0.15,2024-06-24 18:24:56+00:00 +5150.0,0.15,2024-06-24 18:24:57+00:00 +5151.0,0.15,2024-06-24 18:24:58+00:00 +5152.0,0.15,2024-06-24 18:24:59+00:00 +5153.0,0.15,2024-06-24 18:25:00+00:00 +5154.0,0.15,2024-06-24 18:25:01+00:00 +5155.0,0.15,2024-06-24 18:25:02+00:00 +5156.0,0.15,2024-06-24 18:25:03+00:00 +5157.0,0.15,2024-06-24 18:25:04+00:00 +5158.0,0.15,2024-06-24 18:25:05+00:00 +5159.0,0.15,2024-06-24 18:25:06+00:00 +5160.0,0.15,2024-06-24 18:25:07+00:00 +5161.0,0.15,2024-06-24 18:25:08+00:00 +5162.0,0.15,2024-06-24 18:25:09+00:00 +5163.0,0.15,2024-06-24 18:25:10+00:00 +5164.0,0.15,2024-06-24 18:25:11+00:00 +5165.0,0.15,2024-06-24 18:25:12+00:00 +5166.0,0.15,2024-06-24 18:25:13+00:00 +5167.0,0.15,2024-06-24 18:25:14+00:00 +5168.0,0.15,2024-06-24 18:25:15+00:00 +5169.0,0.15,2024-06-24 18:25:16+00:00 +5170.0,0.15,2024-06-24 18:25:17+00:00 +5171.0,0.15,2024-06-24 18:25:18+00:00 +5172.0,0.15,2024-06-24 18:25:19+00:00 +5173.0,0.15,2024-06-24 18:25:20+00:00 +5174.0,0.15,2024-06-24 18:25:21+00:00 +5175.0,0.15,2024-06-24 18:25:22+00:00 +5176.0,0.15,2024-06-24 18:25:23+00:00 +5177.0,0.15,2024-06-24 18:25:24+00:00 +5178.0,0.15,2024-06-24 18:25:25+00:00 +5179.0,0.15,2024-06-24 18:25:26+00:00 +5180.0,0.15,2024-06-24 18:25:27+00:00 +5181.0,0.15,2024-06-24 18:25:28+00:00 +5182.0,0.15,2024-06-24 18:25:29+00:00 +5183.0,0.15,2024-06-24 18:25:30+00:00 +5184.0,0.15,2024-06-24 18:25:31+00:00 +5185.0,0.15,2024-06-24 18:25:32+00:00 +5186.0,0.15,2024-06-24 18:25:33+00:00 +5187.0,0.15,2024-06-24 18:25:34+00:00 +5188.0,0.15,2024-06-24 18:25:35+00:00 +5189.0,0.15,2024-06-24 18:25:36+00:00 +5190.0,0.15,2024-06-24 18:25:37+00:00 +5191.0,0.15,2024-06-24 18:25:38+00:00 +5192.0,0.15,2024-06-24 18:25:39+00:00 +5193.0,0.15,2024-06-24 18:25:40+00:00 +5194.0,0.15,2024-06-24 18:25:41+00:00 +5195.0,0.15,2024-06-24 18:25:42+00:00 +5196.0,0.15,2024-06-24 18:25:43+00:00 +5197.0,0.15,2024-06-24 18:25:44+00:00 +5198.0,0.15,2024-06-24 18:25:45+00:00 +5199.0,0.15,2024-06-24 18:25:46+00:00 +5200.0,0.15,2024-06-24 18:25:47+00:00 +5201.0,0.15,2024-06-24 18:25:48+00:00 +5202.0,0.15,2024-06-24 18:25:49+00:00 +5203.0,0.15,2024-06-24 18:25:50+00:00 +5204.0,0.15,2024-06-24 18:25:51+00:00 +5205.0,0.15,2024-06-24 18:25:52+00:00 +5206.0,0.15,2024-06-24 18:25:53+00:00 +5207.0,0.15,2024-06-24 18:25:54+00:00 +5208.0,0.15,2024-06-24 18:25:55+00:00 +5209.0,0.15,2024-06-24 18:25:56+00:00 +5210.0,0.15,2024-06-24 18:25:57+00:00 +5211.0,0.15,2024-06-24 18:25:58+00:00 +5212.0,0.15,2024-06-24 18:25:59+00:00 +5213.0,0.15,2024-06-24 18:26:00+00:00 +5214.0,0.15,2024-06-24 18:26:01+00:00 +5215.0,0.15,2024-06-24 18:26:02+00:00 +5216.0,0.15,2024-06-24 18:26:03+00:00 +5217.0,0.15,2024-06-24 18:26:04+00:00 +5218.0,0.15,2024-06-24 18:26:05+00:00 +5219.0,0.15,2024-06-24 18:26:06+00:00 +5220.0,0.15,2024-06-24 18:26:07+00:00 +5221.0,0.15,2024-06-24 18:26:08+00:00 +5222.0,0.15,2024-06-24 18:26:09+00:00 +5223.0,0.15,2024-06-24 18:26:10+00:00 +5224.0,0.15,2024-06-24 18:26:11+00:00 +5225.0,0.15,2024-06-24 18:26:12+00:00 +5226.0,0.15,2024-06-24 18:26:13+00:00 +5227.0,0.15,2024-06-24 18:26:14+00:00 +5228.0,0.15,2024-06-24 18:26:15+00:00 +5229.0,0.15,2024-06-24 18:26:16+00:00 +5230.0,0.15,2024-06-24 18:26:17+00:00 +5231.0,0.15,2024-06-24 18:26:18+00:00 +5232.0,0.15,2024-06-24 18:26:19+00:00 +5233.0,0.15,2024-06-24 18:26:20+00:00 +5234.0,0.15,2024-06-24 18:26:21+00:00 +5235.0,0.15,2024-06-24 18:26:22+00:00 +5236.0,0.15,2024-06-24 18:26:23+00:00 +5237.0,0.15,2024-06-24 18:26:24+00:00 +5238.0,0.15,2024-06-24 18:26:25+00:00 +5239.0,0.15,2024-06-24 18:26:26+00:00 +5240.0,0.15,2024-06-24 18:26:27+00:00 +5241.0,0.15,2024-06-24 18:26:28+00:00 +5242.0,0.15,2024-06-24 18:26:29+00:00 +5243.0,0.15,2024-06-24 18:26:30+00:00 +5244.0,0.15,2024-06-24 18:26:31+00:00 +5245.0,0.15,2024-06-24 18:26:32+00:00 +5246.0,0.15,2024-06-24 18:26:33+00:00 +5247.0,0.15,2024-06-24 18:26:34+00:00 +5248.0,0.15,2024-06-24 18:26:35+00:00 +5249.0,0.15,2024-06-24 18:26:36+00:00 +5250.0,0.15,2024-06-24 18:26:37+00:00 +5251.0,0.15,2024-06-24 18:26:38+00:00 +5252.0,0.15,2024-06-24 18:26:39+00:00 +5253.0,0.15,2024-06-24 18:26:40+00:00 +5254.0,0.15,2024-06-24 18:26:41+00:00 +5255.0,0.15,2024-06-24 18:26:42+00:00 +5256.0,0.15,2024-06-24 18:26:43+00:00 +5257.0,0.15,2024-06-24 18:26:44+00:00 +5258.0,0.15,2024-06-24 18:26:45+00:00 +5259.0,0.15,2024-06-24 18:26:46+00:00 +5260.0,0.15,2024-06-24 18:26:47+00:00 +5261.0,0.15,2024-06-24 18:26:48+00:00 +5262.0,0.15,2024-06-24 18:26:49+00:00 +5263.0,0.15,2024-06-24 18:26:50+00:00 +5264.0,0.15,2024-06-24 18:26:51+00:00 +5265.0,0.15,2024-06-24 18:26:52+00:00 +5266.0,0.15,2024-06-24 18:26:53+00:00 +5267.0,0.15,2024-06-24 18:26:54+00:00 +5268.0,0.15,2024-06-24 18:26:55+00:00 +5269.0,0.15,2024-06-24 18:26:56+00:00 +5270.0,0.15,2024-06-24 18:26:57+00:00 +5271.0,0.15,2024-06-24 18:26:58+00:00 +5272.0,0.15,2024-06-24 18:26:59+00:00 +5273.0,0.15,2024-06-24 18:27:00+00:00 +5274.0,0.15,2024-06-24 18:27:01+00:00 +5275.0,0.15,2024-06-24 18:27:02+00:00 +5276.0,0.15,2024-06-24 18:27:03+00:00 +5277.0,0.15,2024-06-24 18:27:04+00:00 +5278.0,0.15,2024-06-24 18:27:05+00:00 +5279.0,0.15,2024-06-24 18:27:06+00:00 +5280.0,0.15,2024-06-24 18:27:07+00:00 +5281.0,0.15,2024-06-24 18:27:08+00:00 +5282.0,0.15,2024-06-24 18:27:09+00:00 +5283.0,0.15,2024-06-24 18:27:10+00:00 +5284.0,0.15,2024-06-24 18:27:11+00:00 +5285.0,0.15,2024-06-24 18:27:12+00:00 +5286.0,0.15,2024-06-24 18:27:13+00:00 +5287.0,0.15,2024-06-24 18:27:14+00:00 +5288.0,0.15,2024-06-24 18:27:15+00:00 +5289.0,0.15,2024-06-24 18:27:16+00:00 +5290.0,0.15,2024-06-24 18:27:17+00:00 +5291.0,0.15,2024-06-24 18:27:18+00:00 +5292.0,0.15,2024-06-24 18:27:19+00:00 +5293.0,0.15,2024-06-24 18:27:20+00:00 +5294.0,0.15,2024-06-24 18:27:21+00:00 +5295.0,0.15,2024-06-24 18:27:22+00:00 +5296.0,0.15,2024-06-24 18:27:23+00:00 +5297.0,0.15,2024-06-24 18:27:24+00:00 +5298.0,0.15,2024-06-24 18:27:25+00:00 +5299.0,0.15,2024-06-24 18:27:26+00:00 +5300.0,0.15,2024-06-24 18:27:27+00:00 +5301.0,0.15,2024-06-24 18:27:28+00:00 +5302.0,0.15,2024-06-24 18:27:29+00:00 +5303.0,0.15,2024-06-24 18:27:30+00:00 +5304.0,0.15,2024-06-24 18:27:31+00:00 +5305.0,0.15,2024-06-24 18:27:32+00:00 +5306.0,0.15,2024-06-24 18:27:33+00:00 +5307.0,0.15,2024-06-24 18:27:34+00:00 +5308.0,0.15,2024-06-24 18:27:35+00:00 +5309.0,0.15,2024-06-24 18:27:36+00:00 +5310.0,0.15,2024-06-24 18:27:37+00:00 +5311.0,0.15,2024-06-24 18:27:38+00:00 +5312.0,0.15,2024-06-24 18:27:39+00:00 +5313.0,0.15,2024-06-24 18:27:40+00:00 +5314.0,0.15,2024-06-24 18:27:41+00:00 +5315.0,0.15,2024-06-24 18:27:42+00:00 +5316.0,0.15,2024-06-24 18:27:43+00:00 +5317.0,0.15,2024-06-24 18:27:44+00:00 +5318.0,0.15,2024-06-24 18:27:45+00:00 +5319.0,0.15,2024-06-24 18:27:46+00:00 +5320.0,0.15,2024-06-24 18:27:47+00:00 +5321.0,0.15,2024-06-24 18:27:48+00:00 +5322.0,0.15,2024-06-24 18:27:49+00:00 +5323.0,0.15,2024-06-24 18:27:50+00:00 +5324.0,0.15,2024-06-24 18:27:51+00:00 +5325.0,0.15,2024-06-24 18:27:52+00:00 +5326.0,0.15,2024-06-24 18:27:53+00:00 +5327.0,0.15,2024-06-24 18:27:54+00:00 +5328.0,0.15,2024-06-24 18:27:55+00:00 +5329.0,0.15,2024-06-24 18:27:56+00:00 +5330.0,0.15,2024-06-24 18:27:57+00:00 +5331.0,0.15,2024-06-24 18:27:58+00:00 +5332.0,0.15,2024-06-24 18:27:59+00:00 +5333.0,0.15,2024-06-24 18:28:00+00:00 +5334.0,0.15,2024-06-24 18:28:01+00:00 +5335.0,0.15,2024-06-24 18:28:02+00:00 +5336.0,0.15,2024-06-24 18:28:03+00:00 +5337.0,0.15,2024-06-24 18:28:04+00:00 +5338.0,0.15,2024-06-24 18:28:05+00:00 +5339.0,0.15,2024-06-24 18:28:06+00:00 +5340.0,0.15,2024-06-24 18:28:07+00:00 +5341.0,0.15,2024-06-24 18:28:08+00:00 +5342.0,0.15,2024-06-24 18:28:09+00:00 +5343.0,0.15,2024-06-24 18:28:10+00:00 +5344.0,0.15,2024-06-24 18:28:11+00:00 +5345.0,0.15,2024-06-24 18:28:12+00:00 +5346.0,0.15,2024-06-24 18:28:13+00:00 +5347.0,0.15,2024-06-24 18:28:14+00:00 +5348.0,0.15,2024-06-24 18:28:15+00:00 +5349.0,0.15,2024-06-24 18:28:16+00:00 +5350.0,0.15,2024-06-24 18:28:17+00:00 +5351.0,0.15,2024-06-24 18:28:18+00:00 +5352.0,0.15,2024-06-24 18:28:19+00:00 +5353.0,0.15,2024-06-24 18:28:20+00:00 +5354.0,0.15,2024-06-24 18:28:21+00:00 +5355.0,0.15,2024-06-24 18:28:22+00:00 +5356.0,0.15,2024-06-24 18:28:23+00:00 +5357.0,0.15,2024-06-24 18:28:24+00:00 +5358.0,0.15,2024-06-24 18:28:25+00:00 +5359.0,0.15,2024-06-24 18:28:26+00:00 +5360.0,0.15,2024-06-24 18:28:27+00:00 +5361.0,0.15,2024-06-24 18:28:28+00:00 +5362.0,0.15,2024-06-24 18:28:29+00:00 +5363.0,0.15,2024-06-24 18:28:30+00:00 +5364.0,0.15,2024-06-24 18:28:31+00:00 +5365.0,0.15,2024-06-24 18:28:32+00:00 +5366.0,0.15,2024-06-24 18:28:33+00:00 +5367.0,0.15,2024-06-24 18:28:34+00:00 +5368.0,0.15,2024-06-24 18:28:35+00:00 +5369.0,0.15,2024-06-24 18:28:36+00:00 +5370.0,0.15,2024-06-24 18:28:37+00:00 +5371.0,0.15,2024-06-24 18:28:38+00:00 +5372.0,0.15,2024-06-24 18:28:39+00:00 +5373.0,0.15,2024-06-24 18:28:40+00:00 +5374.0,0.15,2024-06-24 18:28:41+00:00 +5375.0,0.15,2024-06-24 18:28:42+00:00 +5376.0,0.15,2024-06-24 18:28:43+00:00 +5377.0,0.15,2024-06-24 18:28:44+00:00 +5378.0,0.15,2024-06-24 18:28:45+00:00 +5379.0,0.15,2024-06-24 18:28:46+00:00 +5380.0,0.15,2024-06-24 18:28:47+00:00 +5381.0,0.15,2024-06-24 18:28:48+00:00 +5382.0,0.15,2024-06-24 18:28:49+00:00 +5383.0,0.15,2024-06-24 18:28:50+00:00 +5384.0,0.15,2024-06-24 18:28:51+00:00 +5385.0,0.15,2024-06-24 18:28:52+00:00 +5386.0,0.15,2024-06-24 18:28:53+00:00 +5387.0,0.15,2024-06-24 18:28:54+00:00 +5388.0,0.15,2024-06-24 18:28:55+00:00 +5389.0,0.15,2024-06-24 18:28:56+00:00 +5390.0,0.15,2024-06-24 18:28:57+00:00 +5391.0,0.15,2024-06-24 18:28:58+00:00 +5392.0,0.15,2024-06-24 18:28:59+00:00 +5393.0,0.15,2024-06-24 18:29:00+00:00 +5394.0,0.15,2024-06-24 18:29:01+00:00 +5395.0,0.15,2024-06-24 18:29:02+00:00 +5396.0,0.15,2024-06-24 18:29:03+00:00 +5397.0,0.15,2024-06-24 18:29:04+00:00 +5398.0,0.15,2024-06-24 18:29:05+00:00 +5399.0,0.15,2024-06-24 18:29:06+00:00 +5400.0,0.15,2024-06-24 18:29:07+00:00 +5401.0,0.1,2024-06-24 18:29:08+00:00 +5402.0,0.1,2024-06-24 18:29:09+00:00 +5403.0,0.1,2024-06-24 18:29:10+00:00 +5404.0,0.1,2024-06-24 18:29:11+00:00 +5405.0,0.1,2024-06-24 18:29:12+00:00 +5406.0,0.1,2024-06-24 18:29:13+00:00 +5407.0,0.1,2024-06-24 18:29:14+00:00 +5408.0,0.1,2024-06-24 18:29:15+00:00 +5409.0,0.1,2024-06-24 18:29:16+00:00 +5410.0,0.1,2024-06-24 18:29:17+00:00 +5411.0,0.1,2024-06-24 18:29:18+00:00 +5412.0,0.1,2024-06-24 18:29:19+00:00 +5413.0,0.1,2024-06-24 18:29:20+00:00 +5414.0,0.1,2024-06-24 18:29:21+00:00 +5415.0,0.1,2024-06-24 18:29:22+00:00 +5416.0,0.1,2024-06-24 18:29:23+00:00 +5417.0,0.1,2024-06-24 18:29:24+00:00 +5418.0,0.1,2024-06-24 18:29:25+00:00 +5419.0,0.1,2024-06-24 18:29:26+00:00 +5420.0,0.1,2024-06-24 18:29:27+00:00 +5421.0,0.1,2024-06-24 18:29:28+00:00 +5422.0,0.1,2024-06-24 18:29:29+00:00 +5423.0,0.1,2024-06-24 18:29:30+00:00 +5424.0,0.1,2024-06-24 18:29:31+00:00 +5425.0,0.1,2024-06-24 18:29:32+00:00 +5426.0,0.1,2024-06-24 18:29:33+00:00 +5427.0,0.1,2024-06-24 18:29:34+00:00 +5428.0,0.1,2024-06-24 18:29:35+00:00 +5429.0,0.1,2024-06-24 18:29:36+00:00 +5430.0,0.1,2024-06-24 18:29:37+00:00 +5431.0,0.1,2024-06-24 18:29:38+00:00 +5432.0,0.1,2024-06-24 18:29:39+00:00 +5433.0,0.1,2024-06-24 18:29:40+00:00 +5434.0,0.1,2024-06-24 18:29:41+00:00 +5435.0,0.1,2024-06-24 18:29:42+00:00 +5436.0,0.1,2024-06-24 18:29:43+00:00 +5437.0,0.1,2024-06-24 18:29:44+00:00 +5438.0,0.1,2024-06-24 18:29:45+00:00 +5439.0,0.1,2024-06-24 18:29:46+00:00 +5440.0,0.1,2024-06-24 18:29:47+00:00 +5441.0,0.1,2024-06-24 18:29:48+00:00 +5442.0,0.1,2024-06-24 18:29:49+00:00 +5443.0,0.1,2024-06-24 18:29:50+00:00 +5444.0,0.1,2024-06-24 18:29:51+00:00 +5445.0,0.1,2024-06-24 18:29:52+00:00 +5446.0,0.1,2024-06-24 18:29:53+00:00 +5447.0,0.1,2024-06-24 18:29:54+00:00 +5448.0,0.1,2024-06-24 18:29:55+00:00 +5449.0,0.1,2024-06-24 18:29:56+00:00 +5450.0,0.1,2024-06-24 18:29:57+00:00 +5451.0,0.1,2024-06-24 18:29:58+00:00 +5452.0,0.1,2024-06-24 18:29:59+00:00 +5453.0,0.1,2024-06-24 18:30:00+00:00 +5454.0,0.1,2024-06-24 18:30:01+00:00 +5455.0,0.1,2024-06-24 18:30:02+00:00 +5456.0,0.1,2024-06-24 18:30:03+00:00 +5457.0,0.1,2024-06-24 18:30:04+00:00 +5458.0,0.1,2024-06-24 18:30:05+00:00 +5459.0,0.1,2024-06-24 18:30:06+00:00 +5460.0,0.1,2024-06-24 18:30:07+00:00 +5461.0,0.1,2024-06-24 18:30:08+00:00 +5462.0,0.1,2024-06-24 18:30:09+00:00 +5463.0,0.1,2024-06-24 18:30:10+00:00 +5464.0,0.1,2024-06-24 18:30:11+00:00 +5465.0,0.1,2024-06-24 18:30:12+00:00 +5466.0,0.1,2024-06-24 18:30:13+00:00 +5467.0,0.1,2024-06-24 18:30:14+00:00 +5468.0,0.1,2024-06-24 18:30:15+00:00 +5469.0,0.1,2024-06-24 18:30:16+00:00 +5470.0,0.1,2024-06-24 18:30:17+00:00 +5471.0,0.1,2024-06-24 18:30:18+00:00 +5472.0,0.1,2024-06-24 18:30:19+00:00 +5473.0,0.1,2024-06-24 18:30:20+00:00 +5474.0,0.1,2024-06-24 18:30:21+00:00 +5475.0,0.1,2024-06-24 18:30:22+00:00 +5476.0,0.1,2024-06-24 18:30:23+00:00 +5477.0,0.1,2024-06-24 18:30:24+00:00 +5478.0,0.1,2024-06-24 18:30:25+00:00 +5479.0,0.1,2024-06-24 18:30:26+00:00 +5480.0,0.1,2024-06-24 18:30:27+00:00 +5481.0,0.1,2024-06-24 18:30:28+00:00 +5482.0,0.1,2024-06-24 18:30:29+00:00 +5483.0,0.1,2024-06-24 18:30:30+00:00 +5484.0,0.1,2024-06-24 18:30:31+00:00 +5485.0,0.1,2024-06-24 18:30:32+00:00 +5486.0,0.1,2024-06-24 18:30:33+00:00 +5487.0,0.1,2024-06-24 18:30:34+00:00 +5488.0,0.1,2024-06-24 18:30:35+00:00 +5489.0,0.1,2024-06-24 18:30:36+00:00 +5490.0,0.1,2024-06-24 18:30:37+00:00 +5491.0,0.1,2024-06-24 18:30:38+00:00 +5492.0,0.1,2024-06-24 18:30:39+00:00 +5493.0,0.1,2024-06-24 18:30:40+00:00 +5494.0,0.1,2024-06-24 18:30:41+00:00 +5495.0,0.1,2024-06-24 18:30:42+00:00 +5496.0,0.1,2024-06-24 18:30:43+00:00 +5497.0,0.1,2024-06-24 18:30:44+00:00 +5498.0,0.1,2024-06-24 18:30:45+00:00 +5499.0,0.1,2024-06-24 18:30:46+00:00 +5500.0,0.1,2024-06-24 18:30:47+00:00 +5501.0,0.1,2024-06-24 18:30:48+00:00 +5502.0,0.1,2024-06-24 18:30:49+00:00 +5503.0,0.1,2024-06-24 18:30:50+00:00 +5504.0,0.1,2024-06-24 18:30:51+00:00 +5505.0,0.1,2024-06-24 18:30:52+00:00 +5506.0,0.1,2024-06-24 18:30:53+00:00 +5507.0,0.1,2024-06-24 18:30:54+00:00 +5508.0,0.1,2024-06-24 18:30:55+00:00 +5509.0,0.1,2024-06-24 18:30:56+00:00 +5510.0,0.1,2024-06-24 18:30:57+00:00 +5511.0,0.1,2024-06-24 18:30:58+00:00 +5512.0,0.1,2024-06-24 18:30:59+00:00 +5513.0,0.1,2024-06-24 18:31:00+00:00 +5514.0,0.1,2024-06-24 18:31:01+00:00 +5515.0,0.1,2024-06-24 18:31:02+00:00 +5516.0,0.1,2024-06-24 18:31:03+00:00 +5517.0,0.1,2024-06-24 18:31:04+00:00 +5518.0,0.1,2024-06-24 18:31:05+00:00 +5519.0,0.1,2024-06-24 18:31:06+00:00 +5520.0,0.1,2024-06-24 18:31:07+00:00 +5521.0,0.1,2024-06-24 18:31:08+00:00 +5522.0,0.1,2024-06-24 18:31:09+00:00 +5523.0,0.1,2024-06-24 18:31:10+00:00 +5524.0,0.1,2024-06-24 18:31:11+00:00 +5525.0,0.1,2024-06-24 18:31:12+00:00 +5526.0,0.1,2024-06-24 18:31:13+00:00 +5527.0,0.1,2024-06-24 18:31:14+00:00 +5528.0,0.1,2024-06-24 18:31:15+00:00 +5529.0,0.1,2024-06-24 18:31:16+00:00 +5530.0,0.1,2024-06-24 18:31:17+00:00 +5531.0,0.1,2024-06-24 18:31:18+00:00 +5532.0,0.1,2024-06-24 18:31:19+00:00 +5533.0,0.1,2024-06-24 18:31:20+00:00 +5534.0,0.1,2024-06-24 18:31:21+00:00 +5535.0,0.1,2024-06-24 18:31:22+00:00 +5536.0,0.1,2024-06-24 18:31:23+00:00 +5537.0,0.1,2024-06-24 18:31:24+00:00 +5538.0,0.1,2024-06-24 18:31:25+00:00 +5539.0,0.1,2024-06-24 18:31:26+00:00 +5540.0,0.1,2024-06-24 18:31:27+00:00 +5541.0,0.1,2024-06-24 18:31:28+00:00 +5542.0,0.1,2024-06-24 18:31:29+00:00 +5543.0,0.1,2024-06-24 18:31:30+00:00 +5544.0,0.1,2024-06-24 18:31:31+00:00 +5545.0,0.1,2024-06-24 18:31:32+00:00 +5546.0,0.1,2024-06-24 18:31:33+00:00 +5547.0,0.1,2024-06-24 18:31:34+00:00 +5548.0,0.1,2024-06-24 18:31:35+00:00 +5549.0,0.1,2024-06-24 18:31:36+00:00 +5550.0,0.1,2024-06-24 18:31:37+00:00 +5551.0,0.1,2024-06-24 18:31:38+00:00 +5552.0,0.1,2024-06-24 18:31:39+00:00 +5553.0,0.1,2024-06-24 18:31:40+00:00 +5554.0,0.1,2024-06-24 18:31:41+00:00 +5555.0,0.1,2024-06-24 18:31:42+00:00 +5556.0,0.1,2024-06-24 18:31:43+00:00 +5557.0,0.1,2024-06-24 18:31:44+00:00 +5558.0,0.1,2024-06-24 18:31:45+00:00 +5559.0,0.1,2024-06-24 18:31:46+00:00 +5560.0,0.1,2024-06-24 18:31:47+00:00 +5561.0,0.1,2024-06-24 18:31:48+00:00 +5562.0,0.1,2024-06-24 18:31:49+00:00 +5563.0,0.1,2024-06-24 18:31:50+00:00 +5564.0,0.1,2024-06-24 18:31:51+00:00 +5565.0,0.1,2024-06-24 18:31:52+00:00 +5566.0,0.1,2024-06-24 18:31:53+00:00 +5567.0,0.1,2024-06-24 18:31:54+00:00 +5568.0,0.1,2024-06-24 18:31:55+00:00 +5569.0,0.1,2024-06-24 18:31:56+00:00 +5570.0,0.1,2024-06-24 18:31:57+00:00 +5571.0,0.1,2024-06-24 18:31:58+00:00 +5572.0,0.1,2024-06-24 18:31:59+00:00 +5573.0,0.1,2024-06-24 18:32:00+00:00 +5574.0,0.1,2024-06-24 18:32:01+00:00 +5575.0,0.1,2024-06-24 18:32:02+00:00 +5576.0,0.1,2024-06-24 18:32:03+00:00 +5577.0,0.1,2024-06-24 18:32:04+00:00 +5578.0,0.1,2024-06-24 18:32:05+00:00 +5579.0,0.1,2024-06-24 18:32:06+00:00 +5580.0,0.1,2024-06-24 18:32:07+00:00 +5581.0,0.1,2024-06-24 18:32:08+00:00 +5582.0,0.1,2024-06-24 18:32:09+00:00 +5583.0,0.1,2024-06-24 18:32:10+00:00 +5584.0,0.1,2024-06-24 18:32:11+00:00 +5585.0,0.1,2024-06-24 18:32:12+00:00 +5586.0,0.1,2024-06-24 18:32:13+00:00 +5587.0,0.1,2024-06-24 18:32:14+00:00 +5588.0,0.1,2024-06-24 18:32:15+00:00 +5589.0,0.1,2024-06-24 18:32:16+00:00 +5590.0,0.1,2024-06-24 18:32:17+00:00 +5591.0,0.1,2024-06-24 18:32:18+00:00 +5592.0,0.1,2024-06-24 18:32:19+00:00 +5593.0,0.1,2024-06-24 18:32:20+00:00 +5594.0,0.1,2024-06-24 18:32:21+00:00 +5595.0,0.1,2024-06-24 18:32:22+00:00 +5596.0,0.1,2024-06-24 18:32:23+00:00 +5597.0,0.1,2024-06-24 18:32:24+00:00 +5598.0,0.1,2024-06-24 18:32:25+00:00 +5599.0,0.1,2024-06-24 18:32:26+00:00 +5600.0,0.1,2024-06-24 18:32:27+00:00 +5601.0,0.1,2024-06-24 18:32:28+00:00 +5602.0,0.1,2024-06-24 18:32:29+00:00 +5603.0,0.1,2024-06-24 18:32:30+00:00 +5604.0,0.1,2024-06-24 18:32:31+00:00 +5605.0,0.1,2024-06-24 18:32:32+00:00 +5606.0,0.1,2024-06-24 18:32:33+00:00 +5607.0,0.1,2024-06-24 18:32:34+00:00 +5608.0,0.1,2024-06-24 18:32:35+00:00 +5609.0,0.1,2024-06-24 18:32:36+00:00 +5610.0,0.1,2024-06-24 18:32:37+00:00 +5611.0,0.1,2024-06-24 18:32:38+00:00 +5612.0,0.1,2024-06-24 18:32:39+00:00 +5613.0,0.1,2024-06-24 18:32:40+00:00 +5614.0,0.1,2024-06-24 18:32:41+00:00 +5615.0,0.1,2024-06-24 18:32:42+00:00 +5616.0,0.1,2024-06-24 18:32:43+00:00 +5617.0,0.1,2024-06-24 18:32:44+00:00 +5618.0,0.1,2024-06-24 18:32:45+00:00 +5619.0,0.1,2024-06-24 18:32:46+00:00 +5620.0,0.1,2024-06-24 18:32:47+00:00 +5621.0,0.1,2024-06-24 18:32:48+00:00 +5622.0,0.1,2024-06-24 18:32:49+00:00 +5623.0,0.1,2024-06-24 18:32:50+00:00 +5624.0,0.1,2024-06-24 18:32:51+00:00 +5625.0,0.1,2024-06-24 18:32:52+00:00 +5626.0,0.1,2024-06-24 18:32:53+00:00 +5627.0,0.1,2024-06-24 18:32:54+00:00 +5628.0,0.1,2024-06-24 18:32:55+00:00 +5629.0,0.1,2024-06-24 18:32:56+00:00 +5630.0,0.1,2024-06-24 18:32:57+00:00 +5631.0,0.1,2024-06-24 18:32:58+00:00 +5632.0,0.1,2024-06-24 18:32:59+00:00 +5633.0,0.1,2024-06-24 18:33:00+00:00 +5634.0,0.1,2024-06-24 18:33:01+00:00 +5635.0,0.1,2024-06-24 18:33:02+00:00 +5636.0,0.1,2024-06-24 18:33:03+00:00 +5637.0,0.1,2024-06-24 18:33:04+00:00 +5638.0,0.1,2024-06-24 18:33:05+00:00 +5639.0,0.1,2024-06-24 18:33:06+00:00 +5640.0,0.1,2024-06-24 18:33:07+00:00 +5641.0,0.1,2024-06-24 18:33:08+00:00 +5642.0,0.1,2024-06-24 18:33:09+00:00 +5643.0,0.1,2024-06-24 18:33:10+00:00 +5644.0,0.1,2024-06-24 18:33:11+00:00 +5645.0,0.1,2024-06-24 18:33:12+00:00 +5646.0,0.1,2024-06-24 18:33:13+00:00 +5647.0,0.1,2024-06-24 18:33:14+00:00 +5648.0,0.1,2024-06-24 18:33:15+00:00 +5649.0,0.1,2024-06-24 18:33:16+00:00 +5650.0,0.1,2024-06-24 18:33:17+00:00 +5651.0,0.1,2024-06-24 18:33:18+00:00 +5652.0,0.1,2024-06-24 18:33:19+00:00 +5653.0,0.1,2024-06-24 18:33:20+00:00 +5654.0,0.1,2024-06-24 18:33:21+00:00 +5655.0,0.1,2024-06-24 18:33:22+00:00 +5656.0,0.1,2024-06-24 18:33:23+00:00 +5657.0,0.1,2024-06-24 18:33:24+00:00 +5658.0,0.1,2024-06-24 18:33:25+00:00 +5659.0,0.1,2024-06-24 18:33:26+00:00 +5660.0,0.1,2024-06-24 18:33:27+00:00 +5661.0,0.1,2024-06-24 18:33:28+00:00 +5662.0,0.1,2024-06-24 18:33:29+00:00 +5663.0,0.1,2024-06-24 18:33:30+00:00 +5664.0,0.1,2024-06-24 18:33:31+00:00 +5665.0,0.1,2024-06-24 18:33:32+00:00 +5666.0,0.1,2024-06-24 18:33:33+00:00 +5667.0,0.1,2024-06-24 18:33:34+00:00 +5668.0,0.1,2024-06-24 18:33:35+00:00 +5669.0,0.1,2024-06-24 18:33:36+00:00 +5670.0,0.1,2024-06-24 18:33:37+00:00 +5671.0,0.1,2024-06-24 18:33:38+00:00 +5672.0,0.1,2024-06-24 18:33:39+00:00 +5673.0,0.1,2024-06-24 18:33:40+00:00 +5674.0,0.1,2024-06-24 18:33:41+00:00 +5675.0,0.1,2024-06-24 18:33:42+00:00 +5676.0,0.1,2024-06-24 18:33:43+00:00 +5677.0,0.1,2024-06-24 18:33:44+00:00 +5678.0,0.1,2024-06-24 18:33:45+00:00 +5679.0,0.1,2024-06-24 18:33:46+00:00 +5680.0,0.1,2024-06-24 18:33:47+00:00 +5681.0,0.1,2024-06-24 18:33:48+00:00 +5682.0,0.1,2024-06-24 18:33:49+00:00 +5683.0,0.1,2024-06-24 18:33:50+00:00 +5684.0,0.1,2024-06-24 18:33:51+00:00 +5685.0,0.1,2024-06-24 18:33:52+00:00 +5686.0,0.1,2024-06-24 18:33:53+00:00 +5687.0,0.1,2024-06-24 18:33:54+00:00 +5688.0,0.1,2024-06-24 18:33:55+00:00 +5689.0,0.1,2024-06-24 18:33:56+00:00 +5690.0,0.1,2024-06-24 18:33:57+00:00 +5691.0,0.1,2024-06-24 18:33:58+00:00 +5692.0,0.1,2024-06-24 18:33:59+00:00 +5693.0,0.1,2024-06-24 18:34:00+00:00 +5694.0,0.1,2024-06-24 18:34:01+00:00 +5695.0,0.1,2024-06-24 18:34:02+00:00 +5696.0,0.1,2024-06-24 18:34:03+00:00 +5697.0,0.1,2024-06-24 18:34:04+00:00 +5698.0,0.1,2024-06-24 18:34:05+00:00 +5699.0,0.1,2024-06-24 18:34:06+00:00 +5700.0,0.1,2024-06-24 18:34:07+00:00 +5701.0,0.1,2024-06-24 18:34:08+00:00 +5702.0,0.1,2024-06-24 18:34:09+00:00 +5703.0,0.1,2024-06-24 18:34:10+00:00 +5704.0,0.1,2024-06-24 18:34:11+00:00 +5705.0,0.1,2024-06-24 18:34:12+00:00 +5706.0,0.1,2024-06-24 18:34:13+00:00 +5707.0,0.1,2024-06-24 18:34:14+00:00 +5708.0,0.1,2024-06-24 18:34:15+00:00 +5709.0,0.1,2024-06-24 18:34:16+00:00 +5710.0,0.1,2024-06-24 18:34:17+00:00 +5711.0,0.1,2024-06-24 18:34:18+00:00 +5712.0,0.1,2024-06-24 18:34:19+00:00 +5713.0,0.1,2024-06-24 18:34:20+00:00 +5714.0,0.1,2024-06-24 18:34:21+00:00 +5715.0,0.1,2024-06-24 18:34:22+00:00 +5716.0,0.1,2024-06-24 18:34:23+00:00 +5717.0,0.1,2024-06-24 18:34:24+00:00 +5718.0,0.1,2024-06-24 18:34:25+00:00 +5719.0,0.1,2024-06-24 18:34:26+00:00 +5720.0,0.1,2024-06-24 18:34:27+00:00 +5721.0,0.1,2024-06-24 18:34:28+00:00 +5722.0,0.1,2024-06-24 18:34:29+00:00 +5723.0,0.1,2024-06-24 18:34:30+00:00 +5724.0,0.1,2024-06-24 18:34:31+00:00 +5725.0,0.1,2024-06-24 18:34:32+00:00 +5726.0,0.1,2024-06-24 18:34:33+00:00 +5727.0,0.1,2024-06-24 18:34:34+00:00 +5728.0,0.1,2024-06-24 18:34:35+00:00 +5729.0,0.1,2024-06-24 18:34:36+00:00 +5730.0,0.1,2024-06-24 18:34:37+00:00 +5731.0,0.1,2024-06-24 18:34:38+00:00 +5732.0,0.1,2024-06-24 18:34:39+00:00 +5733.0,0.1,2024-06-24 18:34:40+00:00 +5734.0,0.1,2024-06-24 18:34:41+00:00 +5735.0,0.1,2024-06-24 18:34:42+00:00 +5736.0,0.1,2024-06-24 18:34:43+00:00 +5737.0,0.1,2024-06-24 18:34:44+00:00 +5738.0,0.1,2024-06-24 18:34:45+00:00 +5739.0,0.1,2024-06-24 18:34:46+00:00 +5740.0,0.1,2024-06-24 18:34:47+00:00 +5741.0,0.1,2024-06-24 18:34:48+00:00 +5742.0,0.1,2024-06-24 18:34:49+00:00 +5743.0,0.1,2024-06-24 18:34:50+00:00 +5744.0,0.1,2024-06-24 18:34:51+00:00 +5745.0,0.1,2024-06-24 18:34:52+00:00 +5746.0,0.1,2024-06-24 18:34:53+00:00 +5747.0,0.1,2024-06-24 18:34:54+00:00 +5748.0,0.1,2024-06-24 18:34:55+00:00 +5749.0,0.1,2024-06-24 18:34:56+00:00 +5750.0,0.1,2024-06-24 18:34:57+00:00 +5751.0,0.1,2024-06-24 18:34:58+00:00 +5752.0,0.1,2024-06-24 18:34:59+00:00 +5753.0,0.1,2024-06-24 18:35:00+00:00 +5754.0,0.1,2024-06-24 18:35:01+00:00 +5755.0,0.1,2024-06-24 18:35:02+00:00 +5756.0,0.1,2024-06-24 18:35:03+00:00 +5757.0,0.1,2024-06-24 18:35:04+00:00 +5758.0,0.1,2024-06-24 18:35:05+00:00 +5759.0,0.1,2024-06-24 18:35:06+00:00 +5760.0,0.1,2024-06-24 18:35:07+00:00 +5761.0,0.1,2024-06-24 18:35:08+00:00 +5762.0,0.1,2024-06-24 18:35:09+00:00 +5763.0,0.1,2024-06-24 18:35:10+00:00 +5764.0,0.1,2024-06-24 18:35:11+00:00 +5765.0,0.1,2024-06-24 18:35:12+00:00 +5766.0,0.1,2024-06-24 18:35:13+00:00 +5767.0,0.1,2024-06-24 18:35:14+00:00 +5768.0,0.1,2024-06-24 18:35:15+00:00 +5769.0,0.1,2024-06-24 18:35:16+00:00 +5770.0,0.1,2024-06-24 18:35:17+00:00 +5771.0,0.1,2024-06-24 18:35:18+00:00 +5772.0,0.1,2024-06-24 18:35:19+00:00 +5773.0,0.1,2024-06-24 18:35:20+00:00 +5774.0,0.1,2024-06-24 18:35:21+00:00 +5775.0,0.1,2024-06-24 18:35:22+00:00 +5776.0,0.1,2024-06-24 18:35:23+00:00 +5777.0,0.1,2024-06-24 18:35:24+00:00 +5778.0,0.1,2024-06-24 18:35:25+00:00 +5779.0,0.1,2024-06-24 18:35:26+00:00 +5780.0,0.1,2024-06-24 18:35:27+00:00 +5781.0,0.1,2024-06-24 18:35:28+00:00 +5782.0,0.1,2024-06-24 18:35:29+00:00 +5783.0,0.1,2024-06-24 18:35:30+00:00 +5784.0,0.1,2024-06-24 18:35:31+00:00 +5785.0,0.1,2024-06-24 18:35:32+00:00 +5786.0,0.1,2024-06-24 18:35:33+00:00 +5787.0,0.1,2024-06-24 18:35:34+00:00 +5788.0,0.1,2024-06-24 18:35:35+00:00 +5789.0,0.1,2024-06-24 18:35:36+00:00 +5790.0,0.1,2024-06-24 18:35:37+00:00 +5791.0,0.1,2024-06-24 18:35:38+00:00 +5792.0,0.1,2024-06-24 18:35:39+00:00 +5793.0,0.1,2024-06-24 18:35:40+00:00 +5794.0,0.1,2024-06-24 18:35:41+00:00 +5795.0,0.1,2024-06-24 18:35:42+00:00 +5796.0,0.1,2024-06-24 18:35:43+00:00 +5797.0,0.1,2024-06-24 18:35:44+00:00 +5798.0,0.1,2024-06-24 18:35:45+00:00 +5799.0,0.1,2024-06-24 18:35:46+00:00 +5800.0,0.1,2024-06-24 18:35:47+00:00 +5801.0,0.1,2024-06-24 18:35:48+00:00 +5802.0,0.1,2024-06-24 18:35:49+00:00 +5803.0,0.1,2024-06-24 18:35:50+00:00 +5804.0,0.1,2024-06-24 18:35:51+00:00 +5805.0,0.1,2024-06-24 18:35:52+00:00 +5806.0,0.1,2024-06-24 18:35:53+00:00 +5807.0,0.1,2024-06-24 18:35:54+00:00 +5808.0,0.1,2024-06-24 18:35:55+00:00 +5809.0,0.1,2024-06-24 18:35:56+00:00 +5810.0,0.1,2024-06-24 18:35:57+00:00 +5811.0,0.1,2024-06-24 18:35:58+00:00 +5812.0,0.1,2024-06-24 18:35:59+00:00 +5813.0,0.1,2024-06-24 18:36:00+00:00 +5814.0,0.1,2024-06-24 18:36:01+00:00 +5815.0,0.1,2024-06-24 18:36:02+00:00 +5816.0,0.1,2024-06-24 18:36:03+00:00 +5817.0,0.1,2024-06-24 18:36:04+00:00 +5818.0,0.1,2024-06-24 18:36:05+00:00 +5819.0,0.1,2024-06-24 18:36:06+00:00 +5820.0,0.1,2024-06-24 18:36:07+00:00 +5821.0,0.1,2024-06-24 18:36:08+00:00 +5822.0,0.1,2024-06-24 18:36:09+00:00 +5823.0,0.1,2024-06-24 18:36:10+00:00 +5824.0,0.1,2024-06-24 18:36:11+00:00 +5825.0,0.1,2024-06-24 18:36:12+00:00 +5826.0,0.1,2024-06-24 18:36:13+00:00 +5827.0,0.1,2024-06-24 18:36:14+00:00 +5828.0,0.1,2024-06-24 18:36:15+00:00 +5829.0,0.1,2024-06-24 18:36:16+00:00 +5830.0,0.1,2024-06-24 18:36:17+00:00 +5831.0,0.1,2024-06-24 18:36:18+00:00 +5832.0,0.1,2024-06-24 18:36:19+00:00 +5833.0,0.1,2024-06-24 18:36:20+00:00 +5834.0,0.1,2024-06-24 18:36:21+00:00 +5835.0,0.1,2024-06-24 18:36:22+00:00 +5836.0,0.1,2024-06-24 18:36:23+00:00 +5837.0,0.1,2024-06-24 18:36:24+00:00 +5838.0,0.1,2024-06-24 18:36:25+00:00 +5839.0,0.1,2024-06-24 18:36:26+00:00 +5840.0,0.1,2024-06-24 18:36:27+00:00 +5841.0,0.1,2024-06-24 18:36:28+00:00 +5842.0,0.1,2024-06-24 18:36:29+00:00 +5843.0,0.1,2024-06-24 18:36:30+00:00 +5844.0,0.1,2024-06-24 18:36:31+00:00 +5845.0,0.1,2024-06-24 18:36:32+00:00 +5846.0,0.1,2024-06-24 18:36:33+00:00 +5847.0,0.1,2024-06-24 18:36:34+00:00 +5848.0,0.1,2024-06-24 18:36:35+00:00 +5849.0,0.1,2024-06-24 18:36:36+00:00 +5850.0,0.1,2024-06-24 18:36:37+00:00 +5851.0,0.1,2024-06-24 18:36:38+00:00 +5852.0,0.1,2024-06-24 18:36:39+00:00 +5853.0,0.1,2024-06-24 18:36:40+00:00 +5854.0,0.1,2024-06-24 18:36:41+00:00 +5855.0,0.1,2024-06-24 18:36:42+00:00 +5856.0,0.1,2024-06-24 18:36:43+00:00 +5857.0,0.1,2024-06-24 18:36:44+00:00 +5858.0,0.1,2024-06-24 18:36:45+00:00 +5859.0,0.1,2024-06-24 18:36:46+00:00 +5860.0,0.1,2024-06-24 18:36:47+00:00 +5861.0,0.1,2024-06-24 18:36:48+00:00 +5862.0,0.1,2024-06-24 18:36:49+00:00 +5863.0,0.1,2024-06-24 18:36:50+00:00 +5864.0,0.1,2024-06-24 18:36:51+00:00 +5865.0,0.1,2024-06-24 18:36:52+00:00 +5866.0,0.1,2024-06-24 18:36:53+00:00 +5867.0,0.1,2024-06-24 18:36:54+00:00 +5868.0,0.1,2024-06-24 18:36:55+00:00 +5869.0,0.1,2024-06-24 18:36:56+00:00 +5870.0,0.1,2024-06-24 18:36:57+00:00 +5871.0,0.1,2024-06-24 18:36:58+00:00 +5872.0,0.1,2024-06-24 18:36:59+00:00 +5873.0,0.1,2024-06-24 18:37:00+00:00 +5874.0,0.1,2024-06-24 18:37:01+00:00 +5875.0,0.1,2024-06-24 18:37:02+00:00 +5876.0,0.1,2024-06-24 18:37:03+00:00 +5877.0,0.1,2024-06-24 18:37:04+00:00 +5878.0,0.1,2024-06-24 18:37:05+00:00 +5879.0,0.1,2024-06-24 18:37:06+00:00 +5880.0,0.1,2024-06-24 18:37:07+00:00 +5881.0,0.1,2024-06-24 18:37:08+00:00 +5882.0,0.1,2024-06-24 18:37:09+00:00 +5883.0,0.1,2024-06-24 18:37:10+00:00 +5884.0,0.1,2024-06-24 18:37:11+00:00 +5885.0,0.1,2024-06-24 18:37:12+00:00 +5886.0,0.1,2024-06-24 18:37:13+00:00 +5887.0,0.1,2024-06-24 18:37:14+00:00 +5888.0,0.1,2024-06-24 18:37:15+00:00 +5889.0,0.1,2024-06-24 18:37:16+00:00 +5890.0,0.1,2024-06-24 18:37:17+00:00 +5891.0,0.1,2024-06-24 18:37:18+00:00 +5892.0,0.1,2024-06-24 18:37:19+00:00 +5893.0,0.1,2024-06-24 18:37:20+00:00 +5894.0,0.1,2024-06-24 18:37:21+00:00 +5895.0,0.1,2024-06-24 18:37:22+00:00 +5896.0,0.1,2024-06-24 18:37:23+00:00 +5897.0,0.1,2024-06-24 18:37:24+00:00 +5898.0,0.1,2024-06-24 18:37:25+00:00 +5899.0,0.1,2024-06-24 18:37:26+00:00 +5900.0,0.1,2024-06-24 18:37:27+00:00 +5901.0,0.1,2024-06-24 18:37:28+00:00 +5902.0,0.1,2024-06-24 18:37:29+00:00 +5903.0,0.1,2024-06-24 18:37:30+00:00 +5904.0,0.1,2024-06-24 18:37:31+00:00 +5905.0,0.1,2024-06-24 18:37:32+00:00 +5906.0,0.1,2024-06-24 18:37:33+00:00 +5907.0,0.1,2024-06-24 18:37:34+00:00 +5908.0,0.1,2024-06-24 18:37:35+00:00 +5909.0,0.1,2024-06-24 18:37:36+00:00 +5910.0,0.1,2024-06-24 18:37:37+00:00 +5911.0,0.1,2024-06-24 18:37:38+00:00 +5912.0,0.1,2024-06-24 18:37:39+00:00 +5913.0,0.1,2024-06-24 18:37:40+00:00 +5914.0,0.1,2024-06-24 18:37:41+00:00 +5915.0,0.1,2024-06-24 18:37:42+00:00 +5916.0,0.1,2024-06-24 18:37:43+00:00 +5917.0,0.1,2024-06-24 18:37:44+00:00 +5918.0,0.1,2024-06-24 18:37:45+00:00 +5919.0,0.1,2024-06-24 18:37:46+00:00 +5920.0,0.1,2024-06-24 18:37:47+00:00 +5921.0,0.1,2024-06-24 18:37:48+00:00 +5922.0,0.1,2024-06-24 18:37:49+00:00 +5923.0,0.1,2024-06-24 18:37:50+00:00 +5924.0,0.1,2024-06-24 18:37:51+00:00 +5925.0,0.1,2024-06-24 18:37:52+00:00 +5926.0,0.1,2024-06-24 18:37:53+00:00 +5927.0,0.1,2024-06-24 18:37:54+00:00 +5928.0,0.1,2024-06-24 18:37:55+00:00 +5929.0,0.1,2024-06-24 18:37:56+00:00 +5930.0,0.1,2024-06-24 18:37:57+00:00 +5931.0,0.1,2024-06-24 18:37:58+00:00 +5932.0,0.1,2024-06-24 18:37:59+00:00 +5933.0,0.1,2024-06-24 18:38:00+00:00 +5934.0,0.1,2024-06-24 18:38:01+00:00 +5935.0,0.1,2024-06-24 18:38:02+00:00 +5936.0,0.1,2024-06-24 18:38:03+00:00 +5937.0,0.1,2024-06-24 18:38:04+00:00 +5938.0,0.1,2024-06-24 18:38:05+00:00 +5939.0,0.1,2024-06-24 18:38:06+00:00 +5940.0,0.1,2024-06-24 18:38:07+00:00 +5941.0,0.1,2024-06-24 18:38:08+00:00 +5942.0,0.1,2024-06-24 18:38:09+00:00 +5943.0,0.1,2024-06-24 18:38:10+00:00 +5944.0,0.1,2024-06-24 18:38:11+00:00 +5945.0,0.1,2024-06-24 18:38:12+00:00 +5946.0,0.1,2024-06-24 18:38:13+00:00 +5947.0,0.1,2024-06-24 18:38:14+00:00 +5948.0,0.1,2024-06-24 18:38:15+00:00 +5949.0,0.1,2024-06-24 18:38:16+00:00 +5950.0,0.1,2024-06-24 18:38:17+00:00 +5951.0,0.1,2024-06-24 18:38:18+00:00 +5952.0,0.1,2024-06-24 18:38:19+00:00 +5953.0,0.1,2024-06-24 18:38:20+00:00 +5954.0,0.1,2024-06-24 18:38:21+00:00 +5955.0,0.1,2024-06-24 18:38:22+00:00 +5956.0,0.1,2024-06-24 18:38:23+00:00 +5957.0,0.1,2024-06-24 18:38:24+00:00 +5958.0,0.1,2024-06-24 18:38:25+00:00 +5959.0,0.1,2024-06-24 18:38:26+00:00 +5960.0,0.1,2024-06-24 18:38:27+00:00 +5961.0,0.1,2024-06-24 18:38:28+00:00 +5962.0,0.1,2024-06-24 18:38:29+00:00 +5963.0,0.1,2024-06-24 18:38:30+00:00 +5964.0,0.1,2024-06-24 18:38:31+00:00 +5965.0,0.1,2024-06-24 18:38:32+00:00 +5966.0,0.1,2024-06-24 18:38:33+00:00 +5967.0,0.1,2024-06-24 18:38:34+00:00 +5968.0,0.1,2024-06-24 18:38:35+00:00 +5969.0,0.1,2024-06-24 18:38:36+00:00 +5970.0,0.1,2024-06-24 18:38:37+00:00 +5971.0,0.1,2024-06-24 18:38:38+00:00 +5972.0,0.1,2024-06-24 18:38:39+00:00 +5973.0,0.1,2024-06-24 18:38:40+00:00 +5974.0,0.1,2024-06-24 18:38:41+00:00 +5975.0,0.1,2024-06-24 18:38:42+00:00 +5976.0,0.1,2024-06-24 18:38:43+00:00 +5977.0,0.1,2024-06-24 18:38:44+00:00 +5978.0,0.1,2024-06-24 18:38:45+00:00 +5979.0,0.1,2024-06-24 18:38:46+00:00 +5980.0,0.1,2024-06-24 18:38:47+00:00 +5981.0,0.1,2024-06-24 18:38:48+00:00 +5982.0,0.1,2024-06-24 18:38:49+00:00 +5983.0,0.1,2024-06-24 18:38:50+00:00 +5984.0,0.1,2024-06-24 18:38:51+00:00 +5985.0,0.1,2024-06-24 18:38:52+00:00 +5986.0,0.1,2024-06-24 18:38:53+00:00 +5987.0,0.1,2024-06-24 18:38:54+00:00 +5988.0,0.1,2024-06-24 18:38:55+00:00 +5989.0,0.1,2024-06-24 18:38:56+00:00 +5990.0,0.1,2024-06-24 18:38:57+00:00 +5991.0,0.1,2024-06-24 18:38:58+00:00 +5992.0,0.1,2024-06-24 18:38:59+00:00 +5993.0,0.1,2024-06-24 18:39:00+00:00 +5994.0,0.1,2024-06-24 18:39:01+00:00 +5995.0,0.1,2024-06-24 18:39:02+00:00 +5996.0,0.1,2024-06-24 18:39:03+00:00 +5997.0,0.1,2024-06-24 18:39:04+00:00 +5998.0,0.1,2024-06-24 18:39:05+00:00 +5999.0,0.1,2024-06-24 18:39:06+00:00 +6000.0,0.1,2024-06-24 18:39:07+00:00 +6001.0,0.1,2024-06-24 18:39:08+00:00 +6002.0,0.1,2024-06-24 18:39:09+00:00 +6003.0,0.1,2024-06-24 18:39:10+00:00 +6004.0,0.1,2024-06-24 18:39:11+00:00 +6005.0,0.1,2024-06-24 18:39:12+00:00 +6006.0,0.1,2024-06-24 18:39:13+00:00 +6007.0,0.1,2024-06-24 18:39:14+00:00 +6008.0,0.1,2024-06-24 18:39:15+00:00 +6009.0,0.1,2024-06-24 18:39:16+00:00 +6010.0,0.1,2024-06-24 18:39:17+00:00 +6011.0,0.1,2024-06-24 18:39:18+00:00 +6012.0,0.1,2024-06-24 18:39:19+00:00 +6013.0,0.1,2024-06-24 18:39:20+00:00 +6014.0,0.1,2024-06-24 18:39:21+00:00 +6015.0,0.1,2024-06-24 18:39:22+00:00 +6016.0,0.1,2024-06-24 18:39:23+00:00 +6017.0,0.1,2024-06-24 18:39:24+00:00 +6018.0,0.1,2024-06-24 18:39:25+00:00 +6019.0,0.1,2024-06-24 18:39:26+00:00 +6020.0,0.1,2024-06-24 18:39:27+00:00 +6021.0,0.1,2024-06-24 18:39:28+00:00 +6022.0,0.1,2024-06-24 18:39:29+00:00 +6023.0,0.1,2024-06-24 18:39:30+00:00 +6024.0,0.1,2024-06-24 18:39:31+00:00 +6025.0,0.1,2024-06-24 18:39:32+00:00 +6026.0,0.1,2024-06-24 18:39:33+00:00 +6027.0,0.1,2024-06-24 18:39:34+00:00 +6028.0,0.1,2024-06-24 18:39:35+00:00 +6029.0,0.1,2024-06-24 18:39:36+00:00 +6030.0,0.1,2024-06-24 18:39:37+00:00 +6031.0,0.1,2024-06-24 18:39:38+00:00 +6032.0,0.1,2024-06-24 18:39:39+00:00 +6033.0,0.1,2024-06-24 18:39:40+00:00 +6034.0,0.1,2024-06-24 18:39:41+00:00 +6035.0,0.1,2024-06-24 18:39:42+00:00 +6036.0,0.1,2024-06-24 18:39:43+00:00 +6037.0,0.1,2024-06-24 18:39:44+00:00 +6038.0,0.1,2024-06-24 18:39:45+00:00 +6039.0,0.1,2024-06-24 18:39:46+00:00 +6040.0,0.1,2024-06-24 18:39:47+00:00 +6041.0,0.1,2024-06-24 18:39:48+00:00 +6042.0,0.1,2024-06-24 18:39:49+00:00 +6043.0,0.1,2024-06-24 18:39:50+00:00 +6044.0,0.1,2024-06-24 18:39:51+00:00 +6045.0,0.1,2024-06-24 18:39:52+00:00 +6046.0,0.1,2024-06-24 18:39:53+00:00 +6047.0,0.1,2024-06-24 18:39:54+00:00 +6048.0,0.1,2024-06-24 18:39:55+00:00 +6049.0,0.1,2024-06-24 18:39:56+00:00 +6050.0,0.1,2024-06-24 18:39:57+00:00 +6051.0,0.1,2024-06-24 18:39:58+00:00 +6052.0,0.1,2024-06-24 18:39:59+00:00 +6053.0,0.1,2024-06-24 18:40:00+00:00 +6054.0,0.1,2024-06-24 18:40:01+00:00 +6055.0,0.1,2024-06-24 18:40:02+00:00 +6056.0,0.1,2024-06-24 18:40:03+00:00 +6057.0,0.1,2024-06-24 18:40:04+00:00 +6058.0,0.1,2024-06-24 18:40:05+00:00 +6059.0,0.1,2024-06-24 18:40:06+00:00 +6060.0,0.1,2024-06-24 18:40:07+00:00 +6061.0,0.1,2024-06-24 18:40:08+00:00 +6062.0,0.1,2024-06-24 18:40:09+00:00 +6063.0,0.1,2024-06-24 18:40:10+00:00 +6064.0,0.1,2024-06-24 18:40:11+00:00 +6065.0,0.1,2024-06-24 18:40:12+00:00 +6066.0,0.1,2024-06-24 18:40:13+00:00 +6067.0,0.1,2024-06-24 18:40:14+00:00 +6068.0,0.1,2024-06-24 18:40:15+00:00 +6069.0,0.1,2024-06-24 18:40:16+00:00 +6070.0,0.1,2024-06-24 18:40:17+00:00 +6071.0,0.1,2024-06-24 18:40:18+00:00 +6072.0,0.1,2024-06-24 18:40:19+00:00 +6073.0,0.1,2024-06-24 18:40:20+00:00 +6074.0,0.1,2024-06-24 18:40:21+00:00 +6075.0,0.1,2024-06-24 18:40:22+00:00 +6076.0,0.1,2024-06-24 18:40:23+00:00 +6077.0,0.1,2024-06-24 18:40:24+00:00 +6078.0,0.1,2024-06-24 18:40:25+00:00 +6079.0,0.1,2024-06-24 18:40:26+00:00 +6080.0,0.1,2024-06-24 18:40:27+00:00 +6081.0,0.1,2024-06-24 18:40:28+00:00 +6082.0,0.1,2024-06-24 18:40:29+00:00 +6083.0,0.1,2024-06-24 18:40:30+00:00 +6084.0,0.1,2024-06-24 18:40:31+00:00 +6085.0,0.1,2024-06-24 18:40:32+00:00 +6086.0,0.1,2024-06-24 18:40:33+00:00 +6087.0,0.1,2024-06-24 18:40:34+00:00 +6088.0,0.1,2024-06-24 18:40:35+00:00 +6089.0,0.1,2024-06-24 18:40:36+00:00 +6090.0,0.1,2024-06-24 18:40:37+00:00 +6091.0,0.1,2024-06-24 18:40:38+00:00 +6092.0,0.1,2024-06-24 18:40:39+00:00 +6093.0,0.1,2024-06-24 18:40:40+00:00 +6094.0,0.1,2024-06-24 18:40:41+00:00 +6095.0,0.1,2024-06-24 18:40:42+00:00 +6096.0,0.1,2024-06-24 18:40:43+00:00 +6097.0,0.1,2024-06-24 18:40:44+00:00 +6098.0,0.1,2024-06-24 18:40:45+00:00 +6099.0,0.1,2024-06-24 18:40:46+00:00 +6100.0,0.1,2024-06-24 18:40:47+00:00 +6101.0,0.1,2024-06-24 18:40:48+00:00 +6102.0,0.1,2024-06-24 18:40:49+00:00 +6103.0,0.1,2024-06-24 18:40:50+00:00 +6104.0,0.1,2024-06-24 18:40:51+00:00 +6105.0,0.1,2024-06-24 18:40:52+00:00 +6106.0,0.1,2024-06-24 18:40:53+00:00 +6107.0,0.1,2024-06-24 18:40:54+00:00 +6108.0,0.1,2024-06-24 18:40:55+00:00 +6109.0,0.1,2024-06-24 18:40:56+00:00 +6110.0,0.1,2024-06-24 18:40:57+00:00 +6111.0,0.1,2024-06-24 18:40:58+00:00 +6112.0,0.1,2024-06-24 18:40:59+00:00 +6113.0,0.1,2024-06-24 18:41:00+00:00 +6114.0,0.1,2024-06-24 18:41:01+00:00 +6115.0,0.1,2024-06-24 18:41:02+00:00 +6116.0,0.1,2024-06-24 18:41:03+00:00 +6117.0,0.1,2024-06-24 18:41:04+00:00 +6118.0,0.1,2024-06-24 18:41:05+00:00 +6119.0,0.1,2024-06-24 18:41:06+00:00 +6120.0,0.1,2024-06-24 18:41:07+00:00 +6121.0,0.1,2024-06-24 18:41:08+00:00 +6122.0,0.1,2024-06-24 18:41:09+00:00 +6123.0,0.1,2024-06-24 18:41:10+00:00 +6124.0,0.1,2024-06-24 18:41:11+00:00 +6125.0,0.1,2024-06-24 18:41:12+00:00 +6126.0,0.1,2024-06-24 18:41:13+00:00 +6127.0,0.1,2024-06-24 18:41:14+00:00 +6128.0,0.1,2024-06-24 18:41:15+00:00 +6129.0,0.1,2024-06-24 18:41:16+00:00 +6130.0,0.1,2024-06-24 18:41:17+00:00 +6131.0,0.1,2024-06-24 18:41:18+00:00 +6132.0,0.1,2024-06-24 18:41:19+00:00 +6133.0,0.1,2024-06-24 18:41:20+00:00 +6134.0,0.1,2024-06-24 18:41:21+00:00 +6135.0,0.1,2024-06-24 18:41:22+00:00 +6136.0,0.1,2024-06-24 18:41:23+00:00 +6137.0,0.1,2024-06-24 18:41:24+00:00 +6138.0,0.1,2024-06-24 18:41:25+00:00 +6139.0,0.1,2024-06-24 18:41:26+00:00 +6140.0,0.1,2024-06-24 18:41:27+00:00 +6141.0,0.1,2024-06-24 18:41:28+00:00 +6142.0,0.1,2024-06-24 18:41:29+00:00 +6143.0,0.1,2024-06-24 18:41:30+00:00 +6144.0,0.1,2024-06-24 18:41:31+00:00 +6145.0,0.1,2024-06-24 18:41:32+00:00 +6146.0,0.1,2024-06-24 18:41:33+00:00 +6147.0,0.1,2024-06-24 18:41:34+00:00 +6148.0,0.1,2024-06-24 18:41:35+00:00 +6149.0,0.1,2024-06-24 18:41:36+00:00 +6150.0,0.1,2024-06-24 18:41:37+00:00 +6151.0,0.1,2024-06-24 18:41:38+00:00 +6152.0,0.1,2024-06-24 18:41:39+00:00 +6153.0,0.1,2024-06-24 18:41:40+00:00 +6154.0,0.1,2024-06-24 18:41:41+00:00 +6155.0,0.1,2024-06-24 18:41:42+00:00 +6156.0,0.1,2024-06-24 18:41:43+00:00 +6157.0,0.1,2024-06-24 18:41:44+00:00 +6158.0,0.1,2024-06-24 18:41:45+00:00 +6159.0,0.1,2024-06-24 18:41:46+00:00 +6160.0,0.1,2024-06-24 18:41:47+00:00 +6161.0,0.1,2024-06-24 18:41:48+00:00 +6162.0,0.1,2024-06-24 18:41:49+00:00 +6163.0,0.1,2024-06-24 18:41:50+00:00 +6164.0,0.1,2024-06-24 18:41:51+00:00 +6165.0,0.1,2024-06-24 18:41:52+00:00 +6166.0,0.1,2024-06-24 18:41:53+00:00 +6167.0,0.1,2024-06-24 18:41:54+00:00 +6168.0,0.1,2024-06-24 18:41:55+00:00 +6169.0,0.1,2024-06-24 18:41:56+00:00 +6170.0,0.1,2024-06-24 18:41:57+00:00 +6171.0,0.1,2024-06-24 18:41:58+00:00 +6172.0,0.1,2024-06-24 18:41:59+00:00 +6173.0,0.1,2024-06-24 18:42:00+00:00 +6174.0,0.1,2024-06-24 18:42:01+00:00 +6175.0,0.1,2024-06-24 18:42:02+00:00 +6176.0,0.1,2024-06-24 18:42:03+00:00 +6177.0,0.1,2024-06-24 18:42:04+00:00 +6178.0,0.1,2024-06-24 18:42:05+00:00 +6179.0,0.1,2024-06-24 18:42:06+00:00 +6180.0,0.1,2024-06-24 18:42:07+00:00 +6181.0,0.1,2024-06-24 18:42:08+00:00 +6182.0,0.1,2024-06-24 18:42:09+00:00 +6183.0,0.1,2024-06-24 18:42:10+00:00 +6184.0,0.1,2024-06-24 18:42:11+00:00 +6185.0,0.1,2024-06-24 18:42:12+00:00 +6186.0,0.1,2024-06-24 18:42:13+00:00 +6187.0,0.1,2024-06-24 18:42:14+00:00 +6188.0,0.1,2024-06-24 18:42:15+00:00 +6189.0,0.1,2024-06-24 18:42:16+00:00 +6190.0,0.1,2024-06-24 18:42:17+00:00 +6191.0,0.1,2024-06-24 18:42:18+00:00 +6192.0,0.1,2024-06-24 18:42:19+00:00 +6193.0,0.1,2024-06-24 18:42:20+00:00 +6194.0,0.1,2024-06-24 18:42:21+00:00 +6195.0,0.1,2024-06-24 18:42:22+00:00 +6196.0,0.1,2024-06-24 18:42:23+00:00 +6197.0,0.1,2024-06-24 18:42:24+00:00 +6198.0,0.1,2024-06-24 18:42:25+00:00 +6199.0,0.1,2024-06-24 18:42:26+00:00 +6200.0,0.1,2024-06-24 18:42:27+00:00 +6201.0,0.1,2024-06-24 18:42:28+00:00 +6202.0,0.1,2024-06-24 18:42:29+00:00 +6203.0,0.1,2024-06-24 18:42:30+00:00 +6204.0,0.1,2024-06-24 18:42:31+00:00 +6205.0,0.1,2024-06-24 18:42:32+00:00 +6206.0,0.1,2024-06-24 18:42:33+00:00 +6207.0,0.1,2024-06-24 18:42:34+00:00 +6208.0,0.1,2024-06-24 18:42:35+00:00 +6209.0,0.1,2024-06-24 18:42:36+00:00 +6210.0,0.1,2024-06-24 18:42:37+00:00 +6211.0,0.1,2024-06-24 18:42:38+00:00 +6212.0,0.1,2024-06-24 18:42:39+00:00 +6213.0,0.1,2024-06-24 18:42:40+00:00 +6214.0,0.1,2024-06-24 18:42:41+00:00 +6215.0,0.1,2024-06-24 18:42:42+00:00 +6216.0,0.1,2024-06-24 18:42:43+00:00 +6217.0,0.1,2024-06-24 18:42:44+00:00 +6218.0,0.1,2024-06-24 18:42:45+00:00 +6219.0,0.1,2024-06-24 18:42:46+00:00 +6220.0,0.1,2024-06-24 18:42:47+00:00 +6221.0,0.1,2024-06-24 18:42:48+00:00 +6222.0,0.1,2024-06-24 18:42:49+00:00 +6223.0,0.1,2024-06-24 18:42:50+00:00 +6224.0,0.1,2024-06-24 18:42:51+00:00 +6225.0,0.1,2024-06-24 18:42:52+00:00 +6226.0,0.1,2024-06-24 18:42:53+00:00 +6227.0,0.1,2024-06-24 18:42:54+00:00 +6228.0,0.1,2024-06-24 18:42:55+00:00 +6229.0,0.1,2024-06-24 18:42:56+00:00 +6230.0,0.1,2024-06-24 18:42:57+00:00 +6231.0,0.1,2024-06-24 18:42:58+00:00 +6232.0,0.1,2024-06-24 18:42:59+00:00 +6233.0,0.1,2024-06-24 18:43:00+00:00 +6234.0,0.1,2024-06-24 18:43:01+00:00 +6235.0,0.1,2024-06-24 18:43:02+00:00 +6236.0,0.1,2024-06-24 18:43:03+00:00 +6237.0,0.1,2024-06-24 18:43:04+00:00 +6238.0,0.1,2024-06-24 18:43:05+00:00 +6239.0,0.1,2024-06-24 18:43:06+00:00 +6240.0,0.1,2024-06-24 18:43:07+00:00 +6241.0,0.1,2024-06-24 18:43:08+00:00 +6242.0,0.1,2024-06-24 18:43:09+00:00 +6243.0,0.1,2024-06-24 18:43:10+00:00 +6244.0,0.1,2024-06-24 18:43:11+00:00 +6245.0,0.1,2024-06-24 18:43:12+00:00 +6246.0,0.1,2024-06-24 18:43:13+00:00 +6247.0,0.1,2024-06-24 18:43:14+00:00 +6248.0,0.1,2024-06-24 18:43:15+00:00 +6249.0,0.1,2024-06-24 18:43:16+00:00 +6250.0,0.1,2024-06-24 18:43:17+00:00 +6251.0,0.1,2024-06-24 18:43:18+00:00 +6252.0,0.1,2024-06-24 18:43:19+00:00 +6253.0,0.1,2024-06-24 18:43:20+00:00 +6254.0,0.1,2024-06-24 18:43:21+00:00 +6255.0,0.1,2024-06-24 18:43:22+00:00 +6256.0,0.1,2024-06-24 18:43:23+00:00 +6257.0,0.1,2024-06-24 18:43:24+00:00 +6258.0,0.1,2024-06-24 18:43:25+00:00 +6259.0,0.1,2024-06-24 18:43:26+00:00 +6260.0,0.1,2024-06-24 18:43:27+00:00 +6261.0,0.1,2024-06-24 18:43:28+00:00 +6262.0,0.1,2024-06-24 18:43:29+00:00 +6263.0,0.1,2024-06-24 18:43:30+00:00 +6264.0,0.1,2024-06-24 18:43:31+00:00 +6265.0,0.1,2024-06-24 18:43:32+00:00 +6266.0,0.1,2024-06-24 18:43:33+00:00 +6267.0,0.1,2024-06-24 18:43:34+00:00 +6268.0,0.1,2024-06-24 18:43:35+00:00 +6269.0,0.1,2024-06-24 18:43:36+00:00 +6270.0,0.1,2024-06-24 18:43:37+00:00 +6271.0,0.1,2024-06-24 18:43:38+00:00 +6272.0,0.1,2024-06-24 18:43:39+00:00 +6273.0,0.1,2024-06-24 18:43:40+00:00 +6274.0,0.1,2024-06-24 18:43:41+00:00 +6275.0,0.1,2024-06-24 18:43:42+00:00 +6276.0,0.1,2024-06-24 18:43:43+00:00 +6277.0,0.1,2024-06-24 18:43:44+00:00 +6278.0,0.1,2024-06-24 18:43:45+00:00 +6279.0,0.1,2024-06-24 18:43:46+00:00 +6280.0,0.1,2024-06-24 18:43:47+00:00 +6281.0,0.1,2024-06-24 18:43:48+00:00 +6282.0,0.1,2024-06-24 18:43:49+00:00 +6283.0,0.1,2024-06-24 18:43:50+00:00 +6284.0,0.1,2024-06-24 18:43:51+00:00 +6285.0,0.1,2024-06-24 18:43:52+00:00 +6286.0,0.1,2024-06-24 18:43:53+00:00 +6287.0,0.1,2024-06-24 18:43:54+00:00 +6288.0,0.1,2024-06-24 18:43:55+00:00 +6289.0,0.1,2024-06-24 18:43:56+00:00 +6290.0,0.1,2024-06-24 18:43:57+00:00 +6291.0,0.1,2024-06-24 18:43:58+00:00 +6292.0,0.1,2024-06-24 18:43:59+00:00 +6293.0,0.1,2024-06-24 18:44:00+00:00 +6294.0,0.1,2024-06-24 18:44:01+00:00 +6295.0,0.1,2024-06-24 18:44:02+00:00 +6296.0,0.1,2024-06-24 18:44:03+00:00 +6297.0,0.1,2024-06-24 18:44:04+00:00 +6298.0,0.1,2024-06-24 18:44:05+00:00 +6299.0,0.1,2024-06-24 18:44:06+00:00 +6300.0,0.1,2024-06-24 18:44:07+00:00 +6301.0,0.1,2024-06-24 18:44:08+00:00 +6302.0,0.1,2024-06-24 18:44:09+00:00 +6303.0,0.1,2024-06-24 18:44:10+00:00 +6304.0,0.1,2024-06-24 18:44:11+00:00 +6305.0,0.1,2024-06-24 18:44:12+00:00 +6306.0,0.1,2024-06-24 18:44:13+00:00 +6307.0,0.1,2024-06-24 18:44:14+00:00 +6308.0,0.1,2024-06-24 18:44:15+00:00 +6309.0,0.1,2024-06-24 18:44:16+00:00 +6310.0,0.1,2024-06-24 18:44:17+00:00 +6311.0,0.1,2024-06-24 18:44:18+00:00 +6312.0,0.1,2024-06-24 18:44:19+00:00 +6313.0,0.1,2024-06-24 18:44:20+00:00 +6314.0,0.1,2024-06-24 18:44:21+00:00 +6315.0,0.1,2024-06-24 18:44:22+00:00 +6316.0,0.1,2024-06-24 18:44:23+00:00 +6317.0,0.1,2024-06-24 18:44:24+00:00 +6318.0,0.1,2024-06-24 18:44:25+00:00 +6319.0,0.1,2024-06-24 18:44:26+00:00 +6320.0,0.1,2024-06-24 18:44:27+00:00 +6321.0,0.1,2024-06-24 18:44:28+00:00 +6322.0,0.1,2024-06-24 18:44:29+00:00 +6323.0,0.1,2024-06-24 18:44:30+00:00 +6324.0,0.1,2024-06-24 18:44:31+00:00 +6325.0,0.1,2024-06-24 18:44:32+00:00 +6326.0,0.1,2024-06-24 18:44:33+00:00 +6327.0,0.1,2024-06-24 18:44:34+00:00 +6328.0,0.1,2024-06-24 18:44:35+00:00 +6329.0,0.1,2024-06-24 18:44:36+00:00 +6330.0,0.1,2024-06-24 18:44:37+00:00 +6331.0,0.1,2024-06-24 18:44:38+00:00 +6332.0,0.1,2024-06-24 18:44:39+00:00 +6333.0,0.1,2024-06-24 18:44:40+00:00 +6334.0,0.1,2024-06-24 18:44:41+00:00 +6335.0,0.1,2024-06-24 18:44:42+00:00 +6336.0,0.1,2024-06-24 18:44:43+00:00 +6337.0,0.1,2024-06-24 18:44:44+00:00 +6338.0,0.1,2024-06-24 18:44:45+00:00 +6339.0,0.1,2024-06-24 18:44:46+00:00 +6340.0,0.1,2024-06-24 18:44:47+00:00 +6341.0,0.1,2024-06-24 18:44:48+00:00 +6342.0,0.1,2024-06-24 18:44:49+00:00 +6343.0,0.1,2024-06-24 18:44:50+00:00 +6344.0,0.1,2024-06-24 18:44:51+00:00 +6345.0,0.1,2024-06-24 18:44:52+00:00 +6346.0,0.1,2024-06-24 18:44:53+00:00 +6347.0,0.1,2024-06-24 18:44:54+00:00 +6348.0,0.1,2024-06-24 18:44:55+00:00 +6349.0,0.1,2024-06-24 18:44:56+00:00 +6350.0,0.1,2024-06-24 18:44:57+00:00 +6351.0,0.1,2024-06-24 18:44:58+00:00 +6352.0,0.1,2024-06-24 18:44:59+00:00 +6353.0,0.1,2024-06-24 18:45:00+00:00 +6354.0,0.1,2024-06-24 18:45:01+00:00 +6355.0,0.1,2024-06-24 18:45:02+00:00 +6356.0,0.1,2024-06-24 18:45:03+00:00 +6357.0,0.1,2024-06-24 18:45:04+00:00 +6358.0,0.1,2024-06-24 18:45:05+00:00 +6359.0,0.1,2024-06-24 18:45:06+00:00 +6360.0,0.1,2024-06-24 18:45:07+00:00 +6361.0,0.1,2024-06-24 18:45:08+00:00 +6362.0,0.1,2024-06-24 18:45:09+00:00 +6363.0,0.1,2024-06-24 18:45:10+00:00 +6364.0,0.1,2024-06-24 18:45:11+00:00 +6365.0,0.1,2024-06-24 18:45:12+00:00 +6366.0,0.1,2024-06-24 18:45:13+00:00 +6367.0,0.1,2024-06-24 18:45:14+00:00 +6368.0,0.1,2024-06-24 18:45:15+00:00 +6369.0,0.1,2024-06-24 18:45:16+00:00 +6370.0,0.1,2024-06-24 18:45:17+00:00 +6371.0,0.1,2024-06-24 18:45:18+00:00 +6372.0,0.1,2024-06-24 18:45:19+00:00 +6373.0,0.1,2024-06-24 18:45:20+00:00 +6374.0,0.1,2024-06-24 18:45:21+00:00 +6375.0,0.1,2024-06-24 18:45:22+00:00 +6376.0,0.1,2024-06-24 18:45:23+00:00 +6377.0,0.1,2024-06-24 18:45:24+00:00 +6378.0,0.1,2024-06-24 18:45:25+00:00 +6379.0,0.1,2024-06-24 18:45:26+00:00 +6380.0,0.1,2024-06-24 18:45:27+00:00 +6381.0,0.1,2024-06-24 18:45:28+00:00 +6382.0,0.1,2024-06-24 18:45:29+00:00 +6383.0,0.1,2024-06-24 18:45:30+00:00 +6384.0,0.1,2024-06-24 18:45:31+00:00 +6385.0,0.1,2024-06-24 18:45:32+00:00 +6386.0,0.1,2024-06-24 18:45:33+00:00 +6387.0,0.1,2024-06-24 18:45:34+00:00 +6388.0,0.1,2024-06-24 18:45:35+00:00 +6389.0,0.1,2024-06-24 18:45:36+00:00 +6390.0,0.1,2024-06-24 18:45:37+00:00 +6391.0,0.1,2024-06-24 18:45:38+00:00 +6392.0,0.1,2024-06-24 18:45:39+00:00 +6393.0,0.1,2024-06-24 18:45:40+00:00 +6394.0,0.1,2024-06-24 18:45:41+00:00 +6395.0,0.1,2024-06-24 18:45:42+00:00 +6396.0,0.1,2024-06-24 18:45:43+00:00 +6397.0,0.1,2024-06-24 18:45:44+00:00 +6398.0,0.1,2024-06-24 18:45:45+00:00 +6399.0,0.1,2024-06-24 18:45:46+00:00 +6400.0,0.1,2024-06-24 18:45:47+00:00 +6401.0,0.1,2024-06-24 18:45:48+00:00 +6402.0,0.1,2024-06-24 18:45:49+00:00 +6403.0,0.1,2024-06-24 18:45:50+00:00 +6404.0,0.1,2024-06-24 18:45:51+00:00 +6405.0,0.1,2024-06-24 18:45:52+00:00 +6406.0,0.1,2024-06-24 18:45:53+00:00 +6407.0,0.1,2024-06-24 18:45:54+00:00 +6408.0,0.1,2024-06-24 18:45:55+00:00 +6409.0,0.1,2024-06-24 18:45:56+00:00 +6410.0,0.1,2024-06-24 18:45:57+00:00 +6411.0,0.1,2024-06-24 18:45:58+00:00 +6412.0,0.1,2024-06-24 18:45:59+00:00 +6413.0,0.1,2024-06-24 18:46:00+00:00 +6414.0,0.1,2024-06-24 18:46:01+00:00 +6415.0,0.1,2024-06-24 18:46:02+00:00 +6416.0,0.1,2024-06-24 18:46:03+00:00 +6417.0,0.1,2024-06-24 18:46:04+00:00 +6418.0,0.1,2024-06-24 18:46:05+00:00 +6419.0,0.1,2024-06-24 18:46:06+00:00 +6420.0,0.1,2024-06-24 18:46:07+00:00 +6421.0,0.1,2024-06-24 18:46:08+00:00 +6422.0,0.1,2024-06-24 18:46:09+00:00 +6423.0,0.1,2024-06-24 18:46:10+00:00 +6424.0,0.1,2024-06-24 18:46:11+00:00 +6425.0,0.1,2024-06-24 18:46:12+00:00 +6426.0,0.1,2024-06-24 18:46:13+00:00 +6427.0,0.1,2024-06-24 18:46:14+00:00 +6428.0,0.1,2024-06-24 18:46:15+00:00 +6429.0,0.1,2024-06-24 18:46:16+00:00 +6430.0,0.1,2024-06-24 18:46:17+00:00 +6431.0,0.1,2024-06-24 18:46:18+00:00 +6432.0,0.1,2024-06-24 18:46:19+00:00 +6433.0,0.1,2024-06-24 18:46:20+00:00 +6434.0,0.1,2024-06-24 18:46:21+00:00 +6435.0,0.1,2024-06-24 18:46:22+00:00 +6436.0,0.1,2024-06-24 18:46:23+00:00 +6437.0,0.1,2024-06-24 18:46:24+00:00 +6438.0,0.1,2024-06-24 18:46:25+00:00 +6439.0,0.1,2024-06-24 18:46:26+00:00 +6440.0,0.1,2024-06-24 18:46:27+00:00 +6441.0,0.1,2024-06-24 18:46:28+00:00 +6442.0,0.1,2024-06-24 18:46:29+00:00 +6443.0,0.1,2024-06-24 18:46:30+00:00 +6444.0,0.1,2024-06-24 18:46:31+00:00 +6445.0,0.1,2024-06-24 18:46:32+00:00 +6446.0,0.1,2024-06-24 18:46:33+00:00 +6447.0,0.1,2024-06-24 18:46:34+00:00 +6448.0,0.1,2024-06-24 18:46:35+00:00 +6449.0,0.1,2024-06-24 18:46:36+00:00 +6450.0,0.1,2024-06-24 18:46:37+00:00 +6451.0,0.1,2024-06-24 18:46:38+00:00 +6452.0,0.1,2024-06-24 18:46:39+00:00 +6453.0,0.1,2024-06-24 18:46:40+00:00 +6454.0,0.1,2024-06-24 18:46:41+00:00 +6455.0,0.1,2024-06-24 18:46:42+00:00 +6456.0,0.1,2024-06-24 18:46:43+00:00 +6457.0,0.1,2024-06-24 18:46:44+00:00 +6458.0,0.1,2024-06-24 18:46:45+00:00 +6459.0,0.1,2024-06-24 18:46:46+00:00 +6460.0,0.1,2024-06-24 18:46:47+00:00 +6461.0,0.1,2024-06-24 18:46:48+00:00 +6462.0,0.1,2024-06-24 18:46:49+00:00 +6463.0,0.1,2024-06-24 18:46:50+00:00 +6464.0,0.1,2024-06-24 18:46:51+00:00 +6465.0,0.1,2024-06-24 18:46:52+00:00 +6466.0,0.1,2024-06-24 18:46:53+00:00 +6467.0,0.1,2024-06-24 18:46:54+00:00 +6468.0,0.1,2024-06-24 18:46:55+00:00 +6469.0,0.1,2024-06-24 18:46:56+00:00 +6470.0,0.1,2024-06-24 18:46:57+00:00 +6471.0,0.1,2024-06-24 18:46:58+00:00 +6472.0,0.1,2024-06-24 18:46:59+00:00 +6473.0,0.1,2024-06-24 18:47:00+00:00 +6474.0,0.1,2024-06-24 18:47:01+00:00 +6475.0,0.1,2024-06-24 18:47:02+00:00 +6476.0,0.1,2024-06-24 18:47:03+00:00 +6477.0,0.1,2024-06-24 18:47:04+00:00 +6478.0,0.1,2024-06-24 18:47:05+00:00 +6479.0,0.1,2024-06-24 18:47:06+00:00 +6480.0,0.1,2024-06-24 18:47:07+00:00 +6481.0,0.1,2024-06-24 18:47:08+00:00 +6482.0,0.1,2024-06-24 18:47:09+00:00 +6483.0,0.1,2024-06-24 18:47:10+00:00 +6484.0,0.1,2024-06-24 18:47:11+00:00 +6485.0,0.1,2024-06-24 18:47:12+00:00 +6486.0,0.1,2024-06-24 18:47:13+00:00 +6487.0,0.1,2024-06-24 18:47:14+00:00 +6488.0,0.1,2024-06-24 18:47:15+00:00 +6489.0,0.1,2024-06-24 18:47:16+00:00 +6490.0,0.1,2024-06-24 18:47:17+00:00 +6491.0,0.1,2024-06-24 18:47:18+00:00 +6492.0,0.1,2024-06-24 18:47:19+00:00 +6493.0,0.1,2024-06-24 18:47:20+00:00 +6494.0,0.1,2024-06-24 18:47:21+00:00 +6495.0,0.1,2024-06-24 18:47:22+00:00 +6496.0,0.1,2024-06-24 18:47:23+00:00 +6497.0,0.1,2024-06-24 18:47:24+00:00 +6498.0,0.1,2024-06-24 18:47:25+00:00 +6499.0,0.1,2024-06-24 18:47:26+00:00 +6500.0,0.1,2024-06-24 18:47:27+00:00 +6501.0,0.1,2024-06-24 18:47:28+00:00 +6502.0,0.1,2024-06-24 18:47:29+00:00 +6503.0,0.1,2024-06-24 18:47:30+00:00 +6504.0,0.1,2024-06-24 18:47:31+00:00 +6505.0,0.1,2024-06-24 18:47:32+00:00 +6506.0,0.1,2024-06-24 18:47:33+00:00 +6507.0,0.1,2024-06-24 18:47:34+00:00 +6508.0,0.1,2024-06-24 18:47:35+00:00 +6509.0,0.1,2024-06-24 18:47:36+00:00 +6510.0,0.1,2024-06-24 18:47:37+00:00 +6511.0,0.1,2024-06-24 18:47:38+00:00 +6512.0,0.1,2024-06-24 18:47:39+00:00 +6513.0,0.1,2024-06-24 18:47:40+00:00 +6514.0,0.1,2024-06-24 18:47:41+00:00 +6515.0,0.1,2024-06-24 18:47:42+00:00 +6516.0,0.1,2024-06-24 18:47:43+00:00 +6517.0,0.1,2024-06-24 18:47:44+00:00 +6518.0,0.1,2024-06-24 18:47:45+00:00 +6519.0,0.1,2024-06-24 18:47:46+00:00 +6520.0,0.1,2024-06-24 18:47:47+00:00 +6521.0,0.1,2024-06-24 18:47:48+00:00 +6522.0,0.1,2024-06-24 18:47:49+00:00 +6523.0,0.1,2024-06-24 18:47:50+00:00 +6524.0,0.1,2024-06-24 18:47:51+00:00 +6525.0,0.1,2024-06-24 18:47:52+00:00 +6526.0,0.1,2024-06-24 18:47:53+00:00 +6527.0,0.1,2024-06-24 18:47:54+00:00 +6528.0,0.1,2024-06-24 18:47:55+00:00 +6529.0,0.1,2024-06-24 18:47:56+00:00 +6530.0,0.1,2024-06-24 18:47:57+00:00 +6531.0,0.1,2024-06-24 18:47:58+00:00 +6532.0,0.1,2024-06-24 18:47:59+00:00 +6533.0,0.1,2024-06-24 18:48:00+00:00 +6534.0,0.1,2024-06-24 18:48:01+00:00 +6535.0,0.1,2024-06-24 18:48:02+00:00 +6536.0,0.1,2024-06-24 18:48:03+00:00 +6537.0,0.1,2024-06-24 18:48:04+00:00 +6538.0,0.1,2024-06-24 18:48:05+00:00 +6539.0,0.1,2024-06-24 18:48:06+00:00 +6540.0,0.1,2024-06-24 18:48:07+00:00 +6541.0,0.1,2024-06-24 18:48:08+00:00 +6542.0,0.1,2024-06-24 18:48:09+00:00 +6543.0,0.1,2024-06-24 18:48:10+00:00 +6544.0,0.1,2024-06-24 18:48:11+00:00 +6545.0,0.1,2024-06-24 18:48:12+00:00 +6546.0,0.1,2024-06-24 18:48:13+00:00 +6547.0,0.1,2024-06-24 18:48:14+00:00 +6548.0,0.1,2024-06-24 18:48:15+00:00 +6549.0,0.1,2024-06-24 18:48:16+00:00 +6550.0,0.1,2024-06-24 18:48:17+00:00 +6551.0,0.1,2024-06-24 18:48:18+00:00 +6552.0,0.1,2024-06-24 18:48:19+00:00 +6553.0,0.1,2024-06-24 18:48:20+00:00 +6554.0,0.1,2024-06-24 18:48:21+00:00 +6555.0,0.1,2024-06-24 18:48:22+00:00 +6556.0,0.1,2024-06-24 18:48:23+00:00 +6557.0,0.1,2024-06-24 18:48:24+00:00 +6558.0,0.1,2024-06-24 18:48:25+00:00 +6559.0,0.1,2024-06-24 18:48:26+00:00 +6560.0,0.1,2024-06-24 18:48:27+00:00 +6561.0,0.1,2024-06-24 18:48:28+00:00 +6562.0,0.1,2024-06-24 18:48:29+00:00 +6563.0,0.1,2024-06-24 18:48:30+00:00 +6564.0,0.1,2024-06-24 18:48:31+00:00 +6565.0,0.1,2024-06-24 18:48:32+00:00 +6566.0,0.1,2024-06-24 18:48:33+00:00 +6567.0,0.1,2024-06-24 18:48:34+00:00 +6568.0,0.1,2024-06-24 18:48:35+00:00 +6569.0,0.1,2024-06-24 18:48:36+00:00 +6570.0,0.1,2024-06-24 18:48:37+00:00 +6571.0,0.1,2024-06-24 18:48:38+00:00 +6572.0,0.1,2024-06-24 18:48:39+00:00 +6573.0,0.1,2024-06-24 18:48:40+00:00 +6574.0,0.1,2024-06-24 18:48:41+00:00 +6575.0,0.1,2024-06-24 18:48:42+00:00 +6576.0,0.1,2024-06-24 18:48:43+00:00 +6577.0,0.1,2024-06-24 18:48:44+00:00 +6578.0,0.1,2024-06-24 18:48:45+00:00 +6579.0,0.1,2024-06-24 18:48:46+00:00 +6580.0,0.1,2024-06-24 18:48:47+00:00 +6581.0,0.1,2024-06-24 18:48:48+00:00 +6582.0,0.1,2024-06-24 18:48:49+00:00 +6583.0,0.1,2024-06-24 18:48:50+00:00 +6584.0,0.1,2024-06-24 18:48:51+00:00 +6585.0,0.1,2024-06-24 18:48:52+00:00 +6586.0,0.1,2024-06-24 18:48:53+00:00 +6587.0,0.1,2024-06-24 18:48:54+00:00 +6588.0,0.1,2024-06-24 18:48:55+00:00 +6589.0,0.1,2024-06-24 18:48:56+00:00 +6590.0,0.1,2024-06-24 18:48:57+00:00 +6591.0,0.1,2024-06-24 18:48:58+00:00 +6592.0,0.1,2024-06-24 18:48:59+00:00 +6593.0,0.1,2024-06-24 18:49:00+00:00 +6594.0,0.1,2024-06-24 18:49:01+00:00 +6595.0,0.1,2024-06-24 18:49:02+00:00 +6596.0,0.1,2024-06-24 18:49:03+00:00 +6597.0,0.1,2024-06-24 18:49:04+00:00 +6598.0,0.1,2024-06-24 18:49:05+00:00 +6599.0,0.1,2024-06-24 18:49:06+00:00 +6600.0,0.1,2024-06-24 18:49:07+00:00 +6601.0,0.1,2024-06-24 18:49:08+00:00 +6602.0,0.1,2024-06-24 18:49:09+00:00 +6603.0,0.1,2024-06-24 18:49:10+00:00 +6604.0,0.1,2024-06-24 18:49:11+00:00 +6605.0,0.1,2024-06-24 18:49:12+00:00 +6606.0,0.1,2024-06-24 18:49:13+00:00 +6607.0,0.1,2024-06-24 18:49:14+00:00 +6608.0,0.1,2024-06-24 18:49:15+00:00 +6609.0,0.1,2024-06-24 18:49:16+00:00 +6610.0,0.1,2024-06-24 18:49:17+00:00 +6611.0,0.1,2024-06-24 18:49:18+00:00 +6612.0,0.1,2024-06-24 18:49:19+00:00 +6613.0,0.1,2024-06-24 18:49:20+00:00 +6614.0,0.1,2024-06-24 18:49:21+00:00 +6615.0,0.1,2024-06-24 18:49:22+00:00 +6616.0,0.1,2024-06-24 18:49:23+00:00 +6617.0,0.1,2024-06-24 18:49:24+00:00 +6618.0,0.1,2024-06-24 18:49:25+00:00 +6619.0,0.1,2024-06-24 18:49:26+00:00 +6620.0,0.1,2024-06-24 18:49:27+00:00 +6621.0,0.1,2024-06-24 18:49:28+00:00 +6622.0,0.1,2024-06-24 18:49:29+00:00 +6623.0,0.1,2024-06-24 18:49:30+00:00 +6624.0,0.1,2024-06-24 18:49:31+00:00 +6625.0,0.1,2024-06-24 18:49:32+00:00 +6626.0,0.1,2024-06-24 18:49:33+00:00 +6627.0,0.1,2024-06-24 18:49:34+00:00 +6628.0,0.1,2024-06-24 18:49:35+00:00 +6629.0,0.1,2024-06-24 18:49:36+00:00 +6630.0,0.1,2024-06-24 18:49:37+00:00 +6631.0,0.1,2024-06-24 18:49:38+00:00 +6632.0,0.1,2024-06-24 18:49:39+00:00 +6633.0,0.1,2024-06-24 18:49:40+00:00 +6634.0,0.1,2024-06-24 18:49:41+00:00 +6635.0,0.1,2024-06-24 18:49:42+00:00 +6636.0,0.1,2024-06-24 18:49:43+00:00 +6637.0,0.1,2024-06-24 18:49:44+00:00 +6638.0,0.1,2024-06-24 18:49:45+00:00 +6639.0,0.1,2024-06-24 18:49:46+00:00 +6640.0,0.1,2024-06-24 18:49:47+00:00 +6641.0,0.1,2024-06-24 18:49:48+00:00 +6642.0,0.1,2024-06-24 18:49:49+00:00 +6643.0,0.1,2024-06-24 18:49:50+00:00 +6644.0,0.1,2024-06-24 18:49:51+00:00 +6645.0,0.1,2024-06-24 18:49:52+00:00 +6646.0,0.1,2024-06-24 18:49:53+00:00 +6647.0,0.1,2024-06-24 18:49:54+00:00 +6648.0,0.1,2024-06-24 18:49:55+00:00 +6649.0,0.1,2024-06-24 18:49:56+00:00 +6650.0,0.1,2024-06-24 18:49:57+00:00 +6651.0,0.1,2024-06-24 18:49:58+00:00 +6652.0,0.1,2024-06-24 18:49:59+00:00 +6653.0,0.1,2024-06-24 18:50:00+00:00 +6654.0,0.1,2024-06-24 18:50:01+00:00 +6655.0,0.1,2024-06-24 18:50:02+00:00 +6656.0,0.1,2024-06-24 18:50:03+00:00 +6657.0,0.1,2024-06-24 18:50:04+00:00 +6658.0,0.1,2024-06-24 18:50:05+00:00 +6659.0,0.1,2024-06-24 18:50:06+00:00 +6660.0,0.1,2024-06-24 18:50:07+00:00 +6661.0,0.1,2024-06-24 18:50:08+00:00 +6662.0,0.1,2024-06-24 18:50:09+00:00 +6663.0,0.1,2024-06-24 18:50:10+00:00 +6664.0,0.1,2024-06-24 18:50:11+00:00 +6665.0,0.1,2024-06-24 18:50:12+00:00 +6666.0,0.1,2024-06-24 18:50:13+00:00 +6667.0,0.1,2024-06-24 18:50:14+00:00 +6668.0,0.1,2024-06-24 18:50:15+00:00 +6669.0,0.1,2024-06-24 18:50:16+00:00 +6670.0,0.1,2024-06-24 18:50:17+00:00 +6671.0,0.1,2024-06-24 18:50:18+00:00 +6672.0,0.1,2024-06-24 18:50:19+00:00 +6673.0,0.1,2024-06-24 18:50:20+00:00 +6674.0,0.1,2024-06-24 18:50:21+00:00 +6675.0,0.1,2024-06-24 18:50:22+00:00 +6676.0,0.1,2024-06-24 18:50:23+00:00 +6677.0,0.1,2024-06-24 18:50:24+00:00 +6678.0,0.1,2024-06-24 18:50:25+00:00 +6679.0,0.1,2024-06-24 18:50:26+00:00 +6680.0,0.1,2024-06-24 18:50:27+00:00 +6681.0,0.1,2024-06-24 18:50:28+00:00 +6682.0,0.1,2024-06-24 18:50:29+00:00 +6683.0,0.1,2024-06-24 18:50:30+00:00 +6684.0,0.1,2024-06-24 18:50:31+00:00 +6685.0,0.1,2024-06-24 18:50:32+00:00 +6686.0,0.1,2024-06-24 18:50:33+00:00 +6687.0,0.1,2024-06-24 18:50:34+00:00 +6688.0,0.1,2024-06-24 18:50:35+00:00 +6689.0,0.1,2024-06-24 18:50:36+00:00 +6690.0,0.1,2024-06-24 18:50:37+00:00 +6691.0,0.1,2024-06-24 18:50:38+00:00 +6692.0,0.1,2024-06-24 18:50:39+00:00 +6693.0,0.1,2024-06-24 18:50:40+00:00 +6694.0,0.1,2024-06-24 18:50:41+00:00 +6695.0,0.1,2024-06-24 18:50:42+00:00 +6696.0,0.1,2024-06-24 18:50:43+00:00 +6697.0,0.1,2024-06-24 18:50:44+00:00 +6698.0,0.1,2024-06-24 18:50:45+00:00 +6699.0,0.1,2024-06-24 18:50:46+00:00 +6700.0,0.1,2024-06-24 18:50:47+00:00 +6701.0,0.1,2024-06-24 18:50:48+00:00 +6702.0,0.1,2024-06-24 18:50:49+00:00 +6703.0,0.1,2024-06-24 18:50:50+00:00 +6704.0,0.1,2024-06-24 18:50:51+00:00 +6705.0,0.1,2024-06-24 18:50:52+00:00 +6706.0,0.1,2024-06-24 18:50:53+00:00 +6707.0,0.1,2024-06-24 18:50:54+00:00 +6708.0,0.1,2024-06-24 18:50:55+00:00 +6709.0,0.1,2024-06-24 18:50:56+00:00 +6710.0,0.1,2024-06-24 18:50:57+00:00 +6711.0,0.1,2024-06-24 18:50:58+00:00 +6712.0,0.1,2024-06-24 18:50:59+00:00 +6713.0,0.1,2024-06-24 18:51:00+00:00 +6714.0,0.1,2024-06-24 18:51:01+00:00 +6715.0,0.1,2024-06-24 18:51:02+00:00 +6716.0,0.1,2024-06-24 18:51:03+00:00 +6717.0,0.1,2024-06-24 18:51:04+00:00 +6718.0,0.1,2024-06-24 18:51:05+00:00 +6719.0,0.1,2024-06-24 18:51:06+00:00 +6720.0,0.1,2024-06-24 18:51:07+00:00 +6721.0,0.1,2024-06-24 18:51:08+00:00 +6722.0,0.1,2024-06-24 18:51:09+00:00 +6723.0,0.1,2024-06-24 18:51:10+00:00 +6724.0,0.1,2024-06-24 18:51:11+00:00 +6725.0,0.1,2024-06-24 18:51:12+00:00 +6726.0,0.1,2024-06-24 18:51:13+00:00 +6727.0,0.1,2024-06-24 18:51:14+00:00 +6728.0,0.1,2024-06-24 18:51:15+00:00 +6729.0,0.1,2024-06-24 18:51:16+00:00 +6730.0,0.1,2024-06-24 18:51:17+00:00 +6731.0,0.1,2024-06-24 18:51:18+00:00 +6732.0,0.1,2024-06-24 18:51:19+00:00 +6733.0,0.1,2024-06-24 18:51:20+00:00 +6734.0,0.1,2024-06-24 18:51:21+00:00 +6735.0,0.1,2024-06-24 18:51:22+00:00 +6736.0,0.1,2024-06-24 18:51:23+00:00 +6737.0,0.1,2024-06-24 18:51:24+00:00 +6738.0,0.1,2024-06-24 18:51:25+00:00 +6739.0,0.1,2024-06-24 18:51:26+00:00 +6740.0,0.1,2024-06-24 18:51:27+00:00 +6741.0,0.1,2024-06-24 18:51:28+00:00 +6742.0,0.1,2024-06-24 18:51:29+00:00 +6743.0,0.1,2024-06-24 18:51:30+00:00 +6744.0,0.1,2024-06-24 18:51:31+00:00 +6745.0,0.1,2024-06-24 18:51:32+00:00 +6746.0,0.1,2024-06-24 18:51:33+00:00 +6747.0,0.1,2024-06-24 18:51:34+00:00 +6748.0,0.1,2024-06-24 18:51:35+00:00 +6749.0,0.1,2024-06-24 18:51:36+00:00 +6750.0,0.1,2024-06-24 18:51:37+00:00 +6751.0,0.1,2024-06-24 18:51:38+00:00 +6752.0,0.1,2024-06-24 18:51:39+00:00 +6753.0,0.1,2024-06-24 18:51:40+00:00 +6754.0,0.1,2024-06-24 18:51:41+00:00 +6755.0,0.1,2024-06-24 18:51:42+00:00 +6756.0,0.1,2024-06-24 18:51:43+00:00 +6757.0,0.1,2024-06-24 18:51:44+00:00 +6758.0,0.1,2024-06-24 18:51:45+00:00 +6759.0,0.1,2024-06-24 18:51:46+00:00 +6760.0,0.1,2024-06-24 18:51:47+00:00 +6761.0,0.1,2024-06-24 18:51:48+00:00 +6762.0,0.1,2024-06-24 18:51:49+00:00 +6763.0,0.1,2024-06-24 18:51:50+00:00 +6764.0,0.1,2024-06-24 18:51:51+00:00 +6765.0,0.1,2024-06-24 18:51:52+00:00 +6766.0,0.1,2024-06-24 18:51:53+00:00 +6767.0,0.1,2024-06-24 18:51:54+00:00 +6768.0,0.1,2024-06-24 18:51:55+00:00 +6769.0,0.1,2024-06-24 18:51:56+00:00 +6770.0,0.1,2024-06-24 18:51:57+00:00 +6771.0,0.1,2024-06-24 18:51:58+00:00 +6772.0,0.1,2024-06-24 18:51:59+00:00 +6773.0,0.1,2024-06-24 18:52:00+00:00 +6774.0,0.1,2024-06-24 18:52:01+00:00 +6775.0,0.1,2024-06-24 18:52:02+00:00 +6776.0,0.1,2024-06-24 18:52:03+00:00 +6777.0,0.1,2024-06-24 18:52:04+00:00 +6778.0,0.1,2024-06-24 18:52:05+00:00 +6779.0,0.1,2024-06-24 18:52:06+00:00 +6780.0,0.1,2024-06-24 18:52:07+00:00 +6781.0,0.1,2024-06-24 18:52:08+00:00 +6782.0,0.1,2024-06-24 18:52:09+00:00 +6783.0,0.1,2024-06-24 18:52:10+00:00 +6784.0,0.1,2024-06-24 18:52:11+00:00 +6785.0,0.1,2024-06-24 18:52:12+00:00 +6786.0,0.1,2024-06-24 18:52:13+00:00 +6787.0,0.1,2024-06-24 18:52:14+00:00 +6788.0,0.1,2024-06-24 18:52:15+00:00 +6789.0,0.1,2024-06-24 18:52:16+00:00 +6790.0,0.1,2024-06-24 18:52:17+00:00 +6791.0,0.1,2024-06-24 18:52:18+00:00 +6792.0,0.1,2024-06-24 18:52:19+00:00 +6793.0,0.1,2024-06-24 18:52:20+00:00 +6794.0,0.1,2024-06-24 18:52:21+00:00 +6795.0,0.1,2024-06-24 18:52:22+00:00 +6796.0,0.1,2024-06-24 18:52:23+00:00 +6797.0,0.1,2024-06-24 18:52:24+00:00 +6798.0,0.1,2024-06-24 18:52:25+00:00 +6799.0,0.1,2024-06-24 18:52:26+00:00 +6800.0,0.1,2024-06-24 18:52:27+00:00 +6801.0,0.1,2024-06-24 18:52:28+00:00 +6802.0,0.1,2024-06-24 18:52:29+00:00 +6803.0,0.1,2024-06-24 18:52:30+00:00 +6804.0,0.1,2024-06-24 18:52:31+00:00 +6805.0,0.1,2024-06-24 18:52:32+00:00 +6806.0,0.1,2024-06-24 18:52:33+00:00 +6807.0,0.1,2024-06-24 18:52:34+00:00 +6808.0,0.1,2024-06-24 18:52:35+00:00 +6809.0,0.1,2024-06-24 18:52:36+00:00 +6810.0,0.1,2024-06-24 18:52:37+00:00 +6811.0,0.1,2024-06-24 18:52:38+00:00 +6812.0,0.1,2024-06-24 18:52:39+00:00 +6813.0,0.1,2024-06-24 18:52:40+00:00 +6814.0,0.1,2024-06-24 18:52:41+00:00 +6815.0,0.1,2024-06-24 18:52:42+00:00 +6816.0,0.1,2024-06-24 18:52:43+00:00 +6817.0,0.1,2024-06-24 18:52:44+00:00 +6818.0,0.1,2024-06-24 18:52:45+00:00 +6819.0,0.1,2024-06-24 18:52:46+00:00 +6820.0,0.1,2024-06-24 18:52:47+00:00 +6821.0,0.1,2024-06-24 18:52:48+00:00 +6822.0,0.1,2024-06-24 18:52:49+00:00 +6823.0,0.1,2024-06-24 18:52:50+00:00 +6824.0,0.1,2024-06-24 18:52:51+00:00 +6825.0,0.1,2024-06-24 18:52:52+00:00 +6826.0,0.1,2024-06-24 18:52:53+00:00 +6827.0,0.1,2024-06-24 18:52:54+00:00 +6828.0,0.1,2024-06-24 18:52:55+00:00 +6829.0,0.1,2024-06-24 18:52:56+00:00 +6830.0,0.1,2024-06-24 18:52:57+00:00 +6831.0,0.1,2024-06-24 18:52:58+00:00 +6832.0,0.1,2024-06-24 18:52:59+00:00 +6833.0,0.1,2024-06-24 18:53:00+00:00 +6834.0,0.1,2024-06-24 18:53:01+00:00 +6835.0,0.1,2024-06-24 18:53:02+00:00 +6836.0,0.1,2024-06-24 18:53:03+00:00 +6837.0,0.1,2024-06-24 18:53:04+00:00 +6838.0,0.1,2024-06-24 18:53:05+00:00 +6839.0,0.1,2024-06-24 18:53:06+00:00 +6840.0,0.1,2024-06-24 18:53:07+00:00 +6841.0,0.1,2024-06-24 18:53:08+00:00 +6842.0,0.1,2024-06-24 18:53:09+00:00 +6843.0,0.1,2024-06-24 18:53:10+00:00 +6844.0,0.1,2024-06-24 18:53:11+00:00 +6845.0,0.1,2024-06-24 18:53:12+00:00 +6846.0,0.1,2024-06-24 18:53:13+00:00 +6847.0,0.1,2024-06-24 18:53:14+00:00 +6848.0,0.1,2024-06-24 18:53:15+00:00 +6849.0,0.1,2024-06-24 18:53:16+00:00 +6850.0,0.1,2024-06-24 18:53:17+00:00 +6851.0,0.1,2024-06-24 18:53:18+00:00 +6852.0,0.1,2024-06-24 18:53:19+00:00 +6853.0,0.1,2024-06-24 18:53:20+00:00 +6854.0,0.1,2024-06-24 18:53:21+00:00 +6855.0,0.1,2024-06-24 18:53:22+00:00 +6856.0,0.1,2024-06-24 18:53:23+00:00 +6857.0,0.1,2024-06-24 18:53:24+00:00 +6858.0,0.1,2024-06-24 18:53:25+00:00 +6859.0,0.1,2024-06-24 18:53:26+00:00 +6860.0,0.1,2024-06-24 18:53:27+00:00 +6861.0,0.1,2024-06-24 18:53:28+00:00 +6862.0,0.1,2024-06-24 18:53:29+00:00 +6863.0,0.1,2024-06-24 18:53:30+00:00 +6864.0,0.1,2024-06-24 18:53:31+00:00 +6865.0,0.1,2024-06-24 18:53:32+00:00 +6866.0,0.1,2024-06-24 18:53:33+00:00 +6867.0,0.1,2024-06-24 18:53:34+00:00 +6868.0,0.1,2024-06-24 18:53:35+00:00 +6869.0,0.1,2024-06-24 18:53:36+00:00 +6870.0,0.1,2024-06-24 18:53:37+00:00 +6871.0,0.1,2024-06-24 18:53:38+00:00 +6872.0,0.1,2024-06-24 18:53:39+00:00 +6873.0,0.1,2024-06-24 18:53:40+00:00 +6874.0,0.1,2024-06-24 18:53:41+00:00 +6875.0,0.1,2024-06-24 18:53:42+00:00 +6876.0,0.1,2024-06-24 18:53:43+00:00 +6877.0,0.1,2024-06-24 18:53:44+00:00 +6878.0,0.1,2024-06-24 18:53:45+00:00 +6879.0,0.1,2024-06-24 18:53:46+00:00 +6880.0,0.1,2024-06-24 18:53:47+00:00 +6881.0,0.1,2024-06-24 18:53:48+00:00 +6882.0,0.1,2024-06-24 18:53:49+00:00 +6883.0,0.1,2024-06-24 18:53:50+00:00 +6884.0,0.1,2024-06-24 18:53:51+00:00 +6885.0,0.1,2024-06-24 18:53:52+00:00 +6886.0,0.1,2024-06-24 18:53:53+00:00 +6887.0,0.1,2024-06-24 18:53:54+00:00 +6888.0,0.1,2024-06-24 18:53:55+00:00 +6889.0,0.1,2024-06-24 18:53:56+00:00 +6890.0,0.1,2024-06-24 18:53:57+00:00 +6891.0,0.1,2024-06-24 18:53:58+00:00 +6892.0,0.1,2024-06-24 18:53:59+00:00 +6893.0,0.1,2024-06-24 18:54:00+00:00 +6894.0,0.1,2024-06-24 18:54:01+00:00 +6895.0,0.1,2024-06-24 18:54:02+00:00 +6896.0,0.1,2024-06-24 18:54:03+00:00 +6897.0,0.1,2024-06-24 18:54:04+00:00 +6898.0,0.1,2024-06-24 18:54:05+00:00 +6899.0,0.1,2024-06-24 18:54:06+00:00 +6900.0,0.1,2024-06-24 18:54:07+00:00 +6901.0,0.1,2024-06-24 18:54:08+00:00 +6902.0,0.1,2024-06-24 18:54:09+00:00 +6903.0,0.1,2024-06-24 18:54:10+00:00 +6904.0,0.1,2024-06-24 18:54:11+00:00 +6905.0,0.1,2024-06-24 18:54:12+00:00 +6906.0,0.1,2024-06-24 18:54:13+00:00 +6907.0,0.1,2024-06-24 18:54:14+00:00 +6908.0,0.1,2024-06-24 18:54:15+00:00 +6909.0,0.1,2024-06-24 18:54:16+00:00 +6910.0,0.1,2024-06-24 18:54:17+00:00 +6911.0,0.1,2024-06-24 18:54:18+00:00 +6912.0,0.1,2024-06-24 18:54:19+00:00 +6913.0,0.1,2024-06-24 18:54:20+00:00 +6914.0,0.1,2024-06-24 18:54:21+00:00 +6915.0,0.1,2024-06-24 18:54:22+00:00 +6916.0,0.1,2024-06-24 18:54:23+00:00 +6917.0,0.1,2024-06-24 18:54:24+00:00 +6918.0,0.1,2024-06-24 18:54:25+00:00 +6919.0,0.1,2024-06-24 18:54:26+00:00 +6920.0,0.1,2024-06-24 18:54:27+00:00 +6921.0,0.1,2024-06-24 18:54:28+00:00 +6922.0,0.1,2024-06-24 18:54:29+00:00 +6923.0,0.1,2024-06-24 18:54:30+00:00 +6924.0,0.1,2024-06-24 18:54:31+00:00 +6925.0,0.1,2024-06-24 18:54:32+00:00 +6926.0,0.1,2024-06-24 18:54:33+00:00 +6927.0,0.1,2024-06-24 18:54:34+00:00 +6928.0,0.1,2024-06-24 18:54:35+00:00 +6929.0,0.1,2024-06-24 18:54:36+00:00 +6930.0,0.1,2024-06-24 18:54:37+00:00 +6931.0,0.1,2024-06-24 18:54:38+00:00 +6932.0,0.1,2024-06-24 18:54:39+00:00 +6933.0,0.1,2024-06-24 18:54:40+00:00 +6934.0,0.1,2024-06-24 18:54:41+00:00 +6935.0,0.1,2024-06-24 18:54:42+00:00 +6936.0,0.1,2024-06-24 18:54:43+00:00 +6937.0,0.1,2024-06-24 18:54:44+00:00 +6938.0,0.1,2024-06-24 18:54:45+00:00 +6939.0,0.1,2024-06-24 18:54:46+00:00 +6940.0,0.1,2024-06-24 18:54:47+00:00 +6941.0,0.1,2024-06-24 18:54:48+00:00 +6942.0,0.1,2024-06-24 18:54:49+00:00 +6943.0,0.1,2024-06-24 18:54:50+00:00 +6944.0,0.1,2024-06-24 18:54:51+00:00 +6945.0,0.1,2024-06-24 18:54:52+00:00 +6946.0,0.1,2024-06-24 18:54:53+00:00 +6947.0,0.1,2024-06-24 18:54:54+00:00 +6948.0,0.1,2024-06-24 18:54:55+00:00 +6949.0,0.1,2024-06-24 18:54:56+00:00 +6950.0,0.1,2024-06-24 18:54:57+00:00 +6951.0,0.1,2024-06-24 18:54:58+00:00 +6952.0,0.1,2024-06-24 18:54:59+00:00 +6953.0,0.1,2024-06-24 18:55:00+00:00 +6954.0,0.1,2024-06-24 18:55:01+00:00 +6955.0,0.1,2024-06-24 18:55:02+00:00 +6956.0,0.1,2024-06-24 18:55:03+00:00 +6957.0,0.1,2024-06-24 18:55:04+00:00 +6958.0,0.1,2024-06-24 18:55:05+00:00 +6959.0,0.1,2024-06-24 18:55:06+00:00 +6960.0,0.1,2024-06-24 18:55:07+00:00 +6961.0,0.1,2024-06-24 18:55:08+00:00 +6962.0,0.1,2024-06-24 18:55:09+00:00 +6963.0,0.1,2024-06-24 18:55:10+00:00 +6964.0,0.1,2024-06-24 18:55:11+00:00 +6965.0,0.1,2024-06-24 18:55:12+00:00 +6966.0,0.1,2024-06-24 18:55:13+00:00 +6967.0,0.1,2024-06-24 18:55:14+00:00 +6968.0,0.1,2024-06-24 18:55:15+00:00 +6969.0,0.1,2024-06-24 18:55:16+00:00 +6970.0,0.1,2024-06-24 18:55:17+00:00 +6971.0,0.1,2024-06-24 18:55:18+00:00 +6972.0,0.1,2024-06-24 18:55:19+00:00 +6973.0,0.1,2024-06-24 18:55:20+00:00 +6974.0,0.1,2024-06-24 18:55:21+00:00 +6975.0,0.1,2024-06-24 18:55:22+00:00 +6976.0,0.1,2024-06-24 18:55:23+00:00 +6977.0,0.1,2024-06-24 18:55:24+00:00 +6978.0,0.1,2024-06-24 18:55:25+00:00 +6979.0,0.1,2024-06-24 18:55:26+00:00 +6980.0,0.1,2024-06-24 18:55:27+00:00 +6981.0,0.1,2024-06-24 18:55:28+00:00 +6982.0,0.1,2024-06-24 18:55:29+00:00 +6983.0,0.1,2024-06-24 18:55:30+00:00 +6984.0,0.1,2024-06-24 18:55:31+00:00 +6985.0,0.1,2024-06-24 18:55:32+00:00 +6986.0,0.1,2024-06-24 18:55:33+00:00 +6987.0,0.1,2024-06-24 18:55:34+00:00 +6988.0,0.1,2024-06-24 18:55:35+00:00 +6989.0,0.1,2024-06-24 18:55:36+00:00 +6990.0,0.1,2024-06-24 18:55:37+00:00 +6991.0,0.1,2024-06-24 18:55:38+00:00 +6992.0,0.1,2024-06-24 18:55:39+00:00 +6993.0,0.1,2024-06-24 18:55:40+00:00 +6994.0,0.1,2024-06-24 18:55:41+00:00 +6995.0,0.1,2024-06-24 18:55:42+00:00 +6996.0,0.1,2024-06-24 18:55:43+00:00 +6997.0,0.1,2024-06-24 18:55:44+00:00 +6998.0,0.1,2024-06-24 18:55:45+00:00 +6999.0,0.1,2024-06-24 18:55:46+00:00 +7000.0,0.1,2024-06-24 18:55:47+00:00 +7001.0,0.1,2024-06-24 18:55:48+00:00 +7002.0,0.1,2024-06-24 18:55:49+00:00 +7003.0,0.1,2024-06-24 18:55:50+00:00 +7004.0,0.1,2024-06-24 18:55:51+00:00 +7005.0,0.1,2024-06-24 18:55:52+00:00 +7006.0,0.1,2024-06-24 18:55:53+00:00 +7007.0,0.1,2024-06-24 18:55:54+00:00 +7008.0,0.1,2024-06-24 18:55:55+00:00 +7009.0,0.1,2024-06-24 18:55:56+00:00 +7010.0,0.1,2024-06-24 18:55:57+00:00 +7011.0,0.1,2024-06-24 18:55:58+00:00 +7012.0,0.1,2024-06-24 18:55:59+00:00 +7013.0,0.1,2024-06-24 18:56:00+00:00 +7014.0,0.1,2024-06-24 18:56:01+00:00 +7015.0,0.1,2024-06-24 18:56:02+00:00 +7016.0,0.1,2024-06-24 18:56:03+00:00 +7017.0,0.1,2024-06-24 18:56:04+00:00 +7018.0,0.1,2024-06-24 18:56:05+00:00 +7019.0,0.1,2024-06-24 18:56:06+00:00 +7020.0,0.1,2024-06-24 18:56:07+00:00 +7021.0,0.1,2024-06-24 18:56:08+00:00 +7022.0,0.1,2024-06-24 18:56:09+00:00 +7023.0,0.1,2024-06-24 18:56:10+00:00 +7024.0,0.1,2024-06-24 18:56:11+00:00 +7025.0,0.1,2024-06-24 18:56:12+00:00 +7026.0,0.1,2024-06-24 18:56:13+00:00 +7027.0,0.1,2024-06-24 18:56:14+00:00 +7028.0,0.1,2024-06-24 18:56:15+00:00 +7029.0,0.1,2024-06-24 18:56:16+00:00 +7030.0,0.1,2024-06-24 18:56:17+00:00 +7031.0,0.1,2024-06-24 18:56:18+00:00 +7032.0,0.1,2024-06-24 18:56:19+00:00 +7033.0,0.1,2024-06-24 18:56:20+00:00 +7034.0,0.1,2024-06-24 18:56:21+00:00 +7035.0,0.1,2024-06-24 18:56:22+00:00 +7036.0,0.1,2024-06-24 18:56:23+00:00 +7037.0,0.1,2024-06-24 18:56:24+00:00 +7038.0,0.1,2024-06-24 18:56:25+00:00 +7039.0,0.1,2024-06-24 18:56:26+00:00 +7040.0,0.1,2024-06-24 18:56:27+00:00 +7041.0,0.1,2024-06-24 18:56:28+00:00 +7042.0,0.1,2024-06-24 18:56:29+00:00 +7043.0,0.1,2024-06-24 18:56:30+00:00 +7044.0,0.1,2024-06-24 18:56:31+00:00 +7045.0,0.1,2024-06-24 18:56:32+00:00 +7046.0,0.1,2024-06-24 18:56:33+00:00 +7047.0,0.1,2024-06-24 18:56:34+00:00 +7048.0,0.1,2024-06-24 18:56:35+00:00 +7049.0,0.1,2024-06-24 18:56:36+00:00 +7050.0,0.1,2024-06-24 18:56:37+00:00 +7051.0,0.1,2024-06-24 18:56:38+00:00 +7052.0,0.1,2024-06-24 18:56:39+00:00 +7053.0,0.1,2024-06-24 18:56:40+00:00 +7054.0,0.1,2024-06-24 18:56:41+00:00 +7055.0,0.1,2024-06-24 18:56:42+00:00 +7056.0,0.1,2024-06-24 18:56:43+00:00 +7057.0,0.1,2024-06-24 18:56:44+00:00 +7058.0,0.1,2024-06-24 18:56:45+00:00 +7059.0,0.1,2024-06-24 18:56:46+00:00 +7060.0,0.1,2024-06-24 18:56:47+00:00 +7061.0,0.1,2024-06-24 18:56:48+00:00 +7062.0,0.1,2024-06-24 18:56:49+00:00 +7063.0,0.1,2024-06-24 18:56:50+00:00 +7064.0,0.1,2024-06-24 18:56:51+00:00 +7065.0,0.1,2024-06-24 18:56:52+00:00 +7066.0,0.1,2024-06-24 18:56:53+00:00 +7067.0,0.1,2024-06-24 18:56:54+00:00 +7068.0,0.1,2024-06-24 18:56:55+00:00 +7069.0,0.1,2024-06-24 18:56:56+00:00 +7070.0,0.1,2024-06-24 18:56:57+00:00 +7071.0,0.1,2024-06-24 18:56:58+00:00 +7072.0,0.1,2024-06-24 18:56:59+00:00 +7073.0,0.1,2024-06-24 18:57:00+00:00 +7074.0,0.1,2024-06-24 18:57:01+00:00 +7075.0,0.1,2024-06-24 18:57:02+00:00 +7076.0,0.1,2024-06-24 18:57:03+00:00 +7077.0,0.1,2024-06-24 18:57:04+00:00 +7078.0,0.1,2024-06-24 18:57:05+00:00 +7079.0,0.1,2024-06-24 18:57:06+00:00 +7080.0,0.1,2024-06-24 18:57:07+00:00 +7081.0,0.1,2024-06-24 18:57:08+00:00 +7082.0,0.1,2024-06-24 18:57:09+00:00 +7083.0,0.1,2024-06-24 18:57:10+00:00 +7084.0,0.1,2024-06-24 18:57:11+00:00 +7085.0,0.1,2024-06-24 18:57:12+00:00 +7086.0,0.1,2024-06-24 18:57:13+00:00 +7087.0,0.1,2024-06-24 18:57:14+00:00 +7088.0,0.1,2024-06-24 18:57:15+00:00 +7089.0,0.1,2024-06-24 18:57:16+00:00 +7090.0,0.1,2024-06-24 18:57:17+00:00 +7091.0,0.1,2024-06-24 18:57:18+00:00 +7092.0,0.1,2024-06-24 18:57:19+00:00 +7093.0,0.1,2024-06-24 18:57:20+00:00 +7094.0,0.1,2024-06-24 18:57:21+00:00 +7095.0,0.1,2024-06-24 18:57:22+00:00 +7096.0,0.1,2024-06-24 18:57:23+00:00 +7097.0,0.1,2024-06-24 18:57:24+00:00 +7098.0,0.1,2024-06-24 18:57:25+00:00 +7099.0,0.1,2024-06-24 18:57:26+00:00 +7100.0,0.1,2024-06-24 18:57:27+00:00 +7101.0,0.1,2024-06-24 18:57:28+00:00 +7102.0,0.1,2024-06-24 18:57:29+00:00 +7103.0,0.1,2024-06-24 18:57:30+00:00 +7104.0,0.1,2024-06-24 18:57:31+00:00 +7105.0,0.1,2024-06-24 18:57:32+00:00 +7106.0,0.1,2024-06-24 18:57:33+00:00 +7107.0,0.1,2024-06-24 18:57:34+00:00 +7108.0,0.1,2024-06-24 18:57:35+00:00 +7109.0,0.1,2024-06-24 18:57:36+00:00 +7110.0,0.1,2024-06-24 18:57:37+00:00 +7111.0,0.1,2024-06-24 18:57:38+00:00 +7112.0,0.1,2024-06-24 18:57:39+00:00 +7113.0,0.1,2024-06-24 18:57:40+00:00 +7114.0,0.1,2024-06-24 18:57:41+00:00 +7115.0,0.1,2024-06-24 18:57:42+00:00 +7116.0,0.1,2024-06-24 18:57:43+00:00 +7117.0,0.1,2024-06-24 18:57:44+00:00 +7118.0,0.1,2024-06-24 18:57:45+00:00 +7119.0,0.1,2024-06-24 18:57:46+00:00 +7120.0,0.1,2024-06-24 18:57:47+00:00 +7121.0,0.1,2024-06-24 18:57:48+00:00 +7122.0,0.1,2024-06-24 18:57:49+00:00 +7123.0,0.1,2024-06-24 18:57:50+00:00 +7124.0,0.1,2024-06-24 18:57:51+00:00 +7125.0,0.1,2024-06-24 18:57:52+00:00 +7126.0,0.1,2024-06-24 18:57:53+00:00 +7127.0,0.1,2024-06-24 18:57:54+00:00 +7128.0,0.1,2024-06-24 18:57:55+00:00 +7129.0,0.1,2024-06-24 18:57:56+00:00 +7130.0,0.1,2024-06-24 18:57:57+00:00 +7131.0,0.1,2024-06-24 18:57:58+00:00 +7132.0,0.1,2024-06-24 18:57:59+00:00 +7133.0,0.1,2024-06-24 18:58:00+00:00 +7134.0,0.1,2024-06-24 18:58:01+00:00 +7135.0,0.1,2024-06-24 18:58:02+00:00 +7136.0,0.1,2024-06-24 18:58:03+00:00 +7137.0,0.1,2024-06-24 18:58:04+00:00 +7138.0,0.1,2024-06-24 18:58:05+00:00 +7139.0,0.1,2024-06-24 18:58:06+00:00 +7140.0,0.1,2024-06-24 18:58:07+00:00 +7141.0,0.1,2024-06-24 18:58:08+00:00 +7142.0,0.1,2024-06-24 18:58:09+00:00 +7143.0,0.1,2024-06-24 18:58:10+00:00 +7144.0,0.1,2024-06-24 18:58:11+00:00 +7145.0,0.1,2024-06-24 18:58:12+00:00 +7146.0,0.1,2024-06-24 18:58:13+00:00 +7147.0,0.1,2024-06-24 18:58:14+00:00 +7148.0,0.1,2024-06-24 18:58:15+00:00 +7149.0,0.1,2024-06-24 18:58:16+00:00 +7150.0,0.1,2024-06-24 18:58:17+00:00 +7151.0,0.1,2024-06-24 18:58:18+00:00 +7152.0,0.1,2024-06-24 18:58:19+00:00 +7153.0,0.1,2024-06-24 18:58:20+00:00 +7154.0,0.1,2024-06-24 18:58:21+00:00 +7155.0,0.1,2024-06-24 18:58:22+00:00 +7156.0,0.1,2024-06-24 18:58:23+00:00 +7157.0,0.1,2024-06-24 18:58:24+00:00 +7158.0,0.1,2024-06-24 18:58:25+00:00 +7159.0,0.1,2024-06-24 18:58:26+00:00 +7160.0,0.1,2024-06-24 18:58:27+00:00 +7161.0,0.1,2024-06-24 18:58:28+00:00 +7162.0,0.1,2024-06-24 18:58:29+00:00 +7163.0,0.1,2024-06-24 18:58:30+00:00 +7164.0,0.1,2024-06-24 18:58:31+00:00 +7165.0,0.1,2024-06-24 18:58:32+00:00 +7166.0,0.1,2024-06-24 18:58:33+00:00 +7167.0,0.1,2024-06-24 18:58:34+00:00 +7168.0,0.1,2024-06-24 18:58:35+00:00 +7169.0,0.1,2024-06-24 18:58:36+00:00 +7170.0,0.1,2024-06-24 18:58:37+00:00 +7171.0,0.1,2024-06-24 18:58:38+00:00 +7172.0,0.1,2024-06-24 18:58:39+00:00 +7173.0,0.1,2024-06-24 18:58:40+00:00 +7174.0,0.1,2024-06-24 18:58:41+00:00 +7175.0,0.1,2024-06-24 18:58:42+00:00 +7176.0,0.1,2024-06-24 18:58:43+00:00 +7177.0,0.1,2024-06-24 18:58:44+00:00 +7178.0,0.1,2024-06-24 18:58:45+00:00 +7179.0,0.1,2024-06-24 18:58:46+00:00 +7180.0,0.1,2024-06-24 18:58:47+00:00 +7181.0,0.1,2024-06-24 18:58:48+00:00 +7182.0,0.1,2024-06-24 18:58:49+00:00 +7183.0,0.1,2024-06-24 18:58:50+00:00 +7184.0,0.1,2024-06-24 18:58:51+00:00 +7185.0,0.1,2024-06-24 18:58:52+00:00 +7186.0,0.1,2024-06-24 18:58:53+00:00 +7187.0,0.1,2024-06-24 18:58:54+00:00 +7188.0,0.1,2024-06-24 18:58:55+00:00 +7189.0,0.1,2024-06-24 18:58:56+00:00 +7190.0,0.1,2024-06-24 18:58:57+00:00 +7191.0,0.1,2024-06-24 18:58:58+00:00 +7192.0,0.1,2024-06-24 18:58:59+00:00 +7193.0,0.1,2024-06-24 18:59:00+00:00 +7194.0,0.1,2024-06-24 18:59:01+00:00 +7195.0,0.1,2024-06-24 18:59:02+00:00 +7196.0,0.1,2024-06-24 18:59:03+00:00 +7197.0,0.1,2024-06-24 18:59:04+00:00 +7198.0,0.1,2024-06-24 18:59:05+00:00 +7199.0,0.1,2024-06-24 18:59:06+00:00 +7200.0,0.1,2024-06-24 18:59:07+00:00 diff --git a/examples/06_wind_and_hydrogen/plot_outputs.py b/examples/06_wind_and_hydrogen/plot_outputs.py new file mode 100644 index 00000000..f0eaa81d --- /dev/null +++ b/examples/06_wind_and_hydrogen/plot_outputs.py @@ -0,0 +1,109 @@ +# Plot the outputs of the simulation for the wind and storage example + +import matplotlib.pyplot as plt +from hercules import HerculesOutput + +# Read the Hercules output file using HerculesOutput +ho = HerculesOutput("outputs/hercules_output.h5") + +# Print metadata information +print("Simulation Metadata:") +ho.print_metadata() +print() + +# Create a shortcut to the dataframe +df = ho.df + +# Get the h_dict from metadata +h_dict = ho.h_dict +print(h_dict.keys()) +print(h_dict["electrolyzer"].keys()) + +# Set number of turbines +turbines_to_plot = [0, 1, 2, 3, 4, 5, 6, 7, 8] + +# Define a consistent color map with 9 +colors = [ + "tab:blue", + "tab:orange", + "tab:green", + "tab:red", + "tab:purple", + "tab:brown", + "tab:pink", + "tab:gray", + "tab:olive", +] + + +fig, axarr = plt.subplots(4, 1, sharex=True) + + +# Plot wind resource +ax = axarr[0] + +# Plot the FLORIS wind speed +ax.plot( + df["time_utc"], + df["wind_farm.wind_speed_mean_background"], + label="Mean Unwaked Wind Speed", + color="black", + lw=2, +) +ax.grid(True) +ax.legend() +ax.set_ylabel("Wind Speed [m/s]") + +# Plot the turbine powers +ax = axarr[1] +for t_idx in turbines_to_plot: + ax.plot( + df["time_utc"], + df[f"wind_farm.turbine_powers.{t_idx:03}"], + label=f"Turbine {t_idx}", + color=colors[t_idx], + ) +ax.set_ylabel("Power [kW]") + +# Plot the hybrid plant power +ax = axarr[2] +ax.plot( + df["time_utc"], + df["wind_farm.power"], + label="Wind Power", + color="b", + alpha=0.75, +) +ax.plot( + df["time_utc"], + df["electrolyzer.power_input_kw"], + label="Electrolyzer Input Power", + color="r", + alpha=0.75, +) +ax.fill_between( + df["time_utc"], + -df["electrolyzer.power"], + label="Electrolzyer Power Used", + color="b", + alpha=0.5, +) + +ax.set_ylabel("Power [kW]") + +# Plot hydrogen output +ax = axarr[3] +ax.plot( + df["time_utc"], df["external_signals.hydrogen_reference"], label="Hydrogen Reference", color="k" +) +ax.set_ylabel("Hydrogen production [kg]") +ax.plot(df["time_utc"], df["electrolyzer.H2_mfr"], label="Hydrogen Output", color="b") + +ax.set_xlabel("Time [s]") + +for ax in axarr: + ax.grid(True) + ax.legend() + + +plt.show() diff --git a/examples/grid/grid_status_download_example.py b/examples/grid/grid_status_download_example.py new file mode 100644 index 00000000..7e5917d1 --- /dev/null +++ b/examples/grid/grid_status_download_example.py @@ -0,0 +1,63 @@ +# Example version of the gridstatus_download.py script +# Download 2 days of data for the OKGE.FRONTIER location +# Download both real time and day ahead data +# See the documentation for more details of this example: +# https://nrel.github.io/hercules/gridstatus_download.html +# Note api key is required, see the documentation for more details: +# https://nrel.github.io/hercules/gridstatus_download.html#api-key-setup + +# Run using uvx --with gridstatusio --with pyarrow python grid_status_download_example.py + +from gridstatusio import GridStatusClient + +# PARAMETERS +QUERY_LIMIT = 1000 +start = "2024-01-01" +end = "2024-01-03" +filter_column = "location" +filter_value = "OKGE.FRONTIER" + + +for dataset in ["spp_lmp_real_time_5_min", "spp_lmp_day_ahead_hourly"]: + # Initialize Grid Status client + client = GridStatusClient() + + # Download data + df = client.get_dataset( + dataset=dataset, + start=start, + end=end, + filter_column=filter_column, + filter_value=filter_value, + limit=QUERY_LIMIT, + ) + + print("--------------------------------") + print(f"Downloaded {df.shape[0]} rows") + + # Print the first value of each column + print("Columns:") + for column in df.columns: + print(f"{column}: {df[column].iloc[0]}") + + # Remove columns not used by hercules if in dataframe + columns_to_drop = ["interval_end_utc", "location", "location_type", "pnode"] + df = df.drop(columns=columns_to_drop, errors="ignore") + + # Show the dataframe head + print("DataFrame head:") + print(df.head()) + + # Come up with a filename for the feather file + filename = f"gs_{dataset}_{start}_{filter_value}" + + # Replace all dashes and dots with underscores + filename = filename.replace("-", "_").replace(".", "_") + + # Add .ftr extension + filename = filename + ".ftr" + + # Save the dataframe to a feather file + df.to_feather(filename) + + print(f"Saved dataframe to {filename}") diff --git a/examples/grid/gs_spp_lmp_day_ahead_hourly_2024_01_01_OKGE_FRONTIER.ftr b/examples/grid/gs_spp_lmp_day_ahead_hourly_2024_01_01_OKGE_FRONTIER.ftr new file mode 100644 index 00000000..0c65dd09 Binary files /dev/null and b/examples/grid/gs_spp_lmp_day_ahead_hourly_2024_01_01_OKGE_FRONTIER.ftr differ diff --git a/examples/grid/gs_spp_lmp_real_time_5_min_2024_01_01_OKGE_FRONTIER.ftr b/examples/grid/gs_spp_lmp_real_time_5_min_2024_01_01_OKGE_FRONTIER.ftr new file mode 100644 index 00000000..2c977ad4 Binary files /dev/null and b/examples/grid/gs_spp_lmp_real_time_5_min_2024_01_01_OKGE_FRONTIER.ftr differ diff --git a/examples/grid/process_grid_status_data.py b/examples/grid/process_grid_status_data.py new file mode 100644 index 00000000..e01fcff6 --- /dev/null +++ b/examples/grid/process_grid_status_data.py @@ -0,0 +1,34 @@ +# Process the results of the grid status download +# using generate_locational_marginal_price_dataframe. +# The output dataframe is formatted for use as external data in Hercules and includes convenient +# forward hours of day ahead for certain Hycon applications. +# See the documentation for more details of this example: +# https://nrel.github.io/hercules/gridstatus_download.html#combining-real-time-and-day-ahead-data-for-hycon + +import pandas as pd +from hercules.grid.grid_utilities import ( + generate_locational_marginal_price_dataframe_from_gridstatus, +) + +# Read the real time and day ahead data +df_rt = pd.read_feather("gs_spp_lmp_real_time_5_min_2024_01_01_OKGE_FRONTIER.ftr") +df_da = pd.read_feather("gs_spp_lmp_day_ahead_hourly_2024_01_01_OKGE_FRONTIER.ftr") + +# Print the first 10 rows of each dataframe +print("First 10 rows of real time data:") +print(df_rt.head(10)) +print("First 10 rows of day ahead data:") +print(df_da.head(10)) +print("--------------------------------") + +# Process the data +df = generate_locational_marginal_price_dataframe_from_gridstatus( + df_da, + df_rt, + day_ahead_market_name="DAY_AHEAD_HOURLY", + real_time_market_name="REAL_TIME_5_MIN", +) + +# Print the first 10 rows of the resultant dataframe +print("First 10 rows of resultant dataframe:") +print(df.head(10)) diff --git a/examples/hercules_input_example.yaml b/examples/hercules_input_example.yaml new file mode 100644 index 00000000..898897da --- /dev/null +++ b/examples/hercules_input_example.yaml @@ -0,0 +1,178 @@ +# Input YAML for Hercules simulation +# +# Configure this file for your specific scenario + +# Name and description +name: example_scenario # Simulation name identifier +description: Example scenario configuration # Description of the simulation setup + +# Simulation time parameters +dt: 1.0 # Simulation time step (seconds) +starttime_utc: "2020-01-01T00:00:00Z" # Simulation start time in UTC (ISO 8601 format) +endtime_utc: "2020-01-31T23:59:59Z" # Simulation end time in UTC (ISO 8601 format) +verbose: False # Enable verbose console output (True/False) +log_every_n: 10 # Log output every N time steps (positive integer, default: 1) + +# Output file configuration +output_file: outputs/hercules_output.h5 # Output HDF5 file path (automatically adds .h5 extension if missing) +output_use_compression: True # Enable HDF5 compression (True/False, default: True) +output_buffer_size: 50000 # Memory buffer size for writing data in rows (default: 50000) + +# Plant-level configuration +plant: + interconnect_limit: 201300 # kW - grid interconnection capacity limit + +# Wind farm configuration (comment out if not using wind) +wind_farm: + component_type: WindFarm # Options: WindFarm, WindFarmSCADAPower + wake_method: dynamic # Options: dynamic, precomputed, no_added_wakes + floris_input_file: ../inputs/floris_input.yaml # Path to FLORIS farm configuration file + wind_input_filename: ../inputs/wind_input.ftr # Path to wind resource data file (CSV, pickle, or feather format) + turbine_file_name: ../inputs/turbine_filter_model.yaml # Path to turbine model configuration file + log_file_name: outputs/log_wind_farm.log # Path to wind farm log file (default: outputs/log_wind_farm.log) + floris_update_time_s: 300.0 # How often to update FLORIS wake calculations in seconds (for "dynamic" wake_method) or precomputation cadence (for "precomputed" wake_method) + log_channels: # List of output channels to log (power is always logged even if not specified) + - power # Total wind farm power output (kW) - always logged + - wind_speed_mean_background # Farm-average background wind speed (m/s) + - wind_speed_mean_withwakes # Farm-average with-wakes wind speed (m/s) + - wind_direction_mean # Farm-average wind direction (degrees) + # Array channels (uncomment to log all turbines, or use selective logging below): + # - turbine_powers # Power output for all turbines (creates datasets like wind_farm.turbine_powers.000, wind_farm.turbine_powers.001, etc.) + # - turbine_power_setpoints # Power setpoints for all turbines + # - wind_speeds_background # Background wind speeds for all turbines + # - wind_speeds_withwakes # With-wakes wind speeds for all turbines + # Selective array element logging (log specific turbine indices): + # - turbine_powers.000 # Log turbine 0 power + # - turbine_powers.005 # Log turbine 5 power + # - turbine_powers.010 # Log turbine 10 power + +# WindFarmSCADAPower configuration (alternative to WindFarm, comment out if not using) +# Uses SCADA power data directly rather than computing power from wind speeds +# wind_farm: +# component_type: WindFarmSCADAPower # Uses SCADA power measurements directly +# scada_filename: ../inputs/scada_data.ftr # Path to SCADA data file (CSV, pickle, or feather format) +# log_file_name: outputs/log_wind_farm.log # Path to wind farm log file (default: outputs/log_wind_farm.log) +# log_channels: # List of output channels to log (power is always logged even if not specified) +# - power # Total wind farm power output (kW) - always logged +# - wind_speed_mean_background # Farm-average background wind speed (m/s) - equals wind_speed_mean_withwakes for this model +# - wind_speed_mean_withwakes # Farm-average with-wakes wind speed (m/s) - equals wind_speed_mean_background for this model +# - wind_direction_mean # Farm-average wind direction (degrees) +# # Array channels (uncomment to log all turbines, or use selective logging below): +# # - turbine_powers # Power output for all turbines (creates datasets like wind_farm.turbine_powers.000, wind_farm.turbine_powers.001, etc.) +# # - turbine_power_setpoints # Power setpoints for all turbines +# # - wind_speeds_background # Background wind speeds for all turbines (equals wind_speeds_withwakes for this model) +# # - wind_speeds_withwakes # With-wakes wind speeds for all turbines (equals wind_speeds_background for this model) +# # Selective array element logging (log specific turbine indices): +# # - turbine_powers.000 # Log turbine 0 power +# # - turbine_powers.005 # Log turbine 5 power +# # - turbine_powers.010 # Log turbine 10 power + +# Solar farm configuration (comment out if not using solar) +# solar_farm: +# component_type: SolarPySAMPVWatts # Currently only SolarPySAMPVWatts is available +# solar_input_filename: ../inputs/solar_input.ftr # Path to solar resource data file (CSV, pickle, or feather format) +# lat: 39.7442 # Latitude in degrees (required) +# lon: -105.1778 # Longitude in degrees (required) +# elev: 1829 # Elevation in meters (required) +# system_capacity: 100000 # DC system capacity in kW under Standard Test Conditions (required) +# tilt: 0 # Array tilt angle in degrees (required) +# losses: 0 # System losses as a percentage (0-100, default: 0) +# log_file_name: outputs/log_solar_farm.log # Path to solar farm log file (default: outputs/log_solar_farm.log) +# log_channels: # List of output channels to log (power is always logged even if not specified) +# - power # DC power output in kW (always logged) +# - poa # Plane-of-array irradiance in W/m² +# - dni # Direct normal irradiance in W/m² +# - aoi # Angle of incidence in degrees +# initial_conditions: # Initial conditions for solar farm +# power: 10000 # Initial power in kW +# dni: 1000 # Initial direct normal irradiance in W/m² +# poa: 1000 # Initial plane-of-array irradiance in W/m² + +# Battery storage configuration (comment out if not using storage) +# battery: +# component_type: BatterySimple # Options: BatterySimple, BatteryLithiumIon +# energy_capacity: 200000 # Total energy capacity in kWh (required) +# charge_rate: 50000 # Maximum charge rate in kW (required) +# discharge_rate: 50000 # Maximum discharge rate in kW (required) +# max_SOC: 0.9 # Maximum state of charge (0-1, required) +# min_SOC: 0.1 # Minimum state of charge (0-1, required) +# allow_grid_power_consumption: False # Allow battery to consume grid power for charging (True/False, default: False) +# log_file_name: outputs/log_battery.log # Path to battery log file (default: outputs/log_battery.log) +# log_channels: # List of output channels to log (power is always logged even if not specified) +# - power # Actual battery power output in kW (always logged, positive=discharge, negative=charge) +# - soc # State of charge (0-1) +# - power_setpoint # Requested power setpoint in kW +# initial_conditions: # Initial conditions for battery +# SOC: 0.5 # Initial state of charge (between min_SOC and max_SOC, required) +# # BatterySimple-specific optional parameters: +# # roundtrip_efficiency: 0.95 # Roundtrip efficiency (0-1, default: 1.0, BatterySimple only) +# # self_discharge_time_constant: inf # Self-discharge time constant in seconds (default: inf, BatterySimple only) +# # track_usage: False # Enable usage tracking for degradation modeling (True/False, default: False, BatterySimple only) +# # usage_calc_interval: 100 # Interval for usage calculations in seconds (default: 100, BatterySimple only) +# # usage_lifetime: 20.0 # Battery lifetime in years for time-based degradation (BatterySimple only) +# # usage_cycles: 10000 # Number of cycles until replacement for cycle-based degradation (BatterySimple only) + +# BatteryLithiumIon example (alternative to BatterySimple): +# battery: +# component_type: BatteryLithiumIon # Detailed lithium-ion battery model +# energy_capacity: 200000 # Total energy capacity in kWh (required) +# charge_rate: 50000 # Maximum charge rate in kW (required) +# discharge_rate: 50000 # Maximum discharge rate in kW (required) +# max_SOC: 0.9 # Maximum state of charge (0-1, required) +# min_SOC: 0.1 # Minimum state of charge (0-1, required) +# allow_grid_power_consumption: False # Allow battery to consume grid power for charging (True/False, default: False) +# log_file_name: outputs/log_battery.log # Path to battery log file (default: outputs/log_battery.log) +# log_channels: # List of output channels to log (power is always logged even if not specified) +# - power # Actual battery power output in kW (always logged, positive=discharge, negative=charge) +# - soc # State of charge (0-1) +# - power_setpoint # Requested power setpoint in kW +# initial_conditions: # Initial conditions for battery +# SOC: 0.5 # Initial state of charge (between min_SOC and max_SOC, required) + +# Electrolyzer configuration (comment out if not using electrolyzer) +# electrolyzer: +# component_type: ElectrolyzerPlant # Currently only ElectrolyzerPlant is available +# initialize: True # Initialize electrolyzer (True/False, required) +# initial_power_kW: 3000 # Initial power in kW (required) +# allow_grid_power_consumption: False # Allow electrolyzer to consume grid power (True/False, default: False) +# log_file_name: outputs/log_electrolyzer.log # Path to electrolyzer log file (default: outputs/log_electrolyzer.log) +# log_channels: # List of output channels to log (power is always logged even if not specified) +# - power # Electrolyzer power consumption in kW (always logged) +# supervisor: # Supervisor configuration (required) +# n_stacks: 10 # Number of electrolyzer stacks +# stack: # Stack configuration (required) +# cell_type: PEM # Cell type (e.g., PEM) +# cell_area: 1000.0 # Cell area in cm² +# max_current: 2000 # Maximum current in A +# temperature: 60 # Operating temperature in °C +# n_cells: 100 # Number of cells per stack +# min_power: 50 # Minimum power in kW +# stack_rating_kW: 500 # Stack rating in kW +# include_degradation_penalty: True # Include degradation penalty (True/False) +# controller: # Controller configuration (required) +# n_stacks: 10 # Number of stacks (must match supervisor.n_stacks) +# control_type: DecisionControl # Control type +# policy: # Control policy +# eager_on: False # Eager turn-on behavior (True/False) +# eager_off: False # Eager turn-off behavior (True/False) +# sequential: False # Sequential control (True/False) +# even_dist: False # Even distribution (True/False) +# baseline: True # Baseline policy (True/False) +# costs: null # Cost parameters (optional, can be null) +# cell_params: # Cell parameters (required) +# cell_type: PEM # Cell type (must match stack.cell_type) +# PEM_params: # PEM-specific parameters +# cell_area: 1000 # Cell area in cm² +# turndown_ratio: 0.1 # Turndown ratio +# max_current_density: 2 # Maximum current density in A/cm² +# degradation: # Degradation parameters (required) +# PEM_params: # PEM-specific degradation parameters +# rate_steady: 1.41737929e-10 # Steady-state degradation rate +# rate_fatigue: 3.33330244e-07 # Fatigue degradation rate +# rate_onoff: 1.47821515e-04 # On/off degradation rate + +# Controller configuration (optional) +controller: + # Add controller parameters here if using WHOC or other controllers + # Example controller configuration would go here + diff --git a/examples/hercules_output_example.py b/examples/hercules_output_example.py index f791e95b..ccebdd95 100644 --- a/examples/hercules_output_example.py +++ b/examples/hercules_output_example.py @@ -72,7 +72,7 @@ def create_example_hdf5_file(filename: str): f["metadata"].attrs["log_every_n"] = 300 f["metadata"].attrs["start_clock_time"] = 1234567890.0 f["metadata"].attrs["end_clock_time"] = 1234567890.0 + 21600 - f["metadata"].attrs["start_time_utc"] = 1234567890.0 # Unix timestamp for UTC time + f["metadata"].attrs["starttime_utc"] = 1234567890.0 # Unix timestamp for UTC time f["metadata"].attrs["zero_time_utc"] = 1234567890.0 # Add h_dict as JSON string diff --git a/examples/inputs/00_generate_wind_history_small.py b/examples/inputs/00_generate_wind_history_small.py index d05fe23f..f7d3a136 100644 --- a/examples/inputs/00_generate_wind_history_small.py +++ b/examples/inputs/00_generate_wind_history_small.py @@ -86,7 +86,6 @@ # Note: For simplicity, set mean wind direction equal to turbine 0 direction df = pd.DataFrame( { - "time": np.arange(N) * dt, "time_utc": pd.date_range(start="1/1/2020", periods=N, freq="1s"), "wd_mean": wd_0, "ws_000": ws_0, @@ -98,5 +97,8 @@ fig.suptitle("Turbine wind speeds and directions") df.to_feather("wind_input_small.ftr") +print(f"First time (UTC): {df['time_utc'].iloc[0]}") +print(f"Last time (UTC): {df['time_utc'].iloc[-1]}") + if show_plots: plt.show() diff --git a/examples/inputs/01_generate_wind_history_large.py b/examples/inputs/01_generate_wind_history_large.py index f48b3626..a4b6ceae 100644 --- a/examples/inputs/01_generate_wind_history_large.py +++ b/examples/inputs/01_generate_wind_history_large.py @@ -142,7 +142,6 @@ df = pd.DataFrame( { - "time": np.arange(len(wd_inf_drop)) * dt, "time_utc": time_utc, "wd_mean": wd_inf_drop, } @@ -154,5 +153,8 @@ # Save to feather df.to_feather("wind_input_large.ftr") +print(f"First time (UTC): {df['time_utc'].iloc[0]}") +print(f"Last time (UTC): {df['time_utc'].iloc[-1]}") + if show_plots: plt.show() diff --git a/examples/inputs/02_generate_solar_history.py b/examples/inputs/02_generate_solar_history.py index 0c7dc0c1..a05b9f5c 100644 --- a/examples/inputs/02_generate_solar_history.py +++ b/examples/inputs/02_generate_solar_history.py @@ -19,11 +19,11 @@ df_solar["time_utc"] = pd.to_datetime(df_solar["Timestamp"], format="ISO8601", utc=True) df_solar = df_solar.drop(columns=["Timestamp"]) -# Add a zero-based seconds column (not time-synced with wind in examples) -df_solar["time"] = (df_solar["time_utc"] - df_solar["time_utc"].min()).dt.total_seconds() - # Clean index and finalize columns df_solar = df_solar.reset_index(drop=True) # Save the data df_solar.to_feather("solar_input.ftr") + +print(f"First time (UTC): {df_solar['time_utc'].iloc[0]}") +print(f"Last time (UTC): {df_solar['time_utc'].iloc[-1]}") diff --git a/examples/inputs/03_download_small_nsrdb_wtk_solar_wind_example.py b/examples/inputs/03_download_small_nsrdb_wtk_solar_wind_example.py new file mode 100644 index 00000000..c7c4c794 --- /dev/null +++ b/examples/inputs/03_download_small_nsrdb_wtk_solar_wind_example.py @@ -0,0 +1,117 @@ +""" +Small example using the real WTK/NSRDB downloader with minimal data. + +Note that this example uses the download_nsrdb_data function, which requires an NLR API key that +can be obtained by visiting https://developer.nrel.gov/signup/. After receiving your API key, you +must make a configuration file at ~/.hscfg containing the following: + + hs_endpoint = https://developer.nrel.gov/api/hsds + + hs_api_key = YOUR_API_KEY_GOES_HERE + +More information can be found at: https://github.com/NREL/hsds-examples. +""" + +import os +import sys + +from hercules.resource.wind_solar_resource_downloader import ( + download_nsrdb_data, + download_wtk_data, +) +from matplotlib import pyplot as plt + +sys.path.append(".") + + +def run_small_example(): + """Run a small example with real data but limited time range and area""" + + # ARM Southern Great Plains coordinates + target_lat = 36.607322 + target_lon = -97.487643 + year = 2020 + + # Create data directory + data_dir = "data/small_wtk_nsrdb_example" + os.makedirs(data_dir, exist_ok=True) + + print("=" * 60) + print("SMALL EXAMPLE: DOWNLOADING NSRDB DATA") + print("=" * 60) + + # Download a small sample of NSRDB data with plotting + try: + nsrdb_data = download_nsrdb_data( + target_lat=target_lat, + target_lon=target_lon, + year=year, + variables=["ghi"], # Just one variable + nsrdb_dataset_path="/nrel/nsrdb/conus", # Demonstrating using a non-default dataset + coord_delta=0.05, # Small area + output_dir=data_dir, + filename_prefix="nsrdb_small_example", + plot_data=True, + plot_type="timeseries", + ) + + if nsrdb_data: + print("\n✓ Successfully downloaded NSRDB data!") + for var, df in nsrdb_data.items(): + if var != "coordinates": + print(f" {var}: {df.shape}") + + except Exception as e: + print(f"✗ NSRDB download failed: {e}") + return False + + print("\n" + "=" * 60) + print("SMALL EXAMPLE: DOWNLOADING WTK DATA") + print("=" * 60) + + # Download a small sample of WTK data with plotting + try: + wtk_data = download_wtk_data( + target_lat=target_lat, + target_lon=target_lon, + year=year, + variables=["windspeed_100m"], # Just one variable + coord_delta=0.05, # Small area + output_dir=data_dir, + filename_prefix="wtk_small_example", + plot_data=True, + plot_type="map", + ) + + if wtk_data: + print("\n✓ Successfully downloaded WTK data!") + for var, df in wtk_data.items(): + if var != "coordinates": + print(f" {var}: {df.shape}") + + return True + + except Exception as e: + print(f"✗ WTK download failed: {e}") + return False + + +if __name__ == "__main__": + print("Running small example with real NLR data...") + print("This will download a small sample and create plots.") + print("Note: This may take several minutes due to data download times.\n") + + success = run_small_example() + + if success: + print("\n✓ Small example completed successfully!") + print("\nThe script has demonstrated:") + print(" - Real NLR data download (both NSRDB and WTK)") + print(" - Time-series plotting") + print(" - Spatial map plotting") + print(" - Data saving in feather format") + print("\nYou can now use the full script for larger datasets!") + else: + print("\n✗ Example failed. Check error messages above.") + + plt.show() diff --git a/examples/inputs/04_download_and_upsample_wtk_wind_example.py b/examples/inputs/04_download_and_upsample_wtk_wind_example.py new file mode 100644 index 00000000..1bc4cafd --- /dev/null +++ b/examples/inputs/04_download_and_upsample_wtk_wind_example.py @@ -0,0 +1,182 @@ +""" +Example using the real WTK downloader to download and and spatially and temporally upsample wind +speed and direction data. + +Note that this example uses the download_wtk_data function, which requires an NLR API key that +can be obtained by visiting https://developer.nrel.gov/signup/. After receiving your API key, you +must make a configuration file at ~/.hscfg containing the following: + + hs_endpoint = https://developer.nrel.gov/api/hsds + + hs_api_key = YOUR_API_KEY_GOES_HERE + +More information can be found at: https://github.com/NREL/hsds-examples. +""" + +import os +import sys +from pathlib import Path + +import numpy as np +import pandas as pd +import utm +from hercules.resource.upsample_wind_data import upsample_wind_data +from hercules.resource.wind_solar_resource_downloader import download_wtk_data +from matplotlib import pyplot as plt + +sys.path.append(".") + + +def run_small_example(): + """Run a small example with real data but limited time range and area""" + + # ARM Southern Great Plains coordinates + target_lat = 36.607322 + target_lon = -97.487643 + year = 2020 + + # Create data directory + data_dir = "data/wtk_upsample_example" + os.makedirs(data_dir, exist_ok=True) + + print("\n" + "=" * 60) + print("EXAMPLE: DOWNLOADING AND UPSAMPLING WTK DATA") + print("=" * 60) + + # Download a sample of WTK wind speed and direction data with plotting + try: + wtk_data = download_wtk_data( + target_lat=target_lat, + target_lon=target_lon, + year=year, + variables=["windspeed_100m", "winddirection_100m"], + coord_delta=0.05, # Small area + output_dir=data_dir, + filename_prefix="wtk_small_example", + plot_data=True, + plot_type="map", + ) + + if wtk_data: + print("\n✓ Successfully downloaded WTK data!") + for var, df in wtk_data.items(): + if var != "coordinates": + print(f" {var}: {df.shape}") + + except Exception as e: + print(f"✗ WTK download failed: {e}") + return False + + # Spatially and temporally upsample the WTK wind speed and direction data at wind turbine + # locations in a 2 x 3 array wind farm + x_turbine_locs = np.array([-1500.0, 0.0, 1500.0, -1500.0, 0.0, 1500.0]) + y_turbine_locs = np.array([-1500.0, -1500.0, -1500.0, 1500.0, 1500.0, 1500.0]) + + wtk_ws_data_filepath = Path(data_dir) / f"wtk_small_example_windspeed_100m_{year}.feather" + wtk_wd_data_filepath = Path(data_dir) / f"wtk_small_example_winddirection_100m_{year}.feather" + wtk_coords_filepath = Path(data_dir) / f"wtk_small_example_coords_{year}.feather" + + # Using downloaded WTK files, spatially interpolate wind speeds and directions at 6 + # turbine locations and upsample wind speeds by adding stochastic turbulence. The combined + # upsampled dataframe will be saved in the same directory as the WTK files. + # + # The arguments turbulence_Uhub and turbulence_L are parameters used in the Kaimal + # turbulence spectrum model + # + # Turbulence intensity is assigned as a function of wind speed based on the IEC normal + # turbulence model such that a desired TI is achieved at a reference wind speed + + print("\nUpsampling raw WTK data...") + + df_upsample = upsample_wind_data( + ws_data_filepath=wtk_ws_data_filepath, + wd_data_filepath=wtk_wd_data_filepath, + coords_filepath=wtk_coords_filepath, + upsampled_data_dir=data_dir, + upsampled_data_filename="wtk_small_example_upsample_6turbines.ftr", + x_locs_upsample=x_turbine_locs, + y_locs_upsample=y_turbine_locs, + origin_lat=None, # None sets the y origin to the mean latitude in the WTK files + origin_lon=None, # None sets the x origin to the mean longitude in the WTK files + timestep_upsample=1, # Upsample from 5-minute WTK resolution to 1-second resolution + turbulence_Uhub=None, # None sets turbulence_Uhub to the mean WTK wind speed + turbulence_L=340.2, # Default turbulence length scale defined in the IEC standard + TI_ref=0.1, # The desired TI corresponding to the reference wind speed TI_ws_ref + TI_ws_ref=8.0, + save_individual_wds=True, # True saved wind directions for each upsampled location + ) + + # Load raw WTK wind speeds and locations + df_wtk_ws = pd.read_feather(wtk_ws_data_filepath) + df_wtk_coords = pd.read_feather(wtk_coords_filepath) + + # Convert WTK coordinates to easting and northing locations + x_locs_wtk, y_locs_wtk, zone_number, zone_letter = utm.from_latlon( + df_wtk_coords["lat"].values, df_wtk_coords["lon"].values + ) + + origin_lat = df_wtk_coords["lat"].mean() + origin_lon = df_wtk_coords["lon"].mean() + origin_x, origin_y, origin_zone_number, origin_zone_letter = utm.from_latlon( + origin_lat, origin_lon + ) + + x_locs_wtk -= origin_x + y_locs_wtk -= origin_y + + # Plot WTK grid points and upsampled turbine locations + plt.figure() + plt.scatter(x_locs_wtk, y_locs_wtk, label="WTK points") + plt.scatter(x_turbine_locs, y_turbine_locs, color="r", label="Upsampled locations") + for i in range(len(x_turbine_locs)): + plt.text(x_turbine_locs[i] - 200.0, y_turbine_locs[i] + 175.0, i) + plt.grid() + plt.xlabel("Easting (m)") + plt.ylabel("Northing (m)") + plt.legend() + plt.axis("equal") + + # Compare the upsampled wind speed at the first turbine location to the original WTK wind + # speed at the nearest grid point for a single day (k can be 0 to 364) + k = 1 + plt.figure(figsize=(9, 5)) + plt.plot( + df_upsample["time_utc"][k * 60 * 60 * 24 : (k + 1) * 60 * 60 * 24], + df_upsample["ws_000"][k * 60 * 60 * 24 : (k + 1) * 60 * 60 * 24], + label="Upsampled wind speed at location 0", + ) + plt.plot( + df_wtk_ws["time_index"][k * 12 * 24 : (k + 1) * 12 * 24], + df_wtk_ws["2279021"][k * 12 * 24 : (k + 1) * 12 * 24], + label="WTK wind speed at nearest grid point", + ) + plt.grid() + plt.ylabel("Wind Speed (m/s)") + plt.legend() + + return True + + +if __name__ == "__main__": + print("Running small example with real NLR Wind Toolkit data...") + print("This will download a small sample and create plots.") + print("Next, the WTK data will be upsampled at 6 wind turbine locations.") + print("Examples of the upsampled and original wind speed time series will be plotted.") + print("Note: This may take several minutes due to data download times.\n") + print("Also note that the upsampled wind file is approximately 2 GB.\n") + + success = run_small_example() + + if success: + print("\n✓ Small example completed successfully!") + print("\nThe script has demonstrated:") + print(" - Real NLR WTK data download (both wind speed and direction)") + print(" - Time-series plotting") + print(" - Spatial map plotting") + print(" - Data saving in feather format") + print(" - Spatial interpolation and temporal upsampling with turbulence added") + print("\nYou can now use the full script for larger datasets!") + else: + print("\n✗ Example failed. Check error messages above.") + + plt.show() diff --git a/examples/inputs/05_download_small_openmeteo_solar_wind_example.py b/examples/inputs/05_download_small_openmeteo_solar_wind_example.py new file mode 100644 index 00000000..7ef4f5c3 --- /dev/null +++ b/examples/inputs/05_download_small_openmeteo_solar_wind_example.py @@ -0,0 +1,148 @@ +""" +Small example using the Open-Meteo downloader with minimal data +""" + +import os +import sys + +import numpy as np +from hercules.resource.wind_solar_resource_downloader import ( + download_openmeteo_data, +) +from matplotlib import pyplot as plt + +sys.path.append(".") + + +def run_small_example(): + """Run a small example with real data but limited time range and area""" + + # ARM Southern Great Plains coordinates + target_lat = 36.607322 + target_lon = -97.487643 + year = 2023 + + # Create data directory + data_dir = "data/small_openmeteo_example" + os.makedirs(data_dir, exist_ok=True) + + print("=" * 60) + print("SMALL EXAMPLE: DOWNLOADING OPEN-METEO SOLAR DATA") + print("=" * 60) + + # Download a small sample of Open-Meteo solar data with plotting for a single point at the + # target coordinates. Note that data will be returned for the nearest weather grid point to + # the requested coordinates. + try: + solar_data = download_openmeteo_data( + target_lat=target_lat, + target_lon=target_lon, + year=year, + variables=["shortwave_radiation_instant"], # Just one variable + output_dir=data_dir, + filename_prefix="openmeteo_small_example", + plot_data=True, + plot_type="timeseries", + ) + + if solar_data: + print("\n✓ Successfully downloaded Open-Meteo solar data!") + for var, df in solar_data.items(): + if var != "coordinates": + print(f" {var}: {df.shape}") + + except Exception as e: + print(f"✗ Open-Meteo solar download failed: {e}") + return False + + print("\n" + "=" * 60) + print("SMALL EXAMPLE: DOWNLOADING OPEN-METEO WIND DATA") + print("=" * 60) + + # Download a small sample of wind data with plotting for a grid of points + + # Define the grid of locations to download data for. Note that data will be returned for the + # weather grid points nearest to the requested points and any duplicate points will be + # excluded. The grid cell resolution varies with latitude, but at ~35 degrees latitude, the + # grid cell resolution is approximately 0.027 degrees latitude (~2.4 km in the N-S direction) + # and 0.0333 degrees longitude (~3.7km in the E-W direction). + coord_delta = 0.05 + coord_resolution = 0.025 + + target_lats = [] + target_lons = [] + + for delta_lat in np.arange(-1 * coord_delta, coord_delta + coord_resolution, coord_resolution): + for delta_lon in np.arange( + -1 * coord_delta, coord_delta + coord_resolution, coord_resolution + ): + target_lats += [target_lat + delta_lat] + target_lons += [target_lon + delta_lon] + + try: + wind_data = download_openmeteo_data( + target_lat=target_lats, + target_lon=target_lons, + year=year, + variables=["wind_speed_80m"], # Just one variable + output_dir=data_dir, + filename_prefix="openmeteo_small_example", + plot_data=True, + plot_type="map", + remove_duplicate_coords=True, + ) + + if wind_data: + print("\n✓ Successfully downloaded Open-Meteo wind data!") + for var, df in wind_data.items(): + if var != "coordinates": + print(f" {var}: {df.shape}") + + # Plot requested and actual coordinates + print( + "\nNote that the actual coordinates corresponding to the Open-Meteo data grid " + "differ from the requested coordinates. Open-Meteo data is obtained at the " + "nearest weather grid points to the requested coordinates." + ) + + plt.figure() + plt.scatter(target_lons, target_lats, color="k", label="Requested Coordinates") + plt.scatter( + wind_data["coordinates"]["lon"], + wind_data["coordinates"]["lat"], + color="r", + label="Actual Coordinates", + ) + plt.axis("equal") + plt.grid() + plt.xlabel("Longitude") + plt.ylabel("Latitude") + plt.title("Wind Data Coordinates") + plt.legend() + + return True + + except Exception as e: + print(f"✗ Open-Meteo wind download failed: {e}") + return False + + +if __name__ == "__main__": + print("Running small example with real Open-Meteo data...") + print("This will download a small sample and create plots.") + print("Note: This may take several minutes due to data download times.\n") + + success = run_small_example() + + if success: + print("\n✓ Small example completed successfully!") + print("\nThe script has demonstrated:") + print(" - Real Open-Meteo data download (both solar and wind)") + print(" - Time-series plotting") + print(" - Spatial map plotting") + print(" - Data saving in feather format") + print("\nYou can now use the full script for larger datasets!") + else: + print("\n✗ Example failed. Check error messages above.") + + plt.show() diff --git a/examples/inputs/06_download_and_upsample_openmeteo_wind_example.py b/examples/inputs/06_download_and_upsample_openmeteo_wind_example.py new file mode 100644 index 00000000..4f462723 --- /dev/null +++ b/examples/inputs/06_download_and_upsample_openmeteo_wind_example.py @@ -0,0 +1,218 @@ +""" +Example using the Open-Meteo downloader to download and and spatially and temporally upsample wind +speed and direction data +""" + +import os +import sys +from pathlib import Path + +import numpy as np +import pandas as pd +import utm +from hercules.resource.upsample_wind_data import upsample_wind_data +from hercules.resource.wind_solar_resource_downloader import ( + download_openmeteo_data, +) +from matplotlib import pyplot as plt + +sys.path.append(".") + + +def run_small_example(): + """Run a small example with real data but limited time range and area""" + + # ARM Southern Great Plains coordinates + target_lat = 36.607322 + target_lon = -97.487643 + year = 2023 + + # Create data directory + data_dir = "data/openmeteo_upsample_example" + os.makedirs(data_dir, exist_ok=True) + + print("\n" + "=" * 60) + print("EXAMPLE: DOWNLOADING AND UPSAMPLING OPEN-METEO WIND DATA") + print("=" * 60) + + # Download a sample of Open-Meteo wind speed and direction data with plotting + + # Define the grid of locations to download data for. Note that data will be returned for the + # weather grid points nearest to the requested points and any duplicate points will be + # excluded. The grid cell resolution varies with latitude, but at ~35 degrees latitude, the + # grid cell resolution is approximately 0.027 degrees latitude (~2.4 km in the N-S direction) + # and 0.0333 degrees longitude (~3.7km in the E-W direction). + coord_delta = 0.05 + coord_resolution = 0.025 + + target_lats = [] + target_lons = [] + + for delta_lat in np.arange(-1 * coord_delta, coord_delta + coord_resolution, coord_resolution): + for delta_lon in np.arange( + -1 * coord_delta, coord_delta + coord_resolution, coord_resolution + ): + target_lats += [target_lat + delta_lat] + target_lons += [target_lon + delta_lon] + + try: + wind_data = download_openmeteo_data( + target_lat=target_lats, + target_lon=target_lons, + year=year, + variables=["windspeed_80m", "winddirection_80m"], + output_dir=data_dir, + filename_prefix="openmeteo_small_example", + plot_data=True, + plot_type="map", + remove_duplicate_coords=True, + ) + + if wind_data: + print("\n✓ Successfully downloaded Open-Meteo wind data!") + for var, df in wind_data.items(): + if var != "coordinates": + print(f" {var}: {df.shape}") + + print( + "\nNote that the actual coordinates corresponding to the Open-Meteo data grid " + "differ from the requested coordinates. Open-Meteo data is obtained at the " + "nearest weather grid points to the requested coordinates." + ) + + plt.figure() + plt.scatter(target_lons, target_lats, color="k", label="Requested Coordinates") + plt.scatter( + wind_data["coordinates"]["lon"], + wind_data["coordinates"]["lat"], + color="r", + label="Actual Coordinates", + ) + plt.axis("equal") + plt.grid() + plt.xlabel("Longitude") + plt.ylabel("Latitude") + plt.title("Wind Data Coordinates") + plt.legend() + + except Exception as e: + print(f"✗ Open-Meteo download failed: {e}") + return False + + # Spatially and temporally upsample the Open-Meteo wind speed and direction data at wind + # turbine locations in a 2 x 3 array wind farm + x_turbine_locs = np.array([-2500.0, 0.0, 2500.0, -2500.0, 0.0, 2500.0]) + y_turbine_locs = np.array([-1500.0, -1500.0, -1500.0, 1500.0, 1500.0, 1500.0]) + + openmeteo_ws_data_filepath = ( + Path(data_dir) / f"openmeteo_small_example_windspeed_80m_{year}.feather" + ) + openmeteo_wd_data_filepath = ( + Path(data_dir) / f"openmeteo_small_example_winddirection_80m_{year}.feather" + ) + openmeteo_coords_filepath = Path(data_dir) / f"openmeteo_small_example_coords_{year}.feather" + + # Using downloaded Open-Meteo wind files, spatially interpolate wind speeds and directions + # and at 6 turbine locations and upsample wind speeds by adding stochastic turbulence. The + # combined upsampled dataframe will be saved in the same directory as the Open-Meteo files. + # + # The arguments turbulence_Uhub and turbulence_L are parameters used in the Kaimal + # turbulence spectrum model + # + # Turbulence intensity is assigned as a function of wind speed based on the IEC normal + # turbulence model such that a desired TI is achieved at a reference wind speed + + print("\nUpsampling raw Open-Meteo data...") + + df_upsample = upsample_wind_data( + ws_data_filepath=openmeteo_ws_data_filepath, + wd_data_filepath=openmeteo_wd_data_filepath, + coords_filepath=openmeteo_coords_filepath, + upsampled_data_dir=data_dir, + upsampled_data_filename="openmeteo_small_example_upsample_6turbines.ftr", + x_locs_upsample=x_turbine_locs, + y_locs_upsample=y_turbine_locs, + origin_lat=None, # None sets the y origin to the mean latitude in the Open-Meteo files + origin_lon=None, # None sets the x origin to the mean longitude in the Open-Meteo files + timestep_upsample=1, # Upsample from 15-minute Open-Meteo resolution to 1-second res. + turbulence_Uhub=None, # None sets turbulence_Uhub to the mean Open-Meteo wind speed + turbulence_L=340.2, # Default turbulence length scale defined in the IEC standard + TI_ref=0.1, # The desired TI corresponding to the reference wind speed TI_ws_ref + TI_ws_ref=8.0, + save_individual_wds=True, # True saved wind directions for each upsampled location + ) + + # Load raw Open-Meteo wind speeds and locations + df_openmeteo_ws = pd.read_feather(openmeteo_ws_data_filepath) + df_openmeteo_coords = pd.read_feather(openmeteo_coords_filepath) + + # Convert Open-Meteo coordinates to easting and northing locations + x_locs_openmeteo, y_locs_openmeteo, zone_number, zone_letter = utm.from_latlon( + df_openmeteo_coords["lat"].values, df_openmeteo_coords["lon"].values + ) + + origin_lat = df_openmeteo_coords["lat"].mean() + origin_lon = df_openmeteo_coords["lon"].mean() + origin_x, origin_y, origin_zone_number, origin_zone_letter = utm.from_latlon( + origin_lat, origin_lon + ) + + x_locs_openmeteo -= origin_x + y_locs_openmeteo -= origin_y + + # Plot Open-Meteo grid points and upsampled turbine locations + plt.figure() + plt.scatter(x_locs_openmeteo, y_locs_openmeteo, label="Open-Meteo points") + plt.scatter(x_turbine_locs, y_turbine_locs, color="r", label="Upsampled locations") + for i in range(len(x_turbine_locs)): + plt.text(x_turbine_locs[i] - 200.0, y_turbine_locs[i] + 175.0, i) + plt.grid() + plt.xlabel("Easting (m)") + plt.ylabel("Northing (m)") + plt.legend() + plt.axis("equal") + + # Compare the upsampled wind speed at the first turbine location to the original + # Open-Meteo wind speed at the nearest grid point for a single day (k can be 0 to 364) + k = 1 + plt.figure(figsize=(9, 5)) + plt.plot( + df_upsample["time_utc"][k * 60 * 60 * 24 : (k + 1) * 60 * 60 * 24], + df_upsample["ws_000"][k * 60 * 60 * 24 : (k + 1) * 60 * 60 * 24], + label="Upsampled wind speed at location 0", + ) + plt.plot( + df_openmeteo_ws["time_index"][k * 4 * 24 : (k + 1) * 4 * 24], + df_openmeteo_ws["9"][k * 4 * 24 : (k + 1) * 4 * 24], + label="Open-Meteo wind speed at nearest grid point", + ) + plt.grid() + plt.ylabel("Wind Speed (m/s)") + plt.legend() + + return True + + +if __name__ == "__main__": + print("Running small example with real Open-Meteo wind data...") + print("This will download a small sample and create plots.") + print("Next, the Open-Meteo data will be upsampled at 6 wind turbine locations.") + print("Examples of the upsampled and original wind speed time series will be plotted.") + print("Note: This may take several minutes due to data download times.\n") + print("Also note that the upsampled wind file is approximately 2 GB.\n") + + success = run_small_example() + + if success: + print("\n✓ Small example completed successfully!") + print("\nThe script has demonstrated:") + print(" - Real Open-Meteo wind data download (both wind speed and direction)") + print(" - Time-series plotting") + print(" - Spatial map plotting") + print(" - Data saving in feather format") + print(" - Spatial interpolation and temporal upsampling with turbulence added") + print("\nYou can now use the full script for larger datasets!") + else: + print("\n✗ Example failed. Check error messages above.") + + plt.show() diff --git a/examples/01_wind_farm_dof1_model/inputs/Cp_Ct_Cq_NREL5MW.txt b/examples/inputs/Cp_Ct_Cq_NREL5MW.txt similarity index 100% rename from examples/01_wind_farm_dof1_model/inputs/Cp_Ct_Cq_NREL5MW.txt rename to examples/inputs/Cp_Ct_Cq_NREL5MW.txt diff --git a/examples/inputs/turbine_1dof_model.yaml b/examples/inputs/turbine_1dof_model.yaml new file mode 100644 index 00000000..e8b1e802 --- /dev/null +++ b/examples/inputs/turbine_1dof_model.yaml @@ -0,0 +1,27 @@ +# Input YAML for turbine model + +# Name +name: NREL 5MW - DOF1 Model + +turbine_model_type: dof1_model # Can be filter_model or dof1_model + +dof1_model: + rotor_inertia: 38677052 # [Kg m^2] + rated_rotor_speed: 1.267 # [rad/sec] + rated_torque: 43093.55 # [N-m] + cq_table_file: ../inputs/Cp_Ct_Cq_NREL5MW.txt # Can be obtained using ccblade + + # Optional parameters + rho: 1.225 # [Kg/m^3] (Optional, default = 1.225) + filterfreq_rotor_speed: 1.5708 # [rad/sec] (Optional, default = 1.5708) + gearbox_ratio: 97 # [-] (Optional, default = 1.0) + gen_efficiency: 0.944 # [-] (Optiona, default = 1.0) + max_torque_rate: 15000 # [Nm/s] (Optional, default = infinity) + max_pitch_rate: 0.1745 # [rad/s] (Optional, default = infinity) + initial_rpm: 10 # [RPM] (Optional, default = 10) + + controller: + r2_k_torque: 2.8533 # Torque constant in region-2 + # This is higher than published value as we ignore region 2.5 and match k*omega_gen^2 = rated_torque + kp_pitch: 0.01882681 # Proportional gain of blade pitch controller + ki_pitch: 0.008068634 # Integral gain of blade pitch controller diff --git a/hercules/__init__.py b/hercules/__init__.py index 9e7c9fdb..4f9a9c6d 100644 --- a/hercules/__init__.py +++ b/hercules/__init__.py @@ -2,4 +2,5 @@ __version__ = version("hercules") +from .hercules_model import HerculesModel from .hercules_output import HerculesOutput diff --git a/hercules/emulator.py b/hercules/emulator.py deleted file mode 100644 index 2c3d807e..00000000 --- a/hercules/emulator.py +++ /dev/null @@ -1,619 +0,0 @@ -import datetime as dt -import json -import os -import sys -import time as _time -from pathlib import Path - -import h5py -import numpy as np -import pandas as pd -from tqdm import tqdm - -from hercules.utilities import hercules_float_type - -LOGFILE = str(dt.datetime.now()).replace(":", "_").replace(" ", "_").replace(".", "_") - -Path("outputs").mkdir(parents=True, exist_ok=True) - - -class Emulator: - def __init__(self, controller, hybrid_plant, h_dict, logger): - """ - Initializes the emulator. - - Args: - controller (object): The controller object responsible for managing the simulation. - hybrid_plant (object): An object containing hybrid plant components. - h_dict (dict): A dictionary contains parameters and values for the simulation. - logger (object): A logger instance for logging messages during the simulation. - - """ - - # Make sure output folder exists - Path("outputs").mkdir(parents=True, exist_ok=True) - - # Use the provided logger - self.logger = logger - - # Save the input dict to main dict - self.h_dict = h_dict - - # Initialize the flattened h_dict - self.h_dict_flat = {} - - # Save time step, start time and end time first - self.dt = h_dict["dt"] - self.starttime = h_dict["starttime"] - self.endtime = h_dict["endtime"] - - # Initialize logging configuration - self.log_every_n = h_dict.get("log_every_n", 1) - self.dt_log = self.dt * self.log_every_n - - # Initialize HDF5 output configuration - if "output_file" in h_dict: - self.output_file = h_dict["output_file"] - # Ensure .h5 extension - if not self.output_file.endswith(".h5"): - self.output_file = self.output_file.rsplit(".", 1)[0] + ".h5" - else: - self.output_file = "outputs/hercules_output.h5" - - # Initialize HDF5 output system - self.hdf5_file = None - self.hdf5_datasets = {} - self.output_structure_determined = False - self.output_written = False - self.current_row = 0 - self.total_rows_written = 0 - - # HDF5 configuration - # Enable/disable compression - self.use_compression = h_dict.get("output_use_compression", True) - - # Buffering configuration - # Buffer 10000 rows in memory (optimized default) - self.buffer_size = h_dict.get("output_buffer_size", 50000) - self.data_buffers = {} # Dictionary to hold buffered data - self.buffer_row = 0 # Current position in buffer - - # Get verbose flag from h_dict - self.verbose = h_dict.get("verbose", False) - self.total_simulation_time = self.endtime - self.starttime # In seconds - self.total_simulation_days = self.total_simulation_time / 86400 - self.time = self.starttime - - # Initialize the step - self.step = 0 - self.n_steps = int(self.total_simulation_time / self.dt) - - # How often to update the user on current emulator time - # In simulated time - if "time_log_interval" in h_dict: - self.time_log_interval = h_dict["time_log_interval"] - else: - self.time_log_interval = 600 # seconds - self.step_log_interval = self.time_log_interval / self.dt - - # Round to step_log_interval to be an integer greater than 0 - self.step_log_interval = np.max([1, np.round(self.step_log_interval)]) - - # Calculate progress bar update interval (independent of verbose logging) - # Update every 1% of completion or every 100 steps, whichever is more frequent - self.progress_update_interval = min(max(1, self.n_steps // 100), 100) - - # Initialize components - self.controller = controller - self.hybrid_plant = hybrid_plant - - # Add plant component metadata to the h_dict - self.h_dict = self.hybrid_plant.add_plant_metadata_to_h_dict(self.h_dict) - - # Save zero time and start time following add meta data - self.zero_time_utc = h_dict.get("zero_time_utc", None) - self.start_time_utc = h_dict.get("start_time_utc", None) - - # Read in any external data - self.external_data_all = {} - if "external_data_file" in h_dict: - self._read_external_data_file(h_dict["external_data_file"]) - self.h_dict["external_signals"] = {} - - def _read_external_data_file(self, filename): - """ - Read and interpolate external data from a CSV file. - - This method reads external data from the specified CSV file and interpolates it - according to the simulation time steps. The external data must include a 'time' column. - The interpolated data is stored in self.external_data_all. - Args: - filename (str): Path to the CSV file containing external data. - """ - - # Read in the external data file - df_ext = pd.read_csv(filename) - if "time" not in df_ext.columns: - raise ValueError("External data file must have a 'time' column") - - # Interpolate the external data according to time. - # Goes to 1 time step past stoptime specified in the input file. - times = np.arange( - self.starttime, - self.endtime + (2 * self.dt), - self.dt, - ) - self.external_data_all["time"] = times - for c in df_ext.columns: - if c != "time": - self.external_data_all[c] = np.interp(times, df_ext.time, df_ext[c]) - - def _initialize_hdf5_file(self): - """Initialize HDF5 file with metadata and data structure.""" - - # Create output directory if it doesn't exist - output_dir = os.path.dirname(os.path.abspath(self.output_file)) - os.makedirs(output_dir, exist_ok=True) - - # Open HDF5 file - self.hdf5_file = h5py.File(self.output_file, "w") - - # Create metadata group - metadata_group = self.hdf5_file.create_group("metadata") - - # Store h_dict as JSON string in attributes - # Use a custom serializer that handles numpy types properly - def numpy_serializer(obj): - if hasattr(obj, "tolist"): # numpy arrays - return obj.tolist() - elif hasattr(obj, "item"): # numpy scalars - return obj.item() - else: - return str(obj) - - h_dict_json = json.dumps(self.h_dict, default=numpy_serializer) - metadata_group.attrs["h_dict"] = h_dict_json - - # Store simulation info - metadata_group.attrs["starttime"] = self.starttime - metadata_group.attrs["endtime"] = self.endtime - metadata_group.attrs["dt_sim"] = self.dt - metadata_group.attrs["dt_log"] = self.dt_log - metadata_group.attrs["log_every_n"] = self.log_every_n - metadata_group.attrs["total_simulation_time"] = self.total_simulation_time - metadata_group.attrs["total_simulation_days"] = self.total_simulation_days - - # Store zero and start time UTC information if not None - if self.zero_time_utc is not None: - # Convert pandas Timestamp to Unix timestamp for HDF5 compatibility - if hasattr(self.zero_time_utc, "timestamp"): - metadata_group.attrs["zero_time_utc"] = self.zero_time_utc.timestamp() - else: - metadata_group.attrs["zero_time_utc"] = self.zero_time_utc - if self.start_time_utc is not None: - # Convert pandas Timestamp to Unix timestamp for HDF5 compatibility - if hasattr(self.start_time_utc, "timestamp"): - metadata_group.attrs["start_time_utc"] = self.start_time_utc.timestamp() - else: - metadata_group.attrs["start_time_utc"] = self.start_time_utc - - # Create data group - data_group = self.hdf5_file.create_group("data") - - # Calculate total number of rows with logging stride - total_rows = self.n_steps // self.log_every_n - if self.n_steps % self.log_every_n != 0: - total_rows += 1 - - # Set compression parameters based on configuration - if self.use_compression: - # Optimized compression with chunking for better performance - # Ensure chunk size doesn't exceed dataset size - chunk_size = min(1000, self.buffer_size, total_rows) - compression_params = { - "compression": "gzip", - "compression_opts": 6, # Higher compression level - "chunks": (chunk_size,), - } - else: - compression_params = {} - - self.hdf5_datasets["time"] = data_group.create_dataset( - "time", - shape=(total_rows,), - dtype=hercules_float_type, - **compression_params, - ) - - self.hdf5_datasets["step"] = data_group.create_dataset( - "step", - shape=(total_rows,), - dtype=np.int32, - **compression_params, - ) - - # Create plant-level datasets - self.hdf5_datasets["plant_power"] = data_group.create_dataset( - "plant_power", - shape=(total_rows,), - dtype=hercules_float_type, - **compression_params, - ) - - self.hdf5_datasets["plant_locally_generated_power"] = data_group.create_dataset( - "plant_locally_generated_power", - shape=(total_rows,), - dtype=hercules_float_type, - **compression_params, - ) - - # Create component datasets - components_group = data_group.create_group("components") - for component_name in self.hybrid_plant.component_names: - component_obj = self.hybrid_plant.component_objects[component_name] - log_outputs = getattr(component_obj, "log_outputs", ["power"]) - - for output_name in log_outputs: - if output_name in self.h_dict[component_name]: - output_value = self.h_dict[component_name][output_name] - - if isinstance(output_value, (list, np.ndarray)): - # Handle arrays by creating individual datasets - arr = np.asarray(output_value) - for i in range(len(arr)): - dataset_name = f"{component_name}.{output_name}.{i:03d}" - self.hdf5_datasets[dataset_name] = components_group.create_dataset( - dataset_name, - shape=(total_rows,), - dtype=hercules_float_type, - **compression_params, - ) - else: - # Handle scalar values - dataset_name = f"{component_name}.{output_name}" - self.hdf5_datasets[dataset_name] = components_group.create_dataset( - dataset_name, - shape=(total_rows,), - dtype=hercules_float_type, - **compression_params, - ) - - # Create external signals datasets - if "external_signals" in self.h_dict and self.h_dict["external_signals"]: - external_signals_group = data_group.create_group("external_signals") - for signal_name in self.h_dict["external_signals"].keys(): - dataset_name = f"external_signals.{signal_name}" - self.hdf5_datasets[dataset_name] = external_signals_group.create_dataset( - dataset_name, - shape=(total_rows,), - dtype=hercules_float_type, - **compression_params, - ) - - self.output_structure_determined = True - - def _save_h_dict_as_text(self): - """ - Save the main dictionary to a text file. - - This method redirects stdout to a file, prints the main dictionary, and then - restores stdout to its original state. The dictionary is saved to - 'outputs/h_dict.echo' to help with log interpretation. - """ - - # Echo the dictionary to a separate file in case it is helpful - # to see full dictionary in interpreting log - - original_stdout = sys.stdout - with open("outputs/h_dict.echo", "w") as f_i: - sys.stdout = f_i # Change the standard output to the file we created. - print(self.h_dict) - sys.stdout = original_stdout # Reset the standard output to its original value - - def enter_execution(self, function_targets=[], function_arguments=[[]]): - """ - Execute the main simulation loop and handle timing and logging. - - This method initiates the simulation execution, runs the main loop, and handles - all associated timing calculations, logging, and file operations. It ensures proper - cleanup of resources even if exceptions occur during simulation. - - Args: - function_targets (list, optional): List of functions to execute during simulation. - Defaults to empty list. - function_arguments (list of lists, optional): List of argument lists to pass to each - corresponding function in function_targets. - Defaults to a list containing an empty list. - """ - - # No need to open output file upfront with fast logging - - # Wrap this effort in a try block to ensure proper cleanup - try: - # Record start clock time for metadata - self.start_clock_time = _time.time() - - # Run the main loop - self.run() - - # Note the total elapsed time - - self.end_clock_time = _time.time() - self.total_time_wall = self.end_clock_time - self.start_clock_time - - # Update the user on time performance - self.logger.info("=====================================") - self.logger.info( - ( - "Total simulated time: ", - f"{self.total_simulation_time} seconds ({self.total_simulation_days} days)", - ) - ) - self.logger.info(f"Total wall time: {self.total_time_wall}") - self.logger.info( - ( - "Rate of simulation: ", - f"{self.total_simulation_time / self.total_time_wall:.1f}", - "x real time", - ) - ) - self.logger.info("=====================================") - - except Exception as e: - # Log the error - self.logger.error(f"Error during execution: {str(e)}", exc_info=True) - # Re-raise the exception after cleanup - raise - - finally: - # Ensure output data is written to file - self.logger.info("Finalizing HDF5 output file") - self._finalize_hdf5_file() - - def run(self): - """Run the main emulation loop until the end time is reached. - - Executes the simulation step by step, updating controller and Python - simulators, logging state, and handling external data interpolation. - Logs progress at specified intervals and saves initial state on first iteration. - """ - self.logger.info(" #### Entering main loop #### ") - - first_iteration = True - - # Create progress bar - progress_bar = tqdm( - total=self.n_steps, - desc="Simulation Progress", - unit="steps", - ncols=100, - leave=True, - mininterval=5.0, # Update at most once every 5 seconds - maxinterval=30.0, # Update at least every 30 seconds - ) - - # Cache frequently accessed attributes and methods locally for speed - controller_step = self.controller.step - plant_step = self.hybrid_plant.step - log_current_state = self._log_data_to_hdf5 - external_data_all = self.external_data_all - h_dict = self.h_dict - - # Set current time and run simulation through steps - self.time = self.starttime - last_progress_update = 0 - for self.step in range(self.n_steps): - # Log the current time - if self.verbose: - if (self.step % self.step_log_interval == 0) or first_iteration: - self.logger.info(f"Emulator time: {self.time} (ending at {self.endtime})") - self.logger.info(f"Step: {self.step} of {self.n_steps}") - self.logger.info(f"--Percent completed: {100 * self.step / self.n_steps:.2f}%") - - # Update progress bar independently of verbose logging, more frequently - if (self.step % self.progress_update_interval == 0) or first_iteration: - steps_to_update = self.step - last_progress_update - if steps_to_update > 0: - progress_bar.update(steps_to_update) - last_progress_update = self.step - - # Fast external data lookup by step index (avoids per-step array equality checks) - if external_data_all: - for k in external_data_all: - if k == "time": - continue - h_dict["external_signals"][k] = external_data_all[k][self.step] - - # Update controller and py sims - h_dict["time"] = self.time - h_dict["step"] = self.step - h_dict = controller_step(h_dict) - h_dict = plant_step(h_dict) - self.h_dict = h_dict - - # Log the current state - log_current_state() - - # If this is first iteration log the input dict - # And turn off the first iteration flag - if first_iteration: - # self.logger.info(self.h_dict) - self._save_h_dict_as_text() - first_iteration = False - - # Update the time - self.time = self.time + self.dt - - # Update progress bar to final step and close - final_steps_to_update = self.n_steps - last_progress_update - if final_steps_to_update > 0: - progress_bar.update(final_steps_to_update) - progress_bar.close() - - def _finalize_hdf5_file(self): - """Finalize HDF5 file with proper compression and metadata.""" - if self.output_written or self.hdf5_file is None: - return - - try: - # Flush any remaining buffered data - if hasattr(self, "data_buffers") and self.data_buffers and self.buffer_row > 0: - self._flush_buffer_to_hdf5() - - # Flush any remaining data - if self.hdf5_file: - self.hdf5_file.flush() - - # Add final metadata - if self.hdf5_file: - metadata_group = self.hdf5_file["metadata"] - metadata_group.attrs["total_rows_written"] = self.total_rows_written - metadata_group.attrs["hercules_version"] = "2.0" - metadata_group.attrs["start_clock_time"] = getattr( - self, "start_clock_time", _time.time() - ) - metadata_group.attrs["end_clock_time"] = getattr( - self, "end_clock_time", _time.time() - ) - metadata_group.attrs["total_time_wall"] = getattr( - self, "total_time_wall", _time.time() - ) - - if self.verbose: - file_size = os.path.getsize(self.output_file) / (1024 * 1024) # MB - self.logger.info( - f"Finalized HDF5 file: {self.output_file} " - f"({file_size:.2f} MB, {self.total_rows_written} rows)" - ) - - except Exception as e: - self.logger.error(f"Error finalizing HDF5 file: {e}") - raise - finally: - # Close HDF5 file - if self.hdf5_file: - self.hdf5_file.close() - self.hdf5_file = None - - self.output_written = True - - def __del__(self): - """Cleanup method to properly close output files when object is destroyed.""" - try: - # Only attempt cleanup if Python is not shutting down - import sys - - if sys.meta_path is not None: - self._finalize_hdf5_file() - except (ImportError, AttributeError): - # Ignore errors during Python shutdown - pass - - def close(self): - """Explicitly close all resources and cleanup.""" - self._finalize_hdf5_file() - - def _log_data_to_hdf5(self): - """ - Logs the state of the main dict to memory buffers and writes to HDF5 periodically. - - This method buffers data in memory and only writes to disk when the buffer is full, - significantly improving performance by reducing disk I/O frequency. - """ - # Initialize HDF5 file on first call - if not self.output_structure_determined: - self._initialize_hdf5_file() - - # Apply logging stride - if self.step % self.log_every_n != 0: - return - - # Initialize buffers on first call - if not self.data_buffers: - self._initialize_data_buffers() - - # Buffer basic time information - self.data_buffers["time"][self.buffer_row] = self.h_dict["time"] - self.data_buffers["step"][self.buffer_row] = self.h_dict["step"] - - # Buffer plant-level outputs - self.data_buffers["plant_power"][self.buffer_row] = self.h_dict["plant"]["power"] - self.data_buffers["plant_locally_generated_power"][self.buffer_row] = self.h_dict["plant"][ - "locally_generated_power" - ] - - # Buffer component outputs - for component_name in self.hybrid_plant.component_names: - component_obj = self.hybrid_plant.component_objects[component_name] - log_outputs = getattr(component_obj, "log_outputs", ["power"]) - - for output_name in log_outputs: - if output_name in self.h_dict[component_name]: - output_value = self.h_dict[component_name][output_name] - - if isinstance(output_value, (list, np.ndarray)): - # Handle arrays by buffering to individual datasets - arr = np.asarray(output_value) - for i in range(len(arr)): - dataset_name = f"{component_name}.{output_name}.{i:03d}" - if dataset_name in self.data_buffers: - self.data_buffers[dataset_name][self.buffer_row] = arr[i] - else: - # Handle scalar values - dataset_name = f"{component_name}.{output_name}" - if dataset_name in self.data_buffers: - self.data_buffers[dataset_name][self.buffer_row] = output_value - - # Buffer external signals - if "external_signals" in self.h_dict and self.h_dict["external_signals"]: - for signal_name, signal_value in self.h_dict["external_signals"].items(): - dataset_name = f"external_signals.{signal_name}" - if dataset_name in self.data_buffers: - self.data_buffers[dataset_name][self.buffer_row] = signal_value - - # Increment buffer row counter - self.buffer_row += 1 - self.total_rows_written += 1 - - # Write buffer to disk when full - if self.buffer_row >= self.buffer_size: - self._flush_buffer_to_hdf5() - - def _initialize_data_buffers(self): - """Initialize memory buffers for all datasets.""" - for dataset_name in self.hdf5_datasets.keys(): - if dataset_name == "step": - # Integer buffer for step - self.data_buffers[dataset_name] = np.zeros(self.buffer_size, dtype=np.int32) - else: - # Float buffer for everything else - self.data_buffers[dataset_name] = np.zeros( - self.buffer_size, dtype=hercules_float_type - ) - - def _flush_buffer_to_hdf5(self): - """Write buffered data to HDF5 datasets and reset buffer.""" - if self.buffer_row == 0: - return # Nothing to flush - - # Calculate the range to write - start_row = self.current_row - end_row = start_row + self.buffer_row - - # Pre-filter valid datasets to avoid redundant lookups - valid_datasets = { - name: buffer_data - for name, buffer_data in self.data_buffers.items() - if name in self.hdf5_datasets - } - - # Write all buffered data at once (optimized) - for dataset_name, buffer_data in valid_datasets.items(): - # Use direct slice assignment without creating intermediate views - self.hdf5_datasets[dataset_name][start_row:end_row] = buffer_data[: self.buffer_row] - - # Update current row position - self.current_row = end_row - - # Reset buffer - self.buffer_row = 0 diff --git a/hercules/grid/grid_utilities.py b/hercules/grid/grid_utilities.py new file mode 100644 index 00000000..6efe9ab4 --- /dev/null +++ b/hercules/grid/grid_utilities.py @@ -0,0 +1,85 @@ +import pandas as pd + + +def generate_locational_marginal_price_dataframe_from_gridstatus( + df_day_ahead_lmp: pd.DataFrame, + df_real_time_lmp: pd.DataFrame, + day_ahead_market_name: str = "DAY_AHEAD_HOURLY", + real_time_market_name: str = "REAL_TIME_5_MIN", +): + """ + Create a dataframe containing the day ahead price forecast and the real time price + at real-time price intervals. + + Input dataframes must contain the following columns: + interval_start_utc (UTC time for the row) + market (REAL_TIME_5_MIN, DAY_AHEAD_HOURLY, etc) + lmp (price of the market for that interval) + + The RT dataframe is assumed to have minute time resolution, while the DA dataframe + is assumed to have hourly time resolution. + + Returns a dataframe with the RT LMP and DA LMP at the base intervals, along with + the DA LMP for each future hour over the next 24 hours in separate columns. For use as external + data in Hercules. + + Args: + df_day_ahead_lmp (pd.DataFrame): DataFrame with day ahead data + df_real_time_lmp (pd.DataFrame): DataFrame with real time data + day_ahead_market_name (str): Market name for day ahead data + real_time_market_name (str): Market name for real time data + + Returns: + pd.DataFrame: DataFrame with columns + "time_utc", "lmp_rt", "lmp_da", "lmp_da_00", ..., "lmp_da_23" + """ + # Check correct market on each + if df_day_ahead_lmp["market"].unique() != [day_ahead_market_name]: + raise ValueError(f"df_day_ahead_lmp must only contain {day_ahead_market_name} market data.") + if df_real_time_lmp["market"].unique() != [real_time_market_name]: + raise ValueError(f"df_real_time_lmp must only contain {real_time_market_name} market data.") + + # Trim and rename + df_da = df_day_ahead_lmp[["interval_start_utc", "lmp"]].rename( + columns={"interval_start_utc": "time_utc", "lmp": "lmp_da"} + ) + df_rt = df_real_time_lmp[["interval_start_utc", "lmp"]].rename( + columns={"interval_start_utc": "time_utc", "lmp": "lmp_rt"} + ) + + # Ensure datetime format + df_da["time_utc"] = pd.to_datetime(df_da["time_utc"]) + df_rt["time_utc"] = pd.to_datetime(df_rt["time_utc"]) + + # Check that there is an overlap between time ranges + if max(df_da["time_utc"].min(), df_rt["time_utc"].min()) >= min( + df_da["time_utc"].max(), df_rt["time_utc"].max() + ): + raise ValueError( + f"No time overlap between day-ahead and real-time data.\n" + f"Day-ahead range: {df_da.time_utc.min()} to {df_da.time_utc.max()}.\n" + f"Real-time range: {df_rt.time_utc.min()} to {df_rt.time_utc.max()}." + ) + + # Merge on time_utc + df = pd.merge(df_da, df_rt, on="time_utc", how="outer").ffill() + + # Get time step for merged data + dt = (df["time_utc"].iloc[1] - df["time_utc"].iloc[0]).total_seconds() + + # Create 24 rolling hourly columns (forward-looking) + periods_per_hour = 3600 / dt + if not periods_per_hour.is_integer(): + raise ValueError(f"Data time step of {dt} seconds is not compatible with hourly periods.") + periods_per_hour = int(periods_per_hour) + + for h in range(24): + h_shift = -h * periods_per_hour + df[f"lmp_da_{h:02d}"] = df["lmp_da"].shift(h_shift) + + # Add rows representing the end of each interval for step-like interpolation + df_2 = df.copy(deep=True) + df_2["time_utc"] = df_2["time_utc"] + pd.Timedelta(seconds=dt - 1) + df = pd.merge(df, df_2, how="outer").sort_values("time_utc").reset_index(drop=True) + + return df diff --git a/hercules/hercules_model.py b/hercules/hercules_model.py new file mode 100644 index 00000000..dcb0450f --- /dev/null +++ b/hercules/hercules_model.py @@ -0,0 +1,772 @@ +import datetime as dt +import json +import os +import sys +import time as _time +from pathlib import Path + +import h5py +import numpy as np +import pandas as pd +from tqdm import tqdm + +from hercules.hybrid_plant import HybridPlant +from hercules.utilities import ( + close_logging, + hercules_float_type, + interpolate_df, + load_hercules_input, + setup_logging, +) + +LOGFILE = str(dt.datetime.now()).replace(":", "_").replace(" ", "_").replace(".", "_") + +Path("outputs").mkdir(parents=True, exist_ok=True) + + +class HerculesModel: + def __init__(self, input_file): + """ + Initializes the HerculesModel. + + Args: + input_file (Union[str, dict]): Path to Hercules input YAML file or dictionary + containing input configuration. + + """ + + # Make sure output folder exists + Path("outputs").mkdir(parents=True, exist_ok=True) + + # Set up logging + self.logger = self._setup_logging() + + # Load and validate the input file + h_dict = self._load_hercules_input(input_file) + + # Initialize the flattened h_dict + self.h_dict_flat = {} + + # Save time step, start time and end time first + self.dt = h_dict["dt"] + self.starttime = h_dict["starttime"] # Always 0, computed from UTC + self.endtime = h_dict["endtime"] # Duration in seconds, computed from UTC + + # Save UTC timestamps + self.starttime_utc = h_dict["starttime_utc"] + self.endtime_utc = h_dict["endtime_utc"] + + # Initialize logging configuration + self.log_every_n = h_dict.get("log_every_n", 1) + self.dt_log = self.dt * self.log_every_n + + # Initialize the hybrid plant + self.hybrid_plant = HybridPlant(h_dict) + + # Add plant component metadata to h_dict + self.h_dict = self.hybrid_plant.add_plant_metadata_to_h_dict(h_dict) + + # Initialize the controller as None, to be assigned in a subsequent call + self._controller = None + + # Read in any external data + self.external_signals_all = {} + self.external_data_log_channels = None + self.h_dict["external_signals"] = {} + if "external_data" in self.h_dict: + self._read_external_data_file(self.h_dict["external_data"]["external_data_file"]) + self.external_data_log_channels = self.h_dict["external_data"]["log_channels"] + + # Initialize HDF5 output configuration + if "output_file" in self.h_dict: + self.output_file = self.h_dict["output_file"] + # Ensure .h5 extension + if not self.output_file.endswith(".h5"): + self.output_file = self.output_file.rsplit(".", 1)[0] + ".h5" + else: + self.output_file = "outputs/hercules_output.h5" + + # Initialize HDF5 output system + self.hdf5_file = None + self.hdf5_datasets = {} + self.output_structure_determined = False + self.output_written = False + self.current_row = 0 + self.total_rows_written = 0 + + # HDF5 configuration + # Enable/disable compression + self.use_compression = self.h_dict.get("output_use_compression", True) + + # Buffering configuration + # Buffer 10000 rows in memory (optimized default) + self.buffer_size = self.h_dict.get("output_buffer_size", 50000) + self.data_buffers = {} # Dictionary to hold buffered data + self.buffer_row = 0 # Current position in buffer + + # Get verbose flag from h_dict + self.verbose = self.h_dict.get("verbose", False) + self.total_simulation_time = self.endtime - self.starttime # In seconds + self.total_simulation_days = self.total_simulation_time / 86400 + self.time = self.starttime + + # Initialize the step + self.step = 0 + self.n_steps = int(self.total_simulation_time / self.dt) + + # Set progress_update_interval to log 100 times per simulation + self.progress_update_interval = self.n_steps / 100 + + # Round progress_update_interval to be an integer greater than 0 + self.progress_update_interval = np.max([1, np.round(self.progress_update_interval)]) + + # Save start time UTC (zero_time_utc is redundant since time=0 corresponds to starttime_utc) + # starttime_utc is required and should already be set, but ensure it's still present + self.starttime_utc = self.h_dict["starttime_utc"] + + def _setup_logging(self, logfile="log_hercules.log", console_output=True): + """Set up logging to file and console. + + Creates 'outputs' directory and configures file/console logging with timestamps. + This method wraps the utilities.setup_logging function for backward compatibility. + + Args: + logfile (str, optional): Log file name. Defaults to "log_hercules.log". + console_output (bool, optional): Enable console output. Defaults to True. + + Returns: + logging.Logger: Configured logger instance. + """ + return setup_logging( + logger_name="hercules", + log_file=logfile, + console_output=console_output, + console_prefix="HERCULES", + ) + + def _load_hercules_input(self, filename): + """Load and validate Hercules input file. + + Loads YAML file and validates input structure, required keys, and data types. + + Args: + filename (Union[str, dict]): Path to Hercules input YAML file or dictionary. + + Returns: + dict: Validated Hercules input configuration with computed starttime/endtime. + + Raises: + ValueError: If required keys missing, invalid data types, or incorrect structure. + """ + h_dict = load_hercules_input(filename) + + # Add in starttime and endttime as needed for Hercules simulation + h_dict["starttime"] = 0.0 + h_dict["endtime"] = ( + h_dict["endtime_utc"] - h_dict["starttime_utc"] + ).total_seconds() + float(h_dict["dt"]) + + return h_dict + + def _read_external_data_file(self, filename): + """ + Read and interpolate external data from a CSV, feather, or pickle file. + + This method reads external data from the specified file (CSV, feather, or pickle) + and interpolates it according to the simulation time steps. The external data must + include a 'time_utc' column which will be converted to simulation time. + The interpolated data is stored in self.external_signals_all. + + Args: + filename (str): Path to the file containing external data. Supported formats: + - CSV files (.csv) + - Feather files (.feather) + - Pickle files (.pkl, .pickle) + """ + + # Determine file format from extension + filename_lower = filename.lower() + if filename_lower.endswith(".csv"): + df_ext = pd.read_csv(filename) + elif filename_lower.endswith((".feather", ".ftr")): + df_ext = pd.read_feather(filename) + elif filename_lower.endswith((".pickle", ".p", ".pkl")): + df_ext = pd.read_pickle(filename) + else: + raise ValueError( + f"Unsupported file format for '{filename}'. " + "Supported formats: CSV (.csv), Feather (.ftr, .f, .feather), " + "Pickle (.p, .pkl, .pickle)" + ) + if "time_utc" not in df_ext.columns: + raise ValueError("External data file must have a 'time_utc' column") + + # Convert time_utc to pandas datetime and then to simulation time + df_ext["time_utc"] = pd.to_datetime(df_ext["time_utc"], utc=True) + starttime_utc = pd.to_datetime(self.starttime_utc, utc=True) + df_ext["time"] = (df_ext["time_utc"] - starttime_utc).dt.total_seconds() + + # Create simulation time array + # Goes to 1 time step past stoptime specified in the input file. + new_times = np.arange( + self.starttime, + self.endtime + (2 * self.dt), + self.dt, + ) + + # Interpolate using the utility function + df_interpolated = interpolate_df(df_ext, new_times) + + # Convert interpolated DataFrame to dictionary format + for col in df_interpolated.columns: + self.external_signals_all[col] = df_interpolated[col].values + + def _initialize_hdf5_file(self): + """Initialize HDF5 file with metadata and data structure.""" + + # Create output directory if it doesn't exist + output_dir = os.path.dirname(os.path.abspath(self.output_file)) + os.makedirs(output_dir, exist_ok=True) + + # Open HDF5 file + self.hdf5_file = h5py.File(self.output_file, "w") + + # Create metadata group + metadata_group = self.hdf5_file.create_group("metadata") + + # Store h_dict as JSON string in attributes + # Use a custom serializer that handles numpy types properly + def numpy_serializer(obj): + if hasattr(obj, "tolist"): # numpy arrays + return obj.tolist() + elif hasattr(obj, "item"): # numpy scalars + return obj.item() + else: + return str(obj) + + h_dict_json = json.dumps(self.h_dict, default=numpy_serializer) + metadata_group.attrs["h_dict"] = h_dict_json + + # Store simulation info + metadata_group.attrs["starttime"] = self.starttime + metadata_group.attrs["endtime"] = self.endtime + metadata_group.attrs["dt_sim"] = self.dt + metadata_group.attrs["dt_log"] = self.dt_log + metadata_group.attrs["log_every_n"] = self.log_every_n + metadata_group.attrs["total_simulation_time"] = self.total_simulation_time + metadata_group.attrs["total_simulation_days"] = self.total_simulation_days + + # Store start time UTC information (required) + # Convert pandas Timestamp to Unix timestamp for HDF5 compatibility + if hasattr(self.starttime_utc, "timestamp"): + metadata_group.attrs["starttime_utc"] = self.starttime_utc.timestamp() + else: + metadata_group.attrs["starttime_utc"] = self.starttime_utc + + # Create data group + data_group = self.hdf5_file.create_group("data") + + # Calculate total number of rows with logging stride + total_rows = self.n_steps // self.log_every_n + if self.n_steps % self.log_every_n != 0: + total_rows += 1 + + # Set compression parameters based on configuration + if self.use_compression: + # Optimized compression with chunking for better performance + # Ensure chunk size doesn't exceed dataset size + chunk_size = min(1000, self.buffer_size, total_rows) + compression_params = { + "compression": "gzip", + "compression_opts": 6, # Higher compression level + "chunks": (chunk_size,), + } + else: + compression_params = {} + + self.hdf5_datasets["time"] = data_group.create_dataset( + "time", + shape=(total_rows,), + dtype=hercules_float_type, + **compression_params, + ) + + self.hdf5_datasets["step"] = data_group.create_dataset( + "step", + shape=(total_rows,), + dtype=np.int32, + **compression_params, + ) + + # Create plant-level datasets + self.hdf5_datasets["plant_power"] = data_group.create_dataset( + "plant_power", + shape=(total_rows,), + dtype=hercules_float_type, + **compression_params, + ) + + self.hdf5_datasets["plant_locally_generated_power"] = data_group.create_dataset( + "plant_locally_generated_power", + shape=(total_rows,), + dtype=hercules_float_type, + **compression_params, + ) + + # Create component datasets + components_group = data_group.create_group("components") + for component_name in self.hybrid_plant.component_names: + component_obj = self.hybrid_plant.component_objects[component_name] + + for c in component_obj.log_channels: + # First check if channel name ends with a 3-digit number after a period + if len(c) >= 4 and c[-4] == "." and c[-3:].isdigit(): + # In this case, we want a single index from within an array output + # For example, wind_farm.turbine_powers.000 + # We want to create a dataset for this index + index = int(c[-3:]) + channel_name = c[:-4] + channel_obj = self.h_dict[component_name][channel_name] + if isinstance(channel_obj, (list, np.ndarray)): + if index < len(channel_obj): + dataset_name = f"{component_name}.{channel_name}.{index:03d}" + self.hdf5_datasets[dataset_name] = components_group.create_dataset( + dataset_name, + shape=(total_rows,), + dtype=hercules_float_type, + **compression_params, + ) + else: + raise ValueError( + ( + f"Index {index} is out of range for {channel_name} " + f"in {component_name}" + ) + ) + else: + raise ValueError( + f"Channel {channel_name} is not an array in {component_name}" + ) + + else: + # In this case, either the value is a scalar, or we want to log the entire array + if c in self.h_dict[component_name]: + output_value = self.h_dict[component_name][c] + + if isinstance(output_value, (list, np.ndarray)): + # Handle arrays by creating individual datasets + arr = np.asarray(output_value) + for i in range(len(arr)): + dataset_name = f"{component_name}.{c}.{i:03d}" + self.hdf5_datasets[dataset_name] = components_group.create_dataset( + dataset_name, + shape=(total_rows,), + dtype=hercules_float_type, + **compression_params, + ) + else: + # Handle scalar values + dataset_name = f"{component_name}.{c}" + self.hdf5_datasets[dataset_name] = components_group.create_dataset( + dataset_name, + shape=(total_rows,), + dtype=hercules_float_type, + **compression_params, + ) + else: + raise ValueError(f"Output {c} not found in {component_name}") + + # Create external signals datasets + if "external_signals" in self.h_dict and self.h_dict["external_signals"]: + external_signals_group = data_group.create_group("external_signals") + for signal_name in self.h_dict["external_signals"].keys(): + # Only create dataset if signal should be logged + should_log = ( + self.external_data_log_channels is None + or signal_name in self.external_data_log_channels + ) + if should_log: + dataset_name = f"external_signals.{signal_name}" + self.hdf5_datasets[dataset_name] = external_signals_group.create_dataset( + dataset_name, + shape=(total_rows,), + dtype=hercules_float_type, + **compression_params, + ) + + self.output_structure_determined = True + + def _save_h_dict_as_text(self): + """ + Save the main dictionary to a text file. + + This method redirects stdout to a file, prints the main dictionary, and then + restores stdout to its original state. The dictionary is saved to + 'outputs/h_dict.echo' to help with log interpretation. + """ + + # Echo the dictionary to a separate file in case it is helpful + # to see full dictionary in interpreting log + + original_stdout = sys.stdout + with open("outputs/h_dict.echo", "w") as f_i: + sys.stdout = f_i # Change the standard output to the file we created. + print(self.h_dict) + sys.stdout = original_stdout # Reset the standard output to its original value + + def assign_controller(self, controller): + """ + Assign a controller instance to the HerculesModel. + + This method allows setting controller instance used in the simulation. + It is useful when the controller needs to be initialized separately or changed after + the HerculesModel has been created. + + Alternatively, the controller can be set directly using HerculesModel.controller = ... + + Args: + controller (object): An instance of the controller to be used in the simulation. + """ + if not hasattr(controller, "step"): + raise ValueError( + "Assigned controller does not have a 'step' method. ", + "Ensure the controller is properly implemented.", + ) + self._controller = controller + + @property + def controller(self): + """Get the assigned controller instance. + + Returns: + object: The controller instance assigned to the HerculesModel. + """ + return self._controller + + def run(self): + """ + Execute the main simulation loop and handle timing and logging. + + This method runs the complete simulation from start to end, including timing calculations, + progress logging, and resource cleanup. It executes the simulation step by step, updating + controller and Python simulators, logging state, and handling external data interpolation. + Ensures proper cleanup of resources even if exceptions occur during simulation. + """ + + # Check that a valid controller has been assigned + if self._controller is None: + raise ValueError( + "No valid controller assigned to HerculesModel. ", + "Call assign_controller() before running the simulation.", + ) + + # Wrap this effort in a try block to ensure proper cleanup + try: + # Record start clock time for metadata + self.start_clock_time = _time.time() + + # Begin the main simulation loop + self.logger.info(" #### Entering main loop #### ") + + first_iteration = True + + # Create progress bar + progress_bar = tqdm( + total=self.n_steps, + desc="Simulation Progress", + unit="steps", + ncols=100, + leave=True, + mininterval=5.0, # Update at most once every 5 seconds + maxinterval=30.0, # Update at least every 30 seconds + ) + + # Cache frequently accessed attributes and methods locally for speed + controller_step = self.controller.step + plant_step = self.hybrid_plant.step + log_current_state = self._log_data_to_hdf5 + external_signals_all = self.external_signals_all + h_dict = self.h_dict + + # Set current time and run simulation through steps + self.time = self.starttime + last_progress_update = 0 + for self.step in range(self.n_steps): + # Log the current time + if self.verbose: + if (self.step % self.progress_update_interval == 0) or first_iteration: + self.logger.info(f"Simulation time: {self.time} (ending at {self.endtime})") + self.logger.info(f"Step: {self.step} of {self.n_steps}") + percent_complete = 100 * self.step / self.n_steps + self.logger.info(f"--Percent completed: {percent_complete:.2f}%") + + # Update progress bar independently of verbose logging, more frequently + if (self.step % self.progress_update_interval == 0) or first_iteration: + steps_to_update = self.step - last_progress_update + if steps_to_update > 0: + progress_bar.update(steps_to_update) + last_progress_update = self.step + + # Fast external data lookup by step index (avoids per-step array equality checks) + if external_signals_all: + for k in external_signals_all: + if k == "time": + continue + h_dict["external_signals"][k] = external_signals_all[k][self.step] + + # Update controller and py sims + h_dict["time"] = self.time + h_dict["step"] = self.step + h_dict = controller_step(h_dict) + h_dict = plant_step(h_dict) + self.h_dict = h_dict + + # Log the current state + log_current_state() + + # If this is first iteration log the input dict + # And turn off the first iteration flag + if first_iteration: + # self.logger.info(self.h_dict) + self._save_h_dict_as_text() + first_iteration = False + + # Update the time + self.time = self.time + self.dt + + # Update progress bar to final step and close + final_steps_to_update = self.n_steps - last_progress_update + if final_steps_to_update > 0: + progress_bar.update(final_steps_to_update) + progress_bar.close() + + # Note the total elapsed time + self.end_clock_time = _time.time() + self.total_time_wall = self.end_clock_time - self.start_clock_time + + # Update the user on time performance + self.logger.info("=====================================") + self.logger.info( + ( + "Total simulated time: ", + f"{self.total_simulation_time} seconds ({self.total_simulation_days} days)", + ) + ) + self.logger.info(f"Total wall time: {self.total_time_wall}") + self.logger.info( + ( + "Rate of simulation: ", + f"{self.total_simulation_time / self.total_time_wall:.1f}", + "x real time", + ) + ) + self.logger.info("=====================================") + + except Exception as e: + # Log the error + self.logger.error(f"Error during execution: {str(e)}", exc_info=True) + # Re-raise the exception after cleanup + raise + + finally: + # Ensure output data is written to file + self.logger.info("Finalizing HDF5 output file") + self._finalize_hdf5_file() + + def _finalize_hdf5_file(self): + """Finalize HDF5 file with proper compression and metadata.""" + if self.output_written or self.hdf5_file is None: + return + + try: + # Flush any remaining buffered data + if hasattr(self, "data_buffers") and self.data_buffers and self.buffer_row > 0: + self._flush_buffer_to_hdf5() + + # Flush any remaining data + if self.hdf5_file: + self.hdf5_file.flush() + + # Add final metadata + if self.hdf5_file: + metadata_group = self.hdf5_file["metadata"] + metadata_group.attrs["total_rows_written"] = self.total_rows_written + metadata_group.attrs["hercules_version"] = "2.0" + metadata_group.attrs["start_clock_time"] = getattr( + self, "start_clock_time", _time.time() + ) + metadata_group.attrs["end_clock_time"] = getattr( + self, "end_clock_time", _time.time() + ) + metadata_group.attrs["total_time_wall"] = getattr( + self, "total_time_wall", _time.time() + ) + + if self.verbose: + file_size = os.path.getsize(self.output_file) / (1024 * 1024) # MB + self.logger.info( + f"Finalized HDF5 file: {self.output_file} " + f"({file_size:.2f} MB, {self.total_rows_written} rows)" + ) + + except Exception as e: + self.logger.error(f"Error finalizing HDF5 file: {e}") + raise + finally: + # Close HDF5 file + if self.hdf5_file: + self.hdf5_file.close() + self.hdf5_file = None + + self.output_written = True + + def __del__(self): + """Cleanup method to properly close output files and logging when destroyed.""" + try: + # Only attempt cleanup if Python is not shutting down + import sys + + if sys.meta_path is not None: + self._finalize_hdf5_file() + if hasattr(self, "logger"): + close_logging(self.logger) + except (ImportError, AttributeError): + # Ignore errors during Python shutdown + pass + + def close(self): + """Explicitly close all resources and cleanup.""" + self._finalize_hdf5_file() + if hasattr(self, "logger"): + close_logging(self.logger) + + def _log_data_to_hdf5(self): + """ + Logs the state of the main dict to memory buffers and writes to HDF5 periodically. + + This method buffers data in memory and only writes to disk when the buffer is full, + significantly improving performance by reducing disk I/O frequency. + """ + # Initialize HDF5 file on first call + if not self.output_structure_determined: + self._initialize_hdf5_file() + + # Apply logging stride + if self.step % self.log_every_n != 0: + return + + # Initialize buffers on first call + if not self.data_buffers: + self._initialize_data_buffers() + + # Buffer basic time information + self.data_buffers["time"][self.buffer_row] = self.h_dict["time"] + self.data_buffers["step"][self.buffer_row] = self.h_dict["step"] + + # Buffer plant-level outputs + self.data_buffers["plant_power"][self.buffer_row] = self.h_dict["plant"]["power"] + self.data_buffers["plant_locally_generated_power"][self.buffer_row] = self.h_dict["plant"][ + "locally_generated_power" + ] + + # Buffer component outputs + for component_name in self.hybrid_plant.component_names: + component_obj = self.hybrid_plant.component_objects[component_name] + + for c in component_obj.log_channels: + # First check if channel ends in with a 3-digit number after a period + if len(c) >= 4 and c[-4] == "." and c[-3:].isdigit(): + # In this case, we want a single index from within an array output + # For example, wind_farm.turbine_powers.000 + # We want to create a dataset for this index + index = int(c[-3:]) + channel_name = c[:-4] + channel_obj = self.h_dict[component_name][channel_name] + if isinstance(channel_obj, (list, np.ndarray)): + if index < len(channel_obj): + dataset_name = f"{component_name}.{channel_name}.{index:03d}" + if dataset_name in self.data_buffers: + self.data_buffers[dataset_name][self.buffer_row] = channel_obj[ + index + ] + else: + raise ValueError( + f"Channel {channel_name} is not an array in {component_name}" + ) + else: + # In this case, either the value is a scalar, or we want to log the entire array + if c in self.h_dict[component_name]: + output_value = self.h_dict[component_name][c] + + if isinstance(output_value, (list, np.ndarray)): + # Handle arrays by buffering to individual datasets + arr = np.asarray(output_value) + for i in range(len(arr)): + dataset_name = f"{component_name}.{c}.{i:03d}" + if dataset_name in self.data_buffers: + self.data_buffers[dataset_name][self.buffer_row] = arr[i] + else: + # Handle scalar values + dataset_name = f"{component_name}.{c}" + if dataset_name in self.data_buffers: + self.data_buffers[dataset_name][self.buffer_row] = output_value + + # Buffer external signals (only those specified in log_channels) + if "external_signals" in self.h_dict and self.h_dict["external_signals"]: + for signal_name, signal_value in self.h_dict["external_signals"].items(): + # Only buffer if signal should be logged + should_log = ( + self.external_data_log_channels is None + or signal_name in self.external_data_log_channels + ) + if should_log: + dataset_name = f"external_signals.{signal_name}" + if dataset_name in self.data_buffers: + self.data_buffers[dataset_name][self.buffer_row] = signal_value + + # Increment buffer row counter + self.buffer_row += 1 + self.total_rows_written += 1 + + # Write buffer to disk when full + if self.buffer_row >= self.buffer_size: + self._flush_buffer_to_hdf5() + + def _initialize_data_buffers(self): + """Initialize memory buffers for all datasets.""" + for dataset_name in self.hdf5_datasets.keys(): + if dataset_name == "step": + # Integer buffer for step + self.data_buffers[dataset_name] = np.zeros(self.buffer_size, dtype=np.int32) + else: + # Float buffer for everything else + self.data_buffers[dataset_name] = np.zeros( + self.buffer_size, dtype=hercules_float_type + ) + + def _flush_buffer_to_hdf5(self): + """Write buffered data to HDF5 datasets and reset buffer.""" + if self.buffer_row == 0: + return # Nothing to flush + + # Calculate the range to write + start_row = self.current_row + end_row = start_row + self.buffer_row + + # Pre-filter valid datasets to avoid redundant lookups + valid_datasets = { + name: buffer_data + for name, buffer_data in self.data_buffers.items() + if name in self.hdf5_datasets + } + + # Write all buffered data at once (optimized) + for dataset_name, buffer_data in valid_datasets.items(): + # Use direct slice assignment without creating intermediate views + self.hdf5_datasets[dataset_name][start_row:end_row] = buffer_data[: self.buffer_row] + + # Update current row position + self.current_row = end_row + + # Reset buffer + self.buffer_row = 0 diff --git a/hercules/hercules_output.py b/hercules/hercules_output.py index 7770490a..7f8bf98e 100644 --- a/hercules/hercules_output.py +++ b/hercules/hercules_output.py @@ -80,18 +80,21 @@ def print_metadata(self): # UTC time information print("UTC Time----") - zero_time_utc = self.metadata.get("zero_time_utc") - if zero_time_utc is not None: - zero_time_utc = pd.to_datetime(zero_time_utc, unit="s", utc=True) - print(f" Zero Time (UTC): {zero_time_utc}") - else: - print(" Zero Time (UTC): Not available") - start_time_utc = self.metadata.get("start_time_utc") - if start_time_utc is not None: - start_time_utc = pd.to_datetime(start_time_utc, unit="s", utc=True) - print(f" Start Time (UTC): {start_time_utc}") + + # Backward compatibility: Try to read starttime_utc (new format) + # or fall back to zero_time_utc (deprecated, from old output files) + starttime_utc = self.metadata.get("starttime_utc") + if starttime_utc is not None: + starttime_utc = pd.to_datetime(starttime_utc, unit="s", utc=True) + print(f" Start Time (UTC): {starttime_utc}") else: - print(" Start Time (UTC): Not available") + # Backward compatibility: Check for deprecated zero_time_utc + zero_time_utc = self.metadata.get("zero_time_utc") + if zero_time_utc is not None: + zero_time_utc = pd.to_datetime(zero_time_utc, unit="s", utc=True) + print(f" Start Time (UTC): {zero_time_utc} (from deprecated zero_time_utc)") + else: + print(" Start Time (UTC): Not available") # Check if time_utc column exists in the data if "time_utc" in self.df.columns: @@ -114,11 +117,9 @@ def print_metadata(self): else: print(" Elapsed Calendar Time: Not available") else: - print(" Zero Time (UTC): Not available") - print(" Start Time (UTC): Not available") - print(" First Time (UTC): Not available") - print(" Last Time (UTC): Not available") - print(" Elapsed Calendar Time: Not available") + print(" First Time (UTC): Not available (no time_utc column in data)") + print(" Last Time (UTC): Not available (no time_utc column in data)") + print(" Elapsed Calendar Time: Not available (no time_utc column in data)") print("Model Setup----") diff --git a/hercules/hybrid_plant.py b/hercules/hybrid_plant.py index 2568a3ab..c80fb8f8 100644 --- a/hercules/hybrid_plant.py +++ b/hercules/hybrid_plant.py @@ -4,13 +4,13 @@ from hercules.plant_components.battery_simple import BatterySimple from hercules.plant_components.electrolyzer_plant import ElectrolyzerPlant from hercules.plant_components.solar_pysam_pvwatts import SolarPySAMPVWatts -from hercules.plant_components.wind_meso_to_power import Wind_MesoToPower -from hercules.plant_components.wind_meso_to_power_precom_floris import Wind_MesoToPowerPrecomFloris +from hercules.plant_components.wind_farm import WindFarm +from hercules.plant_components.wind_farm_scada_power import WindFarmSCADAPower from hercules.utilities import get_available_component_names, get_available_generator_names class HybridPlant: - """Manages hybrid plant components for the Hercules emulator. + """Manages hybrid plant components for Hercules. This class handles the initialization, execution, and coordination of various plant components including wind farms, solar panels, batteries, @@ -77,39 +77,6 @@ def add_plant_metadata_to_h_dict(self, h_dict): h_dict ) - # If any components include time_utc fields, confirm that all components - # have the same values and add them to the h_dict at top level - for time_field in ["zero_time_utc", "start_time_utc"]: - time_value = None - components_with_field = [] - - # Find all components that have this time field - for component_name in self.component_names: - if time_field in h_dict[component_name]: - components_with_field.append(component_name) - current_time = h_dict[component_name][time_field] - - if time_value is None: - time_value = current_time - else: - # Normalize both times for comparison - # Convert timezone-naive to UTC if needed, keep timezone-aware as is - normalized_time_value = ( - time_value.replace(tzinfo=None) - if hasattr(time_value, "tzinfo") and time_value.tzinfo is not None - else time_value - ) - normalized_current_time = ( - current_time.replace(tzinfo=None) - if hasattr(current_time, "tzinfo") and current_time.tzinfo is not None - else current_time - ) - - if normalized_current_time != normalized_time_value: - raise ValueError(f"All components must have the same {time_field}") - - h_dict[time_field] = time_value - # Add the plant level outputs to the h_dict h_dict = self.compute_plant_level_outputs(h_dict) @@ -128,25 +95,28 @@ def get_plant_component(self, component_name, h_dict): Raises: Exception: If the component_type is not recognized. """ - if h_dict[component_name]["component_type"] == "Wind_MesoToPower": - return Wind_MesoToPower(h_dict) + component_type = h_dict[component_name]["component_type"] + + # Handle wind farm component types with unified WindFarm class + if component_type == "WindFarm": + return WindFarm(h_dict) - if h_dict[component_name]["component_type"] == "Wind_MesoToPowerPrecomFloris": - return Wind_MesoToPowerPrecomFloris(h_dict) + if component_type == "WindFarmSCADAPower": + return WindFarmSCADAPower(h_dict) - if h_dict[component_name]["component_type"] == "SolarPySAMPVWatts": + if component_type == "SolarPySAMPVWatts": return SolarPySAMPVWatts(h_dict) - if h_dict[component_name]["component_type"] == "BatteryLithiumIon": + if component_type == "BatteryLithiumIon": return BatteryLithiumIon(h_dict) - if h_dict[component_name]["component_type"] == "BatterySimple": + if component_type == "BatterySimple": return BatterySimple(h_dict) - if h_dict[component_name]["component_type"] == "ElectrolyzerPlant": + if component_type == "ElectrolyzerPlant": return ElectrolyzerPlant(h_dict) - raise Exception("Unknown component_type: ", h_dict[component_name]["component_type"]) + raise Exception("Unknown component_type: ", component_type) def step(self, h_dict): """Execute one simulation step for all plant components. diff --git a/hercules/plant_components/battery_lithium_ion.py b/hercules/plant_components/battery_lithium_ion.py index 35ef1fe2..5812e108 100644 --- a/hercules/plant_components/battery_lithium_ion.py +++ b/hercules/plant_components/battery_lithium_ion.py @@ -113,10 +113,6 @@ def __init__(self, h_dict): # Call the base class init super().__init__(h_dict, self.component_name) - # Add to the log outputs with specific outputs - # Note that power is assumed in the base class - self.log_outputs = self.log_outputs + ["soc", "power_setpoint"] - self.V_cell_nom = 3.3 # [V] self.C_cell = 15.756 # [Ah] mean value from [1] Table 1 @@ -230,7 +226,7 @@ def post_init(self): dtype=hercules_float_type, ) - # initial state of battery outputs for hercules emulator + # initial state of battery outputs for hercules self.power_kw = 0 self.P_reject = 0 self.P_charge = 0 diff --git a/hercules/plant_components/battery_simple.py b/hercules/plant_components/battery_simple.py index 520eba7a..8e77bf22 100644 --- a/hercules/plant_components/battery_simple.py +++ b/hercules/plant_components/battery_simple.py @@ -109,10 +109,6 @@ def __init__(self, h_dict): # Call the base class init super().__init__(h_dict, self.component_name) - # Add to the log outputs with specific outputs - # Note that power is assumed in the base class - self.log_outputs = self.log_outputs + ["soc", "power_setpoint"] - # size = h_dict[self.component_name]["size"] self.energy_capacity = h_dict[self.component_name]["energy_capacity"] # [kWh] initial_conditions = h_dict[self.component_name]["initial_conditions"] diff --git a/hercules/plant_components/component_base.py b/hercules/plant_components/component_base.py index 4e87a9cf..963fa3a4 100644 --- a/hercules/plant_components/component_base.py +++ b/hercules/plant_components/component_base.py @@ -1,17 +1,18 @@ # Base class for plant components in Hercules. -import logging -from pathlib import Path + +from hercules.utilities import setup_logging class ComponentBase: - """ - Base class for plant components. + """Base class for plant components. + + Provides common functionality for all Hercules plant components including logging setup, + time step management, and shared configuration parameters. """ def __init__(self, h_dict, component_name): - """ - Initialize the base component with a dictionary of parameters. + """Initialize the base component with a dictionary of parameters. Args: h_dict (dict): Dictionary containing simulation parameters. @@ -30,8 +31,27 @@ def __init__(self, h_dict, component_name): self.logger = self._setup_logging(self.log_file_name) - # Initialize the outputs to log - self.log_outputs = ["power"] + # Parse log_channels from the h_dict + if "log_channels" in h_dict[component_name]: + log_channels_input = h_dict[component_name]["log_channels"] + # Require list format + if isinstance(log_channels_input, list): + self.log_channels = log_channels_input + else: + raise TypeError( + f"log_channels must be a list, got {type(log_channels_input)}. " + f"Use YAML list format:\n" + f" log_channels:\n" + f" - power\n" + f" - channel_name" + ) + + # If power is not in the list, add it + if "power" not in self.log_channels: + self.log_channels.append("power") + else: + # Default to just power if not specified + self.log_channels = ["power"] # Save the time information self.dt = h_dict["dt"] @@ -46,58 +66,36 @@ def __init__(self, h_dict, component_name): self.logger.info(f"read in verbose flag = {self.verbose}") def _setup_logging(self, log_file_name): - """ - Sets up logging for the component. + """Set up logging for the component. + + + Configures a logger to write to both file and console. Creates log directory + if needed and clears any existing handlers to avoid duplicates. - This method configures a logger named after the component to log messages to a specified - file and console. It ensures the log directory exists, clears any existing handlers to - avoid duplicates, and formats log messages with timestamps, log levels, and messages. - Both file and console output are enabled with component identification in console messages. Args: - log_file_name (str): The full path to the log file where log messages will be written. + log_file_name (str): Full path to the log file. + Returns: logging.Logger: Configured logger instance for the component. """ - - # Split the logfile into directory and filename - log_dir = Path(log_file_name).parent - log_dir.mkdir(parents=True, exist_ok=True) - - # Get the logger for this component, use the component_name for uniqueness - logger = logging.getLogger(self.component_name) - logger.setLevel(logging.INFO) - - # Clear any existing handlers to avoid duplicates - for handler in logger.handlers[:]: - logger.removeHandler(handler) - - # Add file handler - file_handler = logging.FileHandler(log_file_name) - file_handler.setFormatter(logging.Formatter("%(asctime)s - %(levelname)s - %(message)s")) - logger.addHandler(file_handler) - - # Add console handler with component identification - console_handler = logging.StreamHandler() - formatter_str = f"[{self.component_name.upper()}] %(asctime)s - %(levelname)s - %(message)s" - console_handler.setFormatter(logging.Formatter(formatter_str)) - logger.addHandler(console_handler) - - return logger + return setup_logging( + logger_name=self.component_name, + log_file=log_file_name, + console_output=True, + console_prefix=self.component_name.upper(), + use_outputs_dir=False, # log_file_name is already a full path + ) def __del__(self): - """ - Cleanup method to properly close log file handlers. - """ + """Cleanup method to properly close log file handlers.""" if hasattr(self, "logger"): for handler in self.logger.handlers[:]: handler.close() self.logger.removeHandler(handler) def close_logging(self): - """ - Explicitly close all log file handlers. - """ + """Explicitly close all log file handlers.""" if hasattr(self, "logger"): for handler in self.logger.handlers[:]: handler.close() diff --git a/hercules/plant_components/electrolyzer_plant.py b/hercules/plant_components/electrolyzer_plant.py index 0ae6b94b..3599be24 100644 --- a/hercules/plant_components/electrolyzer_plant.py +++ b/hercules/plant_components/electrolyzer_plant.py @@ -1,3 +1,4 @@ +import electrolyzer.tools.validation as val import numpy as np # Electrolyzer plant module @@ -6,12 +7,88 @@ class ElectrolyzerPlant(ComponentBase): + """Electrolyzer plant component for hydrogen production. + + This component models an electrolyzer system that converts electrical power + into hydrogen using the electrolyzer module simulation. + The Electrolyzer plant uses the electrolyzer model from https://github.com/NREL/electrolyzer + """ + def __init__(self, h_dict): - """ - Initializes the ElectrolyzerPlant class. + """Initialize the ElectrolyzerPlant class. + Args: - h_dict (dict): Dict containing values for the simulation + h_dict (dict): Dictionary containing simulation parameters including: + - general: General simulation parameters. + - initial_conditions: Initial conditions for the simulation including: + - power_available_kw: Initial power available to the electrolyzer [kW] + - electrolyzer: Electrolyzer plant specific parameters including: + - initialize: boolean. Whether to initialize the electrolyzer. + - initial_power_kW: Initial power input to the electrolyzer [kW]. + - supervisor: + - system_rating_MW: Total system rating in MW. + - n_stacks: Number of electrolyzer stacks in the plant. + - stack: Electrolyzer stack parameters including: + - cell_type: Type of electrolyzer cell (e.g., PEM, Alkaline). + - max_current: Maximum current of the stack [A]. + - temperature: Stack operating temperature [degC]. + - n_cells: Number of cells per stack. + - min_power: Minimum power for electrolyzer operation [kW]. + - stack_rating_kW: Stack rated power [kW]. + - include_degradation_penalty: *Optional* boolean, Whether to include + degradation penalty. + - hydrogen_degradation_penalty: *Optional* boolean, whether degradation is + applied to hydrogen (True) or power (False) + cell_params: Electrolyzer cell parameters including: + - cell_area: Area of individual cells in the stack [cm^2]. + - turndown_ratio: Minimum turndown ratio for stack operation [between 0 and 1]. + - max current_density: Maximum current density [A/cm^2]. + - p_anode: Anode operating pressure [bar]. + - p_cathode: Cathode operating pressure [bar]. + - alpha_a: anode charge transfer coefficient. + - alpha_c: cathode charge transfer coefficient. + - i_0_a: anode exchange current density [A/cm^2]. + - i_0_c: cathode exchange current density [A/cm^2]. + - e_m: membrane thickness [cm]. + - R_ohmic_elec: electrolyte resistance [A*cm^2]. + - f_1: Faradaic coefficient [mA^2/cm^4]. + - f_2: Faradaic coefficient [mA^2/cm^4]. + degradation: Electrolyzer degradation parameters including: + - eol_eff_percent_loss: End of life efficiency percent loss [%]. + - PEM_params or ALK_params: Degradation parameters specific to PEM or Alkaline + cells: + - rate_steady: Rate of voltage degradation under steady operation alone + - rate_fatigue: Rate of voltage degradation under variable operation alone + - rate_onoff: Rate of voltage degradation per on/off cycle + - controller: Electrolyzer control parameters including: + - control_type: Controller type for electrolyzer plant operation. + - costs: *Optional* Cost parameters for the electrolyzer plant including: + - plant_params: + - plant_life: integer, Plant life in years + - pem_location: Location of the PEM electrolyzer. Options are + [onshore, offshore, in-turbine] + - grid_connected: boolean, Whether the plant is connected to the grid or not + - feedstock: Parameters related to the feedstock including: + - water_feedstock_cost: Cost of water per kg of water + - water_per_kgH2: Amount of water required per kg of hydrogen produced + - opex: Operational expenditure parameters including: + - var_OM: Variable operation and maintenance cost per kW + - fixed_OM: Fixed operation and maintenance cost per kW-year + - stack_replacement: Parameters related to stack replacement costs including: + - d_eol: End of life cell voltage value [V] + - stack_replacement_percent: Stack replacement cost as a percentage of CapEx + [0,1] + - capex: Capital expenditure parameters including: + - capex_learning_rate: Capital expenditure learning rate. + - ref_cost_bop: Reference cost of balance of plant per kW. + - ref_size_bop: Reference size of balance of plant in kW. + - ref_cost_pem: Reference cost of PEM electrolyzer stack per kW. + - ref_size_pem: Reference size of PEM electrolyzer stack in kW. + - finances: Financial parameters including: + - discount_rate: Discount rate for financial calculations [%]. + - install_factor: Installation factor for capital expenditure [0,1]. """ + # Store the name of this component self.component_name = "electrolyzer" @@ -41,20 +118,30 @@ def __init__(self, h_dict): self.allow_grid_power_consumption = False # Remove keys not expected by Supervisor - elec_config = dict(electrolyzer_dict["electrolyzer"]) - elec_config.pop("allow_grid_power_consumption", None) + elec_config = {} + elec_config["electrolyzer"] = dict(electrolyzer_dict["electrolyzer"]["electrolyzer"]) + + elec_config["electrolyzer"]["dt"] = self.dt + + # Validate electrolyzer config + elec_config = val.validate_with_defaults(elec_config, val.fschema_model) + # Initialize electrolyzer plant - self.elec_sys = Supervisor.from_dict(elec_config) + self.elec_sys = Supervisor.from_dict(elec_config["electrolyzer"]) self.n_stacks = self.elec_sys.n_stacks # Right now, the plant initialization power and the initial condition power are the same # power_in is always in kW - power_in = h_dict[self.component_name]["initial_power_kW"] + + power_in = elec_config["electrolyzer"]["initial_power_kW"] self.needed_inputs = {"locally_generated_power": power_in} + self.logger.info("Initializing ElectrolyzerPlant with power input of %.2f kW", power_in) + # Run Electrolyzer two steps to get outputs - for i in range(2): + # Note that power is converted to Watts for electrolyzer input + for i in range(6): H2_produced, H2_mfr, power_left, power_curtailed = self.elec_sys.run_control( power_in * 1e3 ) @@ -70,6 +157,24 @@ def __init__(self, h_dict): self.power_input_kw = power_in self.power_used_kw = self.power_input_kw - (self.curtailed_power_kw + self.power_left_kw) + if self.verbose: + self.logger.info( + "ElectrolyzerPlant initialized: H2_mfr=%.4f kg/s, power_used=%.2f kW, stacks_on=%d", + self.H2_mfr, + self.power_used_kw, + self.stacks_on, + ) + # Update the user + self.logger.info(f"Initialized ElectrolyzerPlant with {self.n_stacks} stacks") + + # Update the h_dict with outputs + h_dict[self.component_name]["H2_output"] = self.H2_output + h_dict[self.component_name]["H2_mfr"] = self.H2_mfr + h_dict[self.component_name]["stacks_on"] = self.stacks_on + h_dict[self.component_name]["stacks_waiting"] = self.stacks_waiting + h_dict[self.component_name]["power"] = -self.power_used_kw + h_dict[self.component_name]["power_input_kw"] = self.power_input_kw + def get_initial_conditions_and_meta_data(self, h_dict): """Add any initial conditions or meta data to the h_dict. @@ -88,8 +193,27 @@ def get_initial_conditions_and_meta_data(self, h_dict): return h_dict def step(self, h_dict): + """Advance the electrolyzer simulation by one time step. + + Updates the electrolyzer state including hydrogen production, power consumption, + and stack status based on available power and control signals. + + Args: + h_dict (dict): Dictionary containing simulation state including: + - locally_generated_power: Available power for electrolyzer [kW] + - electrolyzer.electrolyzer_signal: Optional power command [kW] + + Returns: + dict: Updated h_dict with electrolyzer outputs: + - H2_output: Hydrogen produced in this time step [kg] + - H2_mfr: Hydrogen mass flow rate [kg/s] + - stacks_on: Number of active stacks + - stacks_waiting: List of stack waiting states + - power_used_kw: Power consumed by electrolyzer [kW] + - power_input_kw: Power input to electrolyzer [kW] + """ # Gather inputs - local_power = h_dict["locally_generated_power"] # TODO check what units this is in + local_power = h_dict["plant"]["locally_generated_power"] # kW if "electrolyzer_signal" in h_dict[self.component_name].keys(): power_command_kw = h_dict[self.component_name]["electrolyzer_signal"] elif not self.allow_grid_power_consumption: @@ -103,6 +227,16 @@ def step(self, h_dict): else: power_in_kw = min(local_power, power_command_kw) + if self.verbose: + self.logger.info( + "ElectrolyzerPlant step at time %.2f s with local_power=%.2f kW, " + "power_command=%.2f kW, power_in=%.2f kW", + h_dict["time"], + local_power, + power_command_kw, + power_in_kw, + ) + # Run electrolyzer forward one step ######## Electrolyzer needs input in Watts ######## H2_produced, H2_mfr, power_left_w, power_curtailed_w = self.elec_sys.run_control( @@ -119,12 +253,20 @@ def step(self, h_dict): self.H2_output = H2_produced self.H2_mfr = H2_produced / self.elec_sys.dt + if self.verbose: + self.logger.info( + "ElectrolyzerPlant initialized: H2_mfr=%.4f kg/s, power_used=%.2f kW, stacks_on=%d", + self.H2_mfr, + self.power_used_kw, + self.stacks_on, + ) + # Update the h_dict with outputs h_dict[self.component_name]["H2_output"] = self.H2_output h_dict[self.component_name]["H2_mfr"] = self.H2_mfr h_dict[self.component_name]["stacks_on"] = self.stacks_on h_dict[self.component_name]["stacks_waiting"] = self.stacks_waiting - h_dict[self.component_name]["power_used_kw"] = self.power_used_kw + h_dict[self.component_name]["power"] = -self.power_used_kw h_dict[self.component_name]["power_input_kw"] = self.power_input_kw return h_dict diff --git a/hercules/plant_components/solar_pysam_base.py b/hercules/plant_components/solar_pysam_base.py index 2ddcb051..dcfef032 100644 --- a/hercules/plant_components/solar_pysam_base.py +++ b/hercules/plant_components/solar_pysam_base.py @@ -4,7 +4,6 @@ import pandas as pd from hercules.plant_components.component_base import ComponentBase from hercules.utilities import ( - find_time_utc_value, interpolate_df, ) @@ -30,25 +29,6 @@ def __init__(self, h_dict): # Call the base class init super().__init__(h_dict, self.component_name) - # Add to the log outputs with specific outputs - # Note that power is assumed in the base class - self.log_outputs = self.log_outputs - - # If "log_extra_outputs" is in h_dict[self.component_name], - # Save this value to self.log_extra_outputs - if "log_extra_outputs" in h_dict[self.component_name]: - self.log_extra_outputs = h_dict[self.component_name]["log_extra_outputs"] - else: - self.log_extra_outputs = False - - # If log_extra_outputs is True, add the extra outputs to the log outputs - if self.log_extra_outputs: - self.log_outputs = self.log_outputs + [ - "dni", - "poa", - "aoi", - ] - # Load and process solar data self._load_solar_data(h_dict) @@ -95,30 +75,53 @@ def _load_solar_data(self, h_dict): else: raise ValueError(f"Unsupported file format for solar input: {solar_input_filename}") - # Make sure the df_solar contains a column called "time" - if "time" not in df_solar.columns: - raise ValueError("Solar input file must contain a column called 'time'") + # Make sure the df_solar contains a column called "time_utc" + if "time_utc" not in df_solar.columns: + raise ValueError("Solar input file must contain a column called 'time_utc'") + + # Make sure time_utc is a datetime + if not pd.api.types.is_datetime64_any_dtype(df_solar["time_utc"]): + df_solar["time_utc"] = pd.to_datetime(df_solar["time_utc"], format="ISO8601", utc=True) + + # Ensure time_utc is timezone-aware (UTC) + if not isinstance(df_solar["time_utc"].dtype, pd.DatetimeTZDtype): + df_solar["time_utc"] = df_solar["time_utc"].dt.tz_localize("UTC") - # Make sure that both starttime and endtime are in the df_solar - if not (df_solar["time"].min() <= self.starttime <= df_solar["time"].max()): + # Get starttime_utc and endtime_utc from h_dict + starttime_utc = h_dict["starttime_utc"] + endtime_utc = h_dict["endtime_utc"] + + # Ensure starttime_utc is timezone-aware (UTC) + if not isinstance(starttime_utc, pd.Timestamp): + starttime_utc = pd.to_datetime(starttime_utc, utc=True) + elif starttime_utc.tz is None: + starttime_utc = starttime_utc.tz_localize("UTC") + + # Ensure endtime_utc is timezone-aware (UTC) + if not isinstance(endtime_utc, pd.Timestamp): + endtime_utc = pd.to_datetime(endtime_utc, utc=True) + elif endtime_utc.tz is None: + endtime_utc = endtime_utc.tz_localize("UTC") + + # Generate time column internally: time = 0 corresponds to starttime_utc + df_solar["time"] = (df_solar["time_utc"] - starttime_utc).dt.total_seconds() + + # Validate that starttime_utc and endtime_utc are within the time_utc range + if df_solar["time_utc"].min() > starttime_utc: + min_time = df_solar["time_utc"].min() raise ValueError( - f"Start time {self.starttime} is not in the range of the solar input file" + f"Start time UTC {starttime_utc} is before the earliest time " + f"in the solar input file ({min_time})" ) - if not (df_solar["time"].min() <= self.endtime <= df_solar["time"].max() + self.dt): + if df_solar["time_utc"].max() < endtime_utc: + max_time = df_solar["time_utc"].max() raise ValueError( - f"End time {self.endtime - self.dt} is not in the range of the solar input file" + f"End time UTC {endtime_utc} is after the latest time " + f"in the solar input file ({max_time})" ) - # Solar data must contain time_utc since pysam requires time - if "time_utc" not in df_solar.columns: - raise ValueError("Solar input file must contain a column called 'time_utc'") - - # Make sure time_utc is a datatime - df_solar["time_utc"] = pd.to_datetime(df_solar["time_utc"], format="ISO8601", utc=True) - - # Extract time_utc values for zero_time and start_time - self.zero_time_utc = find_time_utc_value(df_solar, 0.0) - self.start_time_utc = find_time_utc_value(df_solar, self.starttime) + # Set starttime_utc (zero_time_utc is redundant since time=0 corresponds to starttime_utc) + self.starttime_utc = starttime_utc # Interpolate df_solar on to the time steps time_steps_all = np.arange(self.starttime, self.endtime, self.dt) @@ -171,11 +174,9 @@ def get_initial_conditions_and_meta_data(self, h_dict): h_dict["solar_farm"]["poa"] = self.poa h_dict["solar_farm"]["aoi"] = self.aoi - # Log the time_utc values if available - if hasattr(self, "start_time_utc"): - h_dict["solar_farm"]["start_time_utc"] = self.start_time_utc - if hasattr(self, "zero_time_utc"): - h_dict["solar_farm"]["zero_time_utc"] = self.zero_time_utc + # Log the start time UTC if available + if hasattr(self, "starttime_utc"): + h_dict["solar_farm"]["starttime_utc"] = self.starttime_utc return h_dict diff --git a/hercules/plant_components/solar_pysam_pvwatts.py b/hercules/plant_components/solar_pysam_pvwatts.py index d76a2f3a..1ad60444 100644 --- a/hercules/plant_components/solar_pysam_pvwatts.py +++ b/hercules/plant_components/solar_pysam_pvwatts.py @@ -3,6 +3,7 @@ import numpy as np import PySAM.Pvwattsv8 as pvwatts from hercules.plant_components.solar_pysam_base import SolarPySAMBase +from hercules.utilities import hercules_float_type class SolarPySAMPVWatts(SolarPySAMBase): @@ -101,14 +102,16 @@ def _precompute_power_array(self): # Store the pre-computed power array (convert from W to kW) # Use DC power output directly from PVWatts - self.power_uncurtailed = np.array(self.system_model.Outputs.dc) / 1000.0 + self.power_uncurtailed = ( + np.array(self.system_model.Outputs.dc, dtype=hercules_float_type) / 1000.0 + ) # Store other outputs as arrays for efficient access - self.dni_array_output = np.array(self.system_model.Outputs.dn) - self.dhi_array_output = np.array(self.system_model.Outputs.df) - self.ghi_array_output = np.array(self.system_model.Outputs.gh) - self.aoi_array_output = np.array(self.system_model.Outputs.aoi) - self.poa_array_output = np.array(self.system_model.Outputs.poa) + self.dni_array_output = np.array(self.system_model.Outputs.dn, dtype=hercules_float_type) + self.dhi_array_output = np.array(self.system_model.Outputs.df, dtype=hercules_float_type) + self.ghi_array_output = np.array(self.system_model.Outputs.gh, dtype=hercules_float_type) + self.aoi_array_output = np.array(self.system_model.Outputs.aoi, dtype=hercules_float_type) + self.poa_array_output = np.array(self.system_model.Outputs.poa, dtype=hercules_float_type) def _get_step_outputs(self, step): """Get the outputs for a specific step from pre-computed arrays. diff --git a/hercules/plant_components/wind_farm.py b/hercules/plant_components/wind_farm.py new file mode 100644 index 00000000..fd61e7c5 --- /dev/null +++ b/hercules/plant_components/wind_farm.py @@ -0,0 +1,1123 @@ +# Unified wind farm model for Hercules supporting multiple wake modeling strategies. + +import numpy as np +import pandas as pd +from floris import ApproxFlorisModel, FlorisModel +from floris.core import average_velocity +from floris.uncertain_floris_model import map_turbine_powers_uncertain +from hercules.plant_components.component_base import ComponentBase +from hercules.utilities import ( + hercules_float_type, + interpolate_df, + load_yaml, +) +from scipy.interpolate import RegularGridInterpolator +from scipy.optimize import minimize_scalar +from scipy.stats import circmean + +RPM2RADperSec = 2 * np.pi / 60.0 +RAD2DEG = 180.0 / np.pi + + +class WindFarm(ComponentBase): + """Unified wind farm model with configurable wake modeling strategies. + + This model simulates wind farm performance by applying wind speed time signals + to turbine models. It supports three wake modeling strategies: + + 1. **dynamic**: Real-time FLORIS wake calculations at each time step or interval. + Use when turbines may have individual setpoints or non-uniform operation. + + 2. **precomputed**: Pre-computed FLORIS wake deficits for all conditions. + Use when all turbines operate uniformly (all on, all off, or uniform curtailment). + More efficient but less flexible than dynamic. + + 3. **no_added_wakes**: No wake modeling - wind speeds are used directly. + Use when wake effects are already included in the input data or when + wake modeling is not needed. + + All three strategies support detailed turbine dynamics (filter_model or dof1_model). + """ + + def __init__(self, h_dict): + """Initialize the WindFarm class. + + Args: + h_dict (dict): Dictionary containing simulation parameters. + + Raises: + ValueError: If wake_method is invalid or required parameters are missing. + """ + # Store the name of this component + self.component_name = "wind_farm" + + # Get the wake_method from h_dict + wake_method = h_dict[self.component_name].get("wake_method", "dynamic") + + # Validate wake_method + if wake_method not in ["dynamic", "precomputed", "no_added_wakes"]: + raise ValueError( + f"wake_method must be 'dynamic', 'precomputed', or " + f"'no_added_wakes', got '{wake_method}'" + ) + + self.wake_method = wake_method + + # Store the type of this component (for backward compatibility) + component_type = h_dict[self.component_name].get("component_type", "WindFarm") + self.component_type = component_type + + # Call the base class init + super().__init__(h_dict, self.component_name) + + self.logger.info(f"Initializing WindFarm with wake_method='{self.wake_method}'") + + # Track the number of FLORIS calculations + self.num_floris_calcs = 0 + + # Read in the input file names + self.floris_input_file = h_dict[self.component_name]["floris_input_file"] + self.wind_input_filename = h_dict[self.component_name]["wind_input_filename"] + self.turbine_file_name = h_dict[self.component_name]["turbine_file_name"] + + # Require floris_update_time_s for interface consistency + # TODO: Why is there a minimum of 1 second? + # TODO: Consider adding option (e.g. floris_update_time_s = -1) to + # compute FLORIS at every time step (i.e. floris_update_time_s = dt) + if wake_method in ["dynamic", "precomputed"]: + if "floris_update_time_s" not in h_dict[self.component_name]: + raise ValueError( + f"floris_update_time_s must be specified for wake_method='{self.wake_method}'" + ) + elif h_dict[self.component_name]["floris_update_time_s"] < 1: + raise ValueError("FLORIS update time must be at least 1 second") + else: + self.floris_update_time_s = h_dict[self.component_name]["floris_update_time_s"] + else: + self.floris_update_time_s = None + + self.logger.info("Reading in wind input file...") + + # Read in the weather file data + if self.wind_input_filename.endswith(".csv"): + df_wi = pd.read_csv(self.wind_input_filename) + elif self.wind_input_filename.endswith(".p") | self.wind_input_filename.endswith(".pkl"): + df_wi = pd.read_pickle(self.wind_input_filename) + elif (self.wind_input_filename.endswith(".f")) | ( + self.wind_input_filename.endswith(".ftr") + ): + df_wi = pd.read_feather(self.wind_input_filename) + else: + raise ValueError("Wind input file must be a .csv or .p, .f or .ftr file") + + self.logger.info("Checking wind input file...") + # Convert numeric columns to float32 for memory efficiency + for col in df_wi.columns: + if col not in ["time", "time_utc"] and pd.api.types.is_numeric_dtype(df_wi[col]): + df_wi[col] = df_wi[col].astype(hercules_float_type) + + # Make sure the df_wi contains a column called "time_utc" + if "time_utc" not in df_wi.columns: + raise ValueError("Wind input file must contain a column called 'time_utc'") + + # Convert time_utc to datetime if it's not already + if not pd.api.types.is_datetime64_any_dtype(df_wi["time_utc"]): + # Strip whitespace from time_utc values to handle CSV formatting issues + df_wi["time_utc"] = df_wi["time_utc"].astype(str).str.strip() + try: + df_wi["time_utc"] = pd.to_datetime(df_wi["time_utc"], format="ISO8601", utc=True) + except (ValueError, TypeError): + # If ISO8601 format fails, try parsing without specifying format + df_wi["time_utc"] = pd.to_datetime(df_wi["time_utc"], utc=True) + + # Ensure time_utc is timezone-aware (UTC) + if not isinstance(df_wi["time_utc"].dtype, pd.DatetimeTZDtype): + df_wi["time_utc"] = df_wi["time_utc"].dt.tz_localize("UTC") + + # Get starttime_utc and endtime_utc from h_dict + starttime_utc = h_dict["starttime_utc"] + endtime_utc = h_dict["endtime_utc"] + + # Ensure starttime_utc is timezone-aware (UTC) + if not isinstance(starttime_utc, pd.Timestamp): + starttime_utc = pd.to_datetime(starttime_utc, utc=True) + elif starttime_utc.tz is None: + starttime_utc = starttime_utc.tz_localize("UTC") + + # Ensure endtime_utc is timezone-aware (UTC) + if not isinstance(endtime_utc, pd.Timestamp): + endtime_utc = pd.to_datetime(endtime_utc, utc=True) + elif endtime_utc.tz is None: + endtime_utc = endtime_utc.tz_localize("UTC") + + # Generate time column internally: time = 0 corresponds to starttime_utc + df_wi["time"] = (df_wi["time_utc"] - starttime_utc).dt.total_seconds() + + # Validate that starttime_utc and endtime_utc are within the time_utc range + if df_wi["time_utc"].min() > starttime_utc: + min_time = df_wi["time_utc"].min() + raise ValueError( + f"Start time UTC {starttime_utc} is before the earliest time " + f"in the wind input file ({min_time})" + ) + if df_wi["time_utc"].max() < endtime_utc: + max_time = df_wi["time_utc"].max() + raise ValueError( + f"End time UTC {endtime_utc} is after the latest time " + f"in the wind input file ({max_time})" + ) + + # Set starttime_utc + self.starttime_utc = starttime_utc + + # Determine the dt implied by the weather file + self.dt_wi = df_wi["time"].iloc[1] - df_wi["time"].iloc[0] + + # Log the values + if self.verbose: + self.logger.info(f"dt_wi = {self.dt_wi}") + self.logger.info(f"dt = {self.dt}") + + self.logger.info("Interpolating wind input file...") + + # Interpolate df_wi on to the time steps + time_steps_all = np.arange(self.starttime, self.endtime, self.dt) + df_wi = interpolate_df(df_wi, time_steps_all) + + # INITIALIZE FLORIS BASED ON WAKE MODEL + if self.wake_method == "precomputed": + self._init_floris_precomputed(df_wi) + elif self.wake_method == "dynamic": + self._init_floris_dynamic(df_wi) + else: # wake_method == "no_added_wakes" + self._init_floris_none(df_wi) + + # Common post-FLORIS initialization + self.logger.info("Initializing turbines...") + + # Get the turbine information + self.turbine_dict = load_yaml(self.turbine_file_name) + self.turbine_model_type = self.turbine_dict["turbine_model_type"] + + # Initialize the turbine array + if self.turbine_model_type == "filter_model": + # Use vectorized implementation for improved performance + self.turbine_array = TurbineFilterModelVectorized( + self.turbine_dict, self.dt, self.fmodel, self.wind_speeds_withwakes + ) + self.use_vectorized_turbines = True + elif self.turbine_model_type == "dof1_model": + self.turbine_array = [ + Turbine1dofModel( + self.turbine_dict, self.dt, self.fmodel, self.wind_speeds_withwakes[t_idx] + ) + for t_idx in range(self.n_turbines) + ] + self.use_vectorized_turbines = False + else: + raise ValueError("Turbine model type should be either filter_model or dof1_model") + + # Initialize the power array to the initial wind speeds + if self.use_vectorized_turbines: + self.turbine_powers = self.turbine_array.prev_powers.copy() + else: + self.turbine_powers = np.array( + [self.turbine_array[t_idx].prev_power for t_idx in range(self.n_turbines)], + dtype=hercules_float_type, + ) + + # Get the rated power of the turbines + if self.use_vectorized_turbines: + self.rated_turbine_power = self.turbine_array.get_rated_power() + else: + self.rated_turbine_power = self.turbine_array[0].get_rated_power() + + # Get the capacity of the farm + self.capacity = self.n_turbines * self.rated_turbine_power + + # Update the user + self.logger.info( + f"Initialized WindFarm with {self.n_turbines} turbines " + f"(wake_method='{self.wake_method}')" + ) + + def _init_floris_precomputed(self, df_wi): + """Initialize FLORIS with precomputed wake deficits. + + Args: + df_wi (pd.DataFrame): Interpolated wind input dataframe. + """ + self.logger.info("Initializing FLORIS (precomputed mode)...") + + # Initialize the FLORIS model as an ApproxFlorisModel + self.fmodel = ApproxFlorisModel( + self.floris_input_file, + wd_resolution=1.0, + ws_resolution=1.0, + ) + + # Get the layout and number of turbines from FLORIS + self.layout_x = self.fmodel.layout_x + self.layout_y = self.fmodel.layout_y + self.n_turbines = self.fmodel.n_turbines + + self.logger.info("Converting wind input file to numpy matrices...") + + # Convert the wind directions and wind speeds and ti to numpy matrices + if "ws_mean" in df_wi.columns and "ws_000" not in df_wi.columns: + self.ws_mat = np.tile( + df_wi["ws_mean"].values.astype(hercules_float_type)[:, np.newaxis], + (1, self.n_turbines), + ) + else: + self.ws_mat = df_wi[[f"ws_{t_idx:03d}" for t_idx in range(self.n_turbines)]].to_numpy( + dtype=hercules_float_type + ) + + # Compute the turbine averaged wind speeds (axis = 1) using mean + self.ws_mat_mean = np.mean(self.ws_mat, axis=1, dtype=hercules_float_type) + + self.initial_wind_speeds = self.ws_mat[0, :] + self.wind_speed_mean_background = self.ws_mat_mean[0] + + # For now require "wd_mean" to be in the df_wi + if "wd_mean" not in df_wi.columns: + raise ValueError("Wind input file must contain a column called 'wd_mean'") + self.wd_mat_mean = df_wi["wd_mean"].values.astype(hercules_float_type) + + if "ti_000" in df_wi.columns: + self.ti_mat = df_wi[[f"ti_{t_idx:03d}" for t_idx in range(self.n_turbines)]].to_numpy( + dtype=hercules_float_type + ) + + # Compute the turbine averaged turbulence intensities (axis = 1) using mean + self.ti_mat_mean = np.mean(self.ti_mat, axis=1, dtype=hercules_float_type) + + self.initial_tis = self.ti_mat[0, :] + + else: + self.ti_mat_mean = 0.08 * np.ones_like(self.ws_mat_mean, dtype=hercules_float_type) + + # Precompute the wake deficits at the cadence specified by floris_update_time_s + self.logger.info("Precomputing FLORIS wake deficits...") + + # Derived step count + self.floris_update_steps = max(1, int(self.floris_update_time_s / self.dt)) + + # Determine update step cadence and indices to evaluate FLORIS + update_steps = self.floris_update_steps + n_steps = len(self.ws_mat_mean) + eval_indices = np.arange(update_steps - 1, n_steps, update_steps) + # Ensure at least the final time is evaluated + if eval_indices.size == 0: + eval_indices = np.array([n_steps - 1]) + elif eval_indices[-1] != n_steps - 1: + eval_indices = np.append(eval_indices, n_steps - 1) + + # Build right-aligned windowed means for ws, wd, ti at the evaluation indices + def window_mean(arr_1d, idx, win): + start = max(0, idx - win + 1) + return np.mean(arr_1d[start : idx + 1], dtype=hercules_float_type) + + def window_circmean(arr_1d, idx, win): + start = max(0, idx - win + 1) + return circmean(arr_1d[start : idx + 1], high=360.0, low=0.0, nan_policy="omit") + + ws_eval = np.array( + [window_mean(self.ws_mat_mean, i, update_steps) for i in eval_indices], + dtype=hercules_float_type, + ) + wd_eval = np.array( + [window_circmean(self.wd_mat_mean, i, update_steps) for i in eval_indices], + dtype=hercules_float_type, + ) + if np.isscalar(self.ti_mat_mean): + ti_eval = self.ti_mat_mean * np.ones_like(ws_eval, dtype=hercules_float_type) + else: + ti_eval = np.array( + [window_mean(self.ti_mat_mean, i, update_steps) for i in eval_indices], + dtype=hercules_float_type, + ) + + # Evaluate FLORIS at the evaluation cadence + self.fmodel.set( + wind_directions=wd_eval, + wind_speeds=ws_eval, + turbulence_intensities=ti_eval, + ) + self.logger.info("Running FLORIS...") + self.fmodel.run() + self.num_floris_calcs = 1 + self.logger.info("FLORIS run complete") + + # TODO: THIS CODE WILL WORK IN THE FUTURE + # https://github.com/NREL/floris/pull/1135 + # floris_velocities = self.fmodel.turbine_average_velocities + + # For now compute in place here (replace later) + expanded_velocities = average_velocity( + velocities=self.fmodel.fmodel_expanded.core.flow_field.u, + method=self.fmodel.fmodel_expanded.core.grid.average_method, + cubature_weights=self.fmodel.fmodel_expanded.core.grid.cubature_weights, + ) + + floris_velocities = map_turbine_powers_uncertain( + unique_turbine_powers=expanded_velocities, + map_to_expanded_inputs=self.fmodel.map_to_expanded_inputs, + weights=self.fmodel.weights, + n_unexpanded=self.fmodel.n_unexpanded, + n_sample_points=self.fmodel.n_sample_points, + n_turbines=self.fmodel.n_turbines, + ).astype(hercules_float_type) + + # Determine the free_stream velocities as the maximum velocity in each row + free_stream_velocities = np.tile( + np.max(floris_velocities, axis=1)[:, np.newaxis], (1, self.n_turbines) + ).astype(hercules_float_type) + + # Compute wake deficits at evaluation times + floris_wake_deficits_eval = free_stream_velocities - floris_velocities + + # Expand the wake deficits to all time steps by holding constant within each interval + deficits_all = np.zeros_like(self.ws_mat, dtype=hercules_float_type) + # For each block, fill with the corresponding deficits + prev_end = -1 + for block_idx, end_idx in enumerate(eval_indices): + start_idx = prev_end + 1 + prev_end = end_idx + # Use deficits from this evaluation time for the whole block + deficits_all[start_idx : end_idx + 1, :] = floris_wake_deficits_eval[block_idx, :] + + # Compute all the withwakes wind speeds from background minus deficits + self.wind_speeds_withwakes_all = self.ws_mat - deficits_all + + # Initialize the turbine powers to nan + self.turbine_powers = np.zeros(self.n_turbines, dtype=hercules_float_type) * np.nan + + # Get the initial background wind speeds + self.wind_speeds_background = self.ws_mat[0, :] + + # Compute initial withwakes wind speeds + self.wind_speeds_withwakes = self.wind_speeds_withwakes_all[0, :] + + # Get the initial FLORIS wake deficits + self.floris_wake_deficits = self.wind_speeds_background - self.wind_speeds_withwakes + + def _init_floris_dynamic(self, df_wi): + """Initialize FLORIS for dynamic wake calculation. + + Args: + df_wi (pd.DataFrame): Interpolated wind input dataframe. + """ + self.logger.info("Initializing FLORIS (dynamic mode)...") + + # Initialize the FLORIS model + self.fmodel = FlorisModel(self.floris_input_file) + + # Change to the mixed operation model + self.fmodel.set_operation_model("mixed") + + # Get the layout and number of turbines from FLORIS + self.layout_x = self.fmodel.layout_x + self.layout_y = self.fmodel.layout_y + self.n_turbines = self.fmodel.n_turbines + + # How often to update the wake deficits + self.floris_update_steps = int(self.floris_update_time_s / self.dt) + self.floris_update_steps = max(1, self.floris_update_steps) + + # Declare the power_setpoint buffer to hold previous power_setpoint commands + self.turbine_power_setpoints_buffer = ( + np.zeros((self.floris_update_steps, self.n_turbines), dtype=hercules_float_type) + * np.nan + ) + self.turbine_power_setpoints_buffer_idx = 0 # Initialize the index to 0 + + # Add an initial non-nan value to be over-written on first step + self.turbine_power_setpoints_buffer[0, :] = 1e12 + + # Convert the wind directions and wind speeds and ti to numpy matrices + if "ws_mean" in df_wi.columns and "ws_000" not in df_wi.columns: + self.ws_mat = np.tile( + df_wi["ws_mean"].values.astype(hercules_float_type)[:, np.newaxis], + (1, self.n_turbines), + ) + else: + self.ws_mat = df_wi[[f"ws_{t_idx:03d}" for t_idx in range(self.n_turbines)]].to_numpy( + dtype=hercules_float_type + ) + + # Compute the turbine averaged wind speeds (axis = 1) using mean + self.ws_mat_mean = np.mean(self.ws_mat, axis=1, dtype=hercules_float_type) + + self.initial_wind_speeds = self.ws_mat[0, :] + self.wind_speed_mean_background = self.ws_mat_mean[0] + + # For now require "wd_mean" to be in the df_wi + if "wd_mean" not in df_wi.columns: + raise ValueError("Wind input file must contain a column called 'wd_mean'") + self.wd_mat_mean = df_wi["wd_mean"].values.astype(hercules_float_type) + + # Compute the initial floris wind direction + self.floris_wind_direction = self.wd_mat_mean[0] + + if "ti_000" in df_wi.columns: + self.ti_mat = df_wi[[f"ti_{t_idx:03d}" for t_idx in range(self.n_turbines)]].to_numpy( + dtype=hercules_float_type + ) + + # Compute the turbine averaged turbulence intensities (axis = 1) using mean + self.ti_mat_mean = np.mean(self.ti_mat, axis=1, dtype=hercules_float_type) + + self.initial_tis = self.ti_mat[0, :] + + self.floris_ti = self.ti_mat_mean[0] + + else: + self.ti_mat_mean = 0.08 * np.ones_like(self.ws_mat_mean, dtype=hercules_float_type) + self.floris_ti = 0.08 * self.ti_mat_mean[0] + + self.floris_turbine_power_setpoints = np.nanmean( + self.turbine_power_setpoints_buffer, axis=0 + ).astype(hercules_float_type) + + # Initialize the wake deficits + self.floris_wake_deficits = np.zeros(self.n_turbines, dtype=hercules_float_type) + + # Initialize the turbine powers to nan + self.turbine_powers = np.zeros(self.n_turbines, dtype=hercules_float_type) * np.nan + + # Get the initial background wind speeds + self.wind_speeds_background = self.ws_mat[0, :] + + # Compute the initial waked wind speeds + self.update_wake_deficits(step=0) + + # Compute withwakes wind speeds + self.wind_speeds_withwakes = self.ws_mat[0, :] - self.floris_wake_deficits + + def _init_floris_none(self, df_wi): + """Initialize without wake modeling. + + Args: + df_wi (pd.DataFrame): Interpolated wind input dataframe. + """ + self.logger.info("Initializing FLORIS (no wake modeling)...") + + # Initialize the FLORIS model (still needed for turbine power curve) + self.fmodel = FlorisModel(self.floris_input_file) + + # Get the layout and number of turbines from FLORIS + self.layout_x = self.fmodel.layout_x + self.layout_y = self.fmodel.layout_y + self.n_turbines = self.fmodel.n_turbines + + # floris_update_steps not used but set for consistency + # self.floris_update_steps = max(1, int(self.floris_update_time_s / self.dt)) + + # Convert the wind directions and wind speeds and ti to numpy matrices + if "ws_mean" in df_wi.columns and "ws_000" not in df_wi.columns: + self.ws_mat = np.tile( + df_wi["ws_mean"].values.astype(hercules_float_type)[:, np.newaxis], + (1, self.n_turbines), + ) + else: + self.ws_mat = df_wi[[f"ws_{t_idx:03d}" for t_idx in range(self.n_turbines)]].to_numpy( + dtype=hercules_float_type + ) + + # Compute the turbine averaged wind speeds (axis = 1) using mean + self.ws_mat_mean = np.mean(self.ws_mat, axis=1, dtype=hercules_float_type) + + self.initial_wind_speeds = self.ws_mat[0, :] + self.wind_speed_mean_background = self.ws_mat_mean[0] + + # For now require "wd_mean" to be in the df_wi + if "wd_mean" not in df_wi.columns: + raise ValueError("Wind input file must contain a column called 'wd_mean'") + self.wd_mat_mean = df_wi["wd_mean"].values.astype(hercules_float_type) + + if "ti_000" in df_wi.columns: + self.ti_mat = df_wi[[f"ti_{t_idx:03d}" for t_idx in range(self.n_turbines)]].to_numpy( + dtype=hercules_float_type + ) + + # Compute the turbine averaged turbulence intensities (axis = 1) using mean + self.ti_mat_mean = np.mean(self.ti_mat, axis=1, dtype=hercules_float_type) + + self.initial_tis = self.ti_mat[0, :] + + else: + self.ti_mat_mean = 0.08 * np.ones_like(self.ws_mat_mean, dtype=hercules_float_type) + + # No wake deficits + self.floris_wake_deficits = np.zeros(self.n_turbines, dtype=hercules_float_type) + + # Initialize the turbine powers to nan + self.turbine_powers = np.zeros(self.n_turbines, dtype=hercules_float_type) * np.nan + + # Get the initial background wind speeds + self.wind_speeds_background = self.ws_mat[0, :] + + # No wakes: withwakes == background + self.wind_speeds_withwakes = self.wind_speeds_background.copy() + + def get_initial_conditions_and_meta_data(self, h_dict): + """Add any initial conditions or meta data to the h_dict. + + Meta data is data not explicitly in the input yaml but still useful for other + modules. + + Args: + h_dict (dict): Dictionary containing simulation parameters. + + Returns: + dict: Dictionary containing simulation parameters with initial conditions and meta data. + """ + h_dict["wind_farm"]["n_turbines"] = self.n_turbines + h_dict["wind_farm"]["capacity"] = self.capacity + h_dict["wind_farm"]["rated_turbine_power"] = self.rated_turbine_power + h_dict["wind_farm"]["wind_direction_mean"] = self.wd_mat_mean[0] + h_dict["wind_farm"]["wind_speed_mean_background"] = self.ws_mat_mean[0] + h_dict["wind_farm"]["turbine_powers"] = self.turbine_powers + h_dict["wind_farm"]["power"] = np.sum(self.turbine_powers) + + # Log the start time UTC if available + if hasattr(self, "starttime_utc"): + h_dict["wind_farm"]["starttime_utc"] = self.starttime_utc + + return h_dict + + def update_wake_deficits(self, step): + """Update the wake deficits in the FLORIS model (dynamic mode only). + + This method computes the necessary FLORIS inputs (wind direction, wind speed, + turbulence intensity, and power_setpoints) over a specified time window. If any of these + inputs have changed beyond their respective thresholds, the FLORIS model is updated, + and the wake deficits are recalculated. + + Args: + step (int): The current simulation step. + """ + # Get the window start + window_start = max(0, step - self.floris_update_steps + 1) + + # Compute new values of the floris inputs + self.floris_wind_direction = circmean( + self.wd_mat_mean[window_start : step + 1], high=360.0, low=0.0, nan_policy="omit" + ) + self.floris_wind_speed = np.mean( + self.ws_mat_mean[window_start : step + 1], dtype=hercules_float_type + ) + self.floris_ti = np.mean( + self.ti_mat_mean[window_start : step + 1], dtype=hercules_float_type + ) + + # Compute the power_setpoints over the same window + self.floris_turbine_power_setpoints = ( + np.nanmean(self.turbine_power_setpoints_buffer, axis=0) + .astype(hercules_float_type) + .reshape(1, -1) + ) + + # Run FLORIS + self.fmodel.set( + wind_directions=[self.floris_wind_direction], + wind_speeds=[self.floris_wind_speed], + turbulence_intensities=[self.floris_ti], + power_setpoints=self.floris_turbine_power_setpoints * 1000.0, + ) + self.fmodel.run() + velocities = self.fmodel.turbine_average_velocities.flatten() + self.floris_wake_deficits = velocities.max() - velocities + self.num_floris_calcs += 1 + + def _update_power_setpoints_buffer(self, turbine_power_setpoints): + """Update the power_setpoints buffer (dynamic mode only). + + This method stores the given power setpoint values in the current position of the + power_setpoints buffer and updates the index to point to the next position in a + circular manner. + + Args: + turbine_power_setpoints (numpy.ndarray): A 1D array containing the power_setpoint values + to be stored in the buffer. + """ + # Update the power_setpoints buffer + self.turbine_power_setpoints_buffer[self.turbine_power_setpoints_buffer_idx, :] = ( + turbine_power_setpoints + ) + + # Increment the index + self.turbine_power_setpoints_buffer_idx = ( + self.turbine_power_setpoints_buffer_idx + 1 + ) % self.floris_update_steps + + def step(self, h_dict): + """Execute one simulation step for the wind farm. + + Updates wake deficits (if applicable), computes waked velocities, calculates + turbine powers, and updates the simulation dictionary with results. + + Args: + h_dict (dict): Dictionary containing current simulation state including + step number and power_setpoint values for each turbine. + + Returns: + dict: Updated simulation dictionary with wind farm outputs including + turbine powers, total power, and optional extra outputs. + """ + # Get the current step + step = h_dict["step"] + if self.verbose: + self.logger.info(f"step = {step} (of {self.n_steps})") + + # Grab the instantaneous turbine power setpoint signal + turbine_power_setpoints = h_dict[self.component_name]["turbine_power_setpoints"] + + # Update wind speeds based on wake model + if self.wake_method == "dynamic": + # Update power setpoints buffer + self._update_power_setpoints_buffer(turbine_power_setpoints) + + # Get the background wind speeds + self.wind_speeds_background = self.ws_mat[step, :] + + # Check if it is time to update the withwakes wind speeds + if step % self.floris_update_steps == 0: + self.update_wake_deficits(step) + + # Compute withwakes wind speeds + self.wind_speeds_withwakes = self.ws_mat[step, :] - self.floris_wake_deficits + + elif self.wake_method == "precomputed": + # Update all the wind speeds + self.wind_speeds_background = self.ws_mat[step, :] + self.wind_speeds_withwakes = self.wind_speeds_withwakes_all[step, :] + self.floris_wake_deficits = self.wind_speeds_background - self.wind_speeds_withwakes + + else: # wake_method == "no_added_wakes" + # No wake modeling - use background speeds directly + self.wind_speeds_background = self.ws_mat[step, :] + self.wind_speeds_withwakes = self.wind_speeds_background.copy() + self.floris_wake_deficits = np.zeros(self.n_turbines, dtype=hercules_float_type) + + # Update the turbine powers (common for all wake models) + if self.use_vectorized_turbines: + # Vectorized calculation for all turbines at once + self.turbine_powers = self.turbine_array.step( + self.wind_speeds_withwakes, + turbine_power_setpoints, + ) + else: + # Original loop-based calculation + for t_idx in range(self.n_turbines): + self.turbine_powers[t_idx] = self.turbine_array[t_idx].step( + self.wind_speeds_withwakes[t_idx], + power_setpoint=turbine_power_setpoints[t_idx], + ) + + # Update instantaneous wind direction and wind speed + self.wind_direction_mean = self.wd_mat_mean[step] + self.wind_speed_mean_background = self.ws_mat_mean[step] + + # Update the h_dict with outputs + h_dict[self.component_name]["power"] = np.sum(self.turbine_powers) + h_dict[self.component_name]["turbine_powers"] = self.turbine_powers + h_dict[self.component_name]["turbine_power_setpoints"] = turbine_power_setpoints + h_dict[self.component_name]["wind_direction_mean"] = self.wind_direction_mean + h_dict[self.component_name]["wind_speed_mean_background"] = self.wind_speed_mean_background + h_dict[self.component_name]["wind_speed_mean_withwakes"] = np.mean( + self.wind_speeds_withwakes, dtype=hercules_float_type + ) + h_dict[self.component_name]["wind_speeds_withwakes"] = self.wind_speeds_withwakes + h_dict[self.component_name]["wind_speeds_background"] = self.wind_speeds_background + + return h_dict + + +class TurbineFilterModelVectorized: + """Vectorized filter-based wind turbine model for power output simulation. + + This model simulates wind turbine power output using a first-order filter + to smooth the response to changing wind conditions, providing a simplified + representation of turbine dynamics. This vectorized version processes + all turbines simultaneously for improved performance. + """ + + def __init__(self, turbine_dict, dt, fmodel, initial_wind_speeds): + """Initialize the vectorized turbine filter model. + + Args: + turbine_dict (dict): Dictionary containing turbine configuration, + including filter model parameters and other turbine-specific data. + dt (float): Time step for the simulation in seconds. + fmodel (FlorisModel): FLORIS model of the farm. + initial_wind_speeds (np.ndarray): Initial wind speeds in m/s for all turbines + to initialize the simulation. + """ + # Save the time step + self.dt = dt + + # Save the turbine dict + self.turbine_dict = turbine_dict + + # Save the filter time constant + self.filter_time_constant = turbine_dict["filter_model"]["time_constant"] + + # Solve for the filter alpha value given dt and the time constant + self.alpha = 1 - np.exp(-self.dt / self.filter_time_constant) + + # Grab the wind speed power curve from the fmodel and create lookup tables + turbine_type = fmodel.core.farm.turbine_definitions[0] + self.wind_speed_lut = np.array( + turbine_type["power_thrust_table"]["wind_speed"], dtype=hercules_float_type + ) + self.power_lut = np.array( + turbine_type["power_thrust_table"]["power"], dtype=hercules_float_type + ) + + # Number of turbines + self.n_turbines = len(initial_wind_speeds) + + # Initialize the previous powers for all turbines + self.prev_powers = np.interp( + initial_wind_speeds, self.wind_speed_lut, self.power_lut, left=0.0, right=0.0 + ).astype(hercules_float_type) + + def get_rated_power(self): + """Get the rated power of the turbine. + + Returns: + float: The rated power of the turbine in kW. + """ + return np.max(self.power_lut) + + def step(self, wind_speeds, power_setpoints): + """Simulate a single time step for all wind turbines simultaneously. + + This method calculates the power output of all wind turbines based on the + given wind speeds and power setpoints. The power outputs are + smoothed using an exponential moving average to simulate the turbines' + response to changing wind conditions. + + Args: + wind_speeds (np.ndarray): Current wind speeds in m/s for all turbines. + power_setpoints (np.ndarray): Maximum allowable power outputs in kW for all turbines. + + Returns: + np.ndarray: Calculated power outputs of all wind turbines, constrained + by the power setpoints and smoothed using the exponential moving average. + """ + # Vectorized instantaneous power calculation using numpy interpolation + instant_powers = np.interp( + wind_speeds, self.wind_speed_lut, self.power_lut, left=0.0, right=0.0 + ) + + # Vectorized limiting: current power not greater than power_setpoint + instant_powers = np.minimum(instant_powers, power_setpoints) + + # Vectorized limiting: instant power not less than 0 + instant_powers = np.maximum(instant_powers, 0.0) + + # Handle NaNs by replacing with previous power values + nan_mask = np.isnan(instant_powers) + if np.any(nan_mask): + instant_powers[nan_mask] = self.prev_powers[nan_mask] + + # Vectorized exponential filter update + powers = self.alpha * instant_powers + (1 - self.alpha) * self.prev_powers + + # Vectorized limiting: power not greater than power_setpoint + powers = np.minimum(powers, power_setpoints) + + # Vectorized limiting: power not less than 0 + powers = np.maximum(powers, 0.0) + + # Update the previous powers for all turbines + self.prev_powers = powers.copy() + + # Return the powers + return powers + + +class Turbine1dofModel: + """Single degree-of-freedom wind turbine model with detailed dynamics. + + This model simulates wind turbine behavior using a 1-DOF representation + that includes rotor dynamics, pitch control, and generator torque control. + """ + + def __init__(self, turbine_dict, dt, fmodel, initial_wind_speed): + """Initialize the 1-DOF turbine model. + + Args: + turbine_dict (dict): Dictionary containing turbine configuration and + DOF model parameters. + dt (float): Time step for the simulation in seconds. + fmodel (FlorisModel): FLORIS model of the farm. + initial_wind_speed (float): Initial wind speed in m/s to initialize + the simulation. + """ + # Save the time step + self.dt = dt + + # Save the turbine dict + self.turbine_dict = turbine_dict + + # Obtain required data from turbine_dict + self.rotor_inertia = self.turbine_dict["dof1_model"]["rotor_inertia"] + self.rated_rotor_speed = self.turbine_dict["dof1_model"]["rated_rotor_speed"] + self.rated_torque = self.turbine_dict["dof1_model"]["rated_torque"] + self.perffile = turbine_dict["dof1_model"]["cq_table_file"] + + # Set default values of optional parameters + self.rho = self.turbine_dict["dof1_model"].get("rho", 1.225) + self.filterfreq_rotor_speed = self.turbine_dict["dof1_model"].get( + "filterfreq_rotor_speed", 1.5708 + ) + self.gearbox_ratio = self.turbine_dict["dof1_model"].get("gearbox_ratio", 1.0) + self.initial_rpm = self.turbine_dict["dof1_model"].get("initial_rpm", 10) + self.gen_efficiency = self.turbine_dict["dof1_model"].get("gen_efficiency", 1.0) + self.max_pitch_rate = self.turbine_dict["dof1_model"].get("max_pitch_rate", np.inf) + self.max_torque_rate = self.turbine_dict["dof1_model"].get("max_torque_rate", np.inf) + + # Calculate rated power + self.rated_power = ( + self.rated_torque * self.rated_rotor_speed * self.gearbox_ratio * self.gen_efficiency + ) + + # Set filter parameter for rotor speed + self.filteralpha = np.exp( + -self.dt * self.turbine_dict["dof1_model"]["filterfreq_rotor_speed"] + ) + + # Initialize the integrated controller error to 0 + self.omegaferror_integrated = 0.0 + + # Obtain more data from floris + turbine_type = fmodel.core.farm.turbine_definitions[0] + self.rotor_radius = turbine_type["rotor_diameter"] / 2 + self.rotor_area = np.pi * self.rotor_radius**2 + + # Save performance data functions + perffile = turbine_dict["dof1_model"]["cq_table_file"] + self.perffuncs = self.load_perffile(perffile) + + self.rho = self.turbine_dict["dof1_model"]["rho"] + self.max_pitch_rate = self.turbine_dict["dof1_model"]["max_pitch_rate"] + self.max_torque_rate = self.turbine_dict["dof1_model"]["max_torque_rate"] + omega0 = self.turbine_dict["dof1_model"]["initial_rpm"] * RPM2RADperSec + pitch, gentq = self.simplecontroller(omega0) + tsr = self.rotor_radius * omega0 / initial_wind_speed + prev_power = ( + self.perffuncs["Cp"]([tsr, pitch]) + * 0.5 + * self.rho + * self.rotor_area + * initial_wind_speed**3 + * self.gen_efficiency + ) + self.prev_power = np.array(prev_power[0] / 1000.0, dtype=hercules_float_type) + self.prev_omega = omega0 + self.prev_omegaf = omega0 + self.prev_aerotq = ( + 0.5 + * self.rho + * self.rotor_area + * self.rotor_radius + * initial_wind_speed**2 + * self.perffuncs["Cq"]([tsr, pitch])[0] + ) + self.prev_gentq = gentq + self.prev_pitch = pitch + + def get_rated_power(self): + """Get the rated power of the turbine. + + Returns: + float: The rated power of the turbine in kW. + """ + return self.rated_power / 1000 + + def step(self, wind_speed, power_setpoint): + """Execute one simulation step for the 1-DOF turbine model. + + Simulates turbine dynamics including rotor speed, pitch angle, and + generator torque while respecting rate limits and power_setpoint constraints. + + Args: + wind_speed (float): Current wind speed in m/s. + power_setpoint (float): Maximum allowable power output in kW. + + Returns: + float: Calculated turbine power output in kW. + """ + omega = ( + self.prev_omega + + (self.prev_aerotq - self.prev_gentq * self.gearbox_ratio) + * self.dt + / self.rotor_inertia + ) + omegaf = (1 - self.filteralpha) * omega + self.filteralpha * (self.prev_omegaf) + pitch, gentq = self.simplecontroller(omegaf) + tsr = float(omegaf * self.rotor_radius / wind_speed) + desiredcp = 0 + if power_setpoint < self.rated_power / 1000: + desiredcp = ( + power_setpoint + * 1000 + / self.gen_efficiency + / (0.5 * self.rho * self.rotor_area * wind_speed**3) + ) + pitch = self.perffuncs["pitch"]([desiredcp, tsr])[0] + pitch = np.clip( + pitch, + self.prev_pitch - self.max_pitch_rate * self.dt, + self.prev_pitch + self.max_pitch_rate * self.dt, + ) + gentq = np.clip( + gentq, + self.prev_gentq - self.max_torque_rate * self.dt, + self.prev_gentq + self.max_torque_rate * self.dt, + ) + + aerotq = ( + 0.5 + * self.rho + * self.rotor_area + * self.rotor_radius + * wind_speed**2 + * self.perffuncs["Cq"]([tsr, pitch])[0] + ) + + power = gentq * omega * self.gearbox_ratio * self.gen_efficiency + + self.prev_omega = omega + self.prev_aerotq = aerotq + self.prev_gentq = gentq + self.prev_pitch = pitch + self.prev_omegaf = omegaf + self.prev_power = power / 1000.0 + + return self.prev_power + + def simplecontroller(self, omegaf): + """Simple controller to command pitch and generator torque. + + Region-2 controller: + - sets blade pitch to 0 + - sets generator torque using a K$\\omega^2$ controller + region-3 controller: + - sets blade pitch based on a PI controller + - sets generator torque to be inversly proportional to rotor speed. + + Args: + omegaf (float): Filtered rotor speed in rad/s. + + Returns: + tuple: (pitch_angle, generator_torque) where pitch is in radians + and generator torque is in N⋅m. + """ + omegaf_gen = omegaf * self.gearbox_ratio + + if omegaf >= self.rated_rotor_speed: + # Region-3 + gentorque = self.rated_torque * self.rated_rotor_speed / omegaf + self.omegaferror_integrated += omegaf - self.rated_rotor_speed + pitch_i = ( + self.turbine_dict["dof1_model"]["controller"]["ki_pitch"] + * self.dt + * self.omegaferror_integrated + ) + pitch_p = self.turbine_dict["dof1_model"]["controller"]["kp_pitch"] * ( + omegaf - self.rated_rotor_speed + ) + pitch = pitch_i + pitch_p + else: + # Region-2 + gentorque = self.turbine_dict["dof1_model"]["controller"]["r2_k_torque"] * omegaf_gen**2 + pitch = 0.0 + self.omegaferror_integrated = 0.0 + return pitch, gentorque + + def load_perffile(self, perffile): + """Load and parse a wind turbine performance file. + + This function reads a performance file containing wind turbine coefficient data + including power coefficients (Cp), thrust coefficients (Ct), and torque coefficients (Cq) + as functions of tip speed ratio (TSR) and blade pitch angle. + It also outputs a function to calculate optimal pitch for a given TSR and reqired Cp + for faster calculation of derated operations. + The data is converted into RegularGridInterpolator objects for efficient interpolation + during simulation. + + Args: + perffile (str): Path to the performance file containing turbine coefficient data. + + Returns: + dict: A dictionary containing RegularGridInterpolator objects for 'Cp', 'Ct', and 'Cq' + coefficients, and optimal pitch for derated cases keyed by coefficient name. + """ + perffuncs = {} + + with open(perffile) as pfile: + for line in pfile: + # Read Blade Pitch Angles (degrees) + if "Pitch angle" in line: + pitch_initial = np.array([float(x) for x in pfile.readline().strip().split()]) + pitch_initial_rad = pitch_initial / RAD2DEG + + # Read Tip Speed Ratios (rad) + if "TSR" in line: + TSR_initial = np.array([float(x) for x in pfile.readline().strip().split()]) + + # Read Power Coefficients + if "Power" in line: + pfile.readline() + Cp = np.empty((len(TSR_initial), len(pitch_initial))) + for tsr_i in range(len(TSR_initial)): + Cp[tsr_i] = np.array([float(x) for x in pfile.readline().strip().split()]) + perffuncs["Cp"] = RegularGridInterpolator( + (TSR_initial, pitch_initial_rad), Cp, bounds_error=False, fill_value=None + ) + + # Obtain a lookup table to calculate optimal pitch for derated simulations + cpgrid = np.linspace(0, 0.6, 100) + optpitchdata = [] + for cp in cpgrid: + optpitchrow = [] + for tsr in TSR_initial: + optpitch = minimize_scalar( + lambda p: abs(float(perffuncs["Cp"]([tsr, float(p)])) - cp), + method="bounded", + bounds=(0, 1.57), + ) + pitch = optpitch.x + optpitchrow.append(pitch) + optpitchdata.append(optpitchrow) + perffuncs["pitch"] = RegularGridInterpolator( + (cpgrid, TSR_initial), optpitchdata, bounds_error=False, fill_value=None + ) + + # Read Thrust Coefficients + if "Thrust" in line: + pfile.readline() + Ct = np.empty((len(TSR_initial), len(pitch_initial))) + for tsr_i in range(len(TSR_initial)): + Ct[tsr_i] = np.array([float(x) for x in pfile.readline().strip().split()]) + perffuncs["Ct"] = RegularGridInterpolator( + (TSR_initial, pitch_initial_rad), Ct, bounds_error=False, fill_value=None + ) + + # Read Torque Coefficients + if "Torque" in line: + pfile.readline() + Cq = np.empty((len(TSR_initial), len(pitch_initial))) + for tsr_i in range(len(TSR_initial)): + Cq[tsr_i] = np.array([float(x) for x in pfile.readline().strip().split()]) + perffuncs["Cq"] = RegularGridInterpolator( + (TSR_initial, pitch_initial_rad), Cq, bounds_error=False, fill_value=None + ) + + return perffuncs diff --git a/hercules/plant_components/wind_farm_scada_power.py b/hercules/plant_components/wind_farm_scada_power.py new file mode 100644 index 00000000..48e3cc96 --- /dev/null +++ b/hercules/plant_components/wind_farm_scada_power.py @@ -0,0 +1,337 @@ +# Unified wind farm model for Hercules supporting multiple wake modeling strategies. + +import numpy as np +import pandas as pd +from hercules.plant_components.component_base import ComponentBase +from hercules.utilities import ( + hercules_float_type, + interpolate_df, +) + + +class WindFarmSCADAPower(ComponentBase): + """Wind farm model that uses SCADA power data to simulate wind farm performance.""" + + def __init__(self, h_dict): + """Initialize the WindFarm class. + + Args: + h_dict (dict): Dictionary containing simulation parameters. + """ + # Store the name of this component + self.component_name = "wind_farm" + + self.component_type = "WindFarmSCADAPower" + + # Call the base class init + super().__init__(h_dict, self.component_name) + + self.logger.info("Initializing WindFarmSCADAPower") + + # Track the number of FLORIS calculations + self.num_floris_calcs = 0 + + # Read in the input file names + self.scada_filename = h_dict[self.component_name]["scada_filename"] + + self.logger.info("Reading in SCADA power data...") + + # Read in the scada file + if self.scada_filename.endswith(".csv"): + df_scada = pd.read_csv(self.scada_filename) + elif self.scada_filename.endswith(".p") | self.scada_filename.endswith(".pkl"): + df_scada = pd.read_pickle(self.scada_filename) + elif (self.scada_filename.endswith(".f")) | (self.scada_filename.endswith(".ftr")): + df_scada = pd.read_feather(self.scada_filename) + else: + raise ValueError("SCADA file must be a .csv or .p, .f or .ftr file") + + self.logger.info("Checking SCADA file...") + + # Make sure the df_scada contains a column called "time_utc" + if "time_utc" not in df_scada.columns: + raise ValueError("SCADA file must contain a column called 'time_utc'") + + # Convert time_utc to datetime if it's not already + if not pd.api.types.is_datetime64_any_dtype(df_scada["time_utc"]): + # Strip whitespace from time_utc values to handle CSV formatting issues + df_scada["time_utc"] = df_scada["time_utc"].astype(str).str.strip() + try: + df_scada["time_utc"] = pd.to_datetime( + df_scada["time_utc"], format="ISO8601", utc=True + ) + except (ValueError, TypeError): + # If ISO8601 format fails, try parsing without specifying format + df_scada["time_utc"] = pd.to_datetime(df_scada["time_utc"], utc=True) + + # Ensure time_utc is timezone-aware (UTC) + if not isinstance(df_scada["time_utc"].dtype, pd.DatetimeTZDtype): + df_scada["time_utc"] = df_scada["time_utc"].dt.tz_localize("UTC") + + # Get starttime_utc and endtime_utc from h_dict + starttime_utc = h_dict["starttime_utc"] + endtime_utc = h_dict["endtime_utc"] + + # Ensure starttime_utc is timezone-aware (UTC) + if not isinstance(starttime_utc, pd.Timestamp): + starttime_utc = pd.to_datetime(starttime_utc, utc=True) + elif starttime_utc.tz is None: + starttime_utc = starttime_utc.tz_localize("UTC") + + # Ensure endtime_utc is timezone-aware (UTC) + if not isinstance(endtime_utc, pd.Timestamp): + endtime_utc = pd.to_datetime(endtime_utc, utc=True) + elif endtime_utc.tz is None: + endtime_utc = endtime_utc.tz_localize("UTC") + + # Generate time column internally: time = 0 corresponds to starttime_utc + df_scada["time"] = (df_scada["time_utc"] - starttime_utc).dt.total_seconds() + + # Validate that starttime_utc and endtime_utc are within the time_utc range + if df_scada["time_utc"].min() > starttime_utc: + min_time = df_scada["time_utc"].min() + raise ValueError( + f"Start time UTC {starttime_utc} is before the earliest time " + f"in the SCADA file ({min_time})" + ) + if df_scada["time_utc"].max() < endtime_utc: + max_time = df_scada["time_utc"].max() + raise ValueError( + f"End time UTC {endtime_utc} is after the latest time " + f"in the SCADA file ({max_time})" + ) + + # Set starttime_utc + self.starttime_utc = starttime_utc + + # Determine the dt implied by the weather file + self.dt_scada = df_scada["time"].iloc[1] - df_scada["time"].iloc[0] + + # Log the values + if self.verbose: + self.logger.info(f"dt_scada = {self.dt_scada}") + self.logger.info(f"dt = {self.dt}") + + self.logger.info("Interpolating SCADA file...") + + # Interpolate df_scada on to the time steps + time_steps_all = np.arange(self.starttime, self.endtime, self.dt) + df_scada = interpolate_df(df_scada, time_steps_all) + + # Get a list of power columns and infer number of turbines + self.power_columns = sorted([col for col in df_scada.columns if col.startswith("pow_")]) + + # Infer the number of turbines by the number of power columns + self.n_turbines = len(self.power_columns) + + self.logger.info(f"Inferred number of turbines: {self.n_turbines}") + + # Collect the turbine powers + self.scada_powers = df_scada[self.power_columns].to_numpy(dtype=hercules_float_type) + + # Now get the wind speed and directions + + # Convert the wind directions and wind speeds and ti to numpy matrices + if "ws_mean" in df_scada.columns and "ws_000" not in df_scada.columns: + self.ws_mat = np.tile( + df_scada["ws_mean"].values.astype(hercules_float_type)[:, np.newaxis], + (1, self.n_turbines), + ) + else: + self.ws_mat = df_scada[ + [f"ws_{t_idx:03d}" for t_idx in range(self.n_turbines)] + ].to_numpy(dtype=hercules_float_type) + + # Compute the turbine averaged wind speeds (axis = 1) using mean + self.ws_mat_mean = np.mean(self.ws_mat, axis=1, dtype=hercules_float_type) + + self.initial_wind_speeds = self.ws_mat[0, :] + self.wind_speed_mean_background = self.ws_mat_mean[0] + + # Get the initial background wind speeds + self.wind_speeds_background = self.ws_mat[0, :] + + # No wakes: withwakes == background + self.wind_speeds_withwakes = self.wind_speeds_background.copy() + + # For now require "wd_mean" to be in the df_scada + if "wd_mean" in df_scada.columns: + self.wd_mat_mean = df_scada["wd_mean"].values.astype(hercules_float_type) + else: + # Place holder for wind direction mean + self.wd_mat_mean = np.zeros(len(df_scada), dtype=hercules_float_type) + + if "ti_000" in df_scada.columns: + self.ti_mat = df_scada[ + [f"ti_{t_idx:03d}" for t_idx in range(self.n_turbines)] + ].to_numpy(dtype=hercules_float_type) + + # Compute the turbine averaged turbulence intensities (axis = 1) using mean + self.ti_mat_mean = np.mean(self.ti_mat, axis=1, dtype=hercules_float_type) + + self.initial_tis = self.ti_mat[0, :] + + else: + self.ti_mat_mean = 0.08 * np.ones_like(self.ws_mat_mean, dtype=hercules_float_type) + + # No wake deficits + self.floris_wake_deficits = np.zeros(self.n_turbines, dtype=hercules_float_type) + + # Infer the rated power as the 99 percentile of the power column of 0th turbine + self.rated_turbine_power = np.percentile(df_scada[self.power_columns[0]], 99) + + # Get the capacity of the farm + self.capacity = self.n_turbines * self.rated_turbine_power + + self.logger.info(f"Inferred rated turbine power: {self.rated_turbine_power}") + self.logger.info(f"Inferred capacity: {self.capacity / 1e3} MW") + + # Initialize the turbine array + self.turbine_array = TurbineUpdateModelVectorizedSCADA(self.dt, self.scada_powers[0, :]) + + # Initialize the turbine powers to the starting row + self.turbine_powers = self.turbine_array.prev_powers.copy() + + def get_initial_conditions_and_meta_data(self, h_dict): + """Add any initial conditions or meta data to the h_dict. + + Meta data is data not explicitly in the input yaml but still useful for other + modules. + + Args: + h_dict (dict): Dictionary containing simulation parameters. + + Returns: + dict: Dictionary containing simulation parameters with initial conditions and meta data. + """ + h_dict["wind_farm"]["n_turbines"] = self.n_turbines + h_dict["wind_farm"]["capacity"] = self.capacity + h_dict["wind_farm"]["rated_turbine_power"] = self.rated_turbine_power + h_dict["wind_farm"]["wind_direction_mean"] = self.wd_mat_mean[0] + h_dict["wind_farm"]["wind_speed_mean_background"] = self.ws_mat_mean[0] + h_dict["wind_farm"]["turbine_powers"] = self.turbine_powers + h_dict["wind_farm"]["power"] = np.sum(self.turbine_powers) + + # Log the start time UTC if available + if hasattr(self, "starttime_utc"): + h_dict["wind_farm"]["starttime_utc"] = self.starttime_utc + + return h_dict + + def step(self, h_dict): + """Execute one simulation step for the wind farm. + + Updates wake deficits (if applicable), computes waked velocities, calculates + turbine powers, and updates the simulation dictionary with results. + + Args: + h_dict (dict): Dictionary containing current simulation state including + step number and power_setpoint values for each turbine. + + Returns: + dict: Updated simulation dictionary with wind farm outputs including + turbine powers, total power, and optional extra outputs. + """ + # Get the current step + step = h_dict["step"] + if self.verbose: + self.logger.info(f"step = {step} (of {self.n_steps})") + + # Grab the instantaneous turbine power setpoint signal + turbine_power_setpoints = h_dict[self.component_name]["turbine_power_setpoints"] + + # Update wind speeds based on wake model + + # No wake modeling - use background speeds directly + self.wind_speeds_background = self.ws_mat[step, :] + self.wind_speeds_withwakes = self.wind_speeds_background.copy() + self.floris_wake_deficits = np.zeros(self.n_turbines, dtype=hercules_float_type) + + # Update the turbine powers (common for all wake models) + # Vectorized calculation for all turbines at once + self.turbine_powers = self.turbine_array.step( + self.scada_powers[step, :], + turbine_power_setpoints, + ) + + # Update instantaneous wind direction and wind speed + self.wind_direction_mean = self.wd_mat_mean[step] + self.wind_speed_mean_background = self.ws_mat_mean[step] + + # Update the h_dict with outputs + h_dict[self.component_name]["power"] = np.sum(self.turbine_powers) + h_dict[self.component_name]["turbine_powers"] = self.turbine_powers + h_dict[self.component_name]["turbine_power_setpoints"] = turbine_power_setpoints + h_dict[self.component_name]["wind_direction_mean"] = self.wind_direction_mean + h_dict[self.component_name]["wind_speed_mean_background"] = self.wind_speed_mean_background + h_dict[self.component_name]["wind_speed_mean_withwakes"] = np.mean( + self.wind_speeds_withwakes, dtype=hercules_float_type + ) + h_dict[self.component_name]["wind_speeds_withwakes"] = self.wind_speeds_withwakes + h_dict[self.component_name]["wind_speeds_background"] = self.wind_speeds_background + + return h_dict + + +class TurbineUpdateModelVectorizedSCADA: + """Vectorized wind turbine update model for power output simulation.""" + + def __init__(self, dt, initial_scada_powers): + """Initialize the vectorized turbine model. + + Args: + dt (float): Time step for the simulation in seconds. + initial_scada_powers (np.ndarray): Initial SCADA power values in kW for all turbines + to initialize the simulation. + """ + # Save the time step + self.dt = dt + + # Number of turbines + self.n_turbines = len(initial_scada_powers) + + # Initialize the previous powers for all turbines + self.prev_powers = initial_scada_powers.copy() + + print("Filtering not yet implemented for SCADA-based turbine model. Use with caution.") + + def step(self, scada_powers, power_setpoints): + """Simulate a single time step for all wind turbines simultaneously. + + This method calculates the power output of all wind turbines based on the + given wind speeds and power setpoints. + + Args: + scada_powers (np.ndarray): Current SCADA powers for all turbines. + power_setpoints (np.ndarray): Maximum allowable power outputs in kW for all turbines. + + Returns: + np.ndarray: Calculated power outputs of all wind turbines, constrained + by the power setpoints. + """ + + # Vectorized limiting: current power not greater than power_setpoint + instant_powers = np.minimum(scada_powers, power_setpoints) + + # Vectorized limiting: instant power not less than 0 + instant_powers = np.maximum(instant_powers, 0.0) + + # Handle NaNs by replacing with previous power values + nan_mask = np.isnan(instant_powers) + if np.any(nan_mask): + instant_powers[nan_mask] = self.prev_powers[nan_mask] + + # Simple update without any filtering + powers = instant_powers + + # Vectorized limiting: power not greater than power_setpoint + powers = np.minimum(powers, power_setpoints) + + # Vectorized limiting: power not less than 0 + powers = np.maximum(powers, 0.0) + + # Update the previous powers for all turbines + self.prev_powers = powers.copy() + + # Return the powers + return powers diff --git a/hercules/plant_components/wind_meso_to_power.py b/hercules/plant_components/wind_meso_to_power.py deleted file mode 100644 index c2dc7c0f..00000000 --- a/hercules/plant_components/wind_meso_to_power.py +++ /dev/null @@ -1,864 +0,0 @@ -# Implements the meso-scale wind model for Hercules. - - -import numpy as np -import pandas as pd -from floris import FlorisModel -from hercules.plant_components.component_base import ComponentBase -from hercules.utilities import ( - find_time_utc_value, - hercules_float_type, - interpolate_df, - load_perffile, - load_yaml, -) -from scipy.interpolate import interp1d -from scipy.optimize import minimize_scalar -from scipy.stats import circmean - -RPM2RADperSec = 2 * np.pi / 60.0 - - -class Wind_MesoToPower(ComponentBase): - def __init__(self, h_dict): - """Initialize the Wind_MesoToPower class. - - This model focuses on meso-scale wind phenomena by applying a separate wind speed - time signal to each turbine model derived from data. It combines FLORIS wake - modeling with detailed turbine dynamics for wind farm performance analysis. - - Args: - h_dict (dict): Dictionary containing values for the simulation. - """ - # Store the name of this component - self.component_name = "wind_farm" - - # Store the type of this component - self.component_type = "Wind_MesoToPower" - - # Call the base class init - super().__init__(h_dict, self.component_name) - - # Confirm that logging_option is in h_dict[self.component_name] - if "logging_option" not in h_dict[self.component_name]: - raise ValueError(f"logging_option must be in the h_dict for {self.component_name}") - self.logging_option = h_dict[self.component_name]["logging_option"] - if self.logging_option not in ["base", "turb_subset", "all"]: - raise ValueError( - f"logging_option must be one of: base, turb_subset, all for {self.component_name}" - ) - - # Track the number of FLORIS calculations - self.num_floris_calcs = 0 - - # Read in the input file names - self.floris_input_file = h_dict[self.component_name]["floris_input_file"] - self.wind_input_filename = h_dict[self.component_name]["wind_input_filename"] - self.turbine_file_name = h_dict[self.component_name]["turbine_file_name"] - - # Require floris_update_time_s to be in the h_dict - if "floris_update_time_s" not in h_dict[self.component_name]: - raise ValueError("floris_update_time_s must be in the h_dict") - - # Save the floris update time and make sure it is at least 1 second - self.floris_update_time_s = h_dict[self.component_name]["floris_update_time_s"] - if self.floris_update_time_s < 1: - raise ValueError("FLORIS update time must be at least 1 second") - - # Read in the weather file data - # If a csv file is provided, read it in - if self.wind_input_filename.endswith(".csv"): - df_wi = pd.read_csv(self.wind_input_filename) - elif self.wind_input_filename.endswith(".p") | self.wind_input_filename.endswith(".pkl"): - df_wi = pd.read_pickle(self.wind_input_filename) - elif (self.wind_input_filename.endswith(".f")) | ( - self.wind_input_filename.endswith(".ftr") - ): - df_wi = pd.read_feather(self.wind_input_filename) - else: - raise ValueError("Wind input file must be a .csv or .p, .f or .ftr file") - - # Make sure the df_wi contains a column called "time" - if "time" not in df_wi.columns: - raise ValueError("Wind input file must contain a column called 'time'") - - # Make sure that both starttime and endtime are in the df_wi - if not (df_wi["time"].min() <= self.starttime <= df_wi["time"].max()): - raise ValueError( - f"Start time {self.starttime} is not in the range of the wind input file" - ) - if not (df_wi["time"].min() <= self.endtime - self.dt <= df_wi["time"].max()): - raise ValueError( - f"End time {self.endtime} - {self.dt} is not in the range of the wind input file" - ) - - # If time_utc is in the file, convert it to a datetime if it's not already - if "time_utc" in df_wi.columns: - if not pd.api.types.is_datetime64_any_dtype(df_wi["time_utc"]): - # Strip whitespace from time_utc values to handle CSV formatting issues - df_wi["time_utc"] = df_wi["time_utc"].astype(str).str.strip() - try: - df_wi["time_utc"] = pd.to_datetime( - df_wi["time_utc"], format="ISO8601", utc=True - ) - except (ValueError, TypeError): - # If ISO8601 format fails, try parsing without specifying format - df_wi["time_utc"] = pd.to_datetime(df_wi["time_utc"], utc=True) - - # Log the value of time_utc that corresponds to time == 0 - self.zero_time_utc = find_time_utc_value(df_wi, 0.0) - - # Log the value of time_utc which corresponds to starttime - self.start_time_utc = find_time_utc_value(df_wi, self.starttime) - - # Determine the dt implied by the weather file - self.dt_wi = df_wi["time"][1] - df_wi["time"][0] - - # Log the values - if self.verbose: - self.logger.info(f"dt_wi = {self.dt_wi}") - self.logger.info(f"dt = {self.dt}") - - # Interpolate df_wi on to the time steps - time_steps_all = np.arange(self.starttime, self.endtime, self.dt) - df_wi = interpolate_df(df_wi, time_steps_all) - - # FLORIS PREPARATION - - # Initialize the FLORIS model - self.fmodel = FlorisModel(self.floris_input_file) - - # Change to the simple-derating model turbine - # (Note this could also be done with the mixed model) - self.fmodel.set_operation_model("mixed") - - # Get the layout and number of turbines from FLORIS - self.layout_x = self.fmodel.layout_x - self.layout_y = self.fmodel.layout_y - self.n_turbines = self.fmodel.n_turbines - - # Set the logging outputs based on the logging_option - # First add outputs included in every logging option - self.log_outputs = [ - "power", - "wind_speed", - "wind_direction", - "wind_speed_waked", - ] - - # If including subset of turbines, add the turbine indices - if self.logging_option == "turb_subset": - self.random_turbine_indices = np.random.choice(self.n_turbines, size=3, replace=False) - self.log_outputs = self.log_outputs + [ - f"waked_velocities_turb_{t_idx:03d}" for t_idx in self.random_turbine_indices - ] - self.log_outputs = self.log_outputs + [ - f"turbine_powers_turb_{t_idx:03d}" for t_idx in self.random_turbine_indices - ] - self.log_outputs = self.log_outputs + [ - f"turbine_power_setpoints_turb_{t_idx:03d}" for t_idx in self.random_turbine_indices - ] - - # If including all data add these data points - elif self.logging_option == "all": - self.log_outputs = self.log_outputs + [ - "turbine_powers", - "turbine_power_setpoints", - "floris_wind_speed", - "floris_wind_direction", - "floris_ti", - "unwaked_velocities", - "waked_velocities", - ] - - # How often to update the wake deficits - self.floris_update_steps = int(self.floris_update_time_s / self.dt) - self.floris_update_steps = max(1, self.floris_update_steps) - - # Declare the power_setpoint buffer to hold previous power_setpoint commands - self.turbine_power_setpoints_buffer = ( - np.zeros((self.floris_update_steps, self.n_turbines), dtype=hercules_float_type) - * np.nan - ) - self.turbine_power_setpoints_buffer_idx = 0 # Initialize the index to 0 - - # Add an initial non-nan value to be over-written on first step - self.turbine_power_setpoints_buffer[0, :] = 1e12 - - # Convert the wind directions and wind speeds and ti to simply numpy matrices - # Starting with wind speeds - - self.ws_mat = df_wi[[f"ws_{t_idx:03d}" for t_idx in range(self.n_turbines)]].to_numpy( - dtype=hercules_float_type - ) - - # Compute the turbine averaged wind speeds (axis = 1) using mean - self.ws_mat_mean = np.mean(self.ws_mat, axis=1, dtype=hercules_float_type) - - self.initial_wind_speeds = self.ws_mat[0, :] - self.floris_wind_speed = self.ws_mat_mean[0] - - # For now require "wd_mean" to be in the df_wi - if "wd_mean" not in df_wi.columns: - raise ValueError("Wind input file must contain a column called 'wd_mean'") - self.wd_mat_mean = df_wi["wd_mean"].values.astype(hercules_float_type) - - # OLD APPROACH - # # Now the wind directions - # if "wd_000" in df_wi.columns: - # self.wd_mat = df_wi[ - # [f"wd_{t_idx:03d}" for t_idx in range(self.n_turbines)] - # ].to_numpy() - - # # Compute the turbine-averaged wind directions (axis = 1) using circmean - # self.wd_mat_mean = np.apply_along_axis( - # lambda x: circmean(x, high=360.0, low=0.0, nan_policy="omit"), - # axis=1, - # arr=self.wd_mat, - # ) - - # self.initial_wind_directions = self.wd_mat[0, :] - # elif "wd_mean" in df_wi.columns: - # self.wd_mat_mean = df_wi["wd_mean"].values - - # Compute the initial floris wind direction and wind speed as at the start index - self.floris_wind_direction = self.wd_mat_mean[0] - - if "ti_000" in df_wi.columns: - self.ti_mat = df_wi[[f"ti_{t_idx:03d}" for t_idx in range(self.n_turbines)]].to_numpy( - dtype=hercules_float_type - ) - - # Compute the turbine averaged turbulence intensities (axis = 1) using mean - self.ti_mat_mean = np.mean(self.ti_mat, axis=1, dtype=hercules_float_type) - - self.initial_tis = self.ti_mat[0, :] - - self.floris_ti = self.ti_mat_mean[0] - - else: - self.ti_mat_mean = 0.08 * np.ones_like(self.ws_mat_mean, dtype=hercules_float_type) - self.floris_ti = 0.08 * self.ti_mat_mean[0] - - self.floris_turbine_power_setpoints = np.nanmean( - self.turbine_power_setpoints_buffer, axis=0 - ).astype(hercules_float_type) - - # Initialize the wake deficits - self.floris_wake_deficits = np.zeros(self.n_turbines, dtype=hercules_float_type) - - # Initialize the turbine powers to nan - self.turbine_powers = np.zeros(self.n_turbines, dtype=hercules_float_type) * np.nan - - # Get the initial unwaked velocities - # TODO: This is more a debugging thing, not really necessary - self.unwaked_velocities = self.ws_mat[0, :] - - # # Compute the initial waked velocities - self.update_wake_deficits(step=0) - - # Compute waked velocities - self.waked_velocities = self.ws_mat[0, :] - self.floris_wake_deficits - - # Get the turbine information - self.turbine_dict = load_yaml(self.turbine_file_name) - self.turbine_model_type = self.turbine_dict["turbine_model_type"] - - # Initialize the turbine array - if self.turbine_model_type == "filter_model": - # Use vectorized implementation for improved performance - self.turbine_array = TurbineFilterModelVectorized( - self.turbine_dict, self.dt, self.fmodel, self.waked_velocities - ) - self.use_vectorized_turbines = True - elif self.turbine_model_type == "dof1_model": - self.turbine_array = [ - Turbine1dofModel( - self.turbine_dict, self.dt, self.fmodel, self.waked_velocities[t_idx] - ) - for t_idx in range(self.n_turbines) - ] - self.use_vectorized_turbines = False - else: - raise Exception("Turbine model type should be either filter_model or dof1_model") - - # Initialize the power array to the initial wind speeds - if self.use_vectorized_turbines: - self.turbine_powers = self.turbine_array.prev_powers.copy() - else: - self.turbine_powers = np.array( - [self.turbine_array[t_idx].prev_power for t_idx in range(self.n_turbines)] - ).astype(hercules_float_type) - - # Get the rated power of the turbines, for now assume all turbines have the same rated power - if self.use_vectorized_turbines: - self.rated_turbine_power = self.turbine_array.get_rated_power() - else: - self.rated_turbine_power = self.turbine_array[0].get_rated_power() - - # Get the capacity of the farm - self.capacity = self.n_turbines * self.rated_turbine_power - - # Update the user - self.logger.info(f"Initialized Wind_MesoToPower with {self.n_turbines} turbines") - - def get_initial_conditions_and_meta_data(self, h_dict): - """Add any initial conditions or meta data to the h_dict. - - Meta data is data not explicitly in the input yaml but still useful for other - modules. - - Args: - h_dict (dict): Dictionary containing simulation parameters. - - Returns: - dict: Dictionary containing simulation parameters with initial conditions and meta data. - """ - h_dict["wind_farm"]["n_turbines"] = self.n_turbines - h_dict["wind_farm"]["capacity"] = self.capacity - h_dict["wind_farm"]["rated_turbine_power"] = self.rated_turbine_power - h_dict["wind_farm"]["wind_direction"] = self.wd_mat_mean[0] - h_dict["wind_farm"]["wind_speed"] = self.ws_mat_mean[0] - h_dict["wind_farm"]["turbine_powers"] = self.turbine_powers - h_dict["wind_farm"]["power"] = np.sum(self.turbine_powers) - - # Log the start time UTC if available - if hasattr(self, "start_time_utc"): - h_dict["wind_farm"]["start_time_utc"] = self.start_time_utc - if hasattr(self, "zero_time_utc"): - h_dict["wind_farm"]["zero_time_utc"] = self.zero_time_utc - - return h_dict - - def update_wake_deficits(self, step): - """Update the wake deficits in the FLORIS model based on the current simulation step. - - This method computes the necessary FLORIS inputs (wind direction, wind speed, - turbulence intensity, and power_setpoints) over a specified time window. If any of these - inputs have changed beyond their respective thresholds, the FLORIS model is updated, - and the wake deficits are recalculated. - - Args: - step (int): The current simulation step. - """ - # Get the window start - window_start = max(0, step - self.floris_update_steps + 1) - - # Compute new values of the floris inputs - # TODO: CONFIRM THE +1 in the slice is right - self.floris_wind_direction = circmean( - self.wd_mat_mean[window_start : step + 1], high=360.0, low=0.0, nan_policy="omit" - ) - self.floris_wind_speed = np.mean( - self.ws_mat_mean[window_start : step + 1], dtype=hercules_float_type - ) - self.floris_ti = np.mean( - self.ti_mat_mean[window_start : step + 1], dtype=hercules_float_type - ) - - # Compute the power_setpoints over the same window - self.floris_turbine_power_setpoints = ( - np.nanmean(self.turbine_power_setpoints_buffer, axis=0) - .astype(hercules_float_type) - .reshape(1, -1) - ) - - # Run FLORIS - self.fmodel.set( - wind_directions=[self.floris_wind_direction], - wind_speeds=[self.floris_wind_speed], - turbulence_intensities=[self.floris_ti], - power_setpoints=self.floris_turbine_power_setpoints * 1000.0, - ) - self.fmodel.run() - velocities = self.fmodel.turbine_average_velocities.flatten() - self.floris_wake_deficits = velocities.max() - velocities - self.num_floris_calcs += 1 - - def update_power_setpoints_buffer(self, turbine_power_setpoints): - """Update the power_setpoints buffer with the turbine_power_setpoints values. - - This method stores the given power setpoint values in the current position of the - power_setpoints buffer and updates the index to point to the next position in a - circular manner. - - Args: - turbine_power_setpoints (numpy.ndarray): A 1D array containing the power_setpoint values - to be stored in the buffer. - """ - # Update the power_setpoints buffer - self.turbine_power_setpoints_buffer[self.turbine_power_setpoints_buffer_idx, :] = ( - turbine_power_setpoints - ) - - # Increment the index - self.turbine_power_setpoints_buffer_idx = ( - self.turbine_power_setpoints_buffer_idx + 1 - ) % self.floris_update_steps - - def step(self, h_dict): - """Execute one simulation step for the wind farm. - - Updates wake deficits, computes waked velocities, calculates turbine powers, - and updates the simulation dictionary with results. Handles power_setpoint - signals and optional extra logging outputs. - - Args: - h_dict (dict): Dictionary containing current simulation state including - step number and power_setpoint values for each turbine. - - Returns: - dict: Updated simulation dictionary with wind farm outputs including - turbine powers, total power, and optional extra outputs. - """ - # Get the current step - step = h_dict["step"] - if self.verbose: - self.logger.info(f"step = {step} (of {self.n_steps})") - - # Grab the instantaneous turbine power setpoint signal and update the power_setpoints buffer - turbine_power_setpoints = h_dict[self.component_name]["turbine_power_setpoints"] - self.update_power_setpoints_buffer(turbine_power_setpoints) - - # Get the unwaked velocities - # TODO: This is more a debugging thing, not really necessary - self.unwaked_velocities = self.ws_mat[step, :] - - # Check if it is time to update the waked velocities - if step % self.floris_update_steps == 0: - self.update_wake_deficits(step) - - # Compute waked velocities - self.waked_velocities = self.ws_mat[step, :] - self.floris_wake_deficits - - # Update the turbine powers - if self.use_vectorized_turbines: - # Vectorized calculation for all turbines at once - self.turbine_powers = self.turbine_array.step( - self.waked_velocities, - turbine_power_setpoints, - ) - else: - # Original loop-based calculation - for t_idx in range(self.n_turbines): - self.turbine_powers[t_idx] = self.turbine_array[t_idx].step( - self.waked_velocities[t_idx], - power_setpoint=turbine_power_setpoints[t_idx], - ) - - # Update instantaneous wind direction and wind speed - self.wind_direction = self.wd_mat_mean[step] - self.wind_speed = self.ws_mat_mean[step] - - # Update the h_dict with outputs - h_dict[self.component_name]["power"] = np.sum(self.turbine_powers) - h_dict[self.component_name]["turbine_powers"] = self.turbine_powers - h_dict[self.component_name]["turbine_power_setpoints"] = turbine_power_setpoints - h_dict[self.component_name]["wind_direction"] = self.wind_direction - h_dict[self.component_name]["wind_speed"] = self.wind_speed - h_dict[self.component_name]["wind_speed_waked"] = np.mean( - self.waked_velocities, dtype=hercules_float_type - ) - - # If logging_option is "turb_subset", add the turbine indices - if self.logging_option == "turb_subset": - for t_idx in self.random_turbine_indices: - h_dict[self.component_name][f"waked_velocities_turb_{t_idx:03d}"] = ( - self.waked_velocities[t_idx] - ) - h_dict[self.component_name][f"turbine_powers_turb_{t_idx:03d}"] = ( - self.turbine_powers[t_idx] - ) - h_dict[self.component_name][f"turbine_power_setpoints_turb_{t_idx:03d}"] = ( - turbine_power_setpoints[t_idx] - ) - - # Else if logging_option is "all", add the turbine powers - elif self.logging_option == "all": - h_dict[self.component_name]["floris_wind_speed"] = self.floris_wind_speed - h_dict[self.component_name]["floris_wind_direction"] = self.floris_wind_direction - h_dict[self.component_name]["floris_ti"] = self.floris_ti - h_dict[self.component_name]["floris_turbine_power_setpoints"] = ( - self.floris_turbine_power_setpoints - ) - h_dict[self.component_name]["unwaked_velocities"] = self.unwaked_velocities - h_dict[self.component_name]["waked_velocities"] = self.waked_velocities - - return h_dict - - -class TurbineFilterModel: - """Simple filter-based wind turbine model for power output simulation. - - This model simulates wind turbine power output using a first-order filter - to smooth the response to changing wind conditions, providing a simplified - representation of turbine dynamics. - - NOTE: This class is now unused and kept for backward compatibility. - The filter_model turbine_model_type now uses TurbineFilterModelVectorized - for improved performance. - """ - - def __init__(self, turbine_dict, dt, fmodel, initial_wind_speed): - """Initialize the turbine filter model. - - Args: - turbine_dict (dict): Dictionary containing turbine configuration, - including filter model parameters and other turbine-specific data. - dt (float): Time step for the simulation in seconds. - fmodel (FlorisModel): FLORIS model of the farm. - initial_wind_speed (float): Initial wind speed in m/s to initialize - the simulation. - """ - # Save the time step - self.dt = dt - - # Save the turbine dict - self.turbine_dict = turbine_dict - - # Save the filter time constant - self.filter_time_constant = turbine_dict["filter_model"]["time_constant"] - - # Solve for the filter alpha value given dt and the time constant - self.alpha = 1 - np.exp(-self.dt / self.filter_time_constant) - - # Grab the wind speed power curve from the fmodel and define a simple 1D LUT - turbine_type = fmodel.core.farm.turbine_definitions[0] - wind_speeds = turbine_type["power_thrust_table"]["wind_speed"] - powers = turbine_type["power_thrust_table"]["power"] - self.power_lut = interp1d( - wind_speeds, - powers, - fill_value=0.0, - bounds_error=False, - ) - - # Initialize the previous power to the initial wind speed - self.prev_power = self.power_lut(initial_wind_speed) - - def get_rated_power(self): - """Get the rated power of the turbine. - - Returns: - float: The rated power of the turbine in kW. - """ - return np.max(self.power_lut(np.arange(0, 25, 1.0, dtype=hercules_float_type))) - - def step(self, wind_speed, power_setpoint): - """Simulate a single time step of the wind turbine power output. - - This method calculates the power output of a wind turbine based on the - given wind speed and power_setpoint. The power output is - smoothed using an exponential moving average to simulate the turbine's - response to changing wind conditions. - - Args: - wind_speed (float): The current wind speed in meters per second (m/s). - power_setpoint (float): The maximum allowable power output in kW. - - Returns: - float: The calculated power output of the wind turbine, constrained - by the power_setpoint and smoothed using the exponential moving average. - """ - # Instantaneous power - instant_power = self.power_lut(wind_speed) - - # Limit the current power to not be greater than power_setpoint - instant_power = min(instant_power, power_setpoint) - - # Limit the instant power to be greater than 0 - instant_power = max(instant_power, 0.0) - - # TEMP: why are NaNs occurring? - if np.isnan(instant_power): - print( - f"NaN instant power at wind speed {wind_speed} m/s, " - f"power setpoint {power_setpoint} kW, prev power {self.prev_power} kW" - ) - instant_power = self.prev_power - - # Update the power - power = self.alpha * instant_power + (1 - self.alpha) * self.prev_power - - # Limit the power to not be greater than power_setpoint - power = min(power, power_setpoint) - - # Limit the power to be greater than 0 - power = max(power, 0.0) - - # Update the previous power - self.prev_power = power - - # Return the power - return power - - -class TurbineFilterModelVectorized: - """Vectorized filter-based wind turbine model for power output simulation. - - This model simulates wind turbine power output using a first-order filter - to smooth the response to changing wind conditions, providing a simplified - representation of turbine dynamics. This vectorized version processes - all turbines simultaneously for improved performance. - """ - - def __init__(self, turbine_dict, dt, fmodel, initial_wind_speeds): - """Initialize the vectorized turbine filter model. - - Args: - turbine_dict (dict): Dictionary containing turbine configuration, - including filter model parameters and other turbine-specific data. - dt (float): Time step for the simulation in seconds. - fmodel (FlorisModel): FLORIS model of the farm. - initial_wind_speeds (np.ndarray): Initial wind speeds in m/s for all turbines - to initialize the simulation. - """ - # Save the time step - self.dt = dt - - # Save the turbine dict - self.turbine_dict = turbine_dict - - # Save the filter time constant - self.filter_time_constant = turbine_dict["filter_model"]["time_constant"] - - # Solve for the filter alpha value given dt and the time constant - self.alpha = 1 - np.exp(-self.dt / self.filter_time_constant) - - # Grab the wind speed power curve from the fmodel and create lookup tables - turbine_type = fmodel.core.farm.turbine_definitions[0] - self.wind_speed_lut = np.array( - turbine_type["power_thrust_table"]["wind_speed"], dtype=hercules_float_type - ) - self.power_lut = np.array( - turbine_type["power_thrust_table"]["power"], dtype=hercules_float_type - ) - - # Number of turbines - self.n_turbines = len(initial_wind_speeds) - - # Initialize the previous powers for all turbines - self.prev_powers = np.interp( - initial_wind_speeds, self.wind_speed_lut, self.power_lut, left=0.0, right=0.0 - ).astype(hercules_float_type) - - def get_rated_power(self): - """Get the rated power of the turbine. - - Returns: - float: The rated power of the turbine in kW. - """ - return np.max(self.power_lut) - - def step(self, wind_speeds, power_setpoints): - """Simulate a single time step for all wind turbines simultaneously. - - This method calculates the power output of all wind turbines based on the - given wind speeds and power setpoints. The power outputs are - smoothed using an exponential moving average to simulate the turbines' - response to changing wind conditions. - - Args: - wind_speeds (np.ndarray): Current wind speeds in m/s for all turbines. - power_setpoints (np.ndarray): Maximum allowable power outputs in kW for all turbines. - - Returns: - np.ndarray: Calculated power outputs of all wind turbines, constrained - by the power setpoints and smoothed using the exponential moving average. - """ - # Vectorized instantaneous power calculation using numpy interpolation - instant_powers = np.interp( - wind_speeds, self.wind_speed_lut, self.power_lut, left=0.0, right=0.0 - ) - - # Vectorized limiting: current power not greater than power_setpoint - instant_powers = np.minimum(instant_powers, power_setpoints) - - # Vectorized limiting: instant power not less than 0 - instant_powers = np.maximum(instant_powers, 0.0) - - # Handle NaNs by replacing with previous power values - nan_mask = np.isnan(instant_powers) - if np.any(nan_mask): - # Log warning for NaN values (but don't print every occurrence for performance) - # Could add logging here if needed - instant_powers[nan_mask] = self.prev_powers[nan_mask] - - # Vectorized exponential filter update - powers = self.alpha * instant_powers + (1 - self.alpha) * self.prev_powers - - # Vectorized limiting: power not greater than power_setpoint - powers = np.minimum(powers, power_setpoints) - - # Vectorized limiting: power not less than 0 - powers = np.maximum(powers, 0.0) - - # Update the previous powers for all turbines - self.prev_powers = powers.copy() - - # Return the powers - return powers - - -class Turbine1dofModel: - """Single degree-of-freedom wind turbine model with detailed dynamics. - - This model simulates wind turbine behavior using a 1-DOF representation - that includes rotor dynamics, pitch control, and generator torque control. - """ - - def __init__(self, turbine_dict, dt, fmodel, initial_wind_speed): - """Initialize the 1-DOF turbine model. - - Args: - turbine_dict (dict): Dictionary containing turbine configuration and - DOF model parameters. - dt (float): Time step for the simulation in seconds. - fmodel (FlorisModel): FLORIS model of the farm. - initial_wind_speed (float): Initial wind speed in m/s to initialize - the simulation. - """ - # Save the time step - self.dt = dt - - # Save the turbine dict - self.turbine_dict = turbine_dict - - # Set filter parameter for rotor speed - self.filteralpha = np.exp( - -self.dt * self.turbine_dict["dof1_model"]["filterfreq_rotor_speed"] - ) - - # Obtain more data from floris - turbine_type = fmodel.core.farm.turbine_definitions[0] - self.rotor_radius = turbine_type["rotor_diameter"] / 2 - self.rotor_area = np.pi * self.rotor_radius**2 - - # Save performance data functions - perffile = turbine_dict["dof1_model"]["cq_table_file"] - self.perffuncs = load_perffile(perffile) - - self.rho = self.turbine_dict["dof1_model"]["rho"] - self.max_pitch_rate = self.turbine_dict["dof1_model"]["max_pitch_rate"] - self.max_torque_rate = self.turbine_dict["dof1_model"]["max_torque_rate"] - omega0 = self.turbine_dict["dof1_model"]["initial_rpm"] * RPM2RADperSec - pitch, gentq = self.simplecontroller(initial_wind_speed, omega0) - tsr = self.rotor_radius * omega0 / initial_wind_speed - prev_power = ( - self.perffuncs["Cp"]([tsr, pitch]) - * 0.5 - * self.rho - * self.rotor_area - * initial_wind_speed**3 - ) - self.prev_power = np.array(prev_power[0] / 1000.0, dtype=hercules_float_type) - self.prev_omega = omega0 - self.prev_omegaf = omega0 - self.prev_aerotq = ( - 0.5 - * self.rho - * self.rotor_area - * self.rotor_radius - * initial_wind_speed**2 - * self.perffuncs["Cq"]([tsr, pitch]) - ) - self.prev_gentq = gentq - self.prev_pitch = pitch - - def get_rated_power(self): - """Get the rated power of the turbine. - - Raises: - NotImplementedError: 1-DOF turbine model does not have a rated power. - """ - raise NotImplementedError("1-DOF turbine model does not have a rated power") - - def step(self, wind_speed, power_setpoint): - """Execute one simulation step for the 1-DOF turbine model. - - Simulates turbine dynamics including rotor speed, pitch angle, and - generator torque while respecting rate limits and power_setpoint constraints. - - Args: - wind_speed (float): Current wind speed in m/s. - power_setpoint (float): Maximum allowable power output in kW. - - Returns: - float: Calculated turbine power output in kW. - """ - omega = ( - self.prev_omega - + ( - self.prev_aerotq - - self.prev_gentq * self.turbine_dict["dof1_model"]["gearbox_ratio"] - ) - * self.dt - / self.turbine_dict["dof1_model"]["rotor_inertia"] - ) - omegaf = (1 - self.filteralpha) * omega + self.filteralpha * (self.prev_omegaf) - # print(omegaf-omega) - pitch, gentq = self.simplecontroller(wind_speed, omegaf) - tsr = float(omegaf * self.rotor_radius / wind_speed) - if power_setpoint > 0: - desiredcp = power_setpoint * 1000 / (0.5 * self.rho * self.rotor_area * wind_speed**3) - optpitch = minimize_scalar( - lambda p: abs(float(self.perffuncs["Cp"]([tsr, float(p)])) - desiredcp), - method="bounded", - bounds=(0, 1.57), - ) - pitch = optpitch.x - - pitch = np.clip( - pitch, - self.prev_pitch - self.max_pitch_rate * self.dt, - self.prev_pitch + self.max_pitch_rate * self.dt, - ) - gentq = np.clip( - gentq, - self.prev_gentq - self.max_torque_rate * self.dt, - self.prev_gentq + self.max_torque_rate * self.dt, - ) - - aerotq = ( - 0.5 - * self.rho - * self.rotor_area - * self.rotor_radius - * wind_speed**2 - * self.perffuncs["Cq"]([tsr, pitch]) - ) - - # power = ( - # self.perffuncs["Cp"]([tsr, pitch]) * 0.5 * self.rho * self.rotor_area * wind_speed**3 - # ) - power = gentq * omega * self.turbine_dict["dof1_model"]["gearbox_ratio"] - - self.prev_omega = omega - self.prev_aerotq = aerotq - self.prev_gentq = gentq - self.prev_pitch = pitch - self.prev_omegaf = omegaf - self.prev_power = power[0] / 1000.0 - - return self.prev_power - - def simplecontroller(self, wind_speed, omegaf): - """Simple controller for pitch and generator torque. - - Implements a basic Region 2 controller that sets pitch to 0 and - calculates generator torque based on filtered rotor speed. - - Args: - wind_speed (float): Current wind speed in m/s. - omegaf (float): Filtered rotor speed in rad/s. - - Returns: - tuple: (pitch_angle, generator_torque) where pitch is in radians - and generator torque is in N⋅m. - """ - # if omega <= self.turbine_dict['dof1_model']['rated_wind_speed']: - pitch = 0.0 - gentorque = self.turbine_dict["dof1_model"]["controller"]["r2_k_torque"] * omegaf**2 - # else - # raise Exception("Region-3 controller not implemented yet") - return pitch, gentorque diff --git a/hercules/plant_components/wind_meso_to_power_precom_floris.py b/hercules/plant_components/wind_meso_to_power_precom_floris.py deleted file mode 100644 index 600cf30a..00000000 --- a/hercules/plant_components/wind_meso_to_power_precom_floris.py +++ /dev/null @@ -1,610 +0,0 @@ -# Implements the meso-scale wind model for Hercules. - - -import numpy as np -import pandas as pd -from floris import ApproxFlorisModel -from floris.core import average_velocity -from floris.uncertain_floris_model import map_turbine_powers_uncertain -from hercules.plant_components.component_base import ComponentBase -from hercules.plant_components.wind_meso_to_power import ( - Turbine1dofModel, - TurbineFilterModelVectorized, -) -from hercules.utilities import ( - find_time_utc_value, - hercules_float_type, - interpolate_df_fast, - load_yaml, -) -from scipy.interpolate import interp1d -from scipy.stats import circmean - -RPM2RADperSec = 2 * np.pi / 60.0 - - -class Wind_MesoToPowerPrecomFloris(ComponentBase): - def __init__(self, h_dict): - """Initialize the Wind_MesoToPowerPrecomFloris class. - - This model focuses on meso-scale wind phenomena by applying a separate wind speed - time signal to each turbine model derived from data. It combines FLORIS wake - modeling with detailed turbine dynamics for wind farm performance analysis. - - In contrast to the Wind_MesoToPower class, this class pre-computes the FLORIS wake - deficits for all wind speeds and wind directions. This is done by running FLORIS - once for all wind speeds and wind directions (but not for varying power setpoints). - This is valid - for cases where the wind farm is operating: - - all turbines operating normally - - all turbines off - - following a wind-farm wide derating level - - It is in practice conservative with respect to the wake deficits, but it is more efficient - than running FLORIS for each condition. In cases where turbines are: - - partially derated below the curtailment level - - not uniformly curtailed or some turbines are off - - This is not an appropriate model and the more general Wind_MesoToPower class should be used. - - Args: - h_dict (dict): Dictionary containing values for the simulation. - - Required keys in `h_dict['wind_farm']`: - - `floris_input_file` (str): Path to FLORIS configuration file. - - `wind_input_filename` (str): Path to wind input data file. - - `turbine_file_name` (str): Path to turbine configuration file. - - `floris_update_time_s` (float): Update period in seconds. This value - determines the cadence of the wake precomputation. Wind inputs are - averaged over the most recent `floris_update_time_s` and FLORIS is - evaluated at that interval. The resulting wake deficits are then held - constant until the next FLORIS update. - """ - # Store the name of this component - self.component_name = "wind_farm" - - # Store the type of this component - self.component_type = "Wind_MesoToPowerPrecomFloris" - - # Call the base class init - super().__init__(h_dict, self.component_name) - - # Confirm that logging_option is in h_dict[self.component_name] - if "logging_option" not in h_dict[self.component_name]: - raise ValueError(f"logging_option must be in the h_dict for {self.component_name}") - self.logging_option = h_dict[self.component_name]["logging_option"] - if self.logging_option not in ["base", "turb_subset", "all"]: - raise ValueError( - f"logging_option must be one of: base, turb_subset, all for {self.component_name}" - ) - - self.logger.info("Completed base class init...") - - # Track the number of FLORIS calculations - self.num_floris_calcs = 0 - - self.logger.info("Reading in FLORIS input files...") - - # Read in the input file names - self.floris_input_file = h_dict[self.component_name]["floris_input_file"] - self.wind_input_filename = h_dict[self.component_name]["wind_input_filename"] - self.turbine_file_name = h_dict[self.component_name]["turbine_file_name"] - - # Require floris_update_time_s for interface consistency, though it is unused - if "floris_update_time_s" not in h_dict[self.component_name]: - raise ValueError("floris_update_time_s must be in the h_dict") - self.floris_update_time_s = h_dict[self.component_name]["floris_update_time_s"] - if self.floris_update_time_s < 1: - raise ValueError("FLORIS update time must be at least 1 second") - # Derived step count (not used by this precomputed model, but kept for parity) - self.floris_update_steps = max(1, int(self.floris_update_time_s / self.dt)) - - self.logger.info("Reading in wind input file...") - - # Read in the weather file data - # If a csv file is provided, read it in - if self.wind_input_filename.endswith(".csv"): - df_wi = pd.read_csv(self.wind_input_filename) - elif self.wind_input_filename.endswith(".p") | self.wind_input_filename.endswith(".pkl"): - df_wi = pd.read_pickle(self.wind_input_filename) - elif (self.wind_input_filename.endswith(".f")) | ( - self.wind_input_filename.endswith(".ftr") - ): - df_wi = pd.read_feather(self.wind_input_filename) - else: - raise ValueError("Wind input file must be a .csv or .p, .f or .ftr file") - - self.logger.info("Checking wind input file...") - - # Make sure the df_wi contains a column called "time" - if "time" not in df_wi.columns: - raise ValueError("Wind input file must contain a column called 'time'") - - # Make sure that both starttime and endtime are in the df_wi - if not (df_wi["time"].min() <= self.starttime <= df_wi["time"].max()): - raise ValueError( - f"Start time {self.starttime} is not in the range of the wind input file" - ) - if not (df_wi["time"].min() <= self.endtime - self.dt <= df_wi["time"].max()): - raise ValueError( - f"End time {self.endtime} - {self.dt} is not in the range of the wind input file" - ) - - # If time_utc is in the file, convert it to a datetime if it's not already - if "time_utc" in df_wi.columns: - if not pd.api.types.is_datetime64_any_dtype(df_wi["time_utc"]): - # Strip whitespace from time_utc values to handle CSV formatting issues - df_wi["time_utc"] = df_wi["time_utc"].astype(str).str.strip() - try: - df_wi["time_utc"] = pd.to_datetime( - df_wi["time_utc"], format="ISO8601", utc=True - ) - except (ValueError, TypeError): - # If ISO8601 format fails, try parsing without specifying format - df_wi["time_utc"] = pd.to_datetime(df_wi["time_utc"], utc=True) - - # Log the value of time_utc that corresponds to time == 0 - self.zero_time_utc = find_time_utc_value(df_wi, 0.0) - - # Log the value of time_utc which corresponds to starttime - self.start_time_utc = find_time_utc_value(df_wi, self.starttime) - - # Determine the dt implied by the weather file - self.dt_wi = df_wi["time"][1] - df_wi["time"][0] - - # Log the values - if self.verbose: - self.logger.info(f"dt_wi = {self.dt_wi}") - self.logger.info(f"dt = {self.dt}") - - self.logger.info("Interpolating wind input file...") - - # Interpolate df_wi on to the time steps - time_steps_all = np.arange(self.starttime, self.endtime, self.dt) - df_wi = interpolate_df_fast(df_wi, time_steps_all) - - # FLORIS PRECOMPUTATION - - # Initialize the FLORIS model as an ApproxFlorisModel - self.fmodel = ApproxFlorisModel( - self.floris_input_file, - wd_resolution=1.0, - ws_resolution=1.0, - ) - - # Get the layout and number of turbines from FLORIS - self.layout_x = self.fmodel.layout_x - self.layout_y = self.fmodel.layout_y - self.n_turbines = self.fmodel.n_turbines - - self.logger.info("Converting wind input file to numpy matrices...") - - # Convert the wind directions and wind speeds and ti to simply numpy matrices - # Starting with wind speed - # Apply the Hercules float type to the wind speeds - self.ws_mat = df_wi[[f"ws_{t_idx:03d}" for t_idx in range(self.n_turbines)]].to_numpy( - dtype=hercules_float_type - ) - - # Compute the turbine averaged wind speeds (axis = 1) using mean - self.ws_mat_mean = np.mean(self.ws_mat, axis=1, dtype=hercules_float_type) - - self.initial_wind_speeds = self.ws_mat[0, :] - self.floris_wind_speed = self.ws_mat_mean[0] - - # For now require "wd_mean" to be in the df_wi - if "wd_mean" not in df_wi.columns: - raise ValueError("Wind input file must contain a column called 'wd_mean'") - self.wd_mat_mean = df_wi["wd_mean"].values.astype(hercules_float_type) - - if "ti_000" in df_wi.columns: - self.ti_mat = df_wi[[f"ti_{t_idx:03d}" for t_idx in range(self.n_turbines)]].to_numpy( - dtype=hercules_float_type - ) - - # Compute the turbine averaged turbulence intensities (axis = 1) using mean - self.ti_mat_mean = np.mean(self.ti_mat, axis=1, dtype=hercules_float_type) - - self.initial_tis = self.ti_mat[0, :] - - else: - self.ti_mat_mean = 0.08 * np.ones_like(self.ws_mat_mean, dtype=hercules_float_type) - - # Precompute the wake deficits at the cadence specified by floris_update_time_s - self.logger.info("Precomputing FLORIS wake deficits...") - - # Determine update step cadence and indices to evaluate FLORIS - update_steps = self.floris_update_steps - n_steps = len(self.ws_mat_mean) - eval_indices = np.arange(update_steps - 1, n_steps, update_steps) - # Ensure at least the final time is evaluated - if eval_indices.size == 0: - eval_indices = np.array([n_steps - 1]) - elif eval_indices[-1] != n_steps - 1: - eval_indices = np.append(eval_indices, n_steps - 1) - - # Build right-aligned windowed means for ws, wd, ti at the evaluation indices - def window_mean(arr_1d, idx, win): - start = max(0, idx - win + 1) - return np.mean(arr_1d[start : idx + 1], dtype=hercules_float_type) - - def window_circmean(arr_1d, idx, win): - start = max(0, idx - win + 1) - return circmean(arr_1d[start : idx + 1], high=360.0, low=0.0, nan_policy="omit") - - ws_eval = np.array( - [window_mean(self.ws_mat_mean, i, update_steps) for i in eval_indices], - dtype=hercules_float_type, - ) - wd_eval = np.array( - [window_circmean(self.wd_mat_mean, i, update_steps) for i in eval_indices], - dtype=hercules_float_type, - ) - if np.isscalar(self.ti_mat_mean): - ti_eval = self.ti_mat_mean * np.ones_like(ws_eval, dtype=hercules_float_type) - else: - ti_eval = np.array( - [window_mean(self.ti_mat_mean, i, update_steps) for i in eval_indices], - dtype=hercules_float_type, - ) - - # Evaluate FLORIS at the evaluation cadence - self.fmodel.set( - wind_directions=wd_eval, - wind_speeds=ws_eval, - turbulence_intensities=ti_eval, - ) - self.logger.info("Running FLORIS...") - self.fmodel.run() - self.num_floris_calcs = 1 - self.logger.info("FLORIS run complete") - - # TODO: THIS CODE WILL WORK IN THE FUTURE - # https://github.com/NREL/floris/pull/1135 - # floris_velocities = ( - # self.fmodel.turbine_average_velocities - # ) # This is a 2D array of shape (len(wind_directions), n_turbines) - - # For now compute in place here (replace later) - expanded_velocities = average_velocity( - velocities=self.fmodel.fmodel_expanded.core.flow_field.u, - method=self.fmodel.fmodel_expanded.core.grid.average_method, - cubature_weights=self.fmodel.fmodel_expanded.core.grid.cubature_weights, - ) - - floris_velocities = map_turbine_powers_uncertain( - unique_turbine_powers=expanded_velocities, - map_to_expanded_inputs=self.fmodel.map_to_expanded_inputs, - weights=self.fmodel.weights, - n_unexpanded=self.fmodel.n_unexpanded, - n_sample_points=self.fmodel.n_sample_points, - n_turbines=self.fmodel.n_turbines, - ).astype(hercules_float_type) - - # Determine the free_stream velocities as the maximum velocity in each row - # of floris velocities. Make sure to keep shape (len(wind_directions), n_turbines) - # by repeating the maximum velocity accross each column for each row - free_stream_velocities = np.tile( - np.max(floris_velocities, axis=1)[:, np.newaxis], (1, self.n_turbines) - ).astype(hercules_float_type) - - # Compute wake deficits at evaluation times - floris_wake_deficits_eval = free_stream_velocities - floris_velocities - - # Expand the wake deficits to all time steps by holding constant within each interval - deficits_all = np.zeros_like(self.ws_mat, dtype=hercules_float_type) - # For each block, fill with the corresponding deficits - prev_end = -1 - for block_idx, end_idx in enumerate(eval_indices): - start_idx = prev_end + 1 - prev_end = end_idx - # Use deficits from this evaluation time for the whole block - deficits_all[start_idx : end_idx + 1, :] = floris_wake_deficits_eval[block_idx, :] - - # Compute all the waked velocities from unwaked minus deficits - self.waked_velocities_all = self.ws_mat - deficits_all - - # Initialize the turbine powers to nan - self.turbine_powers = np.zeros(self.n_turbines, dtype=hercules_float_type) * np.nan - - # Get the initial unwaked velocities - self.unwaked_velocities = self.ws_mat[0, :] - - # Compute initial waked velocities - self.waked_velocities = self.waked_velocities_all[0, :] - - # Get the initial FLORIS wake deficits - self.floris_wake_deficits = self.unwaked_velocities - self.waked_velocities - - # Get the turbine information - self.turbine_dict = load_yaml(self.turbine_file_name) - self.turbine_model_type = self.turbine_dict["turbine_model_type"] - - # Initialize the turbine array - if self.turbine_model_type == "filter_model": - # Use vectorized implementation for improved performance - self.turbine_array = TurbineFilterModelVectorized( - self.turbine_dict, self.dt, self.fmodel, self.waked_velocities - ) - self.use_vectorized_turbines = True - elif self.turbine_model_type == "dof1_model": - self.turbine_array = [ - Turbine1dofModel( - self.turbine_dict, self.dt, self.fmodel, self.waked_velocities[t_idx] - ) - for t_idx in range(self.n_turbines) - ] - self.use_vectorized_turbines = False - else: - raise Exception("Turbine model type should be either filter_model or dof1_model") - - # Initialize the power array to the initial wind speeds - if self.use_vectorized_turbines: - self.turbine_powers = self.turbine_array.prev_powers.copy() - else: - self.turbine_powers = np.array( - [self.turbine_array[t_idx].prev_power for t_idx in range(self.n_turbines)], - dtype=hercules_float_type, - ) - - # Get the rated power of the turbines, for now assume all turbines have the same rated power - if self.use_vectorized_turbines: - self.rated_turbine_power = self.turbine_array.get_rated_power() - else: - self.rated_turbine_power = self.turbine_array[0].get_rated_power() - - # Get the capacity of the farm - self.capacity = self.n_turbines * self.rated_turbine_power - - # Set the logging outputs based on the logging_option - # First add outputs included in every logging option - self.log_outputs = [ - "power", - "wind_speed", - "wind_direction", - "wind_speed_waked", - ] - - # If including subset of turbines, add the turbine indices - if self.logging_option == "turb_subset": - self.random_turbine_indices = np.random.choice(self.n_turbines, size=3, replace=False) - self.log_outputs = self.log_outputs + [ - f"waked_velocities_turb_{t_idx:03d}" for t_idx in self.random_turbine_indices - ] - self.log_outputs = self.log_outputs + [ - f"turbine_powers_turb_{t_idx:03d}" for t_idx in self.random_turbine_indices - ] - self.log_outputs = self.log_outputs + [ - f"turbine_power_setpoints_turb_{t_idx:03d}" for t_idx in self.random_turbine_indices - ] - - # If including all data add these data points - elif self.logging_option == "all": - self.log_outputs = self.log_outputs + [ - "turbine_powers", - "turbine_power_setpoints", - "floris_wind_speed", - "floris_wind_direction", - "floris_ti", - "unwaked_velocities", - "waked_velocities", - ] - - # Update the user - self.logger.info( - f"Initialized Wind_MesoToPowerPrecomFloris with {self.n_turbines} turbines" - ) - - def get_initial_conditions_and_meta_data(self, h_dict): - """Add any initial conditions or meta data to the h_dict. - - Meta data is data not explicitly in the input yaml but still useful for other - modules. - - Args: - h_dict (dict): Dictionary containing simulation parameters. - - Returns: - dict: Dictionary containing simulation parameters with initial conditions and meta data. - """ - h_dict["wind_farm"]["n_turbines"] = self.n_turbines - h_dict["wind_farm"]["capacity"] = self.capacity - h_dict["wind_farm"]["rated_turbine_power"] = self.rated_turbine_power - h_dict["wind_farm"]["wind_direction"] = self.wd_mat_mean[0] - h_dict["wind_farm"]["wind_speed"] = self.ws_mat_mean[0] - h_dict["wind_farm"]["turbine_powers"] = self.turbine_powers - h_dict["wind_farm"]["power"] = np.sum(self.turbine_powers) - - # Log the start time UTC if available - if hasattr(self, "start_time_utc"): - h_dict["wind_farm"]["start_time_utc"] = self.start_time_utc - if hasattr(self, "zero_time_utc"): - h_dict["wind_farm"]["zero_time_utc"] = self.zero_time_utc - - return h_dict - - def step(self, h_dict): - """Execute one simulation step for the wind farm. - - Calculates turbine powers, - and updates the simulation dictionary with results. Handles power_setpoint - signals and optional extra logging outputs. - - Args: - h_dict (dict): Dictionary containing current simulation state including - step number and power_setpoint values for each turbine. - - Returns: - dict: Updated simulation dictionary with wind farm outputs including - turbine powers, total power, and optional extra outputs. - """ - # Get the current step - step = h_dict["step"] - if self.verbose: - self.logger.info(f"step = {step} (of {self.n_steps})") - - # Grab the instantaneous turbine power setpoint signal and update the power_setpoints buffer - turbine_power_setpoints = h_dict[self.component_name]["turbine_power_setpoints"] - - # Update all the velocities - self.unwaked_velocities = self.ws_mat[step, :] - self.waked_velocities = self.waked_velocities_all[step, :] - self.floris_wake_deficits = self.unwaked_velocities - self.waked_velocities - - # Update the turbine powers - if self.use_vectorized_turbines: - # Vectorized calculation for all turbines at once - self.turbine_powers = self.turbine_array.step( - self.waked_velocities, - turbine_power_setpoints, - ) - else: - # Original loop-based calculation - for t_idx in range(self.n_turbines): - self.turbine_powers[t_idx] = self.turbine_array[t_idx].step( - self.waked_velocities[t_idx], - power_setpoint=turbine_power_setpoints[t_idx], - ) - - # Update instantaneous wind direction and wind speed - self.wind_direction = self.wd_mat_mean[step] - self.wind_speed = self.ws_mat_mean[step] - - # Update the h_dict with outputs - h_dict[self.component_name]["power"] = np.sum(self.turbine_powers) - h_dict[self.component_name]["turbine_powers"] = self.turbine_powers - h_dict[self.component_name]["turbine_power_setpoints"] = turbine_power_setpoints - h_dict[self.component_name]["wind_direction"] = self.wind_direction - h_dict[self.component_name]["wind_speed"] = self.wind_speed - h_dict[self.component_name]["wind_speed_waked"] = np.mean( - self.waked_velocities, dtype=hercules_float_type - ) - - # If logging_option is "turb_subset", add the turbine indices - if self.logging_option == "turb_subset": - for t_idx in self.random_turbine_indices: - h_dict[self.component_name][f"waked_velocities_turb_{t_idx:03d}"] = ( - self.waked_velocities[t_idx] - ) - h_dict[self.component_name][f"turbine_powers_turb_{t_idx:03d}"] = ( - self.turbine_powers[t_idx] - ) - h_dict[self.component_name][f"turbine_power_setpoints_turb_{t_idx:03d}"] = ( - turbine_power_setpoints[t_idx] - ) - - # Else if logging_option is "all", add the turbine powers - elif self.logging_option == "all": - h_dict[self.component_name]["floris_wind_speed"] = self.wind_speed - h_dict[self.component_name]["floris_wind_direction"] = self.wind_direction - h_dict[self.component_name]["floris_ti"] = self.ti_mat_mean[step] - h_dict[self.component_name]["unwaked_velocities"] = self.unwaked_velocities - h_dict[self.component_name]["waked_velocities"] = self.waked_velocities - - return h_dict - - -class TurbineFilterModel: - """Simple filter-based wind turbine model for power output simulation. - - This model simulates wind turbine power output using a first-order filter - to smooth the response to changing wind conditions, providing a simplified - representation of turbine dynamics. - - NOTE: This class is now unused and kept for backward compatibility. - The filter_model turbine_model_type now uses TurbineFilterModelVectorized - for improved performance. - """ - - def __init__(self, turbine_dict, dt, fmodel, initial_wind_speed): - """Initialize the turbine filter model. - - Args: - turbine_dict (dict): Dictionary containing turbine configuration, - including filter model parameters and other turbine-specific data. - dt (float): Time step for the simulation in seconds. - fmodel (FlorisModel): FLORIS model of the farm. - initial_wind_speed (float): Initial wind speed in m/s to initialize - the simulation. - """ - # Save the time step - self.dt = dt - - # Save the turbine dict - self.turbine_dict = turbine_dict - - # Save the filter time constant - self.filter_time_constant = turbine_dict["filter_model"]["time_constant"] - - # Solve for the filter alpha value given dt and the time constant - self.alpha = 1 - np.exp(-self.dt / self.filter_time_constant) - - # Grab the wind speed power curve from the fmodel and define a simple 1D LUT - turbine_type = fmodel.core.farm.turbine_definitions[0] - wind_speeds = turbine_type["power_thrust_table"]["wind_speed"] - powers = turbine_type["power_thrust_table"]["power"] - self.power_lut = interp1d( - wind_speeds, - powers, - fill_value=0.0, - bounds_error=False, - ) - - # Initialize the previous power to the initial wind speed - self.prev_power = self.power_lut(initial_wind_speed) - - def get_rated_power(self): - """Get the rated power of the turbine. - - Returns: - float: The rated power of the turbine in kW. - """ - return np.max(self.power_lut(np.arange(0, 25, 1.0, dtype=hercules_float_type))) - - def step(self, wind_speed, power_setpoint): - """Simulate a single time step of the wind turbine power output. - - This method calculates the power output of a wind turbine based on the - given wind speed and power_setpoint. The power output is - smoothed using an exponential moving average to simulate the turbine's - response to changing wind conditions. - - Args: - wind_speed (float): The current wind speed in meters per second (m/s). - power_setpoint (float): The maximum allowable power output in kW. - - Returns: - float: The calculated power output of the wind turbine, constrained - by the power_setpoint and smoothed using the exponential moving average. - """ - # Instantaneous power - instant_power = self.power_lut(wind_speed) - - # Limit the current power to not be greater than power_setpoint - instant_power = min(instant_power, power_setpoint) - - # Limit the instant power to be greater than 0 - instant_power = max(instant_power, 0.0) - - # TEMP: why are NaNs occurring? - if np.isnan(instant_power): - print( - f"NaN instant power at wind speed {wind_speed} m/s, " - f"power setpoint {power_setpoint} kW, prev power {self.prev_power} kW" - ) - instant_power = self.prev_power - - # Update the power - power = self.alpha * instant_power + (1 - self.alpha) * self.prev_power - - # Limit the power to not be greater than power_setpoint - power = min(power, power_setpoint) - - # Limit the power to be greater than 0 - power = max(power, 0.0) - - # Update the previous power - self.prev_power = power - - # Return the power - return power diff --git a/hercules/resource/upsample_wind_data.py b/hercules/resource/upsample_wind_data.py new file mode 100644 index 00000000..228ce0a2 --- /dev/null +++ b/hercules/resource/upsample_wind_data.py @@ -0,0 +1,440 @@ +""" +This module contains functions for generating wind time series at turbine locations by +a) spatially interpolating wind data at grid locations and +b) temporally upsampling the time series and adding turbulence. +""" + +import os +from itertools import chain +from pathlib import Path + +import numpy as np +import pandas as pd +import utm +from hercules.utilities import hercules_complex_type, hercules_float_type +from scipy.interpolate import CloughTocher2DInterpolator +from shapely.geometry import MultiPoint + + +def _spatially_interpolate_wind_data( + x_locs_orig: np.ndarray, + y_locs_orig: np.ndarray, + wind_values: np.ndarray, + x_locs_interp: np.ndarray, + y_locs_interp: np.ndarray, +) -> np.ndarray: + """Spatially interpolate wind data using 2D Clough-Tocher interpolation. + + Args: + x_locs_orig (np.ndarray): x locations of points at which wind data are provided (meters). + y_locs_orig (np.ndarray): y locations of points at which wind data are provided (meters). + wind_values (np.ndarray): N x M array of wind variable values to spatially interpolate, + where N is the number of time steps and M is the number of locations. + x_locs_interp (np.ndarray): x locations for spatially interpolated wind time series + (meters). + y_locs_interp (np.ndarray): y locations for spatially interpolated wind time series + (meters). + + Returns: + np.ndarray: P x N array of spatially interpolated wind variable values, where P is the + number of interpolated locations and N is the number of time steps. + """ + + N = len(wind_values) + + wind_interp_values = np.zeros((len(x_locs_interp), N), dtype=hercules_float_type) + + points = list(zip(x_locs_orig, y_locs_orig)) + + # Interpolate for each time index + for i in range(N): + interp = CloughTocher2DInterpolator(points, wind_values[i, :]) + + wind_interp_values[:, i] = interp(x_locs_interp, y_locs_interp) + + return wind_interp_values + + +def _upsample_Nyquist( + base_values: np.ndarray, timestep_base: int, timestep_upsample: int = 1 +) -> np.ndarray: + """Upsample time series by adding frequency content up to the Nyquist frequency. + + Creates smoothly interpolated time series without adding higher frequency content. + + Args: + base_values (np.ndarray): M x N array containing M time series of length N to be upsampled. + timestep_base (int): Time step of the original data to be upsampled (seconds). + timestep_upsample (int, optional): Time step of the new upsampled time series (seconds). + This must be defined so that timestep_base is an integer multiple of timestep_upsample. + Defaults to 1. + + Returns: + np.ndarray: M x P array of M temporally upsampled time series of length P, + where P = N * timestep_base / timestep_upsample. + """ + + up_factor = timestep_base // timestep_upsample + + num_points = np.size(base_values, 0) + N = np.size(base_values, 1) + + upsampled_values = np.zeros((num_points, up_factor * N), dtype=hercules_float_type) + + for i in range(num_points): + x = base_values[i, :] + + xf = np.fft.fft(x) + xupf = up_factor * np.hstack( + [ + xf[: N // 2], + xf[N // 2] / 2, + np.zeros((up_factor - 1) * N - 1, dtype=hercules_float_type), + xf[N // 2] / 2, + xf[-(N // 2 - 1) :], + ] + ) + + upsampled_values[i, :] = np.real(np.fft.ifft(xupf)) + + return upsampled_values + + +def _psd_kaimal(f: np.ndarray, Vhub: float, sigma: float = 1.0, L: float = 340.2): + """Generate the Kaimal turbulence power spectral density. + + Args: + f (np.ndarray): Array of frequencies for which the Kaimal PSD will be returned (Hz). + Vhub (float): Hub height wind speed (m/s). + sigma (float, optional): Wind speed standard deviation (m/s). Defaults to 1. + L (float, optional): Turbulence length scale (m). Defaults to 340.2 m, + the value specified in the IEC standard. + + Returns: + np.ndarray: 1D array of power spectral density values. + """ + return (sigma**2) * f * (L / Vhub) / ((1 + 6 * f * L / Vhub) ** (5 / 3)) + + +def _generate_uncorrelated_kaimal_stochastic_turbulence( + N_points: int, + N_samples: int, + timestep: int, + turbulence_Uhub: float, + turbulence_L: float = 340.2, + ws_std: float = 1.0, +) -> np.ndarray: + """Generate spatially uncorrelated zero-mean stochastic wind speed time series. + + Uses the Kaimal turbulence spectrum. + + Args: + N_points (int): Number of turbulence time series to generate. + N_samples (int): Number of samples in the turbulence time series. + An even number of samples must be specified. + timestep (int): Time step of the turbulence time series (seconds). + turbulence_Uhub (float): Mean hub-height wind speed for the Kaimal turbulence + spectrum (m/s). + turbulence_L (float, optional): Turbulence length scale for the Kaimal turbulence + spectrum (m). Defaults to 340.2 m, the value specified in the IEC standard. + ws_std (float, optional): Standard deviation of the stochastic wind speed time series + (m/s). Defaults to 1 m/s. + + Returns: + np.ndarray: N_points x N_samples array of zero-mean stochastic wind speed turbulence + time series. + """ + + fs = 1.0 / timestep # Sampling frequency + + freqs = np.arange(0.0, 0.5 * fs + 0.5 * fs / N_samples, fs / N_samples) # Frequency array + + freq_mat = np.zeros((N_points, N_samples), dtype=hercules_complex_type) # Matrix of frequencies + + # Add phases for uncorrelated components + freq_mat[:, 1 : int(N_samples / 2 + 1)] = np.exp( + np.random.uniform(high=2 * np.pi, size=(N_points, int(N_samples / 2))) * 1.0j + ) + + # Simply add phase component of 1 for the Nyquist frequency + freq_mat[:, int(N_samples / 2 + 1)] = np.ones(N_points, dtype=hercules_complex_type) + + # Add magnitude of spectrum + psd_1side = _psd_kaimal(freqs, turbulence_Uhub, ws_std, turbulence_L) + + freq_mat[:, 0 : int(N_samples / 2) + 1] *= np.tile(np.sqrt(psd_1side), (N_points, 1)) + + # Copy phase information to negative frequencies + freq_mat[:, int(N_samples / 2) + 1 :] = np.conj(np.fliplr(freq_mat[:, 1 : int(N_samples / 2)])) + + # Apply normalization to achieve desired std. dev. + scale_const_total = ws_std * N_samples / np.sqrt(np.sum(np.abs(freq_mat[0, :]) ** 2)) + freq_mat *= scale_const_total + + # Perform ifft to get time series + ws_mat = np.zeros((N_points, N_samples), dtype=hercules_float_type) + + for i in range(N_points): + ws_mat[i, :] = np.real(np.fft.ifft(freq_mat[i, :])).astype(hercules_float_type) + + return ws_mat + + +def _get_iec_turbulence_std( + ws_array: np.ndarray, ws_ref: float, ti_ref: float, offset: float = 3.8 +): + """Generate wind speed standard deviations using the IEC 61400-1 normal turbulence model. + + First, the Iref parameter is defined to achieve the desired reference turbulence intensity + at the provided reference wind speed. Next, this value of Iref is used to determine the + wind speed standard deviation for all "mean" wind speeds in the provided ws_array. + + Args: + ws_array (np.ndarray): Array of mean wind speeds for which corresponding wind speed + standard deviations are computed (m/s). + ws_ref (float): Reference wind speed at which the desired turbulence intensity is + defined (m/s). + ti_ref (float): Reference turbulence intensity corresponding to the reference wind speed. + offset (float, optional): Offset value for IEC normal turbulence model equation. + Defaults to 3.8, as defined in the IEC standard to give the expected value of TI + for each wind speed. + + Returns: + np.ndarray: Array of wind speed standard deviations corresponding to the mean wind speeds + in ws_array. + """ + + Iref = ti_ref * ws_ref / (0.75 * ws_ref + 3.8) + ws_std = Iref * (0.75 * ws_array + offset) + return ws_std + + +def upsample_wind_data( + ws_data_filepath: str | Path, + wd_data_filepath: str | Path, + coords_filepath: str | Path, + upsampled_data_dir: str | Path, + upsampled_data_filename: str, + x_locs_upsample: np.ndarray, + y_locs_upsample: np.ndarray, + origin_lat: float | None = None, + origin_lon: float | None = None, + timestep_upsample: int = 1, + turbulence_Uhub: float | None = None, + turbulence_L: float = 340.2, + TI_ref: float = 0.1, + TI_ws_ref: float = 8.0, + save_individual_wds: bool = False, +) -> dict: + """Spatially interpolate and temporally upsample wind speed and direction data. + + Processes wind files generated using wind data downloading functions in the + wind_solar_resource_downloader module (e.g., for the Wind Toolkit or Open-Meteo datasets). + Spatial interpolation is achieved using 2D Clough-Tocher interpolation. Upsampling is + accomplished by simple Nyquist upsampling to create a smooth signal. Lastly, for the wind + speeds, stochastic, uncorrelated turbulence generated using the Kaimal spectrum is added. + The turbulence intensity is assigned as a function of wind speed based on the IEC normal + turbulence model. + + Args: + ws_data_filepath (str | Path): File path to the wind speed file. + wd_data_filepath (str | Path): File path to the wind direction file. + coords_filepath (str | Path): File path to the coordinates file. + upsampled_data_dir (str | Path): Directory to save upsampled data files. + upsampled_data_filename (str): Filename for upsampled output files. + x_locs_upsample (np.ndarray): x locations for upsampled wind time series (meters). + y_locs_upsample (np.ndarray): y locations for upsampled wind time series (meters). + origin_lat (float | None, optional): Latitude for the origin for defining y locations of + the upsample wind locations (degrees). If None, the mean latitude from the coordinates + will be used. Defaults to None. + origin_lon (float | None, optional): Longitude for the origin for defining x locations of + the upsample wind locations (degrees). If None, the mean longitude from the coordinates + will be used. Defaults to None. + timestep_upsample (int, optional): Time step of upsampled wind time series (seconds). + Defaults to 1 second. + turbulence_Uhub (float | None, optional): Mean hub-height wind speed for the Kaimal + turbulence spectrum (m/s). If None, the mean wind speed from the spatially interpolated + upsample locations from the wind speed file will be used. Defaults to None. + turbulence_L (float, optional): Turbulence length scale for the Kaimal turbulence + spectrum (m). Defaults to 340.2 m, the value specified in the IEC standard. + TI_ref (float, optional): Reference TI corresponding to the reference wind speed TI_ws_ref + (fraction). Defaults to 0.1. + TI_ws_ref (float, optional): Reference wind speed at which the reference TI TI_ref is + defined (m/s). Defaults to 8 m/s. + save_individual_wds (bool, optional): If True, upsampled wind directions will be saved + in the output for each upsampled location. If False, only the mean wind direction + over all locations will be saved. Defaults to False. + + Returns: + pd.DataFrame: DataFrame containing the wind speeds and wind directions at each + upsampled location. + + Note: + The provided wind time series should have an even number of samples (this simplifies + the FFT operations). If the time series have an odd number of samples, the last sample + will be discarded. + """ + + # Create output directory if it doesn't exist + os.makedirs(upsampled_data_dir, exist_ok=True) + + # Load wind files + df_ws = pd.read_feather(ws_data_filepath) + df_wd = pd.read_feather(wd_data_filepath) + df_coords = pd.read_feather(coords_filepath) + + # Get mean lat and lon if needed + if (origin_lat is None) | (origin_lon is None): + origin_lat = df_coords["lat"].mean() + origin_lon = df_coords["lon"].mean() + + # Convert coordinates to easting and northing values and center on origin + x_locs_orig, y_locs_orig, zone_number, zone_letter = utm.from_latlon( + df_coords["lat"].values, df_coords["lon"].values + ) + + origin_x, origin_y, origin_zone_number, origin_zone_letter = utm.from_latlon( + origin_lat, origin_lon + ) + + if (zone_number != origin_zone_number) | (zone_letter != origin_zone_letter): + raise ValueError( + "The provided origin coordinates are in a different UTM zone than the provided wind " + "data." + ) + + x_locs_orig -= origin_x + y_locs_orig -= origin_y + + # Check if upsample locations are within the provided wind data boundaries + multi_point_orig = MultiPoint(list(zip(x_locs_orig, y_locs_orig))) + polygon_orig = multi_point_orig.convex_hull + + N_locs_upsample = len(x_locs_upsample) + multi_point_upsample = MultiPoint(list(zip(x_locs_upsample, y_locs_upsample))) + + if not multi_point_upsample.within(polygon_orig): + raise ValueError( + "At least one of the provided upsampled locations is outside of the boundary of the " + "provided wind data locations." + ) + + # If an odd number of samples in provided wind data, remove last sample + if (len(df_ws) % 2) == 1: + df_ws = df_ws.iloc[:-1] + + if (len(df_wd) % 2) == 1: + df_wd = df_wd.iloc[:-1] + + # Ensure order of points in wind dataframe columns matches order in coordinate dataframe + point_names = list(df_coords["index"].values.astype(str)) + df_ws = df_ws[["time_index"] + point_names] + df_wd = df_wd[["time_index"] + point_names] + + # get time step of provided wind data and check if it is an integer multiple of upsampled time + # step + timestep_orig = int((df_ws.iloc[1]["time_index"] - df_ws.iloc[0]["time_index"]).total_seconds()) + + if (timestep_orig / timestep_upsample % 1) != 0.0: + raise ValueError( + "The time step of the provided wind data must be an integer multiple of the upsampled " + "time series time step." + ) + + # Spatially interpolate wind speeds and cosine and sine components of directions at desired + # upsampled locations + ws_interp = _spatially_interpolate_wind_data( + x_locs_orig, y_locs_orig, df_ws[point_names].values, x_locs_upsample, y_locs_upsample + ) + + wd_cos_interp = _spatially_interpolate_wind_data( + x_locs_orig, + y_locs_orig, + np.cos(np.radians(df_wd[point_names].values)), + x_locs_upsample, + y_locs_upsample, + ) + + wd_sin_interp = _spatially_interpolate_wind_data( + x_locs_orig, + y_locs_orig, + np.sin(np.radians(df_wd[point_names].values)), + x_locs_upsample, + y_locs_upsample, + ) + + # Upsample spatially interpolated wind speeds and direction components using frequencies up to + # Nyquist frequency to create smooth signals + ws_interp_upsample = _upsample_Nyquist(ws_interp, timestep_orig, timestep_upsample) + wd_cos_interp_upsample = _upsample_Nyquist(wd_cos_interp, timestep_orig, timestep_upsample) + wd_sin_interp_upsample = _upsample_Nyquist(wd_sin_interp, timestep_orig, timestep_upsample) + + # Convert wind direction components to direction + wd_interp_upsample = ( + np.degrees(np.arctan2(wd_sin_interp_upsample, wd_cos_interp_upsample)) % 360 + ) + + # Find mean upsampled wind direction over all locations + wd_cos_interp_upsample_mean = np.mean(wd_cos_interp_upsample, axis=0) + wd_sin_interp_upsample_mean = np.mean(wd_sin_interp_upsample, axis=0) + + wd_interp_upsample_mean = ( + np.degrees(np.arctan2(wd_sin_interp_upsample_mean, wd_cos_interp_upsample_mean)) % 360 + ) + + # Next generate stochastic turbulence to add to wind speed times series + + # If turbulence_Uhub is undefined, set to mean wind speed from ws_interp + if turbulence_Uhub is None: + turbulence_Uhub = np.mean(ws_interp) + + N_samples_upsample = np.size(ws_interp_upsample, 1) + + ws_prime_mat = _generate_uncorrelated_kaimal_stochastic_turbulence( + N_locs_upsample, N_samples_upsample, timestep_upsample, turbulence_Uhub, turbulence_L + ) + + # Scale turbulence time series to get desired expected value of TI from IEC normal turbulence + # model + ws_std = _get_iec_turbulence_std(ws_interp_upsample, TI_ws_ref, TI_ref) + ws_prime_mat *= ws_std + + # Combine upsampled interpolated wind speeds and turbulence + ws_interp_upsample += ws_prime_mat + + # Create dataframe with wind speed and direction variables + ws_cols = [f"ws_{i:03}" for i in range(N_locs_upsample)] + df_upsample = pd.DataFrame(ws_interp_upsample.T, columns=ws_cols) + + # Either save wind directions for each location or mean wind direction over all locations + if save_individual_wds: + wd_cols = [f"wd_{i:03}" for i in range(N_locs_upsample)] + df_upsample[wd_cols] = wd_interp_upsample.T + else: + df_upsample["wd_mean"] = wd_interp_upsample_mean + + # Convert numeric columns to float32 for memory efficiency + for c in df_upsample.columns: + df_upsample[c] = df_upsample[c].astype(hercules_float_type) + + df_upsample["time"] = np.arange( + 0.0, N_samples_upsample * timestep_upsample, timestep_upsample, dtype=hercules_float_type + ) + df_upsample["time_utc"] = pd.date_range( + df_ws["time_index"][0], + df_ws.iloc[-1]["time_index"] + pd.Timedelta(seconds=timestep_orig - timestep_upsample), + freq=f"{timestep_upsample}s", + ) + + # Order columns by location + if save_individual_wds: + df_upsample = df_upsample[ + ["time", "time_utc"] + list(chain.from_iterable(zip(ws_cols, wd_cols))) + ] + else: + df_upsample = df_upsample[["time", "time_utc", "wd_mean"] + ws_cols] + + # Save combined dataframe + df_upsample.to_feather(Path(upsampled_data_dir) / upsampled_data_filename) + + return df_upsample diff --git a/hercules/resource/wind_solar_resource_downloader.py b/hercules/resource/wind_solar_resource_downloader.py new file mode 100644 index 00000000..51870963 --- /dev/null +++ b/hercules/resource/wind_solar_resource_downloader.py @@ -0,0 +1,882 @@ +""" +WTK, NSRDB, and Open-Meteo Data Downloader + +This script provides functions to download weather data from multiple sources: +- NLR's Wind Toolkit (WTK) for high-resolution wind data +- NLR's National Solar Radiation Database (NSRDB) for solar irradiance data +- Open-Meteo API for historical weather data with global coverage + +All three data sources provide consistent output formats (feather files) for easy integration +into renewable energy modeling workflows. + +Author: Andrew Kumler +Date: June 2025 +Updated: September 2025 (Added Open-Meteo support) +""" + +import math +import os +import time +import warnings +from typing import List, Optional + +import cartopy.crs as ccrs +import cartopy.feature as cfeature +import matplotlib.pyplot as plt +import numpy as np +import openmeteo_requests +import pandas as pd +import requests_cache +from hercules.utilities import hercules_float_type +from retry_requests import retry +from rex import ResourceX +from scipy.interpolate import griddata + + +def download_nsrdb_data( + target_lat: float, + target_lon: float, + year: Optional[int] = None, + start_date: Optional[str] = None, + end_date: Optional[str] = None, + variables: List[str] = ["ghi", "dni", "dhi", "wind_speed", "air_temperature"], + nsrdb_dataset_path="/nrel/nsrdb/GOES/conus/v4.0.0", + nsrdb_filename_prefix="nsrdb_conus", + coord_delta: float = 0.1, + output_dir: str = "./data", + filename_prefix: str = "nsrdb", + plot_data: bool = False, + plot_type: str = "timeseries", +) -> dict: + """Download NSRDB solar irradiance data for a specified location and time period. + + This function requires an NLR API key, which can be obtained by visiting + https://developer.nrel.gov/signup/. After receiving your API key, you must make a configuration + file at ~/.hscfg containing the following: + + hs_endpoint = https://developer.nrel.gov/api/hsds + + hs_api_key = YOUR_API_KEY_GOES_HERE + + More information can be found at: https://github.com/NREL/hsds-examples. + + Args: + target_lat (float): Target latitude coordinate. + target_lon (float): Target longitude coordinate. + year (int, optional): Year of data to download (if using full year approach). + start_date (str, optional): Start date in format 'YYYY-MM-DD' (if using date range + approach). + end_date (str, optional): End date in format 'YYYY-MM-DD' (if using date range + approach). + variables (List[str], optional): List of variables to download. + Defaults to ['ghi', 'dni', 'dhi', 'wind_speed', 'air_temperature']. + nsrdb_dataset_path (str, optional): Path name of NSRDB dataset. Available datasets at + https://developer.nrel.gov/docs/solar/nsrdb/. + Defaults to "/nrel/nsrdb/GOES/conus/v4.0.0". + nsrdb_filename_prefix (str, optional): File name prefix for the NSRDB HDF5 files in the + format {nsrdb_filename_prefix}_{year}.h5. Defaults to "nsrdb_conus". + coord_delta (float, optional): Coordinate delta for bounding box. Defaults to 0.1 degrees. + output_dir (str, optional): Directory to save output files. Defaults to "./data". + filename_prefix (str, optional): Prefix for output filenames. Defaults to "nsrdb". + plot_data (bool, optional): Whether to create plots of the data. Defaults to False. + plot_type (str, optional): Type of plot to create: 'timeseries' or 'map'. + Defaults to "timeseries". + + Returns: + dict: Dictionary containing DataFrames for each variable and coordinates. + + Note: + Either 'year' OR both 'start_date' and 'end_date' must be provided. Date range approach + allows for more flexible time periods than full year. Plots are not automatically shown. + If plot_data is True, call matplotlib.pyplot.show() to display the figure. + """ + + # Create output directory if it doesn't exist + os.makedirs(output_dir, exist_ok=True) + + # Validate input parameters + if year is not None and (start_date is not None or end_date is not None): + raise ValueError( + "Please provide either 'year' OR both 'start_date' and 'end_date', not both approaches." + ) + + if year is None and (start_date is None or end_date is None): + raise ValueError("Please provide either 'year' OR both 'start_date' and 'end_date'.") + + # Determine the approach and set up file paths and time info + if year is not None: + # Full year approach + file_years = [year] + time_suffix = str(year) + time_description = f"year {year}" + else: + # Date range approach + + start_dt = pd.to_datetime(start_date) + end_dt = pd.to_datetime(end_date) + + if start_dt > end_dt: + raise ValueError("start_date must be before end_date") + + # Get all years in the date range + file_years = list(range(start_dt.year, end_dt.year + 1)) + time_suffix = f"{start_date}_to_{end_date}".replace("-", "") + time_description = f"period {start_date} to {end_date}" + + # Create the bounding box + llcrn_lat = target_lat - coord_delta + llcrn_lon = target_lon - coord_delta + urcrn_lat = target_lat + coord_delta + urcrn_lon = target_lon + coord_delta + + print(f"Downloading NSRDB data for {time_description}") + print(f"Target coordinates: ({target_lat}, {target_lon})") + print(f"Bounding box: ({llcrn_lat}, {llcrn_lon}) to ({urcrn_lat}, {urcrn_lon})") + print(f"Variables: {variables}") + print(f"Years to process: {file_years}") + + t0 = time.time() + + data_dict = {} + all_dataframes = {var: [] for var in variables} + + try: + # Process each year in the range + for file_year in file_years: + print(f"\nProcessing year {file_year}...") + fp = f"{nsrdb_dataset_path}/{nsrdb_filename_prefix}_{file_year}.h5" + + with ResourceX(fp) as res: + # Download each variable for this year + for var in variables: + print(f" Downloading {var} for {file_year}...") + df_year = res.get_box_df( + var, lat_lon_1=[llcrn_lat, llcrn_lon], lat_lon_2=[urcrn_lat, urcrn_lon] + ) + + # Filter by date range if using date range approach + if start_date is not None and end_date is not None: + # Filter the DataFrame to the specified date range + df_year = df_year.loc[start_date:end_date] + + all_dataframes[var].append(df_year) + + # Get coordinates (only need to do this once) + if "coordinates" not in data_dict: + gids = df_year.columns.values + coordinates = res.lat_lon[gids] + df_coords = pd.DataFrame(coordinates, index=gids, columns=["lat", "lon"]) + data_dict["coordinates"] = df_coords + + # Concatenate all years for each variable + for var in variables: + if all_dataframes[var]: + print(f"Concatenating {var} data across {len(all_dataframes[var])} years...") + data_dict[var] = pd.concat(all_dataframes[var], axis=0).sort_index() + + # Convert numeric columns to float32 for memory efficiency + for col in data_dict[var].columns: + if pd.api.types.is_numeric_dtype(data_dict[var][col]): + data_dict[var][col] = data_dict[var][col].astype(hercules_float_type) + + # Clear intermediate DataFrames to free memory + all_dataframes[var].clear() + + # Save to feather format + output_file = os.path.join( + output_dir, f"{filename_prefix}_{var}_{time_suffix}.feather" + ) + data_dict[var].reset_index().to_feather(output_file) + print(f"Saved {var} data to {output_file}") + + # Save coordinates + coords_file = os.path.join(output_dir, f"{filename_prefix}_coords_{time_suffix}.feather") + data_dict["coordinates"].reset_index().to_feather(coords_file) + print(f"Saved coordinates to {coords_file}") + + except OSError as e: + print(f"Error downloading NSRDB data: {e}") + print("This could be caused by an invalid API key, NSRDB dataset path, or date range.") + raise + except Exception as e: + print(f"Error downloading NSRDB data: {e}") + raise + + total_time = (time.time() - t0) / 60 + decimal_part = math.modf(total_time)[0] * 60 + print( + "NSRDB download completed in " + f"{int(np.floor(total_time))}:{int(np.round(decimal_part, 0)):02d} minutes" + ) + + # Create plots if requested + if plot_data and data_dict and "coordinates" in data_dict: + coordinates_array = data_dict["coordinates"][["lat", "lon"]].values + if plot_type == "timeseries": + plot_timeseries( + data_dict, variables, coordinates_array, f"{filename_prefix} NSRDB Data" + ) + elif plot_type == "map": + plot_spatial_map( + data_dict, variables, coordinates_array, f"{filename_prefix} NSRDB Data" + ) + + return data_dict + + +def download_wtk_data( + target_lat: float, + target_lon: float, + year: Optional[int] = None, + start_date: Optional[str] = None, + end_date: Optional[str] = None, + variables: List[str] = ["windspeed_100m", "winddirection_100m"], + coord_delta: float = 0.1, + output_dir: str = "./data", + filename_prefix: str = "wtk", + plot_data: bool = False, + plot_type: str = "timeseries", +) -> dict: + """Download WTK wind data for a specified location and time period. + + This function requires an NLR API key, which can be obtained by visiting + https://developer.nrel.gov/signup/. After receiving your API key, you must make a configuration + file at ~/.hscfg containing the following: + + hs_endpoint = https://developer.nrel.gov/api/hsds + + hs_api_key = YOUR_API_KEY_GOES_HERE + + More information can be found at: https://github.com/NREL/hsds-examples. + + Args: + target_lat (float): Target latitude coordinate. + target_lon (float): Target longitude coordinate. + year (int, optional): Year of data to download (if using full year approach). + start_date (str, optional): Start date in format 'YYYY-MM-DD' (if using date range + approach). + end_date (str, optional): End date in format 'YYYY-MM-DD' (if using date range approach). + variables (List[str], optional): List of variables to download. + Defaults to ['windspeed_100m', 'winddirection_100m']. + coord_delta (float, optional): Coordinate delta for bounding box. Defaults to 0.1 degrees. + output_dir (str, optional): Directory to save output files. Defaults to "./data". + filename_prefix (str, optional): Prefix for output filenames. Defaults to "wtk". + plot_data (bool, optional): Whether to create plots of the data. Defaults to False. + plot_type (str, optional): Type of plot to create: 'timeseries' or 'map'. + Defaults to "timeseries". + + Returns: + dict: Dictionary containing DataFrames for each variable and coordinates. + + Note: + Either 'year' OR both 'start_date' and 'end_date' must be provided. Date range approach + allows for more flexible time periods than full year. Plots are not automatically shown. + If plot_data is True, call matplotlib.pyplot.show() to display the figure. + """ + + # Create output directory if it doesn't exist + os.makedirs(output_dir, exist_ok=True) + + # Validate input parameters + if year is not None and (start_date is not None or end_date is not None): + raise ValueError( + "Please provide either 'year' OR both 'start_date' and 'end_date', not both approaches." + ) + + if year is None and (start_date is None or end_date is None): + raise ValueError("Please provide either 'year' OR both 'start_date' and 'end_date'.") + + # Determine the approach and set up file paths and time info + if year is not None: + # Full year approach + file_years = [year] + time_suffix = str(year) + time_description = f"year {year}" + else: + # Date range approach + + start_dt = pd.to_datetime(start_date) + end_dt = pd.to_datetime(end_date) + + if start_dt > end_dt: + raise ValueError("start_date must be before end_date") + + # Get all years in the date range + file_years = list(range(start_dt.year, end_dt.year + 1)) + time_suffix = f"{start_date}_to_{end_date}".replace("-", "") + time_description = f"period {start_date} to {end_date}" + + # Create the bounding box + llcrn_lat = target_lat - coord_delta + llcrn_lon = target_lon - coord_delta + urcrn_lat = target_lat + coord_delta + urcrn_lon = target_lon + coord_delta + + print(f"Downloading WTK data for {time_description}") + print(f"Target coordinates: ({target_lat}, {target_lon})") + print(f"Bounding box: ({llcrn_lat}, {llcrn_lon}) to ({urcrn_lat}, {urcrn_lon})") + print(f"Variables: {variables}") + print(f"Years to process: {file_years}") + + t0 = time.time() + + data_dict = {} + all_dataframes = {var: [] for var in variables} + + try: + # Process each year in the range + for file_year in file_years: + print(f"\nProcessing year {file_year}...") + fp = f"/nrel/wtk/wtk-led/conus/v1.0.0/5min/wtk_conus_{file_year}.h5" + + with ResourceX(fp) as res: + # Download each variable for this year + for var in variables: + print(f" Downloading {var} for {file_year}...") + df_year = res.get_box_df( + var, lat_lon_1=[llcrn_lat, llcrn_lon], lat_lon_2=[urcrn_lat, urcrn_lon] + ) + + # Filter by date range if using date range approach + if start_date is not None and end_date is not None: + # Filter the DataFrame to the specified date range + df_year = df_year.loc[start_date:end_date] + + all_dataframes[var].append(df_year) + + # Get coordinates (only need to do this once) + if "coordinates" not in data_dict: + gids = df_year.columns.values + coordinates = res.lat_lon[gids] + df_coords = pd.DataFrame(coordinates, index=gids, columns=["lat", "lon"]) + data_dict["coordinates"] = df_coords + + # Concatenate all years for each variable + for var in variables: + if all_dataframes[var]: + print(f"Concatenating {var} data across {len(all_dataframes[var])} years...") + data_dict[var] = pd.concat(all_dataframes[var], axis=0).sort_index() + + # Convert numeric columns to float32 for memory efficiency + for col in data_dict[var].columns: + if pd.api.types.is_numeric_dtype(data_dict[var][col]): + data_dict[var][col] = data_dict[var][col].astype(hercules_float_type) + + # Clear intermediate DataFrames to free memory + all_dataframes[var].clear() + + # Save to feather format + output_file = os.path.join( + output_dir, f"{filename_prefix}_{var}_{time_suffix}.feather" + ) + data_dict[var].reset_index().to_feather(output_file) + print(f"Saved {var} data to {output_file}") + + # Save coordinates + coords_file = os.path.join(output_dir, f"{filename_prefix}_coords_{time_suffix}.feather") + data_dict["coordinates"].reset_index().to_feather(coords_file) + print(f"Saved coordinates to {coords_file}") + + except OSError as e: + print(f"Error downloading WTK data: {e}") + print("This could be caused by an invalid API key or date range.") + raise + except Exception as e: + print(f"Error downloading WTK data: {e}") + raise + + total_time = (time.time() - t0) / 60 + decimal_part = math.modf(total_time)[0] * 60 + print( + "WTK download completed in " + f"{int(np.floor(total_time))}:{int(np.round(decimal_part, 0)):02d} minutes" + ) + + # Create plots if requested + if plot_data and data_dict and "coordinates" in data_dict: + coordinates_array = data_dict["coordinates"][["lat", "lon"]].values + if plot_type == "timeseries": + plot_timeseries(data_dict, variables, coordinates_array, f"{filename_prefix} WTK Data") + elif plot_type == "map": + plot_spatial_map(data_dict, variables, coordinates_array, f"{filename_prefix} WTK Data") + + return data_dict + + +def download_openmeteo_data( + target_lat: float | List[float], + target_lon: float | List[float], + year: Optional[int] = None, + start_date: Optional[str] = None, + end_date: Optional[str] = None, + variables: List[str] = [ + "wind_speed_80m", + "wind_direction_80m", + "temperature_2m", + "shortwave_radiation_instant", + "diffuse_radiation_instant", + "direct_normal_irradiance_instant", + ], + coord_delta: float = 0.1, + output_dir: str = "./data", + filename_prefix: str = "openmeteo", + plot_data: bool = False, + plot_type: str = "timeseries", + remove_duplicate_coords=True, +) -> dict: + """Download Open-Meteo weather data for specified location(s) and time period. + + Data are retrieved from the nearest weather grid cell to the requested locations. The grid cell + resolution varies with latitude, but at ~35 degrees latitude, the grid cell resolution is + approximately 0.027 degrees latitude (~2.4 km in the N-S direction) and 0.0333 degrees + longitude (~3.7km in the E-W direction). + + Args: + target_lat (float | List[float]): Target latitude coordinate or list of latitude + coordinates. + target_lon (float | List[float]): Target longitude coordinate or list of longitude + coordinates. + year (int, optional): Year of data to download (if using full year approach). + start_date (str, optional): Start date in format 'YYYY-MM-DD' (if using date range + approach). + end_date (str, optional): End date in format 'YYYY-MM-DD' (if using date range approach). + variables (List[str], optional): List of variables to download. Available options include + wind_speed_80m, wind_direction_80m, temperature_2m, shortwave_radiation_instant, + diffuse_radiation_instant, direct_normal_irradiance_instant. + coord_delta (float, optional): Not used for Open-Meteo (points specified individually), + kept for consistency. Defaults to 0.1. + output_dir (str, optional): Directory to save output files. Defaults to "./data". + filename_prefix (str, optional): Prefix for output filenames. Defaults to "openmeteo". + plot_data (bool, optional): Whether to create plots of the data. Defaults to False. + plot_type (str, optional): Type of plot to create: 'timeseries' or 'map'. + Defaults to "timeseries". + remove_duplicate_coords (bool, optional): Whether to remove data from duplicate coordinates. + Defaults to True. + + Returns: + dict: Dictionary containing DataFrames for each variable and coordinates. + + Note: + Either 'year' OR both 'start_date' and 'end_date' must be provided. Open-Meteo provides + point data (not gridded), so coord_delta is ignored. Available historical data typically + spans from 1940 to present. Plots are not automatically shown. If plot_data is True, call + matplotlib.pyplot.show() to display the figure. + """ + + # Create output directory if it doesn't exist + os.makedirs(output_dir, exist_ok=True) + + # Validate input parameters + if year is not None and (start_date is not None or end_date is not None): + raise ValueError( + "Please provide either 'year' OR both 'start_date' and 'end_date', not both approaches." + ) + + if year is None and (start_date is None or end_date is None): + raise ValueError("Please provide either 'year' OR both 'start_date' and 'end_date'.") + + # Determine the approach and set up time info + if year is not None: + start_date = f"{year}-01-01" + end_date = f"{year}-12-31" + time_suffix = str(year) + time_description = f"year {year}" + else: + start_dt = pd.to_datetime(start_date) + end_dt = pd.to_datetime(end_date) + + if start_dt > end_dt: + raise ValueError("start_date must be before end_date") + + time_suffix = f"{start_date}_to_{end_date}".replace("-", "") + time_description = f"period {start_date} to {end_date}" + + print(f"Downloading Open-Meteo data for {time_description}") + print(f"Target coordinates: ({target_lat}, {target_lon})") + print(f"Variables: {variables}") + print("Note: Open-Meteo provides point data (coord_delta ignored)") + + # Map variable names to Open-Meteo API parameters + variable_mapping = { + "wind_speed_80m": "wind_speed_80m", + "wind_direction_80m": "wind_direction_80m", + "temperature_2m": "temperature_2m", + "shortwave_radiation_instant": "shortwave_radiation_instant", + "diffuse_radiation_instant": "diffuse_radiation_instant", + "direct_normal_irradiance_instant": "direct_normal_irradiance_instant", + "ghi": "shortwave_radiation_instant", # Alias for solar users + "dni": "direct_normal_irradiance_instant", # Alias for solar users + "dhi": "diffuse_radiation_instant", # Alias for solar users + "windspeed_80m": "wind_speed_80m", # Alias for wind users + "winddirection_80m": "wind_direction_80m", # Alias for wind users + } + + # Validate variables and map them + mapped_variables = [] + for var in variables: + if var in variable_mapping: + mapped_variables.append(variable_mapping[var]) + else: + print(f"Warning: Variable '{var}' not available in Open-Meteo. Skipping.") + + if not mapped_variables: + raise ValueError("No valid variables found for Open-Meteo download.") + + t0 = time.time() + + try: + # Setup the Open-Meteo API client with cache and retry on error + cache_session = requests_cache.CachedSession(".cache", expire_after=3600) + retry_session = retry(cache_session, retries=5, backoff_factor=0.2) + openmeteo = openmeteo_requests.Client(session=retry_session) + + # Setup API parameters + url = "https://historical-forecast-api.open-meteo.com/v1/forecast" + params = { + "latitude": target_lat, + "longitude": target_lon, + "start_date": start_date, + "end_date": end_date, + "minutely_15": mapped_variables, + "wind_speed_unit": "ms", + } + + # Try to make the API request with SSL verification first, then fallback to no verification + try: + responses = openmeteo.weather_api(url, params=params) + print("API request successful with SSL verification.") + except Exception as e: + print(f"SSL verification failed: {str(e)[:100]}...") + print("Trying with SSL verification disabled...") + + # Suppress SSL warnings since we're intentionally disabling verification + warnings.filterwarnings("ignore", message="Unverified HTTPS request") + + # Create a new session with SSL verification disabled + cache_session_no_ssl = requests_cache.CachedSession(".cache", expire_after=3600) + cache_session_no_ssl.verify = False + retry_session_no_ssl = retry(cache_session_no_ssl, retries=5, backoff_factor=0.2) + openmeteo_no_ssl = openmeteo_requests.Client(session=retry_session_no_ssl) + + responses = openmeteo_no_ssl.weather_api(url, params=params) + print("API request successful with SSL verification disabled.") + + # Create data dictionary in the same format as WTK/NSRDB and initialize dataframes + data_dict = {} + data_dict["coordinates"] = pd.DataFrame() + + # Initialize for each variable + original_var_names = [] + for var in mapped_variables: + # Use original variable name (not mapped name) for consistency + original_var_name = None + for orig, mapped in variable_mapping.items(): + if mapped == var and orig in variables: + original_var_name = orig + break + + var_name = original_var_name if original_var_name else var + data_dict[var_name] = pd.DataFrame() + + original_var_names.append(var_name) + + # Process the responses for each lat/lon + for gid, response in enumerate(responses): + print(f"Coordinates retrieved: {response.Latitude()}°N {response.Longitude()}°E") + print(f"Elevation: {response.Elevation()} m asl") + + # Process minutely_15 data + minutely_15 = response.Minutely15() + + # Create the date range + date_range = pd.date_range( + start=pd.to_datetime(minutely_15.Time(), unit="s", utc=True), + end=pd.to_datetime(minutely_15.TimeEnd(), unit="s", utc=True), + freq=pd.Timedelta(seconds=minutely_15.Interval()), + inclusive="left", + ) + + # Create coordinates DataFrame (single point, but match the format) + # Use a synthetic GID (grid ID) to match WTK/NSRDB format + df_coords = pd.DataFrame( + [[response.Latitude(), response.Longitude()]], index=[gid], columns=["lat", "lon"] + ) + data_dict["coordinates"] = pd.concat([data_dict["coordinates"], df_coords], axis=0) + + # Process each requested variable + for i, var_name in enumerate(original_var_names): + var_data = minutely_15.Variables(i).ValuesAsNumpy() + + # Create DataFrame with same structure as WTK/NSRDB (datetime index, gid columns) + # Convert to float32 for memory efficiency + df_var = pd.DataFrame( + var_data.astype(hercules_float_type), index=date_range, columns=[gid] + ) + df_var.index.name = "time_index" + + data_dict[var_name] = pd.concat([data_dict[var_name], df_var], axis=1) + + # Check for duplicates, remove if any exist, and rename locations indices consecutively + if remove_duplicate_coords & (len(data_dict["coordinates"]) > 1): + duplicate_mask = data_dict["coordinates"].duplicated( + subset=["lat", "lon"], keep="first" + ) + data_dict["coordinates"] = data_dict["coordinates"][~duplicate_mask] + + for var_name in original_var_names: + data_dict[var_name] = data_dict[var_name][ + [c for c in data_dict["coordinates"].index] + ] + data_dict[var_name].columns = range(len(data_dict["coordinates"])) + + data_dict["coordinates"] = data_dict["coordinates"].reset_index(drop=True) + + # Save variables to feather format + for var_name in original_var_names: + output_file = os.path.join( + output_dir, f"{filename_prefix}_{var_name}_{time_suffix}.feather" + ) + data_dict[var_name].reset_index().to_feather(output_file) + print(f"Saved {var_name} data to {output_file}") + + # Save coordinates + coords_file = os.path.join(output_dir, f"{filename_prefix}_coords_{time_suffix}.feather") + data_dict["coordinates"].reset_index().to_feather(coords_file) + print(f"Saved coordinates to {coords_file}") + + except Exception as e: + print(f"Error downloading Open-Meteo data: {e}") + raise + + total_time = (time.time() - t0) / 60 + decimal_part = math.modf(total_time)[0] * 60 + print( + "Open-Meteo download completed in " + f"{int(np.floor(total_time))}:{int(np.round(decimal_part, 0)):02d} minutes" + ) + + # Create plots if requested + if plot_data and data_dict and "coordinates" in data_dict: + coordinates_array = data_dict["coordinates"][["lat", "lon"]].values + if plot_type == "timeseries": + plot_timeseries( + data_dict, variables, coordinates_array, f"{filename_prefix} Open-Meteo Data" + ) + elif plot_type == "map": + plot_spatial_map( + data_dict, variables, coordinates_array, f"{filename_prefix} Open-Meteo Data" + ) + + return data_dict + + +def plot_timeseries(data_dict: dict, variables: List[str], coordinates: np.ndarray, title: str): + """Create time-series plots for the downloaded data. + + Args: + data_dict (dict): Dictionary containing DataFrames for each variable. + variables (List[str]): List of variables to plot. + coordinates (np.ndarray): Array of coordinates for the data points. + title (str): Title for the plots. + """ + + n_vars = len(variables) + if n_vars == 0: + return + + # Create subplots based on number of variables + fig, axes = plt.subplots(n_vars, 1, figsize=(12, 4 * n_vars), sharex=True) + if n_vars == 1: + axes = [axes] + + for i, var in enumerate(variables): + if var in data_dict: + df = data_dict[var] + + # Plot all time series (one for each spatial point) + for col in df.columns: + axes[i].plot(df.index, df[col], alpha=0.7, linewidth=0.8) + + axes[i].set_ylabel(get_variable_label(var)) + axes[i].set_title(f"{var.replace('_', ' ').title()}") + axes[i].grid(True, alpha=0.3) + + axes[-1].set_xlabel("Time") + plt.suptitle(f"{title} - Time Series", fontsize=14, fontweight="bold") + plt.tight_layout() + + +def plot_spatial_map(data_dict: dict, variables: List[str], coordinates: np.ndarray, title: str): + """Create spatial maps showing the mean values across the region. + + Args: + data_dict (dict): Dictionary containing DataFrames for each variable. + variables (List[str]): List of variables to plot. + coordinates (np.ndarray): Array of coordinates for the data points. + title (str): Title for the plots. + """ + + n_vars = len(variables) + if n_vars == 0: + return + + # Calculate subplot layout + n_cols = min(2, n_vars) + n_rows = math.ceil(n_vars / n_cols) + + plt.figure(figsize=(8 * n_cols, 6 * n_rows)) + + for i, var in enumerate(variables): + if var in data_dict: + df = data_dict[var] + + # Extract coordinates + lats = coordinates[:, 0] + lons = coordinates[:, 1] + + # Calculate mean values across time + mean_values = df.mean(axis=0).values + + # Create subplot with map projection + ax = plt.subplot(n_rows, n_cols, i + 1, projection=ccrs.PlateCarree()) + + # Add geographic features + ax.add_feature(cfeature.COASTLINE, alpha=0.5) + ax.add_feature(cfeature.BORDERS, linestyle=":", alpha=0.5) + ax.add_feature(cfeature.LAND, edgecolor="black", facecolor="lightgray", alpha=0.3) + ax.add_feature(cfeature.OCEAN, facecolor="lightblue", alpha=0.3) + + # Create interpolated grid for smoother visualization + if len(lats) > 4: # Only interpolate if we have enough points + grid_lon = np.linspace(min(lons), max(lons), 50) + grid_lat = np.linspace(min(lats), max(lats), 50) + grid_lon, grid_lat = np.meshgrid(grid_lon, grid_lat) + + try: + grid_values = griddata( + (lons, lats), mean_values, (grid_lon, grid_lat), method="cubic" + ) + contour = ax.contourf( + grid_lon, + grid_lat, + grid_values, + levels=15, + cmap=get_variable_colormap(var), + transform=ccrs.PlateCarree(), + ) + plt.colorbar( + contour, + ax=ax, + orientation="vertical", + label=get_variable_label(var), + shrink=0.8, + ) + except Exception: + # Fall back to scatter plot if interpolation fails + sc = ax.scatter( + lons, + lats, + c=mean_values, + s=100, + cmap=get_variable_colormap(var), + transform=ccrs.PlateCarree(), + ) + plt.colorbar( + sc, ax=ax, orientation="vertical", label=get_variable_label(var), shrink=0.8 + ) + else: + # Use scatter plot for few points + sc = ax.scatter( + lons, + lats, + c=mean_values, + s=100, + cmap=get_variable_colormap(var), + transform=ccrs.PlateCarree(), + ) + plt.colorbar( + sc, ax=ax, orientation="vertical", label=get_variable_label(var), shrink=0.8 + ) + + # Add points on top + ax.scatter(lons, lats, c="black", s=20, transform=ccrs.PlateCarree(), alpha=0.8) + + # Set title + ax.set_title(f"{var.replace('_', ' ').title()}") + + # Set coordinate labels + ax.set_xticks(np.linspace(min(lons), max(lons), 5)) + ax.set_yticks(np.linspace(min(lats), max(lats), 5)) + ax.set_xticklabels( + [f"{lon:.2f}°" for lon in np.linspace(min(lons), max(lons), 5)], fontsize=8 + ) + ax.set_yticklabels( + [f"{lat:.2f}°" for lat in np.linspace(min(lats), max(lats), 5)], fontsize=8 + ) + ax.set_xlabel("Longitude") + ax.set_ylabel("Latitude") + + plt.suptitle(f"{title} - Spatial Distribution (Time-Averaged)", fontsize=14, fontweight="bold") + plt.tight_layout() + + +def get_variable_label(variable: str) -> str: + """Get appropriate label and units for a variable. + + Args: + variable (str): Variable name. + + Returns: + str: Label with units for the variable. + """ + labels = { + "ghi": "GHI (W/m²)", + "dni": "DNI (W/m²)", + "dhi": "DHI (W/m²)", + "windspeed_100m": "Wind Speed at 100m (m/s)", + "winddirection_100m": "Wind Direction at 100m (°)", + "turbulent_kinetic_energy_100m": "TKE at 100m (m²/s²)", + "temperature_100m": "Temperature at 100m (°C)", + "pressure_100m": "Pressure at 100m (Pa)", + # Open-Meteo variables + "wind_speed_80m": "Wind Speed at 80m (m/s)", + "windspeed_80m": "Wind Speed at 80m (m/s)", + "wind_direction_80m": "Wind Direction at 80m (m/s)", + "winddirection_80m": "Wind Direction at 80m (m/s)", + "temperature_2m": "Temperature at 2m (°C)", + "shortwave_radiation_instant": "Shortwave Radiation (W/m²)", + "diffuse_radiation_instant": "Diffuse Radiation (W/m²)", + "direct_normal_irradiance_instant": "Direct Normal Irradiance (W/m²)", + } + return labels.get(variable, variable.replace("_", " ").title()) + + +def get_variable_colormap(variable: str) -> str: + """Get appropriate colormap for a variable. + + Args: + variable (str): Variable name. + + Returns: + str: Matplotlib colormap name for the variable. + """ + colormaps = { + "ghi": "plasma", + "dni": "plasma", + "dhi": "plasma", + "windspeed_100m": "viridis", + "winddirection_100m": "hsv", + "turbulent_kinetic_energy_100m": "cividis", + "temperature_100m": "RdYlBu_r", + "pressure_100m": "coolwarm", + # Open-Meteo variables + "wind_speed_80m": "viridis", + "windspeed_80m": "viridis", + "wind_direction_80m": "hsv", + "winddirection_80m": "hsv", + "temperature_2m": "RdYlBu_r", + "shortwave_radiation_instant": "plasma", + "diffuse_radiation_instant": "plasma", + "direct_normal_irradiance_instant": "plasma", + } + return colormaps.get(variable, "viridis") diff --git a/hercules/utilities.py b/hercules/utilities.py index 76b011b8..d4fb47da 100644 --- a/hercules/utilities.py +++ b/hercules/utilities.py @@ -1,15 +1,19 @@ import logging import os +import re +import warnings +from pathlib import Path import h5py import numpy as np import pandas as pd import polars as pl import yaml -from scipy.interpolate import interp1d, RegularGridInterpolator +from scipy.interpolate import interp1d # Hercules float type for consistent precision hercules_float_type = np.float32 +hercules_complex_type = np.csingle def get_available_component_names(): @@ -48,7 +52,10 @@ def get_available_component_types(): dict: Component names mapped to available simulation types. """ return { - "wind_farm": ["Wind_MesoToPower", "Wind_MesoToPowerPrecomFloris"], + "wind_farm": [ + "WindFarm", + "WindFarmSCADAPower", + ], "solar_farm": ["SolarPySAMPVWatts"], "battery": ["BatterySimple", "BatteryLithiumIon"], "electrolyzer": ["ElectrolyzerPlant"], @@ -107,6 +114,131 @@ def load_yaml(filename, loader=Loader): return yaml.load(fid, loader) +def _validate_utc_datetime_string(dt_str, field_name): + """Validate that a datetime string represents UTC time. + + Accepts: + - Strings ending with "Z" (explicit UTC in ISO 8601 format) + - Naive strings with no timezone info (treated as UTC) + + Rejects: + - Strings with timezone offsets (e.g., "+05:00", "-08:00") + + Args: + dt_str (str): Datetime string to validate. + field_name (str): Name of the field being validated (for error messages). + + Returns: + pd.Timestamp: UTC-aware timestamp. + + Raises: + ValueError: If string contains timezone offset or is invalid. + """ + if not isinstance(dt_str, str): + raise ValueError(f"{field_name} must be a string") + + dt_str_stripped = dt_str.strip() + + # Check for timezone offsets (not allowed since field name implies UTC) + # Timezone offsets come after 'T' or at the end + # Pattern matches timezone offsets like +05:00, -08:00, +05:30, etc. + tz_offset_pattern = r"[+-]\d{2}:\d{2}$|[T][\d:-]*[+-]\d{2}:\d{2}" + if re.search(tz_offset_pattern, dt_str_stripped): + raise ValueError( + f"{field_name} contains a timezone offset (e.g., +05:00, -08:00). " + f"Since the field is named '{field_name}', it must be UTC time. " + f"Use 'Z' to explicitly mark UTC (e.g., '2020-01-01T00:00:00Z') " + f"or use a naive string without timezone info." + ) + + # Parse with utc=True to ensure result is UTC + try: + return pd.to_datetime(dt_str, utc=True) + except (ValueError, TypeError) as e: + raise ValueError( + f"{field_name} must be a valid UTC datetime string in ISO 8601 format. " + f"Accepted formats: 'YYYY-MM-DDTHH:MM:SSZ' (with Z) or " + f"'YYYY-MM-DDTHH:MM:SS' (naive, treated as UTC). Error: {e}" + ) + + +def local_time_to_utc(local_time, tz): + """Convert local time to UTC time string in ISO 8601 format with Z suffix. + + This utility helps users who only know their local time convert it to UTC, + accounting for daylight saving time automatically. Useful for users less + familiar with timezones who need to provide UTC timestamps for Hercules + input files. + + Args: + local_time (str or pd.Timestamp): Local datetime string or pandas Timestamp. + Accepts formats like "2025-01-01T00:00:00" or "2025-07-01 00:00:00". + tz (str): Timezone string using IANA timezone names (e.g., "America/Denver", + "America/New_York", "Europe/London", "Asia/Tokyo"). Required parameter. + + Returns: + str: UTC datetime string in ISO 8601 format with Z suffix (e.g., + "2025-01-01T07:00:00Z"). + + Examples: + >>> # Midnight Jan 1, 2025 in Mountain Time (MST, UTC-7, no DST) + >>> local_time_to_utc("2025-01-01T00:00:00", tz="America/Denver") + '2025-01-01T07:00:00Z' + >>> # Midnight July 1, 2025 in Mountain Time (MDT, UTC-6, DST in effect) + >>> local_time_to_utc("2025-07-01T00:00:00", tz="America/Denver") + '2025-07-01T06:00:00Z' + >>> # Eastern Time example + >>> local_time_to_utc("2025-01-01T00:00:00", tz="America/New_York") + '2025-01-01T05:00:00Z' + + Raises: + ValueError: If local_time cannot be parsed or tz is invalid or missing. + + Note: + Common timezone names: + - US: "America/New_York", "America/Chicago", "America/Denver", "America/Los_Angeles" + - Europe: "Europe/London", "Europe/Paris", "Europe/Berlin" + - Asia: "Asia/Tokyo", "Asia/Shanghai", "Asia/Dubai" + - Pacific: "Pacific/Auckland", "Pacific/Honolulu" + + For a complete list of all available IANA timezone names, see: + - https://en.wikipedia.org/wiki/List_of_tz_database_time_zones + - Or in Python: `import zoneinfo; zoneinfo.available_timezones()` + """ + if tz is None: + raise ValueError( + "Timezone parameter 'tz' is required. " + "Use IANA timezone names like 'America/Denver' or 'Europe/London'. " + "See https://en.wikipedia.org/wiki/List_of_tz_database_time_zones for valid names." + ) + + # Parse local_time to pandas Timestamp (naive) + try: + dt = pd.to_datetime(local_time) + except (ValueError, TypeError) as e: + raise ValueError(f"Cannot parse local_time '{local_time}': {e}") + + # Localize naive datetime to the specified timezone + try: + dt_localized = dt.tz_localize(tz) + except Exception as e: + raise ValueError( + f"Invalid timezone '{tz}': {e}. " + "Use IANA timezone names like 'America/Denver' or 'Europe/London'. " + "See https://en.wikipedia.org/wiki/List_of_tz_database_time_zones for valid names, " + "or in Python: `import zoneinfo; zoneinfo.available_timezones()`" + ) + + # Convert to UTC + dt_utc = dt_localized.tz_convert("UTC") + + # Format as ISO 8601 with Z suffix + # Remove timezone info and add Z manually to match Hercules format + utc_str = dt_utc.strftime("%Y-%m-%dT%H:%M:%SZ") + + return utc_str + + def load_hercules_input(filename): """Load and validate Hercules input file. @@ -116,7 +248,7 @@ def load_hercules_input(filename): filename (str): Path to Hercules input YAML file. Returns: - dict: Validated Hercules input configuration. + dict: Validated Hercules input configuration with computed starttime/endtime. Raises: ValueError: If required keys missing, invalid data types, or incorrect structure. @@ -124,7 +256,7 @@ def load_hercules_input(filename): h_dict = load_yaml(filename) # Define valid keys - required_keys = ["dt", "starttime", "endtime", "plant"] + required_keys = ["dt", "starttime_utc", "endtime_utc", "plant"] component_names = get_available_component_names() component_types = get_available_component_types() other_keys = [ @@ -135,6 +267,7 @@ def load_hercules_input(filename): "output_file", "log_every_n", "external_data_file", + "external_data", "output_use_compression", "output_buffer_size", ] @@ -144,6 +277,26 @@ def load_hercules_input(filename): if key not in h_dict: raise ValueError(f"Required key {key} not found in input file {filename}") + # Validate and convert starttime_utc and endtime_utc to pandas Timestamps + # If they're already Timestamps (e.g., from test h_dicts), use them directly + if isinstance(h_dict["starttime_utc"], pd.Timestamp): + starttime_utc = h_dict["starttime_utc"] + else: + starttime_utc = _validate_utc_datetime_string(h_dict["starttime_utc"], "starttime_utc") + + if isinstance(h_dict["endtime_utc"], pd.Timestamp): + endtime_utc = h_dict["endtime_utc"] + else: + endtime_utc = _validate_utc_datetime_string(h_dict["endtime_utc"], "endtime_utc") + + # Validate endtime_utc is after starttime_utc + if endtime_utc <= starttime_utc: + raise ValueError(f"endtime_utc must be after starttime_utc in input file {filename}") + + # Store UTC timestamps in h_dict + h_dict["starttime_utc"] = starttime_utc + h_dict["endtime_utc"] = endtime_utc + # Validate plant structure if not isinstance(h_dict["plant"], dict): raise ValueError(f"Plant must be a dictionary in input file {filename}") @@ -157,7 +310,11 @@ def load_hercules_input(filename): # Validate all keys are valid for key in h_dict: if key not in required_keys + component_names + other_keys: - raise ValueError(f"Key {key} not a valid key in input file {filename}") + raise ValueError(f'Key "{key}" not a valid key in input file {filename}') + + # Disallow pre-defined start/end; derive from UTC + dt policy + if ("starttime" in h_dict) or ("endtime" in h_dict): + raise ValueError("starttime/endtime must not be provided; they are derived from *_utc") # Validate component structures for key in component_names: @@ -194,44 +351,124 @@ def load_hercules_input(filename): f"in input file {filename}" ) + # Handle external_data structure normalization + + # First ensure that not both external_data_file and external_data appear + if "external_data_file" in h_dict and "external_data" in h_dict: + raise ValueError( + f"Cannot specify both external_data_file and external_data in input file {filename}. " + "Preferred is to specify external_data_file within external_data " + "and specify log_channels within external_data. " + "The old format is still supported for backward compatibility " + "but will show a deprecation warning." + ) + + # If old-style external_data_file is used at top level, convert to new structure with warning + if "external_data_file" in h_dict: + warnings.warn( + "Specifying 'external_data_file' at the top level is deprecated. " + "Please use 'external_data: {external_data_file: ...}' instead.", + DeprecationWarning, + stacklevel=2, + ) + h_dict["external_data"] = { + "external_data_file": h_dict.pop("external_data_file"), + "log_channels": None, # None means log all + } + + # Validate external_data structure if present + if "external_data" in h_dict: + if not isinstance(h_dict["external_data"], dict): + raise ValueError(f"external_data must be a dictionary in input file {filename}") + + # If external_data_file is not specified, treat external_data as blank (remove it) + if "external_data_file" not in h_dict["external_data"]: + h_dict.pop("external_data") + else: + # Validate and set default for log_channels + # (only if external_data_file is present) + if "log_channels" in h_dict["external_data"]: + log_channels = h_dict["external_data"]["log_channels"] + # Allow None (from backward compatibility conversion) or list + if log_channels is not None and not isinstance(log_channels, list): + raise ValueError( + f"external_data log_channels must be a list or None " + f"in input file {filename}" + ) + # None means log all, empty list means log nothing, + # non-empty list means log only those + else: + # If not specified, default to None (log all channels) + h_dict["external_data"]["log_channels"] = None + return h_dict -def setup_logging(logfile="log_hercules.log", console_output=True): - """Set up logging to file and console. +def setup_logging( + logger_name="hercules", + log_file="log_hercules.log", + console_output=True, + console_prefix=None, + log_level=logging.INFO, + use_outputs_dir=True, +): + """Set up logging to file and console with flexible configuration. - Creates 'outputs' directory and configures file/console logging with timestamps. + This function provides a unified interface for setting up logging across all + Hercules components. It supports both simple filenames (with automatic 'outputs' + directory creation) and full file paths. Console output is optional and can be + customized with a prefix. Args: - logfile (str, optional): Log file name. Defaults to "log_hercules.log". + logger_name (str, optional): Name for the logger instance. Defaults to "hercules". + log_file (str, optional): Log file name or full path. Defaults to "log_hercules.log". console_output (bool, optional): Enable console output. Defaults to True. + console_prefix (str, optional): Prefix for console messages. If None, uses + logger_name in uppercase. Defaults to None. + log_level (int, optional): Logging level (e.g., logging.INFO, logging.DEBUG). + Defaults to logging.INFO. + use_outputs_dir (bool, optional): If True and log_file is a simple filename + (no directory separators), automatically places it in 'outputs' directory. + If False, treats log_file as-is. Defaults to True. Returns: logging.Logger: Configured logger instance. - """ - log_dir = os.path.join(os.getcwd(), "outputs") - os.makedirs(log_dir, exist_ok=True) - log_file = os.path.join(log_dir, logfile) - # Get the root logger - logger = logging.getLogger("emulator") + + """ + # Determine the log file path + if use_outputs_dir and (os.sep not in log_file and "/" not in log_file): + # Simple filename - use outputs directory + log_dir = os.path.join(os.getcwd(), "outputs") + os.makedirs(log_dir, exist_ok=True) + log_file_path = os.path.join(log_dir, log_file) + else: + # Full path or use_outputs_dir=False - use as-is but ensure directory exists + log_file_path = log_file + log_dir = Path(log_file_path).parent + log_dir.mkdir(parents=True, exist_ok=True) + + # Get the logger + logger = logging.getLogger(logger_name) # Clear any existing handlers to avoid duplicates for handler in logger.handlers[:]: logger.removeHandler(handler) - logger.setLevel(logging.INFO) + logger.setLevel(log_level) # Add file handler - file_handler = logging.FileHandler(log_file) + file_handler = logging.FileHandler(log_file_path) file_handler.setFormatter(logging.Formatter("%(asctime)s - %(levelname)s - %(message)s")) logger.addHandler(file_handler) - # Add console handler + # Add console handler if requested if console_output: console_handler = logging.StreamHandler() + # Use provided prefix or default to logger name in uppercase + prefix = console_prefix if console_prefix is not None else logger_name.upper() console_handler.setFormatter( - logging.Formatter("[EMULATOR] %(asctime)s - %(levelname)s - %(message)s") + logging.Formatter(f"[{prefix}] %(asctime)s - %(levelname)s - %(message)s") ) logger.addHandler(console_handler) @@ -253,43 +490,8 @@ def close_logging(logger): def interpolate_df(df, new_time): """Interpolate DataFrame values to match new time axis. - Uses linear interpolation. Converts datetime columns to timestamps for interpolation. - - Args: - df (pd.DataFrame): DataFrame with 'time' column and data columns. - new_time (array-like): New time points for interpolation. - - Returns: - pd.DataFrame: DataFrame with new time axis and interpolated data columns. - """ - # Create dictionary to store all columns - result_dict = {"time": new_time} - - # Populate the dictionary with interpolated values for each column - for col in df.columns: - if col != "time": - # Check if column contains datetime values - if pd.api.types.is_datetime64_any_dtype(df[col]): - # Convert datetime to timestamps (float) for interpolation - timestamps = df[col].astype("int64") / 10**9 # nanoseconds to seconds - f = interp1d(df["time"].values, timestamps, bounds_error=True) - interpolated_timestamps = f(new_time) - # Convert timestamps back to datetime - result_dict[col] = pd.to_datetime(interpolated_timestamps, unit="s", utc=True) - else: - # Standard interpolation for non-datetime columns - f = interp1d(df["time"].values, df[col].values, bounds_error=True) - result_dict[col] = f(new_time) - - # Create DataFrame from the dictionary (all columns at once) - result = pd.DataFrame(result_dict) - return result - - -def interpolate_df_fast(df, new_time): - """Optimized interpolate_df with Polars backend for better performance. - - Same functionality as interpolate_df but with improved memory efficiency and speed. + Uses linear interpolation with Polars backend for better performance and memory efficiency. + Converts datetime columns to timestamps for interpolation. Args: df (pd.DataFrame): DataFrame with 'time' column and data columns. @@ -349,8 +551,10 @@ def _interpolate_with_polars(df, new_time, datetime_cols, numeric_cols): time_values = col_data["time"].to_numpy() col_values = col_data[col].to_numpy() - # Linear interpolation - interpolated_values = np.interp(new_time, time_values, col_values) + # Linear interpolation with float32 precision + interpolated_values = np.interp(new_time, time_values, col_values).astype( + hercules_float_type + ) # Add interpolated column to result result_pl = result_pl.with_columns(pl.lit(interpolated_values).alias(col)) @@ -364,7 +568,7 @@ def _interpolate_with_polars(df, new_time, datetime_cols, numeric_cols): # Convert datetime to timestamps for interpolation datetime_values = col_data[col].to_pandas().astype("int64").values / 10**9 - # Interpolate timestamps + # Interpolate timestamps (datetime precision doesn't need float32 constraint) interpolated_timestamps = np.interp(new_time, time_values, datetime_values) # Convert back to datetime and add to result @@ -376,25 +580,50 @@ def _interpolate_with_polars(df, new_time, datetime_cols, numeric_cols): def find_time_utc_value(df, time_value, time_column="time", time_utc_column="time_utc"): - """Find the time_utc value. - + """Return UTC timestamp at a given time value via linear interpolation or extrapolation. + This function maps a numeric simulation time to a UTC timestamp by linearly + interpolating between rows in ``df``. If ``time_value`` lies outside the + range of ``time_column``, linear extrapolation is performed. Args: - df (pd.DataFrame): DataFrame with time_column and time_utc_column. - time_value (float): Time value to find the time_utc value for. - time_column (str, optional): Name of the time column. Defaults to "time". - time_utc_column (str, optional): Name of the time_utc column. Defaults to "time_utc". + df (pd.DataFrame): Input DataFrame containing time and UTC columns. + time_value (float): Time at which to compute the UTC value. + time_column (str, optional): Name of the numeric time column. Defaults to "time". + time_utc_column (str, optional): Name of the UTC datetime column. Defaults to "time_utc". Returns: - datetime: Time_utc value. + pd.Timestamp: UTC-aware timestamp corresponding to ``time_value``. """ - return ( - df.set_index(time_column)[time_utc_column] - .interpolate(method="linear") - .reindex([time_value]) - .iloc[0] + if time_column not in df.columns or time_utc_column not in df.columns: + raise ValueError(f"DataFrame must contain '{time_column}' and '{time_utc_column}' columns") + + # Drop rows with missing values in either column, then sort by time + df_valid = ( + df[[time_column, time_utc_column]] + .dropna(subset=[time_column, time_utc_column]) + .sort_values(time_column) + ) + + if len(df_valid) < 2: + raise ValueError("At least two valid rows are required for interpolation/extrapolation") + + # Extract arrays for interpolation. Convert datetimes to seconds since epoch (UTC) + time_values = df_valid[time_column].to_numpy() + utc_ns = df_valid[time_utc_column].astype("int64").to_numpy() # nanoseconds since epoch + utc_seconds = utc_ns.astype(np.float64) / 1e9 + + # Linear interpolation/extrapolation + f = interp1d( + time_values, + utc_seconds, + kind="linear", + bounds_error=False, + fill_value="extrapolate", + assume_sorted=True, ) + sec = float(f(time_value)) + return pd.to_datetime(sec, unit="s", utc=True) def load_h_dict_from_text(filename): @@ -447,82 +676,6 @@ def load_h_dict_from_text(filename): raise ValueError(f"Could not parse dictionary from file {filename}: {str(e)}") -def load_perffile(perffile): - """Load and parse a wind turbine performance file. - - This function reads a performance file containing wind turbine coefficient data - including power coefficients (Cp), thrust coefficients (Ct), and torque coefficients (Cq) - as functions of tip speed ratio (TSR) and blade pitch angle. The data is converted - into RegularGridInterpolator objects for efficient interpolation during simulation. - - Args: - perffile (str): Path to the performance file containing turbine coefficient data. - - Returns: - dict: A dictionary containing RegularGridInterpolator objects for 'Cp', 'Ct', and 'Cq' - coefficients, keyed by coefficient name. - """ - perffuncs = {} - - with open(perffile) as pfile: - for line in pfile: - # Read Blade Pitch Angles (degrees) - if "Pitch angle" in line: - pitch_initial = np.array( - [float(x) for x in pfile.readline().strip().split()], dtype=hercules_float_type - ) - pitch_initial_rad = pitch_initial * np.deg2rad( - 1 - ) # degrees to rad -- should this be conditional? - - # Read Tip Speed Ratios (rad) - if "TSR" in line: - TSR_initial = np.array( - [float(x) for x in pfile.readline().strip().split()], dtype=hercules_float_type - ) - - # Read Power Coefficients - if "Power" in line: - pfile.readline() - Cp = np.empty((len(TSR_initial), len(pitch_initial)), dtype=hercules_float_type) - for tsr_i in range(len(TSR_initial)): - Cp[tsr_i] = np.array( - [float(x) for x in pfile.readline().strip().split()], - dtype=hercules_float_type, - ) - perffuncs["Cp"] = RegularGridInterpolator( - (TSR_initial, pitch_initial_rad), Cp, bounds_error=False, fill_value=None - ) - - # Read Thrust Coefficients - if "Thrust" in line: - pfile.readline() - Ct = np.empty((len(TSR_initial), len(pitch_initial)), dtype=hercules_float_type) - for tsr_i in range(len(TSR_initial)): - Ct[tsr_i] = np.array( - [float(x) for x in pfile.readline().strip().split()], - dtype=hercules_float_type, - ) - perffuncs["Ct"] = RegularGridInterpolator( - (TSR_initial, pitch_initial_rad), Ct, bounds_error=False, fill_value=None - ) - - # Read Torque Coefficients - if "Torque" in line: - pfile.readline() - Cq = np.empty((len(TSR_initial), len(pitch_initial)), dtype=hercules_float_type) - for tsr_i in range(len(TSR_initial)): - Cq[tsr_i] = np.array( - [float(x) for x in pfile.readline().strip().split()], - dtype=hercules_float_type, - ) - perffuncs["Cq"] = RegularGridInterpolator( - (TSR_initial, pitch_initial_rad), Cq, bounds_error=False, fill_value=None - ) - - return perffuncs - - def read_hercules_hdf5(filename): """Read Hercules HDF5 output file. @@ -541,11 +694,12 @@ def read_hercules_hdf5(filename): "step": f["data/step"][:], } - # Reconstruct time_utc using zero_time_utc - if "zero_time_utc" in f["metadata"].attrs: - zero_time_utc = pd.to_datetime(f["metadata"].attrs["zero_time_utc"], unit="s", utc=True) - time = pd.to_timedelta(data["time"], unit="s") - data["time_utc"] = zero_time_utc + time + # Reconstruct time_utc using starttime_utc (required) + if "starttime_utc" not in f["metadata"].attrs: + raise ValueError(f"starttime_utc not found in metadata attributes in file {filename}") + starttime_utc = pd.to_datetime(f["metadata"].attrs["starttime_utc"], unit="s", utc=True) + time = pd.to_timedelta(data["time"], unit="s") + data["time_utc"] = starttime_utc + time # Read plant data data["plant.power"] = f["data/plant_power"][:] @@ -564,70 +718,6 @@ def read_hercules_hdf5(filename): return pd.DataFrame(data) -# def read_hercules_hdf5_subset(filename, columns=None, time_range=None, stride=1): -# """Read subset of Hercules HDF5 output file data. - -# Returns only specified columns and time range, reducing memory usage for large datasets. -# Optionally applies stride to read every Nth data point for further downsampling. - -# Args: -# filename (str): Path to Hercules HDF5 output file. -# columns (list, optional): Column names to include. If None, includes only time column. -# time_range (tuple, optional): (start_time, end_time) in seconds. If None, includes all -# times. -# stride (int, optional): Read every Nth data point. Defaults to 1 (read all points). - -# Returns: -# pd.DataFrame: Subset of simulation data. -# """ -# with h5py.File(filename, "r") as f: -# # Get time indices for subset -# time_data = f["data/time"][:] -# start_idx = 0 -# end_idx = len(time_data) - -# if time_range is not None: -# start_time, end_time = time_range -# start_idx = np.searchsorted(time_data, start_time, side="left") -# end_idx = np.searchsorted(time_data, end_time, side="right") - -# # Apply stride to indices -# indices = np.arange(start_idx, end_idx, stride) - -# # Always include time data -# data = {"time": time_data[indices]} - -# # If no columns specified, return only time -# if columns is None: -# return pd.DataFrame(data) - -# # Read requested columns -# for col in columns: -# if col == "step": -# data[col] = f["data/step"][indices] - -# elif col == "time_utc": -# if "time_utc" in f["data"]: -# data[col] = f["data/time_utc"][indices] -# elif "start_time_utc" in f["metadata"].attrs: -# # Reconstruct time_utc from start_time_utc -# start_time_utc = pd.to_datetime( -# f["metadata"].attrs["start_time_utc"], unit="s", utc=True -# ) -# time_subset = pd.to_timedelta(data["time"], unit="s") -# data[col] = start_time_utc + time_subset -# elif col == "plant.power": -# data[col] = f["data/plant_power"][indices] -# elif col == "plant.locally_generated_power": -# data[col] = f["data/plant_locally_generated_power"][indices] -# elif col in f["data/components"]: -# data[col] = f["data/components"][col][indices] -# elif col in f["data/external_signals"]: -# data[col] = f["data/external_signals"][col][indices] - -# return pd.DataFrame(data) - - def get_hercules_metadata(filename): """Read Hercules HDF5 output file metadata. diff --git a/hercules/utilities_examples.py b/hercules/utilities_examples.py index 7f3cc3a8..7c85e457 100644 --- a/hercules/utilities_examples.py +++ b/hercules/utilities_examples.py @@ -1,10 +1,7 @@ -"""Utility helpers for generating and validating example input files. - -This module provides: -- generate_example_inputs: runs the three example data generator scripts. -- ensure_example_inputs_exist: checks for expected input files and generates them if missing. -""" +"""Utility helpers for generating and validating example input files.""" +import os +import shutil import subprocess from pathlib import Path @@ -60,3 +57,18 @@ def ensure_example_inputs_exist(): if not all(p.exists() for p in expected_files): generate_example_inputs() + + +def prepare_output_directory(output_dir="outputs"): + """Remove and recreate an output directory for clean runs. + + If the output directory exists, it will be deleted and recreated. + This ensures a clean output directory before running examples. + + Args: + output_dir (str, optional): Path to the output directory to prepare. + Defaults to "outputs". + """ + if os.path.exists(output_dir): + shutil.rmtree(output_dir) + os.makedirs(output_dir) diff --git a/pyproject.toml b/pyproject.toml index 099ea831..3e675cc0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,11 +37,17 @@ dependencies = [ "polars~=1.0", "pyarrow", "h5py~=3.10", +"NREL-rex[hsds]", +"utm", +"cartopy", +"openmeteo_requests", +"requests_cache", +"retry_requests" ] [project.optional-dependencies] docs = [ - "jupyter-book", + "jupyter-book==1.0.4", "sphinx-book-theme" ] develop = [ diff --git a/tests/electrolyzer_plant_test.py b/tests/electrolyzer_plant_test.py index 960caceb..634e4f2e 100644 --- a/tests/electrolyzer_plant_test.py +++ b/tests/electrolyzer_plant_test.py @@ -11,7 +11,9 @@ def test_allow_grid_power_consumption(): electrolyzer = ElectrolyzerPlant(test_h_dict) step_inputs = { - "locally_generated_power": 3000, + "plant": { + "locally_generated_power": 3000, + }, "electrolyzer": { "electrolyzer_signal": 2000, }, diff --git a/tests/emulator_test.py b/tests/emulator_test.py deleted file mode 100644 index 76ba8a39..00000000 --- a/tests/emulator_test.py +++ /dev/null @@ -1,472 +0,0 @@ -import numpy as np -from hercules.emulator import Emulator -from hercules.hybrid_plant import HybridPlant -from hercules.utilities import setup_logging - -from tests.test_inputs.h_dict import h_dict_battery, h_dict_solar, h_dict_wind - - -class SimpleControllerWind: - """A simple controller for testing that just returns the h_dict unchanged.""" - - def __init__(self, h_dict): - """Initialize the controller. - - Args: - h_dict (dict): The hercules input dictionary. - """ - pass - - def step(self, h_dict): - """Execute one control step. - - Args: - h_dict (dict): The hercules input dictionary. - - Returns: - dict: The updated hercules input dictionary. - """ - # Set power setpoints for wind turbines if wind farm is present - if "wind_farm" in h_dict and "n_turbines" in h_dict["wind_farm"]: - h_dict["wind_farm"]["turbine_power_setpoints"] = 5000 * np.ones( - h_dict["wind_farm"]["n_turbines"] - ) - - # Set power setpoints for battery if present - if "battery" in h_dict: - h_dict["battery"]["power_setpoint"] = 0.0 - - return h_dict - - -class SimpleControllerSolar: - """A simple controller for testing that just returns the h_dict unchanged.""" - - def __init__(self, h_dict): - """Initialize the controller. - - Args: - h_dict (dict): The hercules input dictionary. - """ - pass - - def step(self, h_dict): - """Execute one control step. - - Args: - h_dict (dict): The hercules input dictionary. - - Returns: - dict: The updated hercules input dictionary. - """ - - # Set solar derating to very high to have no impact - h_dict["solar_farm"]["power_setpoint"] = 1e10 - - return h_dict - - -def test_Emulator_instantiation(): - """Test that the Emulator can be instantiated with different configurations.""" - - # Use h_dict_solar as base for testing - test_h_dict = h_dict_solar.copy() - - # Set up logger for testing - logger = setup_logging(console_output=False) - - controller = SimpleControllerSolar(test_h_dict) - hybrid_plant = HybridPlant(test_h_dict) - - emulator = Emulator(controller, hybrid_plant, test_h_dict, logger) - - # Check default settings - assert emulator.output_file == "outputs/hercules_output.h5" - assert emulator.log_every_n == 1 - assert emulator.external_data_all == {} - - # Test with external data file and custom output file - test_h_dict_2 = test_h_dict.copy() - test_h_dict_2["external_data_file"] = "tests/test_inputs/external_data.csv" - test_h_dict_2["output_file"] = "test_output.h5" - test_h_dict_2["dt"] = 0.5 - test_h_dict_2["starttime"] = 0.0 - test_h_dict_2["endtime"] = 10.0 - - emulator = Emulator(controller, hybrid_plant, test_h_dict_2, logger) - - # Check external data loading - assert emulator.external_data_all["power_reference"][0] == 1000 - assert emulator.external_data_all["power_reference"][1] == 1500 - assert emulator.external_data_all["power_reference"][2] == 2000 - assert emulator.external_data_all["power_reference"][-1] == 3000 - - # Check custom output file - assert emulator.output_file == "test_output.h5" - - -def test_log_data_to_hdf5(): - """Test that the new HDF5 logging function works correctly.""" - - # Use h_dict_solar as base for testing - test_h_dict = h_dict_solar.copy() - - # Set up logger for testing - logger = setup_logging(console_output=False) - - controller = SimpleControllerSolar(test_h_dict) - hybrid_plant = HybridPlant(test_h_dict) - - emulator = Emulator(controller, hybrid_plant, test_h_dict, logger) - - # Set up the simulation state - emulator.time = 5.0 - emulator.step = 5 - emulator.h_dict["time"] = 5.0 - emulator.h_dict["step"] = 5 - - # Run controller and hybrid_plant steps to generate plant-level outputs - emulator.h_dict = controller.step(emulator.h_dict) - emulator.h_dict = hybrid_plant.step(emulator.h_dict) - - # Call the new HDF5 logging function - emulator._log_data_to_hdf5() - - # Check that HDF5 file was initialized - assert emulator.output_structure_determined - assert emulator.hdf5_file is not None - assert len(emulator.hdf5_datasets) > 0 - - # Check that expected datasets exist - expected_datasets = { - "time", - "step", - "plant_power", - "plant_locally_generated_power", - "solar_farm.power", - } - - actual_datasets = set(emulator.hdf5_datasets.keys()) - missing_datasets = expected_datasets - actual_datasets - assert expected_datasets.issubset( - actual_datasets - ), f"Missing expected datasets: {missing_datasets}" - - # Flush buffer to write data to HDF5 - if hasattr(emulator, "data_buffers") and emulator.data_buffers and emulator.buffer_row > 0: - emulator._flush_buffer_to_hdf5() - - # Check that data was written correctly - assert emulator.hdf5_datasets["time"][0] == 5.0 - assert emulator.hdf5_datasets["step"][0] == 5 - assert emulator.hdf5_datasets["plant_power"][0] > 0 - assert emulator.hdf5_datasets["solar_farm.power"][0] > 0 - - # Clean up - emulator.close() - - -def test_log_data_to_hdf5_with_external_signals(): - """Test that external signals are logged correctly to HDF5.""" - - # Use h_dict_battery as base for testing (no external data requirements) - test_h_dict = h_dict_battery.copy() - - # Add external data file - test_h_dict["external_data_file"] = "tests/test_inputs/external_data.csv" - test_h_dict["dt"] = 1.0 - test_h_dict["starttime"] = 0.0 - test_h_dict["endtime"] = 10.0 - - # Set up logger for testing - logger = setup_logging(console_output=False) - - controller = SimpleControllerWind(test_h_dict) # Use wind controller (works with any config) - hybrid_plant = HybridPlant(test_h_dict) - - emulator = Emulator(controller, hybrid_plant, test_h_dict, logger) - - # Set up the simulation state - emulator.time = 5.0 - emulator.step = 5 - emulator.h_dict["time"] = 5.0 - emulator.h_dict["step"] = 5 - - # Update external signals (simulate what happens in the run loop) - if emulator.external_data_all: - for k in emulator.external_data_all: - if k == "time": - continue - emulator.h_dict["external_signals"][k] = emulator.external_data_all[k][emulator.step] - - # Run controller and hybrid_plant steps to generate plant-level outputs - emulator.h_dict = controller.step(emulator.h_dict) - emulator.h_dict = hybrid_plant.step(emulator.h_dict) - - # Call the new HDF5 logging function - emulator._log_data_to_hdf5() - - # Check that HDF5 file was initialized - assert emulator.output_structure_determined - assert emulator.hdf5_file is not None - assert len(emulator.hdf5_datasets) > 0 - - # Check that external signals dataset exists - expected_external_dataset = "external_signals.power_reference" - assert expected_external_dataset in emulator.hdf5_datasets - - # Flush buffer to write data to HDF5 - if hasattr(emulator, "data_buffers") and emulator.data_buffers and emulator.buffer_row > 0: - emulator._flush_buffer_to_hdf5() - - # Check that external signal data was written correctly - expected_value = emulator.external_data_all["power_reference"][5] # Value at step 5 - assert emulator.hdf5_datasets[expected_external_dataset][0] == expected_value - - # Clean up - emulator.close() - - -def test_log_data_to_hdf5_with_wind_farm_arrays(): - """Test that the new HDF5 logging function handles wind farm array outputs correctly.""" - - # Use h_dict_wind as base for testing - test_h_dict = h_dict_wind.copy() - - # Set up logger for testing - logger = setup_logging(console_output=False) - - controller = SimpleControllerWind(test_h_dict) - hybrid_plant = HybridPlant(test_h_dict) - - emulator = Emulator(controller, hybrid_plant, test_h_dict, logger) - - # Set up the simulation state - emulator.time = 5.0 - emulator.step = 5 - emulator.h_dict["time"] = 5.0 - emulator.h_dict["step"] = 5 - - # Run controller and hybrid_plant steps to generate plant-level outputs - emulator.h_dict = controller.step(emulator.h_dict) - emulator.h_dict = hybrid_plant.step(emulator.h_dict) - - # Call the new HDF5 logging function - emulator._log_data_to_hdf5() - - # Check that array outputs are handled correctly - expected_datasets = { - "time", - "step", - "plant_power", - "plant_locally_generated_power", - "wind_farm.power", - "wind_farm.turbine_powers.000", - "wind_farm.turbine_powers.001", - "wind_farm.turbine_powers.002", - } - - actual_datasets = set(emulator.hdf5_datasets.keys()) - - # Verify that all expected datasets are present - missing_datasets = expected_datasets - actual_datasets - assert expected_datasets.issubset( - actual_datasets - ), f"Missing expected datasets: {missing_datasets}" - - # Flush buffer to write data to HDF5 - if hasattr(emulator, "data_buffers") and emulator.data_buffers and emulator.buffer_row > 0: - emulator._flush_buffer_to_hdf5() - - # Check that data was written correctly - assert emulator.hdf5_datasets["time"][0] == 5.0 - assert emulator.hdf5_datasets["step"][0] == 5 - assert emulator.hdf5_datasets["wind_farm.power"][0] > 0 - assert emulator.hdf5_datasets["plant_power"][0] > 0 - assert emulator.hdf5_datasets["plant_locally_generated_power"][0] > 0 - - # Verify that turbine_powers array is handled correctly - assert emulator.hdf5_datasets["wind_farm.turbine_powers.000"][0] > 0 - assert emulator.hdf5_datasets["wind_farm.turbine_powers.001"][0] > 0 - assert emulator.hdf5_datasets["wind_farm.turbine_powers.002"][0] > 0 - - # Clean up - emulator.close() - - -def test_hdf5_output_configuration(): - """Test HDF5 output configuration options: downsampling and chunking.""" - import os - import tempfile - - from hercules.utilities import read_hercules_hdf5 - - # Use h_dict_solar as base for testing - test_h_dict = h_dict_solar.copy() - - # Set up logger for testing - logger = setup_logging(console_output=False) - - # Test 1: HDF5 format with downsampling - with tempfile.TemporaryDirectory() as temp_dir: - test_h_dict_hdf5 = test_h_dict.copy() - test_h_dict_hdf5["output_file"] = os.path.join(temp_dir, "test_output.h5") - test_h_dict_hdf5["dt"] = 1.0 - test_h_dict_hdf5["starttime"] = 0.0 - test_h_dict_hdf5["endtime"] = 5.0 - - controller = SimpleControllerSolar(test_h_dict_hdf5) - hybrid_plant = HybridPlant(test_h_dict_hdf5) - emulator = Emulator(controller, hybrid_plant, test_h_dict_hdf5, logger) - - # Run simulation and write output - for step in range(5): # 5 steps (0-4) for dt=1.0, endtime=5.0, starttime=0.0 - emulator.step = step - emulator.time = step * emulator.dt - emulator.h_dict["time"] = emulator.time - emulator.h_dict["step"] = step - emulator.h_dict = controller.step(emulator.h_dict) - emulator.h_dict = hybrid_plant.step(emulator.h_dict) - emulator._log_data_to_hdf5() - - emulator.close() - - # Verify file exists and is readable - assert os.path.exists(emulator.output_file) - df_hdf5 = read_hercules_hdf5(emulator.output_file) - # 5 steps with default log_every_n=1 should give 5 rows - assert len(df_hdf5) == 5 - assert df_hdf5["time"].iloc[0] == 0.0 - assert df_hdf5["time"].iloc[1] == 1.0 - assert df_hdf5["time"].iloc[2] == 2.0 - assert df_hdf5["time"].iloc[3] == 3.0 - assert df_hdf5["time"].iloc[4] == 4.0 - - # Test 2: HDF5 format with custom chunk size - with tempfile.TemporaryDirectory() as temp_dir: - test_h_dict_hdf5_2 = test_h_dict.copy() - test_h_dict_hdf5_2["output_file"] = os.path.join(temp_dir, "test_output.h5") - test_h_dict_hdf5_2["output_buffer_size"] = 500 # Custom chunk size - test_h_dict_hdf5_2["dt"] = 1.0 - test_h_dict_hdf5_2["starttime"] = 0.0 - test_h_dict_hdf5_2["endtime"] = 5.0 - - controller = SimpleControllerSolar(test_h_dict_hdf5_2) - hybrid_plant = HybridPlant(test_h_dict_hdf5_2) - emulator = Emulator(controller, hybrid_plant, test_h_dict_hdf5_2, logger) - - # Check configuration - assert emulator.buffer_size == 500 - - # Run simulation and write output - for step in range(5): # 5 steps to match the array size - emulator.step = step - emulator.time = step * emulator.dt - emulator.h_dict["time"] = emulator.time - emulator.h_dict["step"] = step - emulator.h_dict = controller.step(emulator.h_dict) - emulator.h_dict = hybrid_plant.step(emulator.h_dict) - emulator._log_data_to_hdf5() - - emulator.close() - - # Verify file exists and is readable - assert os.path.exists(emulator.output_file) - df_hdf5 = read_hercules_hdf5(emulator.output_file) - assert len(df_hdf5) == 5 - - -def test_log_every_n_option(): - """Test that the log_every_n option works correctly.""" - import os - import tempfile - - from hercules.utilities import read_hercules_hdf5 - - # Use h_dict_solar as base for testing - test_h_dict = h_dict_solar.copy() - - # Set up logger for testing - logger = setup_logging(console_output=False) - - # Test with log_every_n = 2 - with tempfile.TemporaryDirectory() as temp_dir: - test_h_dict_log = test_h_dict.copy() - test_h_dict_log["output_file"] = os.path.join(temp_dir, "test_output.h5") - test_h_dict_log["log_every_n"] = 2 # Log every 2 steps - test_h_dict_log["dt"] = 1.0 - test_h_dict_log["starttime"] = 0.0 - test_h_dict_log["endtime"] = 6.0 # 6 steps total - - controller = SimpleControllerSolar(test_h_dict_log) - hybrid_plant = HybridPlant(test_h_dict_log) - emulator = Emulator(controller, hybrid_plant, test_h_dict_log, logger) - - # Check configuration - assert emulator.log_every_n == 2 - assert emulator.dt_log == 2.0 - - # Run simulation and write output - for step in range(6): # 6 steps (0-5) for dt=1.0, endtime=6.0, starttime=0.0 - emulator.step = step - emulator.time = step * emulator.dt - emulator.h_dict["time"] = emulator.time - emulator.h_dict["step"] = step - emulator.h_dict = controller.step(emulator.h_dict) - emulator.h_dict = hybrid_plant.step(emulator.h_dict) - emulator._log_data_to_hdf5() - - emulator.close() - - # Verify file exists and is readable - assert os.path.exists(emulator.output_file) - df_hdf5 = read_hercules_hdf5(emulator.output_file) - # 6 steps with log_every_n=2 should give 3 rows (0, 2, 4) - assert len(df_hdf5) == 3 - assert df_hdf5["time"].iloc[0] == 0.0 - assert df_hdf5["time"].iloc[1] == 2.0 - assert df_hdf5["time"].iloc[2] == 4.0 - assert df_hdf5["step"].iloc[0] == 0 - assert df_hdf5["step"].iloc[1] == 2 - assert df_hdf5["step"].iloc[2] == 4 - - # Test with log_every_n = 3 - with tempfile.TemporaryDirectory() as temp_dir: - test_h_dict_log2 = test_h_dict.copy() - test_h_dict_log2["output_file"] = os.path.join(temp_dir, "test_output.h5") - test_h_dict_log2["log_every_n"] = 3 # Log every 3 steps - test_h_dict_log2["dt"] = 1.0 - test_h_dict_log2["starttime"] = 0.0 - test_h_dict_log2["endtime"] = 7.0 # 7 steps total - - controller = SimpleControllerSolar(test_h_dict_log2) - hybrid_plant = HybridPlant(test_h_dict_log2) - emulator = Emulator(controller, hybrid_plant, test_h_dict_log2, logger) - - # Check configuration - assert emulator.log_every_n == 3 - assert emulator.dt_log == 3.0 - - # Run simulation and write output - for step in range(7): # 7 steps (0-6) - emulator.step = step - emulator.time = step * emulator.dt - emulator.h_dict["time"] = emulator.time - emulator.h_dict["step"] = step - emulator.h_dict = controller.step(emulator.h_dict) - emulator.h_dict = hybrid_plant.step(emulator.h_dict) - emulator._log_data_to_hdf5() - - emulator.close() - - # Verify file exists and is readable - assert os.path.exists(emulator.output_file) - df_hdf5 = read_hercules_hdf5(emulator.output_file) - # 7 steps with log_every_n=3 should give 3 rows (0, 3, 6) - assert len(df_hdf5) == 3 - assert df_hdf5["time"].iloc[0] == 0.0 - assert df_hdf5["time"].iloc[1] == 3.0 - assert df_hdf5["time"].iloc[2] == 6.0 - assert df_hdf5["step"].iloc[0] == 0 - assert df_hdf5["step"].iloc[1] == 3 - assert df_hdf5["step"].iloc[2] == 6 diff --git a/tests/example_regression_tests/example_00b_regression_precom_test.py b/tests/example_regression_tests/example_00b_regression_precom_test.py index b0cb53e6..c8c6716c 100644 --- a/tests/example_regression_tests/example_00b_regression_precom_test.py +++ b/tests/example_regression_tests/example_00b_regression_precom_test.py @@ -4,7 +4,7 @@ import tempfile import yaml -from hercules.utilities_examples import ensure_example_inputs_exist +from hercules.utilities_examples import generate_example_inputs from test_example_utilities import ( copy_example_files, generate_input_data, @@ -33,7 +33,7 @@ def modify_input_file_for_precom_floris(temp_dir, input_file): - """Modify the input file to use Wind_MesoToPowerPrecomFloris component. + """Modify the input file to use WindFarm component. Args: temp_dir (str): Path to the temporary directory. @@ -47,13 +47,12 @@ def modify_input_file_for_precom_floris(temp_dir, input_file): # Modify the wind farm component type and ensure floris_update_time_s is present if "wind_farm" in h_dict: - h_dict["wind_farm"]["component_type"] = "Wind_MesoToPowerPrecomFloris" + h_dict["wind_farm"]["component_type"] = "WindFarm" + h_dict["wind_farm"]["wake_method"] = "precomputed" # Ensure a reasonable floris_update_time_s value exists h_dict["wind_farm"]["floris_update_time_s"] = h_dict["wind_farm"].get( "floris_update_time_s", 300.0 ) - # Add logging_option for the new logging system - h_dict["wind_farm"]["logging_option"] = "all" # Write the modified YAML file back with open(input_path, "w") as f: @@ -103,12 +102,12 @@ def print_expected_values(): def test_example_00b_precom_floris_limited_time_regression(): """Test that example 00 with precomputed FLORIS runs correctly with limited time steps. - This test modifies the example 00 configuration to use Wind_MesoToPowerPrecomFloris + This test modifies the example 00 configuration to use the WindFarm component type and run for only a few time steps. It verifies that the final outputs are reasonable and consistent. """ # Ensure centralized example inputs exist - ensure_example_inputs_exist() + generate_example_inputs() # Create a temporary directory for this test with tempfile.TemporaryDirectory() as temp_dir: diff --git a/tests/example_regression_tests/example_03_regression_test.py b/tests/example_regression_tests/example_03_regression_test.py index 5395c1fd..b5e67777 100644 --- a/tests/example_regression_tests/example_03_regression_test.py +++ b/tests/example_regression_tests/example_03_regression_test.py @@ -5,7 +5,7 @@ import numpy as np import pandas as pd -from hercules.utilities_examples import ensure_example_inputs_exist +from hercules.utilities_examples import generate_example_inputs from test_example_utilities import ( copy_example_files, run_simulation, @@ -44,10 +44,18 @@ def create_test_input_files(temp_dir): test_inputs_dir = os.path.join(temp_dir, "test_inputs") os.makedirs(test_inputs_dir, exist_ok=True) + # Load starttime_utc from the copied example input to build time_utc + import yaml + + with open(os.path.join(temp_dir, INPUT_FILE), "r") as f: + _h = yaml.safe_load(f) + starttime_utc = pd.to_datetime(_h["starttime_utc"], utc=True) + # Create wind input data (5 time steps) - feather format for test # We need 9 turbines (ws_000 through ws_008) with wind speeds and directions for large config wind_data = { "time": np.arange(0, NUM_TIME_STEPS, 1), + "time_utc": pd.date_range(start=starttime_utc, periods=NUM_TIME_STEPS, freq="1s", tz="UTC"), "wd_mean": np.array([270.0, 270.0, 270.0, 270.0, 270.0]), # Wind direction } @@ -58,9 +66,7 @@ def create_test_input_files(temp_dir): # Create solar input data (5 time steps) - feather format for test solar_data = { "time": np.arange(0, NUM_TIME_STEPS, 1), - "time_utc": pd.date_range( - "2024-06-24 17:00:00", periods=NUM_TIME_STEPS, freq="1s", tz="UTC" - ), + "time_utc": pd.date_range(start=starttime_utc, periods=NUM_TIME_STEPS, freq="1s", tz="UTC"), # GHI (daytime - realistic values from actual data ~735 W/m²) "SRRL BMS Global Horizontal Irradiance (W/m²_irr)": np.array( [735.0, 737.0, 732.0, 739.0, 735.0] @@ -141,6 +147,14 @@ def update_input_file_paths_for_test(temp_dir, input_file, test_inputs_dir): filename = os.path.basename(h_dict["solar_farm"]["solar_input_filename"]) h_dict["solar_farm"]["solar_input_filename"] = os.path.join(test_inputs_dir, filename) + # Also adjust endtime_utc to match the shortened test duration + if "starttime_utc" in h_dict: + start_ts = pd.to_datetime(h_dict["starttime_utc"], utc=True) + # For NUM_TIME_STEPS time steps (0, 1, 2, ..., NUM_TIME_STEPS-1), + # the end time should be at starttime + (NUM_TIME_STEPS - 1) seconds + new_end = start_ts + pd.to_timedelta(NUM_TIME_STEPS - 1, unit="s") + h_dict["endtime_utc"] = new_end.isoformat().replace("+00:00", "Z") + # Write the updated input file with open(input_file_path, "w") as f: yaml.dump(h_dict, f, default_flow_style=False) @@ -201,7 +215,7 @@ def test_example_03_limited_time_regression(): and verifies that the final outputs are reasonable and consistent. """ # Ensure centralized example inputs exist - ensure_example_inputs_exist() + generate_example_inputs() # Create a temporary directory for this test with tempfile.TemporaryDirectory() as temp_dir: diff --git a/tests/example_regression_tests/test_example_utilities.py b/tests/example_regression_tests/test_example_utilities.py index 994b7b4f..87e509b5 100644 --- a/tests/example_regression_tests/test_example_utilities.py +++ b/tests/example_regression_tests/test_example_utilities.py @@ -6,10 +6,9 @@ import tempfile import numpy as np -from hercules.emulator import Emulator -from hercules.hybrid_plant import HybridPlant -from hercules.utilities import load_hercules_input, setup_logging -from hercules.utilities_examples import ensure_example_inputs_exist +import pandas as pd +from hercules.hercules_model import HerculesModel +from hercules.utilities_examples import generate_example_inputs def copy_example_files(example_dir, temp_dir, input_file, inputs_dir, notebook_file): @@ -120,13 +119,28 @@ def run_simulation(input_file, num_time_steps): pd.DataFrame: The simulation output dataframe. """ # Load the input file - h_dict = load_hercules_input(input_file) - # Modify the endtime to run for the specified number of time steps - h_dict["endtime"] = num_time_steps + # Load the YAML file without full validation (to allow endtime override) + from hercules.utilities import load_yaml - # Set up logging - logger = setup_logging(console_output=False) + h_dict = load_yaml(input_file) + + # Adjust endtime_utc to achieve the requested number of time steps N + # If endtime_utc = starttime_utc + (N-1)*dt, loaders will compute endtime = duration + dt + # producing exactly N steps with dt resolution + if "dt" not in h_dict: + raise ValueError("dt must be specified in the input file") + if "starttime_utc" not in h_dict: + raise ValueError("starttime_utc must be specified in the input file") + + start_ts = pd.to_datetime(h_dict["starttime_utc"], utc=True) + delta_seconds = (num_time_steps - 1) * float(h_dict["dt"]) + new_end = start_ts + pd.to_timedelta(delta_seconds, unit="s") + h_dict["endtime_utc"] = new_end.isoformat().replace("+00:00", "Z") + + # Ensure any stray start/end are removed to satisfy new loader policy + h_dict.pop("starttime", None) + h_dict.pop("endtime", None) class ControllerSimple: """A simple controller for testing.""" @@ -137,7 +151,7 @@ def __init__(self, h_dict): Args: h_dict (dict): Hercules input dictionary. """ - pass + self.h_dict = h_dict def step(self, h_dict): """Execute one control step. @@ -161,17 +175,13 @@ def step(self, h_dict): return h_dict - # Initialize the controller - controller = ControllerSimple(h_dict) - - # Initialize the hybrid plant - hybrid_plant = HybridPlant(h_dict) - - # Initialize the emulator - emulator = Emulator(controller, hybrid_plant, h_dict, logger) + # Initialize and run the Hercules model + hmodel = HerculesModel(h_dict) + hmodel.assign_controller(ControllerSimple(h_dict)) + hmodel.logger.handlers[0].setLevel(100) # Suppress console output - # Run the emulator - emulator.enter_execution(function_targets=[], function_arguments=[[]]) + # Run the simulation + hmodel.run() # Check that the output file was created output_file = "outputs/hercules_output.h5" @@ -216,7 +226,13 @@ def verify_outputs( turbine_power_cols = [ col for col in df.columns if col.startswith("wind_farm.turbine_powers.") ] - assert len(turbine_power_cols) > 0, "Should have turbine power columns" + # Only check turbine power columns if they were logged by the example + # Some examples configure log_channels to only include aggregate power + if len(turbine_power_cols) > 0: + # Ensure values are non-negative and finite when present + for col in turbine_power_cols: + assert all(df[col] >= 0), f"{col} should be non-negative" + assert all(np.isfinite(df[col])), f"{col} should be finite" # Test that the final wind power has not changed much np.testing.assert_allclose( @@ -231,11 +247,11 @@ def verify_outputs( # Test that the final solar power has not changed much (if expected value provided) if expected_final_solar_power is not None: np.testing.assert_allclose( - df["solar_farm.power"].iloc[-1], expected_final_solar_power, atol=1 + df["solar_farm.power"].iloc[-1], expected_final_solar_power, atol=15 ) # Test that the final plant power has not changed much - np.testing.assert_allclose(df["plant.power"].iloc[-1], expected_final_plant_power, atol=1) + np.testing.assert_allclose(df["plant.power"].iloc[-1], expected_final_plant_power, atol=15) def verify_plot_script(temp_dir, original_cwd, example_dir, plot_script_file): @@ -304,7 +320,7 @@ def run_example_regression_test( Defaults to "plot_outputs.py". """ # Ensure centralized example inputs exist - ensure_example_inputs_exist() + generate_example_inputs() # Create a temporary directory for this test with tempfile.TemporaryDirectory() as temp_dir: diff --git a/tests/grid_utilities_test.py b/tests/grid_utilities_test.py new file mode 100644 index 00000000..d0539e95 --- /dev/null +++ b/tests/grid_utilities_test.py @@ -0,0 +1,92 @@ +import pandas as pd +import pytest +from hercules.grid.grid_utilities import ( + generate_locational_marginal_price_dataframe_from_gridstatus, +) + + +def test_generate_locational_marginal_price_dataframe_from_gridstatus(): + df_da = pd.DataFrame( + data={ + "interval_start_utc": pd.date_range(start="2024-01-01", periods=5, freq="h"), + "market": ["DAY_AHEAD_HOURLY"] * 5, + "lmp": [10, 20, 30, 40, 50], + } + ) + + df_rt = pd.DataFrame( + data={ + "interval_start_utc": pd.date_range(start="2024-01-01", periods=12, freq="5min"), + "market": ["REAL_TIME_5_MIN"] * 12, + "lmp": [15, 25, 35, 45, 55, 65, 75, 85, 95, 105, 115, 125], + } + ) + + df_out = generate_locational_marginal_price_dataframe_from_gridstatus(df_da, df_rt) + + assert "time_utc" in df_out.columns + assert "lmp_rt" in df_out.columns + assert "lmp_da" in df_out.columns + for hour in range(24): + assert f"lmp_da_{hour:02d}" in df_out.columns + + # Check dt in output (should be one second less than five minutes; then 1 second) + assert (df_out["time_utc"].iloc[1] - df_out["time_utc"].iloc[0]).total_seconds() == 299 + assert (df_out["time_utc"].iloc[2] - df_out["time_utc"].iloc[1]).total_seconds() == 1 + + # Check that the values covered are the union of the inputs + assert df_out["time_utc"].min() <= df_da["interval_start_utc"].min() + assert df_out["time_utc"].min() <= df_rt["interval_start_utc"].min() + assert df_out["time_utc"].max() >= df_da["interval_start_utc"].max() + assert df_out["time_utc"].max() >= df_rt["interval_start_utc"].max() + + # Check that error is raised if intervals don't overlap at all + df_da_no_overlap = df_da.copy() + df_da_no_overlap["interval_start_utc"] = pd.date_range(start="2023-12-25", periods=5, freq="h") + with pytest.raises(ValueError): + generate_locational_marginal_price_dataframe_from_gridstatus(df_da_no_overlap, df_rt) + + # Check that a different market name also works + df_da_diff_market = df_da.copy() + df_da_diff_market["market"] = ["CUSTOM_DA_MARKET"] * 5 + + df_out_2 = generate_locational_marginal_price_dataframe_from_gridstatus( + df_da_diff_market, df_rt, day_ahead_market_name="CUSTOM_DA_MARKET" + ) + + assert df_out_2.equals(df_out) + + # Check that error is raised if markets are not all consistent + df_da_diff_market.loc[df_da_diff_market.index[0], "market"] = "ANOTHER_MARKET" + with pytest.raises(ValueError): + generate_locational_marginal_price_dataframe_from_gridstatus( + df_da_diff_market, df_rt, day_ahead_market_name="CUSTOM_DA_MARKET" + ) + + # Check that a different (valid) time interval works for real-time data + df_rt_15 = pd.DataFrame( + data={ + "interval_start_utc": pd.date_range(start="2024-01-01", periods=4, freq="15min"), + "market": ["REAL_TIME_15_MIN"] * 4, + "lmp": [15, 45, 75, 105], + } + ) + df_out_3 = generate_locational_marginal_price_dataframe_from_gridstatus( + df_da, df_rt_15, real_time_market_name="REAL_TIME_15_MIN" + ) + + assert (df_out_3["time_utc"].iloc[1] - df_out_3["time_utc"].iloc[0]).total_seconds() == 899 + assert (df_out_3["time_utc"].iloc[2] - df_out_3["time_utc"].iloc[1]).total_seconds() == 1 + + # Check that an invalid time interval raises an error + df_rt_invalid = pd.DataFrame( + data={ + "interval_start_utc": pd.date_range(start="2024-01-01", periods=4, freq="7min"), + "market": ["REAL_TIME_7_MIN"] * 4, + "lmp": [15, 45, 75, 105], + } + ) + with pytest.raises(ValueError): + generate_locational_marginal_price_dataframe_from_gridstatus( + df_da, df_rt_invalid, real_time_market_name="REAL_TIME_7_MIN" + ) diff --git a/tests/hercules_model_test.py b/tests/hercules_model_test.py new file mode 100644 index 00000000..b135b873 --- /dev/null +++ b/tests/hercules_model_test.py @@ -0,0 +1,830 @@ +import numpy as np +import pandas as pd +from hercules.hercules_model import HerculesModel + +from tests.test_inputs.h_dict import h_dict_battery, h_dict_solar, h_dict_wind + + +class SimpleControllerWind: + """A simple controller for testing that just returns the h_dict unchanged.""" + + def __init__(self, h_dict): + """Initialize the controller. + + Args: + h_dict (dict): The hercules input dictionary. + """ + self.h_dict = h_dict + + def step(self, h_dict): + """Execute one control step. + + Args: + h_dict (dict): The hercules input dictionary. + + Returns: + dict: The updated hercules input dictionary. + """ + # Set power setpoints for wind turbines if wind farm is present + if "wind_farm" in h_dict and "n_turbines" in h_dict["wind_farm"]: + h_dict["wind_farm"]["turbine_power_setpoints"] = 5000 * np.ones( + h_dict["wind_farm"]["n_turbines"] + ) + + # Set power setpoints for battery if present + if "battery" in h_dict: + h_dict["battery"]["power_setpoint"] = 0.0 + + return h_dict + + +class SimpleControllerSolar: + """A simple controller for testing that just returns the h_dict unchanged.""" + + def __init__(self, h_dict): + """Initialize the controller. + + Args: + h_dict (dict): The hercules input dictionary. + """ + self.h_dict = h_dict + + def step(self, h_dict): + """Execute one control step. + + Args: + h_dict (dict): The hercules input dictionary. + + Returns: + dict: The updated hercules input dictionary. + """ + + # Set solar derating to very high to have no impact + h_dict["solar_farm"]["power_setpoint"] = 1e10 + + return h_dict + + +def test_HerculesModel_instantiation(): + """Test that the HerculesModel can be instantiated with different configurations.""" + + # Use h_dict_solar as base for testing + test_h_dict = h_dict_solar.copy() + # Enforce new loader policy: remove preset start/end and rely on *_utc + test_h_dict.pop("starttime", None) + test_h_dict.pop("endtime", None) + test_h_dict.pop("time", None) + test_h_dict.pop("step", None) + + hmodel = HerculesModel(test_h_dict) + + # Check default settings + assert hmodel.output_file == "outputs/hercules_output.h5" + assert hmodel.log_every_n == 1 + assert hmodel.external_signals_all == {} + + # Test with external data file and custom output file + test_h_dict_2 = h_dict_solar.copy() + test_h_dict_2["external_data_file"] = "tests/test_inputs/external_data.csv" + test_h_dict_2["output_file"] = "test_output.h5" + test_h_dict_2["dt"] = 0.5 + # Remove preset start/end and adjust endtime_utc to preserve prior behavior + test_h_dict_2.pop("starttime", None) + test_h_dict_2.pop("endtime", None) + test_h_dict_2.pop("time", None) + test_h_dict_2.pop("step", None) + # To achieve endtime = 5.0 and endtime + 2*dt = 6.0, set duration = 4.5s + test_h_dict_2["endtime_utc"] = test_h_dict_2["starttime_utc"] + pd.to_timedelta(4.5, unit="s") + + hmodel = HerculesModel(test_h_dict_2) + + # Check external data loading + assert hmodel.external_signals_all["power_reference"][0] == 1000 + # With dt=0.5 and endtime=5.0, we have times: 0.0, 0.5, 1.0, ..., 5.5, 6.0 + # At time 1.0: value is 2000 (from data), but at index 2 (time=1.0), value is interpolated + # Actually external_signals_all has times from starttime to endtime + 2*dt with step dt + # So times are: 0.0, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 3.5, 4.0, 4.5, 5.0, 5.5, 6.0 + assert hmodel.external_signals_all["power_reference"][-1] == 1000 # At time 6.0 + + # Check custom output file + assert hmodel.output_file == "test_output.h5" + + +def test_log_data_to_hdf5(): + """Test that the new HDF5 logging function works correctly.""" + + # Use h_dict_solar as base for testing + test_h_dict = h_dict_solar.copy() + test_h_dict.pop("starttime", None) + test_h_dict.pop("endtime", None) + test_h_dict.pop("time", None) + test_h_dict.pop("step", None) + + hmodel = HerculesModel(test_h_dict) + hmodel.assign_controller(SimpleControllerSolar(test_h_dict)) + + # Set up the simulation state + hmodel.time = 5.0 + hmodel.step = 5 + hmodel.h_dict["time"] = 5.0 + hmodel.h_dict["step"] = 5 + + # Run controller and hybrid_plant steps to generate plant-level outputs + hmodel.h_dict = hmodel.controller.step(hmodel.h_dict) + hmodel.h_dict = hmodel.hybrid_plant.step(hmodel.h_dict) + + # Call the new HDF5 logging function + hmodel._log_data_to_hdf5() + + # Check that HDF5 file was initialized + assert hmodel.output_structure_determined + assert hmodel.hdf5_file is not None + assert len(hmodel.hdf5_datasets) > 0 + + # Check that expected datasets exist + expected_datasets = { + "time", + "step", + "plant_power", + "plant_locally_generated_power", + "solar_farm.power", + } + + actual_datasets = set(hmodel.hdf5_datasets.keys()) + missing_datasets = expected_datasets - actual_datasets + assert expected_datasets.issubset(actual_datasets), ( + f"Missing expected datasets: {missing_datasets}" + ) + + # Flush buffer to write data to HDF5 + if hasattr(hmodel, "data_buffers") and hmodel.data_buffers and hmodel.buffer_row > 0: + hmodel._flush_buffer_to_hdf5() + + # Check that data was written correctly + assert hmodel.hdf5_datasets["time"][0] == 5.0 + assert hmodel.hdf5_datasets["step"][0] == 5 + assert hmodel.hdf5_datasets["plant_power"][0] > 0 + assert hmodel.hdf5_datasets["solar_farm.power"][0] > 0 + + # Clean up + hmodel.close() + + +def test_log_data_to_hdf5_with_external_signals(): + """Test that external signals are logged correctly to HDF5.""" + + # Use h_dict_battery as base for testing (no external data requirements) + test_h_dict = h_dict_battery.copy() + test_h_dict.pop("starttime", None) + test_h_dict.pop("endtime", None) + test_h_dict.pop("time", None) + test_h_dict.pop("step", None) + + # Add external data file + test_h_dict["external_data_file"] = "tests/test_inputs/external_data.csv" + test_h_dict["dt"] = 1.0 + + hmodel = HerculesModel(test_h_dict) + hmodel.assign_controller(SimpleControllerWind(test_h_dict)) + + # Set up the simulation state + hmodel.time = 5.0 + hmodel.step = 5 + hmodel.h_dict["time"] = 5.0 + hmodel.h_dict["step"] = 5 + + # Update external signals (simulate what happens in the run loop) + if hmodel.external_signals_all: + for k in hmodel.external_signals_all: + if k == "time": + continue + hmodel.h_dict["external_signals"][k] = hmodel.external_signals_all[k][hmodel.step] + + # Run controller and hybrid_plant steps to generate plant-level outputs + hmodel.h_dict = hmodel.controller.step(hmodel.h_dict) + hmodel.h_dict = hmodel.hybrid_plant.step(hmodel.h_dict) + + # Call the new HDF5 logging function + hmodel._log_data_to_hdf5() + + # Check that HDF5 file was initialized + assert hmodel.output_structure_determined + assert hmodel.hdf5_file is not None + assert len(hmodel.hdf5_datasets) > 0 + + # Check that external signals dataset exists + expected_external_dataset = "external_signals.power_reference" + assert expected_external_dataset in hmodel.hdf5_datasets + + # Flush buffer to write data to HDF5 + if hasattr(hmodel, "data_buffers") and hmodel.data_buffers and hmodel.buffer_row > 0: + hmodel._flush_buffer_to_hdf5() + + # Check that external signal data was written correctly + expected_value = hmodel.external_signals_all["power_reference"][5] # Value at step 5 + assert hmodel.hdf5_datasets[expected_external_dataset][0] == expected_value + + # Clean up + hmodel.close() + + +def test_log_data_to_hdf5_with_wind_farm_arrays(): + """Test that the new HDF5 logging function handles wind farm array outputs correctly.""" + + # Use h_dict_wind as base for testing + test_h_dict = h_dict_wind.copy() + test_h_dict.pop("starttime", None) + test_h_dict.pop("endtime", None) + test_h_dict.pop("time", None) + test_h_dict.pop("step", None) + + hmodel = HerculesModel(test_h_dict) + hmodel.assign_controller(SimpleControllerWind(test_h_dict)) + + # Set up the simulation state + hmodel.time = 5.0 + hmodel.step = 5 + hmodel.h_dict["time"] = 5.0 + hmodel.h_dict["step"] = 5 + + # Run controller and hybrid_plant steps to generate plant-level outputs + hmodel.h_dict = hmodel.controller.step(hmodel.h_dict) + hmodel.h_dict = hmodel.hybrid_plant.step(hmodel.h_dict) + + # Call the new HDF5 logging function + hmodel._log_data_to_hdf5() + + # Check that array outputs are handled correctly + expected_datasets = { + "time", + "step", + "plant_power", + "plant_locally_generated_power", + "wind_farm.power", + "wind_farm.turbine_powers.000", + "wind_farm.turbine_powers.001", + "wind_farm.turbine_powers.002", + } + + actual_datasets = set(hmodel.hdf5_datasets.keys()) + + # Verify that all expected datasets are present + missing_datasets = expected_datasets - actual_datasets + assert expected_datasets.issubset(actual_datasets), ( + f"Missing expected datasets: {missing_datasets}" + ) + + # Flush buffer to write data to HDF5 + if hasattr(hmodel, "data_buffers") and hmodel.data_buffers and hmodel.buffer_row > 0: + hmodel._flush_buffer_to_hdf5() + + # Check that data was written correctly + assert hmodel.hdf5_datasets["time"][0] == 5.0 + assert hmodel.hdf5_datasets["step"][0] == 5 + assert hmodel.hdf5_datasets["wind_farm.power"][0] > 0 + assert hmodel.hdf5_datasets["plant_power"][0] > 0 + assert hmodel.hdf5_datasets["plant_locally_generated_power"][0] > 0 + + # Verify that turbine_powers array is handled correctly + assert hmodel.hdf5_datasets["wind_farm.turbine_powers.000"][0] > 0 + assert hmodel.hdf5_datasets["wind_farm.turbine_powers.001"][0] > 0 + assert hmodel.hdf5_datasets["wind_farm.turbine_powers.002"][0] > 0 + + # Clean up + hmodel.close() + + +def test_hdf5_output_configuration(): + """Test HDF5 output configuration options: downsampling and chunking.""" + import os + import tempfile + + from hercules.utilities import read_hercules_hdf5 + + # Use h_dict_solar as base for testing + test_h_dict = h_dict_solar.copy() + + # Test 1: HDF5 format with downsampling + with tempfile.TemporaryDirectory() as temp_dir: + test_h_dict_hdf5 = test_h_dict.copy() + test_h_dict_hdf5["output_file"] = os.path.join(temp_dir, "test_output.h5") + test_h_dict_hdf5["dt"] = 1.0 + # Remove preset start/end and set endtime_utc for 5 steps (duration=4s) + test_h_dict_hdf5.pop("starttime", None) + test_h_dict_hdf5.pop("endtime", None) + test_h_dict_hdf5.pop("time", None) + test_h_dict_hdf5.pop("step", None) + test_h_dict_hdf5["endtime_utc"] = test_h_dict_hdf5["starttime_utc"] + pd.to_timedelta( + 4.0, unit="s" + ) + + hmodel = HerculesModel(test_h_dict_hdf5) + hmodel.assign_controller(SimpleControllerSolar(test_h_dict_hdf5)) + + # Run simulation and write output + for step in range(5): # 5 steps (0-4) for dt=1.0, endtime=5.0, starttime=0.0 + hmodel.step = step + hmodel.time = step * hmodel.dt + hmodel.h_dict["time"] = hmodel.time + hmodel.h_dict["step"] = step + hmodel.h_dict = hmodel.controller.step(hmodel.h_dict) + hmodel.h_dict = hmodel.hybrid_plant.step(hmodel.h_dict) + hmodel._log_data_to_hdf5() + + hmodel.close() + + # Verify file exists and is readable + assert os.path.exists(hmodel.output_file) + df_hdf5 = read_hercules_hdf5(hmodel.output_file) + # 5 steps with default log_every_n=1 should give 5 rows + assert len(df_hdf5) == 5 + assert df_hdf5["time"].iloc[0] == 0.0 + assert df_hdf5["time"].iloc[1] == 1.0 + assert df_hdf5["time"].iloc[2] == 2.0 + assert df_hdf5["time"].iloc[3] == 3.0 + assert df_hdf5["time"].iloc[4] == 4.0 + + # Test 2: HDF5 format with custom chunk size + with tempfile.TemporaryDirectory() as temp_dir: + test_h_dict_hdf5_2 = test_h_dict.copy() + test_h_dict_hdf5_2["output_file"] = os.path.join(temp_dir, "test_output.h5") + test_h_dict_hdf5_2["output_buffer_size"] = 500 # Custom chunk size + test_h_dict_hdf5_2["dt"] = 1.0 + test_h_dict_hdf5_2.pop("starttime", None) + test_h_dict_hdf5_2.pop("endtime", None) + test_h_dict_hdf5_2.pop("time", None) + test_h_dict_hdf5_2.pop("step", None) + test_h_dict_hdf5_2["endtime_utc"] = test_h_dict_hdf5_2["starttime_utc"] + pd.to_timedelta( + 4.0, unit="s" + ) + + hmodel = HerculesModel(test_h_dict_hdf5_2) + hmodel.assign_controller(SimpleControllerSolar(test_h_dict_hdf5_2)) + + # Check configuration + assert hmodel.buffer_size == 500 + + # Run simulation and write output + for step in range(5): # 5 steps to match the array size + hmodel.step = step + hmodel.time = step * hmodel.dt + hmodel.h_dict["time"] = hmodel.time + hmodel.h_dict["step"] = step + hmodel.h_dict = hmodel.controller.step(hmodel.h_dict) + hmodel.h_dict = hmodel.hybrid_plant.step(hmodel.h_dict) + hmodel._log_data_to_hdf5() + + hmodel.close() + + # Verify file exists and is readable + assert os.path.exists(hmodel.output_file) + df_hdf5 = read_hercules_hdf5(hmodel.output_file) + assert len(df_hdf5) == 5 + + +def test_log_every_n_option(): + """Test that the log_every_n option works correctly.""" + import os + import tempfile + + from hercules.utilities import read_hercules_hdf5 + + # Use h_dict_solar as base for testing + test_h_dict = h_dict_solar.copy() + + # Test with log_every_n = 2 + with tempfile.TemporaryDirectory() as temp_dir: + test_h_dict_log = test_h_dict.copy() + test_h_dict_log["output_file"] = os.path.join(temp_dir, "test_output.h5") + test_h_dict_log["log_every_n"] = 2 # Log every 2 steps + test_h_dict_log["dt"] = 1.0 + test_h_dict_log.pop("starttime", None) + test_h_dict_log.pop("endtime", None) + test_h_dict_log.pop("time", None) + test_h_dict_log.pop("step", None) + # For 6 steps total, duration=5s + test_h_dict_log["endtime_utc"] = test_h_dict_log["starttime_utc"] + pd.to_timedelta( + 5.0, unit="s" + ) + + hmodel = HerculesModel(test_h_dict_log) + hmodel.assign_controller(SimpleControllerSolar(test_h_dict_log)) + + # Check configuration + assert hmodel.log_every_n == 2 + assert hmodel.dt_log == 2.0 + + # Run simulation and write output + for step in range(6): # 6 steps (0-5) for dt=1.0, endtime=6.0, starttime=0.0 + hmodel.step = step + hmodel.time = step * hmodel.dt + hmodel.h_dict["time"] = hmodel.time + hmodel.h_dict["step"] = step + hmodel.h_dict = hmodel.controller.step(hmodel.h_dict) + hmodel.h_dict = hmodel.hybrid_plant.step(hmodel.h_dict) + hmodel._log_data_to_hdf5() + + hmodel.close() + + # Verify file exists and is readable + assert os.path.exists(hmodel.output_file) + df_hdf5 = read_hercules_hdf5(hmodel.output_file) + # 6 steps with log_every_n=2 should give 3 rows (0, 2, 4) + assert len(df_hdf5) == 3 + assert df_hdf5["time"].iloc[0] == 0.0 + assert df_hdf5["time"].iloc[1] == 2.0 + assert df_hdf5["time"].iloc[2] == 4.0 + assert df_hdf5["step"].iloc[0] == 0 + assert df_hdf5["step"].iloc[1] == 2 + assert df_hdf5["step"].iloc[2] == 4 + + # Test with log_every_n = 3 + with tempfile.TemporaryDirectory() as temp_dir: + test_h_dict_log2 = test_h_dict.copy() + test_h_dict_log2["output_file"] = os.path.join(temp_dir, "test_output.h5") + test_h_dict_log2["log_every_n"] = 3 # Log every 3 steps + test_h_dict_log2["dt"] = 1.0 + test_h_dict_log2.pop("starttime", None) + test_h_dict_log2.pop("endtime", None) + test_h_dict_log2.pop("time", None) + test_h_dict_log2.pop("step", None) + # For 7 steps total, duration=6s + test_h_dict_log2["endtime_utc"] = test_h_dict_log2["starttime_utc"] + pd.to_timedelta( + 6.0, unit="s" + ) + + hmodel = HerculesModel(test_h_dict_log2) + hmodel.assign_controller(SimpleControllerSolar(test_h_dict_log2)) + + # Check configuration + assert hmodel.log_every_n == 3 + assert hmodel.dt_log == 3.0 + + # Run simulation and write output + for step in range(7): # 7 steps (0-6) + hmodel.step = step + hmodel.time = step * hmodel.dt + hmodel.h_dict["time"] = hmodel.time + hmodel.h_dict["step"] = step + hmodel.h_dict = hmodel.controller.step(hmodel.h_dict) + hmodel.h_dict = hmodel.hybrid_plant.step(hmodel.h_dict) + hmodel._log_data_to_hdf5() + + hmodel.close() + + # Verify file exists and is readable + assert os.path.exists(hmodel.output_file) + df_hdf5 = read_hercules_hdf5(hmodel.output_file) + # 7 steps with log_every_n=3 should give 3 rows (0, 3, 6) + assert len(df_hdf5) == 3 + assert df_hdf5["time"].iloc[0] == 0.0 + assert df_hdf5["time"].iloc[1] == 3.0 + assert df_hdf5["time"].iloc[2] == 6.0 + assert df_hdf5["step"].iloc[0] == 0 + assert df_hdf5["step"].iloc[1] == 3 + assert df_hdf5["step"].iloc[2] == 6 + + +def test_log_selective_array_element(): + """Test that selective array element logging (e.g., turbine_powers.001) works correctly. + + This test verifies that when log_channels specifies a specific array element + (e.g., turbine_powers.001), only that element is logged and not other elements. + """ + import copy + + # Use h_dict_wind as base for testing + test_h_dict = copy.deepcopy(h_dict_wind) + test_h_dict.pop("starttime", None) + test_h_dict.pop("endtime", None) + test_h_dict.pop("time", None) + test_h_dict.pop("step", None) + + # Modify log_channels to only include turbine_powers.001 (not the full array) + test_h_dict["wind_farm"]["log_channels"] = ["power", "turbine_powers.001"] + + hmodel = HerculesModel(test_h_dict) + hmodel.assign_controller(SimpleControllerWind(test_h_dict)) + + # Set up the simulation state + hmodel.time = 5.0 + hmodel.step = 5 + hmodel.h_dict["time"] = 5.0 + hmodel.h_dict["step"] = 5 + + # Run controller and hybrid_plant steps to generate plant-level outputs + hmodel.h_dict = hmodel.controller.step(hmodel.h_dict) + hmodel.h_dict = hmodel.hybrid_plant.step(hmodel.h_dict) + + # Call the new HDF5 logging function + hmodel._log_data_to_hdf5() + + # Check that ONLY turbine_powers.001 is logged (not .000 or .002) + actual_datasets = set(hmodel.hdf5_datasets.keys()) + + # turbine_powers.001 SHOULD be present + assert "wind_farm.turbine_powers.001" in actual_datasets, ( + "Expected wind_farm.turbine_powers.001 to be logged" + ) + + # turbine_powers.000 should NOT be present + assert "wind_farm.turbine_powers.000" not in actual_datasets, ( + "wind_farm.turbine_powers.000 should NOT be logged when only .001 is specified" + ) + + # turbine_powers.002 should NOT be present + assert "wind_farm.turbine_powers.002" not in actual_datasets, ( + "wind_farm.turbine_powers.002 should NOT be logged when only .001 is specified" + ) + + # Verify that basic datasets are still present + assert "time" in actual_datasets + assert "step" in actual_datasets + assert "wind_farm.power" in actual_datasets + + # Flush buffer to write data to HDF5 + if hasattr(hmodel, "data_buffers") and hmodel.data_buffers and hmodel.buffer_row > 0: + hmodel._flush_buffer_to_hdf5() + + # Verify that turbine_powers.001 has a valid value + assert hmodel.hdf5_datasets["wind_farm.turbine_powers.001"][0] > 0 + + # Clean up + hmodel.close() + + +def test_external_data_new_format_with_log_channels(): + """Test new external_data format with selective log_channels.""" + # Create test dict with new external_data format + test_h_dict = h_dict_battery.copy() + test_h_dict.pop("starttime", None) + test_h_dict.pop("endtime", None) + test_h_dict.pop("time", None) + test_h_dict.pop("step", None) + + # Use new format with log_channels + test_h_dict["external_data"] = { + "external_data_file": "tests/test_inputs/external_data.csv", + "log_channels": ["power_reference"], # Only log one channel + } + test_h_dict["dt"] = 1.0 + + hmodel = HerculesModel(test_h_dict) + + # Verify that external_data_log_channels was set + assert hmodel.external_data_log_channels == ["power_reference"] + + # Verify that all external data was loaded into external_signals_all + assert "power_reference" in hmodel.external_signals_all + assert len(hmodel.external_signals_all["power_reference"]) > 0 + + # Clean up + hmodel.close() + + +def test_external_data_backward_compatibility(): + """Test backward compatibility with old external_data_file format.""" + import warnings + + # Create test dict with old format + test_h_dict = h_dict_battery.copy() + test_h_dict.pop("starttime", None) + test_h_dict.pop("endtime", None) + test_h_dict.pop("time", None) + test_h_dict.pop("step", None) + + # Use old format (top-level external_data_file) + test_h_dict["external_data_file"] = "tests/test_inputs/external_data.csv" + test_h_dict["dt"] = 1.0 + + # Should trigger a deprecation warning + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + hmodel = HerculesModel(test_h_dict) + + # Verify that a deprecation warning was issued + assert len(w) == 1 + assert issubclass(w[0].category, DeprecationWarning) + assert "deprecated" in str(w[0].message).lower() + + # Verify that external_data_log_channels is None (log all) + assert hmodel.external_data_log_channels is None + + # Verify that data was loaded + assert "power_reference" in hmodel.external_signals_all + + # Clean up + hmodel.close() + + +def test_external_signals_all_channels_in_h_dict(): + """Test that all external data channels are available in h_dict regardless of log_channels.""" + # Create a CSV with multiple channels + import os + + import pandas as pd + + csv_path = "tests/test_inputs/multi_channel_external_data.csv" + df = pd.DataFrame( + { + "time_utc": pd.date_range("2018-05-10 12:31:00", periods=10, freq="1s"), + "channel_1": [1.0] * 10, + "channel_2": [2.0] * 10, + "channel_3": [3.0] * 10, + } + ) + df.to_csv(csv_path, index=False) + + try: + # Create test dict with only channel_1 in log_channels + test_h_dict = h_dict_battery.copy() + test_h_dict.pop("starttime", None) + test_h_dict.pop("endtime", None) + test_h_dict.pop("time", None) + test_h_dict.pop("step", None) + + test_h_dict["external_data"] = { + "external_data_file": csv_path, + "log_channels": ["channel_1"], # Only log channel_1 + } + test_h_dict["dt"] = 1.0 + + hmodel = HerculesModel(test_h_dict) + hmodel.assign_controller(SimpleControllerWind(test_h_dict)) + + # Set up simulation state + hmodel.time = 5.0 + hmodel.step = 5 + hmodel.h_dict["time"] = 5.0 + hmodel.h_dict["step"] = 5 + + # Populate external signals (as done in run loop) + if hmodel.external_signals_all: + for k in hmodel.external_signals_all: + if k == "time": + continue + hmodel.h_dict["external_signals"][k] = hmodel.external_signals_all[k][hmodel.step] + + # Verify ALL channels are in h_dict["external_signals"] + assert "channel_1" in hmodel.h_dict["external_signals"] + assert "channel_2" in hmodel.h_dict["external_signals"] + assert "channel_3" in hmodel.h_dict["external_signals"] + + # Run controller and hybrid_plant steps + hmodel.h_dict = hmodel.controller.step(hmodel.h_dict) + hmodel.h_dict = hmodel.hybrid_plant.step(hmodel.h_dict) + + # Log data to HDF5 + hmodel._log_data_to_hdf5() + + # Verify only channel_1 was logged to HDF5 + assert "external_signals.channel_1" in hmodel.hdf5_datasets + assert "external_signals.channel_2" not in hmodel.hdf5_datasets + assert "external_signals.channel_3" not in hmodel.hdf5_datasets + + # Clean up + hmodel.close() + finally: + # Remove temporary CSV file + if os.path.exists(csv_path): + os.remove(csv_path) + + +def test_external_data_log_all_when_no_log_channels(): + """Test that all channels are logged when log_channels is not specified.""" + import os + + import pandas as pd + + csv_path = "tests/test_inputs/multi_channel_external_data_2.csv" + df = pd.DataFrame( + { + "time_utc": pd.date_range("2018-05-10 12:31:00", periods=10, freq="1s"), + "channel_a": [10.0] * 10, + "channel_b": [20.0] * 10, + } + ) + df.to_csv(csv_path, index=False) + + try: + # Create test dict without log_channels + test_h_dict = h_dict_battery.copy() + test_h_dict.pop("starttime", None) + test_h_dict.pop("endtime", None) + test_h_dict.pop("time", None) + test_h_dict.pop("step", None) + + test_h_dict["external_data"] = { + "external_data_file": csv_path, + # No log_channels specified - should log all + } + test_h_dict["dt"] = 1.0 + + hmodel = HerculesModel(test_h_dict) + hmodel.assign_controller(SimpleControllerWind(test_h_dict)) + + # Verify log_channels is None + assert hmodel.external_data_log_channels is None + + # Set up simulation state + hmodel.time = 5.0 + hmodel.step = 5 + hmodel.h_dict["time"] = 5.0 + hmodel.h_dict["step"] = 5 + + # Populate external signals + if hmodel.external_signals_all: + for k in hmodel.external_signals_all: + if k == "time": + continue + hmodel.h_dict["external_signals"][k] = hmodel.external_signals_all[k][hmodel.step] + + # Run controller and hybrid_plant steps + hmodel.h_dict = hmodel.controller.step(hmodel.h_dict) + hmodel.h_dict = hmodel.hybrid_plant.step(hmodel.h_dict) + + # Log data to HDF5 + hmodel._log_data_to_hdf5() + + # Verify ALL channels were logged to HDF5 + assert "external_signals.channel_a" in hmodel.hdf5_datasets + assert "external_signals.channel_b" in hmodel.hdf5_datasets + + # Clean up + hmodel.close() + finally: + # Remove temporary CSV file + if os.path.exists(csv_path): + os.remove(csv_path) + + +def test_external_data_log_none_with_empty_list(): + """Test that no channels are logged when log_channels is an empty list.""" + import os + + import pandas as pd + + csv_path = "tests/test_inputs/multi_channel_external_data_3.csv" + df = pd.DataFrame( + { + "time_utc": pd.date_range("2018-05-10 12:31:00", periods=10, freq="1s"), + "channel_x": [100.0] * 10, + "channel_y": [200.0] * 10, + } + ) + df.to_csv(csv_path, index=False) + + try: + # Create test dict with empty log_channels list + test_h_dict = h_dict_battery.copy() + test_h_dict.pop("starttime", None) + test_h_dict.pop("endtime", None) + test_h_dict.pop("time", None) + test_h_dict.pop("step", None) + + test_h_dict["external_data"] = { + "external_data_file": csv_path, + "log_channels": [], # Empty list - should log nothing + } + test_h_dict["dt"] = 1.0 + + hmodel = HerculesModel(test_h_dict) + hmodel.assign_controller(SimpleControllerWind(test_h_dict)) + + # Verify log_channels is an empty list + assert hmodel.external_data_log_channels == [] + + # Set up simulation state + hmodel.time = 5.0 + hmodel.step = 5 + hmodel.h_dict["time"] = 5.0 + hmodel.h_dict["step"] = 5 + + # Populate external signals (all channels should be available) + if hmodel.external_signals_all: + for k in hmodel.external_signals_all: + if k == "time": + continue + hmodel.h_dict["external_signals"][k] = hmodel.external_signals_all[k][hmodel.step] + + # Verify ALL channels are in h_dict["external_signals"] (available to controller) + assert "channel_x" in hmodel.h_dict["external_signals"] + assert "channel_y" in hmodel.h_dict["external_signals"] + + # Run controller and hybrid_plant steps + hmodel.h_dict = hmodel.controller.step(hmodel.h_dict) + hmodel.h_dict = hmodel.hybrid_plant.step(hmodel.h_dict) + + # Log data to HDF5 + hmodel._log_data_to_hdf5() + + # Verify NO external signal channels were logged to HDF5 + assert "external_signals.channel_x" not in hmodel.hdf5_datasets + assert "external_signals.channel_y" not in hmodel.hdf5_datasets + + # Clean up + hmodel.close() + finally: + # Remove temporary CSV file + if os.path.exists(csv_path): + os.remove(csv_path) diff --git a/tests/hercules_output_test.py b/tests/hercules_output_test.py index 29b04012..7fc46fe3 100644 --- a/tests/hercules_output_test.py +++ b/tests/hercules_output_test.py @@ -49,7 +49,7 @@ def create_test_hdf5_file(filename: str): f["metadata"].attrs["log_every_n"] = 5 f["metadata"].attrs["start_clock_time"] = 1234567890.0 f["metadata"].attrs["end_clock_time"] = 1234567895.0 - f["metadata"].attrs["start_time_utc"] = 1234567890.0 # Unix timestamp for UTC time + f["metadata"].attrs["starttime_utc"] = 1234567890.0 # Unix timestamp for UTC time # Add h_dict as JSON string import json diff --git a/tests/regression_tests/electrolyzer_plant_regression_test.py b/tests/regression_tests/electrolyzer_plant_regression_test.py index 8d238daf..2b6ee2e0 100644 --- a/tests/regression_tests/electrolyzer_plant_regression_test.py +++ b/tests/regression_tests/electrolyzer_plant_regression_test.py @@ -12,46 +12,62 @@ "verbose": False, "general": {"verbose": False}, "electrolyzer": { - "initialize": True, - "initial_power_kW": 3000, - "supervisor": { - "n_stacks": 10, + "component_type": "ElectrolyzerPlant", + "initial_conditions": { + "power_available_kW": 3000, }, - "stack": { - "cell_type": "PEM", - "cell_area": 1000.0, - "max_current": 2000, - "temperature": 60, - "n_cells": 100, - "min_power": 50, - "stack_rating_kW": 500, - "include_degradation_penalty": True, - }, - "controller": { - "n_stacks": 10, - "control_type": "DecisionControl", - "policy": { - "eager_on": False, - "eager_off": False, - "sequential": False, - "even_dist": False, - "baseline": True, + "log_channels": ["power"], + "electrolyzer": { + "initialize": True, + "initial_power_kW": 3000, + "supervisor": { + "n_stacks": 10, + "system_rating_MW": 5.0, }, - }, - "costs": None, - "cell_params": { - "cell_type": "PEM", - "PEM_params": { - "cell_area": 1000, - "turndown_ratio": 0.1, - "max_current_density": 2, + "stack": { + "cell_type": "PEM", + "max_current": 2000, + "temperature": 60, + "n_cells": 100, + "stack_rating_kW": 500, + "include_degradation_penalty": True, }, - }, - "degradation": { - "PEM_params": { - "rate_steady": 1.41737929e-10, - "rate_fatigue": 3.33330244e-07, - "rate_onoff": 1.47821515e-04, + "controller": { + "control_type": "DecisionControl", + "policy": { + "eager_on": False, + "eager_off": False, + "sequential": False, + "even_dist": False, + "baseline": True, + }, + }, + "cell_params": { + "cell_type": "PEM", + "max_current_density": 2.0, + "PEM_params": { + "cell_area": 1000, + "turndown_ratio": 0.1, + "max_current_density": 2, + "p_anode": 1.01325, + "p_cathode": 30, + "alpha_a": 2, + "alpha_c": 0.5, + "i_0_a": 2.0e-7, + "i_0_c": 2.0e-3, + "e_m": 0.02, + "R_ohmic_elec": 50.0e-3, + "f_1": 250, + "f_2": 0.996, + }, + }, + "degradation": { + "eol_eff_percent_loss": 10, + "PEM_params": { + "rate_steady": 1.41737929e-10, + "rate_fatigue": 3.33330244e-07, + "rate_onoff": 1.47821515e-04, + }, }, }, }, @@ -60,51 +76,123 @@ np.random.seed(0) locally_generated_power_test = np.concatenate( ( - np.linspace(500, 1000, 3), # Ramp up - np.linspace(1000, 200, 3), # Ramp down - np.ones(3) * 200, # Constant - np.random.normal(500, 100, 3), # Random fluctuations + np.linspace(1000, 3500, 6), # Ramp up + np.linspace(3500, 600, 6), # Ramp down + np.ones(6) * 600, # Constant + np.random.normal(2000, 100, 6), # Random fluctuations ) ) H2_output_base = np.array( [ - 0.00071706, - 0.00073655, - 0.00079499, - 0.00088781, - 0.0009718, - 0.00098348, - 0.00092732, - 0.00087651, - 0.00083054, - 0.00078894, - 0.00083047, - 0.00084576, + 0.00223303, + 0.00220072, + 0.00225428, + 0.00238206, + 0.00257352, + 0.00281915, + 0.00311032, + 0.00337378, + 0.0035319, + 0.00359005, + 0.00355308, + 0.00342535, + 0.00321079, + 0.00301664, + 0.00284096, + 0.00268201, + 0.00253818, + 0.00240803, + 0.00229027, + 0.00244178, + 0.00255792, + 0.00267192, + 0.00279438, + 0.00289949, ] ) -stacks_on_base = np.array([7.0, 7.0, 7.0, 7.0, 7.0, 7.0, 7.0, 7.0, 7.0, 7.0, 7.0, 7.0]) +stacks_on_base = np.array( + [ + 7.0, + 7.0, + 7.0, + 7.0, + 7.0, + 7.0, + 7.0, + 7.0, + 7.0, + 7.0, + 7.0, + 7.0, + 7.0, + 7.0, + 7.0, + 7.0, + 7.0, + 7.0, + 7.0, + 7.0, + 7.0, + 7.0, + 7.0, + 7.0, + ] +) + +H2_mfr_base = np.array( + [ + 0.00446606, + 0.00440144, + 0.00450856, + 0.00476412, + 0.00514704, + 0.0056383, + 0.00622064, + 0.00674756, + 0.0070638, + 0.0071801, + 0.00710616, + 0.0068507, + 0.00642158, + 0.00603328, + 0.00568192, + 0.00536402, + 0.00507636, + 0.00481606, + 0.00458054, + 0.00488356, + 0.00511584, + 0.00534384, + 0.00558876, + 0.00579898, + ] +) def test_ElectrolyzerPlant_regression_(): electrolyzer = ElectrolyzerPlant(test_h_dict) - times_test = np.arange(0, 6.0, test_h_dict["dt"]) + times_test = np.arange(0, 12.0, test_h_dict["dt"]) H2_output_test = np.zeros_like(times_test) + H2_mfr_test = np.zeros_like(times_test) stacks_on_test = np.zeros_like(times_test) for i, t in enumerate(times_test): out = electrolyzer.step( { "time": t, - "locally_generated_power": locally_generated_power_test[i], + "plant": { + "locally_generated_power": locally_generated_power_test[i], + }, "electrolyzer": { "electrolyzer_signal": np.inf # Use all locally generated power }, } ) H2_output_test[i] = out["electrolyzer"]["H2_output"] + H2_mfr_test[i] = out["electrolyzer"]["H2_mfr"] stacks_on_test[i] = out["electrolyzer"]["stacks_on"] # print(out["H2_output"]) @@ -112,6 +200,7 @@ def test_ElectrolyzerPlant_regression_(): if PRINT_VALUES: print("H2 output: ", H2_output_test) print("Stacks on: ", stacks_on_test) + print("H2 mfr: ", H2_mfr_test) assert np.allclose(H2_output_base, H2_output_test) assert np.allclose(stacks_on_base, stacks_on_test) diff --git a/tests/regression_tests/solar_pysam_pvwatts_regression_test.py b/tests/regression_tests/solar_pysam_pvwatts_regression_test.py index 9f719803..f7edcf4b 100644 --- a/tests/regression_tests/solar_pysam_pvwatts_regression_test.py +++ b/tests/regression_tests/solar_pysam_pvwatts_regression_test.py @@ -3,6 +3,7 @@ import os import numpy as np +import pandas as pd from hercules.plant_components.solar_pysam_pvwatts import SolarPySAMPVWatts PRINT_VALUES = True @@ -92,6 +93,17 @@ def get_solar_params(): }, } + # Derive starttime_utc and endtime_utc from the input file to satisfy model requirements + df = pd.read_csv(solar_dict["solar_farm"]["solar_input_filename"]) + if "time_utc" not in df.columns: + raise ValueError("Test input solar_pysam_data.csv must include a 'time_utc' column") + if not pd.api.types.is_datetime64_any_dtype(df["time_utc"]): + df["time_utc"] = pd.to_datetime(df["time_utc"], format="ISO8601", utc=True) + start_ts = df["time_utc"].min() + solar_dict["starttime_utc"] = start_ts.isoformat() + end_ts = start_ts + pd.to_timedelta(solar_dict["endtime"], unit="s") + solar_dict["endtime_utc"] = end_ts.isoformat() + return solar_dict diff --git a/tests/solar_pysam_pvwatts_test.py b/tests/solar_pysam_pvwatts_test.py index 67198dc0..e5fc0ccf 100644 --- a/tests/solar_pysam_pvwatts_test.py +++ b/tests/solar_pysam_pvwatts_test.py @@ -60,10 +60,12 @@ def test_step(): SPS.step(step_inputs) # test the calculated power output (0° tilt) - assert_almost_equal(SPS.power, 17092.157367793126, decimal=8) + # Using decimal=4 for float32 precision (hercules_float_type provides ~6-7 significant digits) + assert_almost_equal(SPS.power, 17092.157367793126, decimal=4) # test the irradiance input - assert_almost_equal(SPS.ghi, 68.23037719726561, decimal=8) + # Using decimal=4 for float32 precision (hercules_float_type provides ~6-7 significant digits) + assert_almost_equal(SPS.ghi, 68.23037719726561, decimal=4) def test_control(): diff --git a/tests/test_inputs/external_data.csv b/tests/test_inputs/external_data.csv index 37e67b2b..662e62e6 100644 --- a/tests/test_inputs/external_data.csv +++ b/tests/test_inputs/external_data.csv @@ -1,11 +1,11 @@ -time,power_reference -0.0,1000.0 -1.0,2000.0 -2.0,4000.0 -3.0,4000.0 -4.0,3000.0 -5.0,1000.0 -6.0,1000.0 -7.0,1000.0 -8.0,2000.0 -9.0,3000.0 \ No newline at end of file +time_utc,power_reference +2018-05-10 12:31:00,1000.0 +2018-05-10 12:31:01,2000.0 +2018-05-10 12:31:02,4000.0 +2018-05-10 12:31:03,4000.0 +2018-05-10 12:31:04,3000.0 +2018-05-10 12:31:05,1000.0 +2018-05-10 12:31:06,1000.0 +2018-05-10 12:31:07,1000.0 +2018-05-10 12:31:08,2000.0 +2018-05-10 12:31:09,3000.0 \ No newline at end of file diff --git a/tests/test_inputs/h_dict.py b/tests/test_inputs/h_dict.py index bf6afb24..70ac6060 100644 --- a/tests/test_inputs/h_dict.py +++ b/tests/test_inputs/h_dict.py @@ -1,15 +1,37 @@ -# Define a test h_dict +# Define test h_dict fixtures for unit tests +# +# IMPORTANT: These are POST-LOADING test fixtures that mimic the h_dict structure +# AFTER it has been processed by load_hercules_input(). +# +# They contain BOTH: +# - starttime_utc/endtime_utc: pd.Timestamp objects (as created by load_hercules_input) +# - starttime/endtime: Computed values (numeric, in seconds from t=0) +# +# Real YAML input files should ONLY contain starttime_utc and endtime_utc as strings. +# The load_hercules_input() function converts them to pd.Timestamp objects and +# computes starttime (always 0.0) and endtime (duration in seconds) automatically. +# +# These test fixtures bypass load_hercules_input() for efficiency, so they +# need to have both sets of values pre-populated. + +import pandas as pd plant = {"interconnect_limit": 30000.0} wind_farm = { - "component_type": "Wind_MesoToPower", + "component_type": "WindFarm", "floris_input_file": "tests/test_inputs/floris_input.yaml", "wind_input_filename": "tests/test_inputs/wind_input.csv", "turbine_file_name": "tests/test_inputs/turbine_filter_model.yaml", "log_file_name": "outputs/wind_farm.log", + "log_channels": [ + "power", + "wind_speed_mean_background", + "wind_speed_mean_withwakes", + "wind_direction_mean", + "turbine_powers", + ], "floris_update_time_s": 30.0, # Required parameter for FLORIS updates - "logging_option": "all", # Required parameter for logging configuration } @@ -22,6 +44,7 @@ "lon": -105.179, "elev": 1828.8, "losses": 0, + "log_channels": ["power", "dni", "poa", "aoi"], "initial_conditions": {"power": 0.0, "dni": 0.0, "poa": 0.0}, } @@ -34,6 +57,7 @@ "system_capacity": 100000.0, # kW (100 MW) "tilt": 0, # degrees "losses": 0, + "log_channels": ["power", "dni", "poa", "aoi"], "initial_conditions": {"power": 25, "dni": 1000, "poa": 1000}, } @@ -44,6 +68,7 @@ "discharge_rate": 50.0, "max_SOC": 0.9, "min_SOC": 0.1, + "log_channels": ["power", "soc", "power_setpoint"], "initial_conditions": {"SOC": 0.5}, } @@ -55,6 +80,7 @@ "discharge_rate": 2000, # discharge rate in kW (2 MW) "max_SOC": 0.9, # upper boundary on battery SOC "min_SOC": 0.1, # lower boundary on battery SOC + "log_channels": ["power", "soc", "power_setpoint"], "initial_conditions": {"SOC": 0.102}, } @@ -66,51 +92,67 @@ "discharge_rate": 2000, # discharge rate in kW (2 MW) "max_SOC": 0.9, # upper boundary on battery SOC "min_SOC": 0.1, # lower boundary on battery SOC + "log_channels": ["power", "soc", "power_setpoint"], "initial_conditions": {"SOC": 0.102}, } electrolyzer = { - # 'component_type': 'ElectrolyzerPlant', # Removed for Supervisor compatibility - "initialize": True, - "initial_power_kW": 3000, - "supervisor": { - "n_stacks": 10, - }, - "stack": { - "cell_type": "PEM", - "cell_area": 1000.0, - "max_current": 2000, - "temperature": 60, - "n_cells": 100, - "min_power": 50, - "stack_rating_kW": 500, - "include_degradation_penalty": True, + "component_type": "ElectrolyzerPlant", + "initial_conditions": { + "power_available_kW": 3000, }, - "controller": { - "n_stacks": 10, - "control_type": "DecisionControl", - "policy": { - "eager_on": False, - "eager_off": False, - "sequential": False, - "even_dist": False, - "baseline": True, + "log_channels": ["power"], + "electrolyzer": { + "initialize": True, + "initial_power_kW": 3000, + "supervisor": { + "n_stacks": 10, + "system_rating_MW": 5.0, }, - }, - "costs": None, - "cell_params": { - "cell_type": "PEM", - "PEM_params": { - "cell_area": 1000, - "turndown_ratio": 0.1, - "max_current_density": 2, + "stack": { + "cell_type": "PEM", + "max_current": 2000, + "temperature": 60, + "n_cells": 100, + "stack_rating_kW": 500, + "include_degradation_penalty": True, }, - }, - "degradation": { - "PEM_params": { - "rate_steady": 1.41737929e-10, - "rate_fatigue": 3.33330244e-07, - "rate_onoff": 1.47821515e-04, + "controller": { + "control_type": "DecisionControl", + "policy": { + "eager_on": False, + "eager_off": False, + "sequential": False, + "even_dist": False, + "baseline": True, + }, + }, + "cell_params": { + "cell_type": "PEM", + "max_current_density": 2.0, + "PEM_params": { + "cell_area": 1000, + "turndown_ratio": 0.1, + "max_current_density": 2, + "p_anode": 1.01325, + "p_cathode": 30, + "alpha_a": 2, + "alpha_c": 0.5, + "i_0_a": 2.0e-7, + "i_0_c": 2.0e-3, + "e_m": 0.02, + "R_ohmic_elec": 50.0e-3, + "f_1": 250, + "f_2": 0.996, + }, + }, + "degradation": { + "eol_eff_percent_loss": 10, + "PEM_params": { + "rate_steady": 1.41737929e-10, + "rate_fatigue": 3.33330244e-07, + "rate_onoff": 1.47821515e-04, + }, }, }, } @@ -118,17 +160,22 @@ # Base h_dict with no components h_dict = { "dt": 1.0, - "starttime": 0.0, - "endtime": 30.0, + # "starttime": 0.0, + # "endtime": 30.0, + "starttime_utc": pd.to_datetime("2018-05-10 12:31:00", utc=True), + "endtime_utc": pd.to_datetime("2018-05-10 12:31:30", utc=True), "plant": plant, "verbose": False, } # h_dict with wind_farm only +# Time range: 0-10 seconds, starting at 2018-05-10 12:31:00 h_dict_wind = { "dt": 1.0, "starttime": 0.0, "endtime": 10.0, + "starttime_utc": pd.to_datetime("2018-05-10 12:31:00", utc=True), + "endtime_utc": pd.to_datetime("2018-05-10 12:31:10", utc=True), "verbose": False, "step": 2, "time": 2.0, @@ -137,10 +184,13 @@ } # h_dict with solar_farm only +# Time range: 0-6 seconds, starting at 2018-05-10 12:31:00 h_dict_solar = { "dt": 1.0, "starttime": 0.0, "endtime": 6.0, + "starttime_utc": pd.to_datetime("2018-05-10 12:31:00", utc=True), + "endtime_utc": pd.to_datetime("2018-05-10 12:31:06", utc=True), "verbose": False, "step": 2, "time": 2.0, @@ -153,6 +203,8 @@ "dt": 1.0, "starttime": 0.0, "endtime": 6.0, + "starttime_utc": pd.to_datetime("2018-05-10 12:31:00", utc=True), + "endtime_utc": pd.to_datetime("2018-05-10 12:31:06", utc=True), "verbose": False, "step": 2, "time": 2.0, @@ -165,6 +217,8 @@ "dt": 0.5, "starttime": 0.0, "endtime": 0.5, + "starttime_utc": pd.to_datetime("2018-05-10 12:31:00", utc=True), + "endtime_utc": pd.to_datetime("2018-05-10 12:31:00.500000", utc=True), "verbose": False, "step": 0, "time": 0.0, @@ -179,6 +233,8 @@ "dt": 1.0, "starttime": 0.0, "endtime": 10.0, + "starttime_utc": pd.to_datetime("2018-05-10 12:31:00", utc=True), + "endtime_utc": pd.to_datetime("2018-05-10 12:31:10", utc=True), "verbose": False, "step": 2, "time": 2.0, @@ -191,6 +247,8 @@ "dt": 1.0, "starttime": 0.0, "endtime": 6.0, + "starttime_utc": pd.to_datetime("2018-05-10 12:31:00", utc=True), + "endtime_utc": pd.to_datetime("2018-05-10 12:31:06", utc=True), "verbose": False, "step": 2, "time": 2.0, @@ -204,6 +262,8 @@ "dt": 1.0, "starttime": 0.0, "endtime": 10.0, + "starttime_utc": pd.to_datetime("2018-05-10 12:31:00", utc=True), + "endtime_utc": pd.to_datetime("2018-05-10 12:31:10", utc=True), "verbose": False, "step": 0, "time": 0.0, @@ -215,6 +275,8 @@ "dt": 1.0, "starttime": 0.0, "endtime": 10.0, + "starttime_utc": pd.to_datetime("2018-05-10 12:31:00", utc=True), + "endtime_utc": pd.to_datetime("2018-05-10 12:31:10", utc=True), "verbose": False, "step": 0, "time": 0.0, @@ -226,6 +288,8 @@ "dt": 1.0, "starttime": 0.0, "endtime": 10.0, + "starttime_utc": pd.to_datetime("2018-05-10 12:31:00", utc=True), + "endtime_utc": pd.to_datetime("2018-05-10 12:31:10", utc=True), "verbose": False, "step": 0, "time": 0.0, diff --git a/tests/test_inputs/hercules_input_test.yaml b/tests/test_inputs/hercules_input_test.yaml index ed0b3a86..abcad60f 100644 --- a/tests/test_inputs/hercules_input_test.yaml +++ b/tests/test_inputs/hercules_input_test.yaml @@ -4,24 +4,30 @@ name: test_input ### -# Describe this emulator setup +# Describe this simulation setup description: Test input file dt: 1.0 -starttime: 0.0 -endtime: 30.0 +starttime_utc: "2020-03-01T05:00:00Z" # Mar 1, 2020 05:00:00 UTC (Zulu time) +endtime_utc: "2020-03-01T05:00:30Z" # 30 seconds later verbose: False plant: interconnect_limit: 30000.0 #kW -wind_farm: # The name of the Wind_MesoToPower wind farm - component_type: Wind_MesoToPower +wind_farm: + component_type: WindFarm floris_input_file: inputs/floris_input.yaml wind_input_filename: inputs/wind_input.p turbine_file_name: inputs/turbine_filter_model.yaml log_file_name: outputs/log_wind_sim.log - logging_option: base + log_channels: + - power + - wind_speed_mean_background + - wind_speed_mean_withwakes + - wind_direction_mean + - turbine_powers + floris_update_time_s: 30.0 solar_farm: # The name of component object 1 component_type: SolarPySAMPVWatts @@ -30,6 +36,11 @@ solar_farm: # The name of component object 1 lat: 39.7442 lon: -105.1778 elev: 1829 + log_channels: + - power + - dni + - poa + - aoi initial_conditions: @@ -39,7 +50,3 @@ solar_farm: # The name of component object 1 controller: - - - - diff --git a/tests/test_inputs/scada_input.csv b/tests/test_inputs/scada_input.csv new file mode 100644 index 00000000..b40f4fee --- /dev/null +++ b/tests/test_inputs/scada_input.csv @@ -0,0 +1,13 @@ +time_utc,wd_mean,ws_000,ws_001,ws_002,pow_000,pow_001,pow_002 +2018-05-10 12:31:00,180.5,8.2,8.1,8.3,2500.0,2400.0,2600.0 +2018-05-10 12:31:01,185.2,9.1,9.0,9.2,3200.0,3100.0,3300.0 +2018-05-10 12:31:02,190.8,7.8,7.7,7.9,2200.0,2100.0,2300.0 +2018-05-10 12:31:03,175.3,6.5,6.4,6.6,1500.0,1400.0,1600.0 +2018-05-10 12:31:04,170.1,10.2,10.1,10.3,4200.0,4100.0,4300.0 +2018-05-10 12:31:05,165.7,11.5,11.4,11.6,5000.0,4900.0,5000.0 +2018-05-10 12:31:06,160.4,9.8,9.7,9.9,5000.0,3800.0,4000.0 +2018-05-10 12:31:07,155.9,8.7,8.6,8.8,3000.0,2900.0,3100.0 +2018-05-10 12:31:08,150.2,7.3,7.2,7.4,1900.0,1800.0,2000.0 +2018-05-10 12:31:09,145.6,6.9,6.8,7.0,1700.0,1600.0,1800.0 +2018-05-10 12:31:10,140.3,8.4,8.3,8.5,2700.0,2600.0,2800.0 + diff --git a/tests/test_inputs/solar_pysam_data.csv b/tests/test_inputs/solar_pysam_data.csv index 00122fb3..61e8b8f5 100644 --- a/tests/test_inputs/solar_pysam_data.csv +++ b/tests/test_inputs/solar_pysam_data.csv @@ -1,14 +1,14 @@ -time,time_utc,SRRL BMS Direct Normal Irradiance (W/m²_irr),SRRL BMS Diffuse Horizontal Irradiance (W/m²_irr),SRRL BMS Global Horizontal Irradiance (W/m²_irr),SRRL BMS Wind Speed at 19' (m/s),SRRL BMS Dry Bulb Temperature (°C) -0.0,2018-05-10 12:31:00+00:00,330.8601989746094,32.576671600341804,68.23037719726561,0.4400002620664621,11.990000406901045 -0.5,2018-05-10 12:31:00.500000+00:00,331.196044921875,32.58352235158285,68.28725236256916,0.44408359767036737,11.989917066362173 -1.0,2018-05-10 12:31:01+00:00,331.5318908691406,32.590373102823904,68.3441275278727,0.4481669332742726,11.9898337258233 -1.5,2018-05-10 12:31:01.500000+00:00,331.86773681640625,32.59722385406495,68.40100269317625,0.4522502688781779,11.989750385284427 -2.0,2018-05-10 12:31:02+00:00,332.2035827636719,32.604074605305996,68.4578778584798,0.45633360448208315,11.989667044745554 -2.5,2018-05-10 12:31:02.500000+00:00,332.5394287109375,32.61092535654704,68.51475302378336,0.46041694008598844,11.989583704206682 -3.0,2018-05-10 12:31:03+00:00,332.8752746582031,32.617776107788096,68.5716281890869,0.46450027568989366,11.989500363667808 -3.5,2018-05-10 12:31:03.500000+00:00,333.21112060546875,32.62462685902914,68.62850335439045,0.46858361129379894,11.989417023128937 -4.0,2018-05-10 12:31:04+00:00,333.5469665527344,32.63147761027019,68.685378519694,0.4726669468977042,11.989333682590063 -4.5,2018-05-10 12:31:04.500000+00:00,333.8828125,32.638328361511235,68.74225368499755,0.47675028250160945,11.989250342051191 -5.0,2018-05-10 12:31:05+00:00,334.2186584472656,32.64517911275229,68.79912885030109,0.4808336181055147,11.989167001512318 -5.5,2018-05-10 12:31:05.500000+00:00,334.55450439453125,32.652029863993334,68.85600401560464,0.48491695370942,11.989083660973446 -6.0,2018-05-10 12:31:06+00:00,334.8903503417969,32.65888061523438,68.91287918090819,0.4890002893133253,11.989000320434574 \ No newline at end of file +time_utc,SRRL BMS Direct Normal Irradiance (W/m²_irr),SRRL BMS Diffuse Horizontal Irradiance (W/m²_irr),SRRL BMS Global Horizontal Irradiance (W/m²_irr),SRRL BMS Wind Speed at 19' (m/s),SRRL BMS Dry Bulb Temperature (°C) +2018-05-10 12:31:00+00:00,330.8601989746094,32.576671600341804,68.23037719726561,0.4400002620664621,11.990000406901045 +2018-05-10 12:31:00.500000+00:00,331.196044921875,32.58352235158285,68.28725236256916,0.44408359767036737,11.989917066362173 +2018-05-10 12:31:01+00:00,331.5318908691406,32.590373102823904,68.3441275278727,0.4481669332742726,11.9898337258233 +2018-05-10 12:31:01.500000+00:00,331.86773681640625,32.59722385406495,68.40100269317625,0.4522502688781779,11.989750385284427 +2018-05-10 12:31:02+00:00,332.2035827636719,32.604074605305996,68.4578778584798,0.45633360448208315,11.989667044745554 +2018-05-10 12:31:02.500000+00:00,332.5394287109375,32.61092535654704,68.51475302378336,0.46041694008598844,11.989583704206682 +2018-05-10 12:31:03+00:00,332.8752746582031,32.617776107788096,68.5716281890869,0.46450027568989366,11.989500363667808 +2018-05-10 12:31:03.500000+00:00,333.21112060546875,32.62462685902914,68.62850335439045,0.46858361129379894,11.989417023128937 +2018-05-10 12:31:04+00:00,333.5469665527344,32.63147761027019,68.685378519694,0.4726669468977042,11.989333682590063 +2018-05-10 12:31:04.500000+00:00,333.8828125,32.638328361511235,68.74225368499755,0.47675028250160945,11.989250342051191 +2018-05-10 12:31:05+00:00,334.2186584472656,32.64517911275229,68.79912885030109,0.4808336181055147,11.989167001512318 +2018-05-10 12:31:05.500000+00:00,334.55450439453125,32.652029863993334,68.85600401560464,0.48491695370942,11.989083660973446 +2018-05-10 12:31:06+00:00,334.8903503417969,32.65888061523438,68.91287918090819,0.4890002893133253,11.989000320434574 diff --git a/tests/test_inputs/wind_input.csv b/tests/test_inputs/wind_input.csv index 9d163280..16e6a4bb 100644 --- a/tests/test_inputs/wind_input.csv +++ b/tests/test_inputs/wind_input.csv @@ -1,12 +1,12 @@ -time,time_utc,wd_mean,ws_000,ws_001,ws_002 -0,2018-05-10 12:31:00,180.5,8.2,8.1,8.3 -1,2018-05-10 12:31:01,185.2,9.1,9.0,9.2 -2,2018-05-10 12:31:02,190.8,7.8,7.7,7.9 -3,2018-05-10 12:31:03,175.3,6.5,6.4,6.6 -4,2018-05-10 12:31:04,170.1,10.2,10.1,10.3 -5,2018-05-10 12:31:05,165.7,11.5,11.4,11.6 -6,2018-05-10 12:31:06,160.4,9.8,9.7,9.9 -7,2018-05-10 12:31:07,155.9,8.7,8.6,8.8 -8,2018-05-10 12:31:08,150.2,7.3,7.2,7.4 -9,2018-05-10 12:31:09,145.6,6.9,6.8,7.0 -10,2018-05-10 12:31:10,140.3,8.4,8.3,8.5 \ No newline at end of file +time_utc,wd_mean,ws_000,ws_001,ws_002 +2018-05-10 12:31:00,180.5,8.2,8.1,8.3 +2018-05-10 12:31:01,185.2,9.1,9.0,9.2 +2018-05-10 12:31:02,190.8,7.8,7.7,7.9 +2018-05-10 12:31:03,175.3,6.5,6.4,6.6 +2018-05-10 12:31:04,170.1,10.2,10.1,10.3 +2018-05-10 12:31:05,165.7,11.5,11.4,11.6 +2018-05-10 12:31:06,160.4,9.8,9.7,9.9 +2018-05-10 12:31:07,155.9,8.7,8.6,8.8 +2018-05-10 12:31:08,150.2,7.3,7.2,7.4 +2018-05-10 12:31:09,145.6,6.9,6.8,7.0 +2018-05-10 12:31:10,140.3,8.4,8.3,8.5 diff --git a/tests/utilities_test.py b/tests/utilities_test.py index 40b2e1be..91e72465 100644 --- a/tests/utilities_test.py +++ b/tests/utilities_test.py @@ -1,14 +1,18 @@ +import logging import os import tempfile +from pathlib import Path import numpy as np import pandas as pd import pytest from hercules.utilities import ( + find_time_utc_value, interpolate_df, - interpolate_df_fast, load_h_dict_from_text, load_hercules_input, + local_time_to_utc, + setup_logging, ) @@ -126,8 +130,8 @@ def test_load_hercules_input_valid_file(): # Check required keys are present assert "dt" in result - assert "starttime" in result - assert "endtime" in result + assert "starttime_utc" in result + assert "endtime_utc" in result assert "plant" in result # Check plant structure @@ -138,7 +142,7 @@ def test_load_hercules_input_valid_file(): # Check component configurations assert "wind_farm" in result assert "solar_farm" in result - assert result["wind_farm"]["component_type"] == "Wind_MesoToPower" + assert result["wind_farm"]["component_type"] == "WindFarm" assert result["solar_farm"]["component_type"] == "SolarPySAMPVWatts" # Check verbose defaults to False @@ -172,7 +176,12 @@ def test_load_hercules_input_invalid_plant_structure(): Creates a config with plant as string instead of dict and verifies the function raises appropriate error. """ - invalid_config = {"dt": 1.0, "starttime": 0.0, "endtime": 30.0, "plant": "not_a_dict"} + invalid_config = { + "dt": 1.0, + "starttime_utc": "2018-05-10 12:31:00", + "endtime_utc": "2018-05-10 12:31:30", + "plant": "not_a_dict", + } with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: import yaml @@ -195,8 +204,8 @@ def test_load_hercules_input_invalid_component_type(): """ invalid_config = { "dt": 1.0, - "starttime": 0.0, - "endtime": 30.0, + "starttime_utc": "2018-05-10 12:31:00", + "endtime_utc": "2018-05-10 12:31:30", "plant": {"interconnect_limit": 30000.0}, "wind_farm": {"component_type": "InvalidType"}, } @@ -222,8 +231,8 @@ def test_load_hercules_input_verbose_default(): """ config_without_verbose = { "dt": 1.0, - "starttime": 0.0, - "endtime": 30.0, + "starttime_utc": "2018-05-10 12:31:00", + "endtime_utc": "2018-05-10 12:31:30", "plant": {"interconnect_limit": 30000.0}, } @@ -240,6 +249,40 @@ def test_load_hercules_input_verbose_default(): os.unlink(temp_file) +def test_load_hercules_input_external_data_without_file(): + """Test that external_data without external_data_file is silently ignored. + + Verifies that if external_data is specified but external_data_file is missing, + the external_data key is removed (treated as blank). + """ + import tempfile + + import yaml + + config = { + "dt": 1.0, + "starttime_utc": "2020-01-01T00:00:00Z", + "endtime_utc": "2020-01-01T00:15:50Z", + "plant": {"interconnect_limit": 30000.0}, + "external_data": { + "log_channels": ["channel1", "channel2"], + # Note: external_data_file is missing + }, + } + + with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: + yaml.dump(config, f) + temp_file = f.name + + try: + result = load_hercules_input(temp_file) + + # Verify that external_data was removed (treated as blank) + assert "external_data" not in result + finally: + os.unlink(temp_file) + + def test_load_h_dict_from_text_valid_file(): """Test loading h_dict from a text file created by _save_h_dict_as_text. @@ -253,7 +296,7 @@ def test_load_h_dict_from_text_valid_file(): "starttime": 0.0, "endtime": 3600.0, "plant": {"interconnect_limit": 30000.0, "location": "test_site"}, - "wind_farm": {"component_type": "Wind_MesoToPower", "capacity": 100.0}, + "wind_farm": {"component_type": "WindFarm", "capacity": 100.0}, "solar_farm": {"component_type": "SolarPySAMPVWatts", "capacity": 50.0}, "verbose": False, "time": 1800.0, @@ -276,7 +319,7 @@ def test_load_h_dict_from_text_valid_file(): # Verify specific nested structures assert result["plant"]["interconnect_limit"] == 30000.0 assert result["plant"]["location"] == "test_site" - assert result["wind_farm"]["component_type"] == "Wind_MesoToPower" + assert result["wind_farm"]["component_type"] == "WindFarm" assert result["solar_farm"]["capacity"] == 50.0 assert result["external_signals"]["wind_speed"] == 8.5 @@ -333,8 +376,8 @@ def test_output_configuration_validation(): base_h_dict = { "dt": 1.0, - "starttime": 0.0, - "endtime": 10.0, + "starttime_utc": "2018-05-10 12:31:00", + "endtime_utc": "2018-05-10 12:31:10", "plant": {"interconnect_limit": 5000}, "solar_farm": {"component_type": "SolarPySAMPVWatts"}, } @@ -355,6 +398,67 @@ def test_output_configuration_validation(): load_hercules_input_from_dict(test_dict) +def test_load_hercules_input_utc_validation(): + """Test UTC datetime string validation. + + Verifies that: + - Strings with 'Z' are accepted + - Naive strings are accepted + - Strings with timezone offsets are rejected + """ + # Test accepted formats: explicit UTC with Z + valid_config_z = { + "dt": 1.0, + "starttime_utc": "2020-01-01T00:00:00Z", + "endtime_utc": "2020-01-01T01:00:00Z", + "plant": {"interconnect_limit": 30000.0}, + } + result = load_hercules_input_from_dict(valid_config_z) + assert isinstance(result["starttime_utc"], pd.Timestamp) + assert result["starttime_utc"].tz is not None + + # Test accepted formats: naive string (treated as UTC) + valid_config_naive = { + "dt": 1.0, + "starttime_utc": "2020-01-01T00:00:00", + "endtime_utc": "2020-01-01T01:00:00", + "plant": {"interconnect_limit": 30000.0}, + } + result = load_hercules_input_from_dict(valid_config_naive) + assert isinstance(result["starttime_utc"], pd.Timestamp) + assert result["starttime_utc"].tz is not None + + # Test rejected formats: timezone offset (positive) + invalid_config_positive_offset = { + "dt": 1.0, + "starttime_utc": "2020-01-01T00:00:00+05:00", + "endtime_utc": "2020-01-01T01:00:00+05:00", + "plant": {"interconnect_limit": 30000.0}, + } + with pytest.raises(ValueError, match="contains a timezone offset"): + load_hercules_input_from_dict(invalid_config_positive_offset) + + # Test rejected formats: timezone offset (negative) + invalid_config_negative_offset = { + "dt": 1.0, + "starttime_utc": "2020-01-01T00:00:00-08:00", + "endtime_utc": "2020-01-01T01:00:00-08:00", + "plant": {"interconnect_limit": 30000.0}, + } + with pytest.raises(ValueError, match="contains a timezone offset"): + load_hercules_input_from_dict(invalid_config_negative_offset) + + # Test rejected formats: UTC offset (even +00:00 should use Z) + invalid_config_utc_offset = { + "dt": 1.0, + "starttime_utc": "2020-01-01T00:00:00+00:00", + "endtime_utc": "2020-01-01T01:00:00+00:00", + "plant": {"interconnect_limit": 30000.0}, + } + with pytest.raises(ValueError, match="contains a timezone offset"): + load_hercules_input_from_dict(invalid_config_utc_offset) + + def load_hercules_input_from_dict(h_dict): """Helper function to test hercules input validation from a dictionary.""" with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: @@ -372,73 +476,72 @@ def load_hercules_input_from_dict(h_dict): # ==================== INTERPOLATION COMPARISON TESTS ==================== -def test_interpolate_df_functions_identical_simple(): - """Test that interpolate_df and interpolate_df_fast produce identical results for simple case. +# ==================== find_time_utc_value TESTS ==================== - Creates a simple DataFrame with linear values and verifies both functions - produce exactly the same interpolated results. - """ - # Create a simple dataframe with time points 0, 2, 4, 6, 8, 10 + +def test_find_time_utc_interpolates_midpoint(): + """Interpolates UTC between two points at the midpoint time.""" df = pd.DataFrame( { - "time": [0, 2, 4, 6, 8, 10], - "value": [0, 2, 4, 6, 8, 10], # Linear function y = x - "power": [0, 4, 16, 36, 64, 100], # Quadratic function y = x^2 + "time": [0.0, 10.0], + "time_utc": pd.to_datetime( + [ + "2023-01-01 00:00:00+00:00", + "2023-01-01 00:00:10+00:00", + ], + utc=True, + ), } ) - # Create new_time with more points (upsampling) - new_time = np.linspace(0, 10, 21) # 0, 0.5, 1.0, ..., 10.0 - - # Interpolate with both functions - result_original = interpolate_df(df, new_time) - result_fast = interpolate_df_fast(df, new_time) - - # Verify results are identical - pd.testing.assert_frame_equal(result_original, result_fast, check_dtype=False) - + mid = find_time_utc_value(df, 5.0) + assert mid == pd.Timestamp("2023-01-01 00:00:05", tz="UTC") -def test_interpolate_df_functions_identical_with_datetime(): - """Test that both functions produce identical results with datetime columns. - Creates a DataFrame with both numeric and datetime columns and verifies - both interpolation functions produce exactly the same results. - """ - # Create a dataframe with time, numeric, and datetime columns +def test_find_time_utc_extrapolates_before_range(): + """Extrapolates UTC for a time before the first sample.""" df = pd.DataFrame( { - "time": [0, 5, 10, 15, 20], - "temperature": [20.0, 25.0, 30.0, 28.0, 22.0], - "pressure": [1013.25, 1015.0, 1012.0, 1014.5, 1013.8], - "time_utc": [ - "2023-01-01 00:00:00", - "2023-01-01 05:00:00", - "2023-01-01 10:00:00", - "2023-01-01 15:00:00", - "2023-01-01 20:00:00", - ], + "time": [0.0, 10.0], + "time_utc": pd.to_datetime( + [ + "2023-01-01 00:00:00+00:00", + "2023-01-01 00:00:10+00:00", + ], + utc=True, + ), } ) - # Convert time_utc to datetime - df["time_utc"] = pd.to_datetime(df["time_utc"], utc=True) + # 1 second per unit time -> time=-5 yields -5 seconds from start + t = find_time_utc_value(df, -5.0) + assert t == pd.Timestamp("2022-12-31 23:59:55", tz="UTC") - # Create new_time points for interpolation - new_time = np.array([0, 2.5, 5, 7.5, 10, 12.5, 15, 17.5, 20]) - # Interpolate with both functions - result_original = interpolate_df(df, new_time) - result_fast = interpolate_df_fast(df, new_time) +def test_find_time_utc_extrapolates_after_range(): + """Extrapolates UTC for a time after the last sample.""" + df = pd.DataFrame( + { + "time": [0.0, 10.0], + "time_utc": pd.to_datetime( + [ + "2023-01-01 00:00:00+00:00", + "2023-01-01 00:00:10+00:00", + ], + utc=True, + ), + } + ) - # Verify results are identical - pd.testing.assert_frame_equal(result_original, result_fast, check_dtype=False) + t = find_time_utc_value(df, 15.0) + assert t == pd.Timestamp("2023-01-01 00:00:15", tz="UTC") -def test_interpolate_df_functions_identical_large_dataset(): - """Test that both functions produce identical results for larger datasets. +def test_interpolate_df_with_large_dataset(): + """Test interpolate_df with larger datasets. - Creates a larger DataFrame to test the polars code path and verify - both functions produce identical results even with size optimizations. + Creates a larger DataFrame to verify the function works correctly + with datasets using the polars backend. """ # Create a larger dataset (>1000 rows to trigger polars path) n_points = 1500 @@ -462,62 +565,13 @@ def test_interpolate_df_functions_identical_large_dataset(): # Create new time points (downsampling to 500 points) new_time = np.linspace(0, 1000, 500) - # Interpolate with both functions - result_original = interpolate_df(df, new_time) - result_fast = interpolate_df_fast(df, new_time) - - # Verify results are identical (allow small floating point differences) - pd.testing.assert_frame_equal(result_original, result_fast, check_dtype=False, rtol=1e-10) - - -def test_interpolate_df_functions_identical_edge_cases(): - """Test that both functions handle edge cases identically. - - Tests various edge cases including single data points, identical time points, - and boundary conditions. - """ - # Test with minimal dataset (3 points) - df_minimal = pd.DataFrame( - { - "time": [0, 1, 2], - "value": [10, 20, 30], - } - ) - new_time_minimal = np.array([0, 0.5, 1, 1.5, 2]) - - result_orig_minimal = interpolate_df(df_minimal, new_time_minimal) - result_fast_minimal = interpolate_df_fast(df_minimal, new_time_minimal) - pd.testing.assert_frame_equal(result_orig_minimal, result_fast_minimal, check_dtype=False) - - # Test with boundary points only - new_time_boundary = np.array([0, 2]) - result_orig_boundary = interpolate_df(df_minimal, new_time_boundary) - result_fast_boundary = interpolate_df_fast(df_minimal, new_time_boundary) - pd.testing.assert_frame_equal(result_orig_boundary, result_fast_boundary, check_dtype=False) - - -def test_interpolate_df_functions_identical_multiple_dtypes(): - """Test both functions with various data types. - - Creates a DataFrame with different numeric types and verifies - both functions handle them identically. - """ - df = pd.DataFrame( - { - "time": np.array([0, 1, 2, 3, 4], dtype=np.float64), - "int_col": np.array([10, 20, 30, 40, 50], dtype=np.int32), - "float32_col": np.array([1.1, 2.2, 3.3, 4.4, 5.5], dtype=np.float32), - "float64_col": np.array([100.1, 200.2, 300.3, 400.4, 500.5], dtype=np.float64), - } - ) - - new_time = np.array([0, 0.5, 1, 1.5, 2, 2.5, 3, 3.5, 4]) - - result_original = interpolate_df(df, new_time) - result_fast = interpolate_df_fast(df, new_time) + # Interpolate + result = interpolate_df(df, new_time) - # Verify results are identical - pd.testing.assert_frame_equal(result_original, result_fast, check_dtype=False) + # Verify result has the correct shape and columns + assert len(result) == len(new_time) + assert list(result.columns) == list(df.columns) + assert np.allclose(result["time"], new_time) def test_read_hercules_hdf5_external_signals(): @@ -536,7 +590,13 @@ def test_read_hercules_hdf5_external_signals(): with h5py.File(temp_file, "w") as f: # Create basic data structure f.create_group("data") - f.create_group("metadata") + metadata = f.create_group("metadata") + + # Add starttime_utc metadata (required) + import pandas as pd + + starttime_utc = pd.to_datetime("2018-05-10 12:31:00", utc=True) + metadata.attrs["starttime_utc"] = starttime_utc.timestamp() # Add basic time data f["data/time"] = np.array([0, 1, 2]) @@ -572,97 +632,295 @@ def test_read_hercules_hdf5_external_signals(): os.unlink(temp_file) -# def test_read_hercules_hdf5_subset(): -# """Test reading subset of HDF5 file data. - -# Creates a mock HDF5 file and verifies that the subset function -# correctly filters by columns and time range. -# """ -# import h5py - -# with tempfile.NamedTemporaryFile(suffix=".h5", delete=False) as f: -# temp_file = f.name - -# try: -# # Create mock HDF5 file with more data -# with h5py.File(temp_file, "w") as f: -# f.create_group("data") -# f.create_group("metadata") - -# # Add time data (0, 1, 2, 3, 4, 5 seconds) -# f["data/time"] = np.array([0, 1, 2, 3, 4, 5]) -# f["data/step"] = np.array([0, 1, 2, 3, 4, 5]) -# f["data/clock_time"] = np.array([0.0, 1.0, 2.0, 3.0, 4.0, 5.0]) - -# # Add plant data -# f["data/plant_power"] = np.array([100, 200, 300, 400, 500, 600]) -# f["data/plant_locally_generated_power"] = np.array([90, 180, 270, 360, 450, 540]) - -# # Add components group -# components_group = f.create_group("data/components") -# components_group["wind_farm.power"] = np.array([50, 100, 150, 200, 250, 300]) -# components_group["solar_farm.power"] = np.array([40, 80, 120, 160, 200, 240]) - -# # Add external signals -# external_signals_group = f.create_group("data/external_signals") -# external_signals_group["external_signals.wind_speed"] = np.array( -# [8.5, 9.0, 8.8, 9.2, 8.9, 9.1] -# ) -# external_signals_group["external_signals.temperature"] = np.array( -# [20.0, 21.0, 20.5, 22.0, 21.5, 22.5] -# ) - -# # Test column filtering -# from hercules.utilities import read_hercules_hdf5_subset - -# result_columns = read_hercules_hdf5_subset( -# temp_file, columns=["wind_farm.power", "external_signals.wind_speed"] -# ) - -# # Verify only requested columns plus time are present -# expected_columns = { -# "time", -# "wind_farm.power", -# "external_signals.wind_speed", -# } -# assert set(result_columns.columns) == expected_columns - -# # Test time range filtering -# result_time = read_hercules_hdf5_subset(temp_file, time_range=(1.5, 4.5)) - -# # Verify time range is correct (should include times 2, 3, 4) -# assert len(result_time) == 3 -# np.testing.assert_array_equal(result_time["time"], [2, 3, 4]) - -# # Test both filters together -# result_both = read_hercules_hdf5_subset( -# temp_file, columns=["solar_farm.power"], time_range=(1.0, 3.0) -# ) - -# # Verify both filters work together -# assert len(result_both) == 3 # times 1, 2, 3 (inclusive of end time) -# assert "solar_farm.power" in result_both.columns -# assert "wind_farm.power" not in result_both.columns -# assert set(result_both.columns) == {"time", "solar_farm.power"} - -# # Test stride parameter -# result_stride = read_hercules_hdf5_subset(temp_file, stride=2) - -# # Verify stride works (should read every 2nd point: 0, 2, 4) -# assert len(result_stride) == 3 -# np.testing.assert_array_equal(result_stride["time"], [0, 2, 4]) - -# # Test stride with time range -# result_stride_time = read_hercules_hdf5_subset(temp_file, time_range=(1.0, 4.0), stride=2) - -# # Should get times 1, 3 (within range, every 2nd point starting from first in range) -# assert len(result_stride_time) == 2 -# np.testing.assert_array_equal(result_stride_time["time"], [1, 3]) - -# # Test with no columns specified (should return only time) -# result_time_only = read_hercules_hdf5_subset(temp_file) -# assert set(result_time_only.columns) == {"time"} -# assert len(result_time_only) == 6 # All time points - -# finally: -# os.unlink(temp_file) +def test_local_time_to_utc_with_timezone(): + """Test local_time_to_utc with explicit timezone. + + Tests conversion of local time to UTC with daylight saving time handling. + """ + # Midnight Jan 1, 2025 in Mountain Time (MST, UTC-7, no DST) + result_jan = local_time_to_utc("2025-01-01T00:00:00", tz="America/Denver") + assert result_jan == "2025-01-01T07:00:00Z" + + # Midnight July 1, 2025 in Mountain Time (MDT, UTC-6, DST) + result_july = local_time_to_utc("2025-07-01T00:00:00", tz="America/Denver") + assert result_july == "2025-07-01T06:00:00Z" + + # Test with different timezone (Eastern Time) + # Midnight Jan 1, 2025 in Eastern Time (EST, UTC-5, no DST) + result_eastern_jan = local_time_to_utc("2025-01-01T00:00:00", tz="America/New_York") + assert result_eastern_jan == "2025-01-01T05:00:00Z" + + # Midnight July 1, 2025 in Eastern Time (EDT, UTC-4, DST) + result_eastern_july = local_time_to_utc("2025-07-01T00:00:00", tz="America/New_York") + assert result_eastern_july == "2025-07-01T04:00:00Z" + + +def test_local_time_to_utc_with_pandas_timestamp(): + """Test local_time_to_utc with pandas Timestamp input.""" + dt = pd.Timestamp("2025-01-01T00:00:00") + result = local_time_to_utc(dt, tz="America/Denver") + assert result == "2025-01-01T07:00:00Z" + + +def test_local_time_to_utc_with_different_formats(): + """Test local_time_to_utc with different datetime string formats.""" + # ISO format with T + result1 = local_time_to_utc("2025-01-01T00:00:00", tz="America/Denver") + assert result1 == "2025-01-01T07:00:00Z" + + # ISO format with space + result2 = local_time_to_utc("2025-01-01 00:00:00", tz="America/Denver") + assert result2 == "2025-01-01T07:00:00Z" + + # Date only (defaults to midnight) + result3 = local_time_to_utc("2025-01-01", tz="America/Denver") + assert result3 == "2025-01-01T07:00:00Z" + + +def test_local_time_to_utc_invalid_timezone(): + """Test local_time_to_utc with invalid timezone raises error.""" + with pytest.raises(ValueError, match="Invalid timezone"): + local_time_to_utc("2025-01-01T00:00:00", tz="Invalid/Timezone") + + +def test_local_time_to_utc_invalid_datetime(): + """Test local_time_to_utc with invalid datetime string raises error.""" + with pytest.raises(ValueError, match="Cannot parse local_time"): + local_time_to_utc("invalid-datetime", tz="America/Denver") + + +def test_local_time_to_utc_missing_timezone(): + """Test local_time_to_utc with missing timezone parameter raises error.""" + with pytest.raises(ValueError, match="Timezone parameter 'tz' is required"): + local_time_to_utc("2025-01-01T00:00:00", tz=None) + + +def test_local_time_to_utc_returns_z_suffix(): + """Test that local_time_to_utc returns string with Z suffix.""" + result = local_time_to_utc("2025-01-01T00:00:00", tz="America/Denver") + assert result.endswith("Z") + assert "T" in result + assert len(result) == 20 # Format: YYYY-MM-DDTHH:MM:SSZ + + +def test_setup_logging_basic(): + """Test basic setup_logging with default parameters.""" + with tempfile.TemporaryDirectory() as tmpdir: + # Change to temp directory for this test + original_cwd = os.getcwd() + try: + os.chdir(tmpdir) + + # Call setup_logging with defaults + logger = setup_logging() + + # Verify logger was created + assert logger is not None + assert logger.name == "hercules" + assert logger.level == logging.INFO + + # Verify handlers were added + assert len(logger.handlers) == 2 # file + console + + # Verify outputs directory was created + assert Path("outputs").exists() + assert Path("outputs/log_hercules.log").exists() + + # Test logging works + logger.info("Test message") + + # Read log file + with open("outputs/log_hercules.log") as f: + content = f.read() + assert "Test message" in content + + finally: + # Clean up logger handlers + for handler in logger.handlers[:]: + handler.close() + logger.removeHandler(handler) + os.chdir(original_cwd) + + +def test_setup_logging_custom_logger_name(): + """Test setup_logging with custom logger name.""" + with tempfile.TemporaryDirectory() as tmpdir: + original_cwd = os.getcwd() + try: + os.chdir(tmpdir) + + logger = setup_logging(logger_name="wind_farm", log_file="log_wind.log") + + assert logger.name == "wind_farm" + assert Path("outputs/log_wind.log").exists() + + # Test prefix in console handler (not file handler) + console_handler = [ + h + for h in logger.handlers + if isinstance(h, logging.StreamHandler) and not isinstance(h, logging.FileHandler) + ][0] + formatter_str = console_handler.formatter._fmt + assert "[WIND_FARM]" in formatter_str + + finally: + for handler in logger.handlers[:]: + handler.close() + logger.removeHandler(handler) + os.chdir(original_cwd) + + +def test_setup_logging_no_console_output(): + """Test setup_logging with console output disabled.""" + with tempfile.TemporaryDirectory() as tmpdir: + original_cwd = os.getcwd() + try: + os.chdir(tmpdir) + + logger = setup_logging(console_output=False) + + # Should only have file handler + assert len(logger.handlers) == 1 + assert isinstance(logger.handlers[0], logging.FileHandler) + + finally: + for handler in logger.handlers[:]: + handler.close() + logger.removeHandler(handler) + os.chdir(original_cwd) + + +def test_setup_logging_custom_console_prefix(): + """Test setup_logging with custom console prefix.""" + with tempfile.TemporaryDirectory() as tmpdir: + original_cwd = os.getcwd() + try: + os.chdir(tmpdir) + + logger = setup_logging(logger_name="solar", console_prefix="SOLAR_PV") + + # Find console handler (not file handler) + console_handler = [ + h + for h in logger.handlers + if isinstance(h, logging.StreamHandler) and not isinstance(h, logging.FileHandler) + ][0] + formatter_str = console_handler.formatter._fmt + assert "[SOLAR_PV]" in formatter_str + + finally: + for handler in logger.handlers[:]: + handler.close() + logger.removeHandler(handler) + os.chdir(original_cwd) + + +def test_setup_logging_full_path(): + """Test setup_logging with full file path and use_outputs_dir=False.""" + with tempfile.TemporaryDirectory() as tmpdir: + log_file = str(Path(tmpdir) / "custom_logs" / "test.log") + + logger = setup_logging(logger_name="battery", log_file=log_file, use_outputs_dir=False) + + # Verify log file was created at specified path + assert Path(log_file).exists() + assert Path(log_file).parent.name == "custom_logs" + + # Test logging + logger.info("Battery test message") + with open(log_file) as f: + content = f.read() + assert "Battery test message" in content + + # Clean up + for handler in logger.handlers[:]: + handler.close() + logger.removeHandler(handler) + + +def test_setup_logging_custom_log_level(): + """Test setup_logging with custom log level.""" + with tempfile.TemporaryDirectory() as tmpdir: + original_cwd = os.getcwd() + try: + os.chdir(tmpdir) + + logger = setup_logging(log_level=logging.DEBUG) + + assert logger.level == logging.DEBUG + + # Test that debug messages are logged + logger.debug("Debug message") + with open("outputs/log_hercules.log") as f: + content = f.read() + assert "Debug message" in content + + finally: + for handler in logger.handlers[:]: + handler.close() + logger.removeHandler(handler) + os.chdir(original_cwd) + + +def test_setup_logging_clears_existing_handlers(): + """Test that setup_logging clears existing handlers to avoid duplicates.""" + with tempfile.TemporaryDirectory() as tmpdir: + original_cwd = os.getcwd() + try: + os.chdir(tmpdir) + + # Create logger twice + logger1 = setup_logging(logger_name="test_logger") + num_handlers_first = len(logger1.handlers) + + logger2 = setup_logging(logger_name="test_logger") + num_handlers_second = len(logger2.handlers) + + # Should have same number of handlers (old ones cleared) + assert num_handlers_first == num_handlers_second + assert logger1 is logger2 # Same logger instance + + finally: + for handler in logger1.handlers[:]: + handler.close() + logger1.removeHandler(handler) + os.chdir(original_cwd) + + +def test_setup_logging_multiple_loggers(): + """Test that multiple loggers can be created with different names.""" + with tempfile.TemporaryDirectory() as tmpdir: + original_cwd = os.getcwd() + try: + os.chdir(tmpdir) + + logger1 = setup_logging(logger_name="logger1", log_file="log1.log") + logger2 = setup_logging(logger_name="logger2", log_file="log2.log") + + # Verify they are different loggers + assert logger1 is not logger2 + assert logger1.name == "logger1" + assert logger2.name == "logger2" + + # Verify separate log files + logger1.info("Message from logger1") + logger2.info("Message from logger2") + + with open("outputs/log1.log") as f: + content1 = f.read() + assert "Message from logger1" in content1 + assert "Message from logger2" not in content1 + + with open("outputs/log2.log") as f: + content2 = f.read() + assert "Message from logger2" in content2 + assert "Message from logger1" not in content2 + + finally: + for handler in logger1.handlers[:]: + handler.close() + logger1.removeHandler(handler) + for handler in logger2.handlers[:]: + handler.close() + logger2.removeHandler(handler) + os.chdir(original_cwd) diff --git a/tests/wind_farm_direct_test.py b/tests/wind_farm_direct_test.py new file mode 100644 index 00000000..75b97bab --- /dev/null +++ b/tests/wind_farm_direct_test.py @@ -0,0 +1,165 @@ +"""Tests for the WindFarm class in direct wake mode (WindFarm with no_added_wakes).""" + +import copy + +import numpy as np +from hercules.plant_components.wind_farm import WindFarm +from hercules.utilities import hercules_float_type + +from tests.test_inputs.h_dict import h_dict_wind + +# Create a base test dictionary for no_added_wakes +h_dict_wind_direct = copy.deepcopy(h_dict_wind) +# Update component type +h_dict_wind_direct["wind_farm"]["wake_method"] = "no_added_wakes" + + +def test_wind_farm_direct_initialization(): + """Test that WindFarm initializes correctly with wake_method='no_added_wakes'.""" + wind_sim = WindFarm(h_dict_wind_direct) + + assert wind_sim.component_name == "wind_farm" + assert wind_sim.component_type == "WindFarm" + assert wind_sim.wake_method == "no_added_wakes" + assert wind_sim.n_turbines == 3 + assert wind_sim.dt == 1.0 + assert wind_sim.starttime == 0.0 + assert wind_sim.endtime == 10.0 + # No FLORIS calculations in direct mode + assert wind_sim.num_floris_calcs == 0 + assert wind_sim.floris_update_time_s is None + + +def test_wind_farm_direct_no_wakes(): + """Test that no wake deficits are applied in direct mode.""" + wind_sim = WindFarm(h_dict_wind_direct) + + # Verify initial wake deficits are zero + assert np.all(wind_sim.floris_wake_deficits == 0.0) + + # Verify initial wind speeds with wakes equal background + assert np.allclose(wind_sim.wind_speeds_withwakes, wind_sim.wind_speeds_background) + + +def test_wind_farm_direct_step(): + """Test that the step method works correctly in direct mode.""" + wind_sim = WindFarm(h_dict_wind_direct) + + # Add power setpoint values to the step h_dict + step_h_dict = {"step": 1} + step_h_dict["wind_farm"] = { + "turbine_power_setpoints": np.array([1000.0, 1500.0, 2000.0]), + } + + result = wind_sim.step(step_h_dict) + + # Verify outputs exist + assert "turbine_powers" in result["wind_farm"] + assert "power" in result["wind_farm"] + assert len(result["wind_farm"]["turbine_powers"]) == 3 + assert isinstance(result["wind_farm"]["turbine_powers"], np.ndarray) + assert "power" in result["wind_farm"] + assert isinstance(result["wind_farm"]["power"], (int, float)) + + # Verify no wake deficits applied + assert np.all(wind_sim.floris_wake_deficits == 0.0) + assert np.allclose( + result["wind_farm"]["wind_speeds_withwakes"], + result["wind_farm"]["wind_speeds_background"], + ) + + +def test_wind_farm_direct_no_wake_deficits_over_time(): + """Test that wake deficits remain zero throughout simulation.""" + wind_sim = WindFarm(h_dict_wind_direct) + + # Run multiple steps + for step in range(5): + step_h_dict = {"step": step} + step_h_dict["wind_farm"] = { + "turbine_power_setpoints": np.ones(3, dtype=hercules_float_type) * 5000.0, + } + + result = wind_sim.step(step_h_dict) + + # Verify no wakes at each step + assert np.all(wind_sim.floris_wake_deficits == 0.0) + assert np.allclose( + result["wind_farm"]["wind_speeds_withwakes"], + result["wind_farm"]["wind_speeds_background"], + ) + + +def test_wind_farm_direct_turbine_dynamics(): + """Test that turbine dynamics still work in direct mode.""" + wind_sim = WindFarm(h_dict_wind_direct) + + # Run a step with very low power setpoint + step_h_dict = {"step": 1} + step_h_dict["wind_farm"] = { + "turbine_power_setpoints": np.array([100.0, 100.0, 100.0]), + } + + result = wind_sim.step(step_h_dict) + + # Turbine powers should be limited by setpoint + assert np.all(result["wind_farm"]["turbine_powers"] <= 100.0 + 1e-6) + + +def test_wind_farm_direct_power_setpoint_zero(): + """Test that turbine powers go to zero when setpoint is zero.""" + wind_sim = WindFarm(h_dict_wind_direct) + + # Run multiple steps with zero setpoint to ensure filter settles + for step in range(10): + step_h_dict = {"step": step} + step_h_dict["wind_farm"] = { + "turbine_power_setpoints": np.zeros(3, dtype=hercules_float_type), + } + result = wind_sim.step(step_h_dict) + + # After multiple steps, powers should be effectively zero + assert np.all(result["wind_farm"]["turbine_powers"] < 1.0) + + +def test_wind_farm_direct_initial_conditions(): + """Test that initial conditions are correctly set in h_dict.""" + wind_sim = WindFarm(h_dict_wind_direct) + + initial_h_dict = copy.deepcopy(h_dict_wind_direct) + result_h_dict = wind_sim.get_initial_conditions_and_meta_data(initial_h_dict) + + assert "n_turbines" in result_h_dict["wind_farm"] + assert "capacity" in result_h_dict["wind_farm"] + assert "rated_turbine_power" in result_h_dict["wind_farm"] + assert "wind_direction_mean" in result_h_dict["wind_farm"] + assert "wind_speed_mean_background" in result_h_dict["wind_farm"] + assert "turbine_powers" in result_h_dict["wind_farm"] + assert "power" in result_h_dict["wind_farm"] + + assert result_h_dict["wind_farm"]["n_turbines"] == 3 + assert result_h_dict["wind_farm"]["capacity"] > 0 + assert result_h_dict["wind_farm"]["rated_turbine_power"] > 0 + + +def test_wind_farm_direct_output_consistency(): + """Test that outputs are consistent with no wake modeling.""" + wind_sim = WindFarm(h_dict_wind_direct) + + # Run a step + step_h_dict = {"step": 2} + step_h_dict["wind_farm"] = { + "turbine_power_setpoints": np.ones(3, dtype=hercules_float_type) * 5000.0, + } + + result = wind_sim.step(step_h_dict) + + # Calculate expected mean withwakes speed (should equal background mean) + expected_mean_withwakes = np.mean( + result["wind_farm"]["wind_speeds_background"], dtype=hercules_float_type + ) + + assert np.isclose(result["wind_farm"]["wind_speed_mean_withwakes"], expected_mean_withwakes) + + # Total power should be sum of turbine powers + assert np.isclose(result["wind_farm"]["power"], np.sum(result["wind_farm"]["turbine_powers"])) diff --git a/tests/wind_farm_scada_power_test.py b/tests/wind_farm_scada_power_test.py new file mode 100644 index 00000000..5d7a449b --- /dev/null +++ b/tests/wind_farm_scada_power_test.py @@ -0,0 +1,450 @@ +"""Tests for the WindFarmSCADAPower class.""" + +import copy +import os +import tempfile + +import numpy as np +import pandas as pd +import pytest +from hercules.plant_components.wind_farm_scada_power import WindFarmSCADAPower +from hercules.utilities import hercules_float_type + +from tests.test_inputs.h_dict import h_dict_wind + +# Create a base test dictionary for WindFarmSCADAPower +h_dict_wind_scada = copy.deepcopy(h_dict_wind) +# Update component type and remove unneeded parameters +h_dict_wind_scada["wind_farm"]["component_type"] = "WindFarmSCADAPower" +h_dict_wind_scada["wind_farm"]["scada_filename"] = "tests/test_inputs/scada_input.csv" +# Keep turbine_file_name for filter model parameters +# Remove FLORIS-specific parameters +del h_dict_wind_scada["wind_farm"]["floris_input_file"] +del h_dict_wind_scada["wind_farm"]["wind_input_filename"] +del h_dict_wind_scada["wind_farm"]["floris_update_time_s"] + + +def test_wind_farm_scada_power_initialization(): + """Test that WindFarmSCADAPower initializes correctly with valid inputs.""" + wind_sim = WindFarmSCADAPower(h_dict_wind_scada) + + assert wind_sim.component_name == "wind_farm" + assert wind_sim.component_type == "WindFarmSCADAPower" + assert wind_sim.n_turbines == 3 + assert wind_sim.dt == 1.0 + assert wind_sim.starttime == 0.0 + assert wind_sim.endtime == 10.0 + # No FLORIS calculations in SCADA power mode + assert wind_sim.num_floris_calcs == 0 + + +def test_wind_farm_scada_power_infers_n_turbines(): + """Test that number of turbines is correctly inferred from power columns.""" + wind_sim = WindFarmSCADAPower(h_dict_wind_scada) + + assert wind_sim.n_turbines == 3 + assert len(wind_sim.power_columns) == 3 + assert wind_sim.power_columns == ["pow_000", "pow_001", "pow_002"] + + +def test_wind_farm_scada_power_infers_rated_power(): + """Test that rated power is correctly inferred from 99th percentile.""" + wind_sim = WindFarmSCADAPower(h_dict_wind_scada) + + # Check that rated power is positive and reasonable + assert wind_sim.rated_turbine_power == 5000.0 + assert wind_sim.capacity == wind_sim.n_turbines * wind_sim.rated_turbine_power + + +def test_wind_farm_scada_power_no_wakes(): + """Test that no wake deficits are applied in SCADA power mode.""" + wind_sim = WindFarmSCADAPower(h_dict_wind_scada) + + # Verify initial wake deficits are zero + assert np.all(wind_sim.floris_wake_deficits == 0.0) + + # Verify initial wind speeds with wakes equal background + assert np.allclose(wind_sim.wind_speeds_withwakes, wind_sim.wind_speeds_background) + + +def test_wind_farm_scada_power_step(): + """Test that the step method works correctly.""" + wind_sim = WindFarmSCADAPower(h_dict_wind_scada) + + # Add power setpoint values to the step h_dict + step_h_dict = {"step": 1} + step_h_dict["wind_farm"] = { + "turbine_power_setpoints": np.array([5000.0, 5000.0, 5000.0]), + } + + result = wind_sim.step(step_h_dict) + + # Verify outputs exist + assert "turbine_powers" in result["wind_farm"] + assert "power" in result["wind_farm"] + assert len(result["wind_farm"]["turbine_powers"]) == 3 + assert isinstance(result["wind_farm"]["turbine_powers"], np.ndarray) + assert "power" in result["wind_farm"] + assert isinstance(result["wind_farm"]["power"], (int, float)) + + # Verify no wake deficits applied + assert np.all(wind_sim.floris_wake_deficits == 0.0) + assert np.allclose( + result["wind_farm"]["wind_speeds_withwakes"], + result["wind_farm"]["wind_speeds_background"], + ) + + # Verify turbine powers + assert np.allclose(result["wind_farm"]["turbine_powers"], [3200.0, 3100.0, 3300.0]) + assert np.isclose(result["wind_farm"]["power"], 3200.0 + 3100.0 + 3300.0) + + +def test_wind_farm_scada_power_power_setpoint_applies(): + """Test that turbine powers are limited by power setpoint when setpoint is low.""" + wind_sim = WindFarmSCADAPower(h_dict_wind_scada) + + # Set very low power setpoint values that should definitely limit power output + # Run multiple steps to let filter settle (within available data range 0-9) + for step in range(wind_sim.n_steps): + step_h_dict = {"step": step} + step_h_dict["wind_farm"] = { + "turbine_power_setpoints": np.array([100.0, 200.0, 300.0]), # Very low setpoints + } + result = wind_sim.step(step_h_dict) + + # Verify that turbine powers are at or below power setpoint limits + turbine_powers = result["wind_farm"]["turbine_powers"] + power_setpoints = [100.0, 200.0, 300.0] + + for i, (power, setpoint) in enumerate(zip(turbine_powers, power_setpoints)): + assert power <= setpoint + 1e-6, ( + f"Turbine {i} power {power} exceeds power setpoint {setpoint}" + ) + + +def test_wind_farm_scada_power_power_setpoint_zero(): + """Test that turbine powers go to zero when setpoint is zero.""" + wind_sim = WindFarmSCADAPower(h_dict_wind_scada) + + # Run multiple steps with zero setpoint to ensure filter settles (within available data range) + for step in range(wind_sim.n_steps): + step_h_dict = {"step": step} + step_h_dict["wind_farm"] = { + "turbine_power_setpoints": np.zeros(3, dtype=hercules_float_type), + } + result = wind_sim.step(step_h_dict) + + # After multiple steps, powers should be effectively zero + assert np.all(result["wind_farm"]["turbine_powers"] < 1.0) + + +def test_wind_farm_scada_power_get_initial_conditions_and_meta_data(): + """Test that get_initial_conditions_and_meta_data adds correct metadata to h_dict.""" + wind_sim = WindFarmSCADAPower(h_dict_wind_scada) + + # Create a copy of the input h_dict to avoid modifying the original + test_h_dict_copy = copy.deepcopy(h_dict_wind_scada) + + # Call the method + result = wind_sim.get_initial_conditions_and_meta_data(test_h_dict_copy) + + # Verify that the method returns the modified h_dict + assert result is test_h_dict_copy + + # Verify that all expected metadata is added to the wind_farm section + assert "n_turbines" in result["wind_farm"] + assert "capacity" in result["wind_farm"] + assert "rated_turbine_power" in result["wind_farm"] + assert "wind_direction_mean" in result["wind_farm"] + assert "wind_speed_mean_background" in result["wind_farm"] + assert "turbine_powers" in result["wind_farm"] + + # Verify the values match the wind_sim attributes + assert result["wind_farm"]["n_turbines"] == wind_sim.n_turbines + assert result["wind_farm"]["capacity"] == wind_sim.capacity + assert result["wind_farm"]["rated_turbine_power"] == wind_sim.rated_turbine_power + assert result["wind_farm"]["wind_direction_mean"] == wind_sim.wd_mat_mean[0] + assert result["wind_farm"]["wind_speed_mean_background"] == wind_sim.ws_mat_mean[0] + + # Verify turbine_powers is a numpy array with correct length + assert isinstance(result["wind_farm"]["turbine_powers"], np.ndarray) + assert len(result["wind_farm"]["turbine_powers"]) == wind_sim.n_turbines + np.testing.assert_array_equal(result["wind_farm"]["turbine_powers"], wind_sim.turbine_powers) + + # Verify that the original h_dict structure is preserved + assert "dt" in result + assert "starttime" in result + assert "endtime" in result + assert "plant" in result + + +def test_wind_farm_scada_power_time_utc_handling(): + """Test that time_utc is correctly parsed and validated.""" + # Create wind input data with time_utc columns + scada_data = { + "time_utc": [ + "2023-01-01T00:00:00Z", + "2023-01-01T00:00:01Z", + "2023-01-01T00:00:02Z", + "2023-01-01T00:00:03Z", + "2023-01-01T00:00:04Z", + ], + "wd_mean": [270.0, 275.0, 280.0, 285.0, 290.0], + "ws_000": [8.0, 9.0, 10.0, 11.0, 12.0], + "ws_001": [8.5, 9.5, 10.5, 11.5, 12.5], + "ws_002": [9.0, 10.0, 11.0, 12.0, 13.0], + "pow_000": [2500.0, 3200.0, 4000.0, 4500.0, 5000.0], + "pow_001": [2400.0, 3100.0, 3900.0, 4400.0, 4900.0], + "pow_002": [2600.0, 3300.0, 4100.0, 4600.0, 5000.0], + } + + # Create temporary file + with tempfile.NamedTemporaryFile(mode="w", suffix=".csv", delete=False) as f: + df = pd.DataFrame(scada_data) + df.to_csv(f.name, index=False) + temp_scada_file = f.name + + try: + # Create test h_dict with the temporary scada file + test_h_dict = copy.deepcopy(h_dict_wind_scada) + test_h_dict["wind_farm"]["scada_filename"] = temp_scada_file + test_h_dict["starttime"] = 0.0 + test_h_dict["endtime"] = 4.0 + test_h_dict["starttime_utc"] = "2023-01-01T00:00:00Z" + test_h_dict["endtime_utc"] = "2023-01-01T00:00:04Z" + test_h_dict["dt"] = 1.0 + + # Initialize wind simulation + wind_sim = WindFarmSCADAPower(test_h_dict) + + # Verify that starttime_utc is set correctly + assert hasattr(wind_sim, "starttime_utc"), "starttime_utc should be set" + + expected_start_time = pd.to_datetime("2023-01-01T00:00:00Z", utc=True) + + # Convert to pandas Timestamp for comparison + actual_start_time = pd.Timestamp(wind_sim.starttime_utc) + + # Compare datetime values + assert actual_start_time.replace(tzinfo=None) == expected_start_time.replace(tzinfo=None), ( + f"starttime_utc mismatch: expected {expected_start_time}, got {actual_start_time}" + ) + + finally: + # Clean up temporary file + if os.path.exists(temp_scada_file): + os.unlink(temp_scada_file) + + +def test_wind_farm_scada_power_time_utc_validation_start_too_early(): + """Test that error is raised when starttime_utc is before earliest SCADA data.""" + # Create SCADA data starting at 2023-01-01T00:00:00Z + scada_data = { + "time_utc": [ + "2023-01-01T00:00:00Z", + "2023-01-01T00:00:01Z", + "2023-01-01T00:00:02Z", + ], + "wd_mean": [270.0, 275.0, 280.0], + "ws_000": [8.0, 9.0, 10.0], + "ws_001": [8.5, 9.5, 10.5], + "ws_002": [9.0, 10.0, 11.0], + "pow_000": [2500.0, 3200.0, 4000.0], + "pow_001": [2400.0, 3100.0, 3900.0], + "pow_002": [2600.0, 3300.0, 4100.0], + } + + with tempfile.NamedTemporaryFile(mode="w", suffix=".csv", delete=False) as f: + df = pd.DataFrame(scada_data) + df.to_csv(f.name, index=False) + temp_scada_file = f.name + + try: + test_h_dict = copy.deepcopy(h_dict_wind_scada) + test_h_dict["wind_farm"]["scada_filename"] = temp_scada_file + test_h_dict["starttime"] = 0.0 + test_h_dict["endtime"] = 2.0 + # Try to start before earliest SCADA data + test_h_dict["starttime_utc"] = "2022-12-31T23:59:59Z" + test_h_dict["endtime_utc"] = "2023-01-01T00:00:02Z" + test_h_dict["dt"] = 1.0 + + with pytest.raises(ValueError, match="Start time UTC .* is before the earliest time"): + WindFarmSCADAPower(test_h_dict) + + finally: + if os.path.exists(temp_scada_file): + os.unlink(temp_scada_file) + + +def test_wind_farm_scada_power_time_utc_validation_end_too_late(): + """Test that error is raised when endtime_utc is after latest SCADA data.""" + # Create SCADA data ending at 2023-01-01T00:00:02Z + scada_data = { + "time_utc": [ + "2023-01-01T00:00:00Z", + "2023-01-01T00:00:01Z", + "2023-01-01T00:00:02Z", + ], + "wd_mean": [270.0, 275.0, 280.0], + "ws_000": [8.0, 9.0, 10.0], + "ws_001": [8.5, 9.5, 10.5], + "ws_002": [9.0, 10.0, 11.0], + "pow_000": [2500.0, 3200.0, 4000.0], + "pow_001": [2400.0, 3100.0, 3900.0], + "pow_002": [2600.0, 3300.0, 4100.0], + } + + with tempfile.NamedTemporaryFile(mode="w", suffix=".csv", delete=False) as f: + df = pd.DataFrame(scada_data) + df.to_csv(f.name, index=False) + temp_scada_file = f.name + + try: + test_h_dict = copy.deepcopy(h_dict_wind_scada) + test_h_dict["wind_farm"]["scada_filename"] = temp_scada_file + test_h_dict["starttime"] = 0.0 + test_h_dict["endtime"] = 5.0 + test_h_dict["starttime_utc"] = "2023-01-01T00:00:00Z" + # Try to end after latest SCADA data + test_h_dict["endtime_utc"] = "2023-01-01T00:00:05Z" + test_h_dict["dt"] = 1.0 + + with pytest.raises(ValueError, match="End time UTC .* is after the latest time"): + WindFarmSCADAPower(test_h_dict) + + finally: + if os.path.exists(temp_scada_file): + os.unlink(temp_scada_file) + + +def test_wind_farm_scada_power_ws_mean_handling(): + """Test that ws_mean is correctly handled when individual speeds are not present.""" + # Create SCADA data with ws_mean but no individual speeds + scada_data = { + "time_utc": [ + "2023-01-01T00:00:00Z", + "2023-01-01T00:00:01Z", + "2023-01-01T00:00:02Z", + "2023-01-01T00:00:03Z", + "2023-01-01T00:00:04Z", + ], + "wd_mean": [270.0, 275.0, 280.0, 285.0, 290.0], + "ws_mean": [8.0, 9.0, 10.0, 11.0, 12.0], + "pow_000": [2500.0, 3200.0, 4000.0, 4500.0, 5000.0], + "pow_001": [2400.0, 3100.0, 3900.0, 4400.0, 4900.0], + "pow_002": [2600.0, 3300.0, 4100.0, 4600.0, 5000.0], + } + + with tempfile.NamedTemporaryFile(mode="w", suffix=".csv", delete=False) as f: + df = pd.DataFrame(scada_data) + df.to_csv(f.name, index=False) + temp_scada_file = f.name + + try: + test_h_dict = copy.deepcopy(h_dict_wind_scada) + test_h_dict["wind_farm"]["scada_filename"] = temp_scada_file + test_h_dict["starttime"] = 0.0 + test_h_dict["endtime"] = 4.0 + test_h_dict["starttime_utc"] = "2023-01-01T00:00:00Z" + test_h_dict["endtime_utc"] = "2023-01-01T00:00:04Z" + test_h_dict["dt"] = 1.0 + + wind_sim = WindFarmSCADAPower(test_h_dict) + + # Verify that ws_mat is properly tiled from ws_mean + assert wind_sim.ws_mat.shape == (4, 3) + # All turbines should have the same wind speed (from ws_mean) + assert (wind_sim.ws_mat[:, 0] == wind_sim.ws_mat[:, 1]).all() + assert (wind_sim.ws_mat[:, 1] == wind_sim.ws_mat[:, 2]).all() + + finally: + if os.path.exists(temp_scada_file): + os.unlink(temp_scada_file) + + +def test_wind_farm_scada_power_output_consistency(): + """Test that outputs are consistent with no wake modeling.""" + wind_sim = WindFarmSCADAPower(h_dict_wind_scada) + + # Run a step + step_h_dict = {"step": 2} + step_h_dict["wind_farm"] = { + "turbine_power_setpoints": np.ones(3, dtype=hercules_float_type) * 5000.0, + } + + result = wind_sim.step(step_h_dict) + + # Calculate expected mean withwakes speed (should equal background mean) + expected_mean_withwakes = np.mean( + result["wind_farm"]["wind_speeds_background"], dtype=hercules_float_type + ) + + assert np.isclose(result["wind_farm"]["wind_speed_mean_withwakes"], expected_mean_withwakes) + + # Total power should be sum of turbine powers + assert np.isclose(result["wind_farm"]["power"], np.sum(result["wind_farm"]["turbine_powers"])) + + +def test_wind_farm_scada_power_multiple_file_formats(): + """Test that SCADA data can be loaded from different file formats.""" + # Test CSV (already tested above, but included for completeness) + wind_sim_csv = WindFarmSCADAPower(h_dict_wind_scada) + assert wind_sim_csv.n_turbines == 3 + + # Test pickle format + current_dir = os.path.dirname(__file__) + df_scada = pd.read_csv(current_dir + "/test_inputs/scada_input.csv") + + # Create temporary pickle file + with tempfile.NamedTemporaryFile(suffix=".pkl", delete=False) as f: + df_scada.to_pickle(f.name) + temp_pickle_file = f.name + + try: + test_h_dict = copy.deepcopy(h_dict_wind_scada) + test_h_dict["wind_farm"]["scada_filename"] = temp_pickle_file + + wind_sim_pkl = WindFarmSCADAPower(test_h_dict) + assert wind_sim_pkl.n_turbines == 3 + + finally: + if os.path.exists(temp_pickle_file): + os.unlink(temp_pickle_file) + + # Test feather format + with tempfile.NamedTemporaryFile(suffix=".ftr", delete=False) as f: + df_scada.to_feather(f.name) + temp_feather_file = f.name + + try: + test_h_dict = copy.deepcopy(h_dict_wind_scada) + test_h_dict["wind_farm"]["scada_filename"] = temp_feather_file + + wind_sim_ftr = WindFarmSCADAPower(test_h_dict) + assert wind_sim_ftr.n_turbines == 3 + + finally: + if os.path.exists(temp_feather_file): + os.unlink(temp_feather_file) + + +def test_wind_farm_scada_power_invalid_file_format(): + """Test that invalid file format raises ValueError.""" + test_h_dict = copy.deepcopy(h_dict_wind_scada) + test_h_dict["wind_farm"]["scada_filename"] = "tests/test_inputs/invalid.txt" + + # Create a dummy file + with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f: + f.write("dummy") + temp_file = f.name + + try: + test_h_dict["wind_farm"]["scada_filename"] = temp_file + + with pytest.raises(ValueError, match="SCADA file must be a .csv or .p, .f or .ftr file"): + WindFarmSCADAPower(test_h_dict) + + finally: + if os.path.exists(temp_file): + os.unlink(temp_file) diff --git a/tests/wind_meso_to_power_precom_floris_test.py b/tests/wind_meso_to_power_precom_floris_test.py index cfeac086..b9bc1888 100644 --- a/tests/wind_meso_to_power_precom_floris_test.py +++ b/tests/wind_meso_to_power_precom_floris_test.py @@ -1,4 +1,4 @@ -"""Tests for the Wind_MesoToPowerPrecomFloris class.""" +"""Tests for the WindFarm class in with precomputed wakes.""" import copy import os @@ -7,25 +7,24 @@ import numpy as np import pandas as pd import pytest -from hercules.plant_components.wind_meso_to_power_precom_floris import ( - Wind_MesoToPowerPrecomFloris, -) +from hercules.plant_components.wind_farm import WindFarm +from hercules.utilities import hercules_float_type from tests.test_inputs.h_dict import h_dict_wind -# Create a base test dictionary for Wind_MesoToPowerPrecomFloris +# Create a base test dictionary for WindFarm with precomputed wakes h_dict_wind_precom_floris = copy.deepcopy(h_dict_wind) -# Update component type and add logging_option for precom_floris tests -h_dict_wind_precom_floris["wind_farm"]["component_type"] = "Wind_MesoToPowerPrecomFloris" -h_dict_wind_precom_floris["wind_farm"]["logging_option"] = "all" +# Update component type +h_dict_wind_precom_floris["wind_farm"]["component_type"] = "WindFarm" +h_dict_wind_precom_floris["wind_farm"]["wake_method"] = "precomputed" def test_wind_meso_to_power_precom_floris_initialization(): - """Test that Wind_MesoToPowerPrecomFloris initializes correctly with valid inputs.""" - wind_sim = Wind_MesoToPowerPrecomFloris(h_dict_wind_precom_floris) + """Test that WindFarm initializes correctly with valid inputs.""" + wind_sim = WindFarm(h_dict_wind_precom_floris) assert wind_sim.component_name == "wind_farm" - assert wind_sim.component_type == "Wind_MesoToPowerPrecomFloris" + assert wind_sim.component_type == "WindFarm" assert wind_sim.n_turbines == 3 assert wind_sim.dt == 1.0 assert wind_sim.starttime == 0.0 @@ -38,13 +37,52 @@ def test_wind_meso_to_power_precom_floris_initialization(): ) +def test_wind_meso_to_power_precom_floris_ws_mean(): + """Test that invalid component_type raises ValueError.""" + + current_dir = os.path.dirname(__file__) + + df_input = pd.read_csv(current_dir + "/test_inputs/wind_input.csv") + df_input["ws_mean"] = 10.0 + df_input.to_csv(current_dir + "/test_inputs/wind_input_temp.csv") + + test_h_dict = copy.deepcopy(h_dict_wind_precom_floris) + test_h_dict["wind_farm"]["wind_input_filename"] = "tests/test_inputs/wind_input_temp.csv" + + # Test that, since individual speed are specified, ws_mean is ignored + # Note that h_dict_wind_precom_floris specifies an end time of 10. + wind_sim = WindFarm(test_h_dict) + assert ( + wind_sim.ws_mat[:, 0] == df_input["ws_000"].to_numpy(dtype=hercules_float_type)[:10] + ).all() + assert np.allclose( + wind_sim.ws_mat_mean, + (df_input[["ws_000", "ws_001", "ws_002"]].mean(axis=1)).to_numpy(dtype=hercules_float_type)[ + :10 + ], + ) + + # Drop individual speeds and test that ws_mean is used instead + df_input = df_input.drop(columns=["ws_000", "ws_001", "ws_002"]) + df_input.to_csv(current_dir + "/test_inputs/wind_input_temp.csv") + + wind_sim = WindFarm(test_h_dict) + assert (wind_sim.ws_mat_mean == 10.0).all() + assert (wind_sim.ws_mat[:, :] == 10.0).all() + + # Delete temp file + os.remove(current_dir + "/test_inputs/wind_input_temp.csv") + + def test_wind_meso_to_power_precom_floris_requires_floris_update_time(): """Test that missing floris_update_time_s raises ValueError.""" test_h_dict = copy.deepcopy(h_dict_wind_precom_floris) del test_h_dict["wind_farm"]["floris_update_time_s"] - with pytest.raises(ValueError, match="floris_update_time_s must be in the h_dict"): - Wind_MesoToPowerPrecomFloris(test_h_dict) + with pytest.raises( + ValueError, match="floris_update_time_s must be specified for wake_method='precomputed'" + ): + WindFarm(test_h_dict) def test_wind_meso_to_power_precom_floris_invalid_update_time(): @@ -53,12 +91,12 @@ def test_wind_meso_to_power_precom_floris_invalid_update_time(): test_h_dict["wind_farm"]["floris_update_time_s"] = 0.5 with pytest.raises(ValueError, match="FLORIS update time must be at least 1 second"): - Wind_MesoToPowerPrecomFloris(test_h_dict) + WindFarm(test_h_dict) def test_wind_meso_to_power_precom_floris_step(): """Test that the step method updates outputs correctly.""" - wind_sim = Wind_MesoToPowerPrecomFloris(h_dict_wind_precom_floris) + wind_sim = WindFarm(h_dict_wind_precom_floris) # Add power setpoint values to the step h_dict step_h_dict = {"step": 1} @@ -78,7 +116,7 @@ def test_wind_meso_to_power_precom_floris_step(): def test_wind_meso_to_power_precom_floris_power_setpoint_applies(): """Test that turbine powers equal power setpoint when setpoint is very low.""" - wind_sim = Wind_MesoToPowerPrecomFloris(h_dict_wind_precom_floris) + wind_sim = WindFarm(h_dict_wind_precom_floris) # Set very low power setpoint values that should definitely limit power output step_h_dict = {"step": 1} @@ -93,14 +131,14 @@ def test_wind_meso_to_power_precom_floris_power_setpoint_applies(): power_setpoint_values = [100.0, 200.0, 300.0] for i, (power, setpoint) in enumerate(zip(turbine_powers, power_setpoint_values)): - assert ( - power == setpoint - ), f"Turbine {i} power {power} should equal power setpoint {setpoint}" + assert power == setpoint, ( + f"Turbine {i} power {power} should equal power setpoint {setpoint}" + ) def test_wind_meso_to_power_precom_floris_get_initial_conditions_and_meta_data(): """Test that get_initial_conditions_and_meta_data adds correct metadata to h_dict.""" - wind_sim = Wind_MesoToPowerPrecomFloris(h_dict_wind_precom_floris) + wind_sim = WindFarm(h_dict_wind_precom_floris) # Create a copy of the input h_dict to avoid modifying the original test_h_dict_copy = copy.deepcopy(h_dict_wind_precom_floris) @@ -115,16 +153,16 @@ def test_wind_meso_to_power_precom_floris_get_initial_conditions_and_meta_data() assert "n_turbines" in result["wind_farm"] assert "capacity" in result["wind_farm"] assert "rated_turbine_power" in result["wind_farm"] - assert "wind_direction" in result["wind_farm"] - assert "wind_speed" in result["wind_farm"] + assert "wind_direction_mean" in result["wind_farm"] + assert "wind_speed_mean_background" in result["wind_farm"] assert "turbine_powers" in result["wind_farm"] # Verify the values match the wind_sim attributes assert result["wind_farm"]["n_turbines"] == wind_sim.n_turbines assert result["wind_farm"]["capacity"] == wind_sim.capacity assert result["wind_farm"]["rated_turbine_power"] == wind_sim.rated_turbine_power - assert result["wind_farm"]["wind_direction"] == wind_sim.wd_mat_mean[0] - assert result["wind_farm"]["wind_speed"] == wind_sim.ws_mat_mean[0] + assert result["wind_farm"]["wind_direction_mean"] == wind_sim.wd_mat_mean[0] + assert result["wind_farm"]["wind_speed_mean_background"] == wind_sim.ws_mat_mean[0] # Verify turbine_powers is a numpy array with correct length assert isinstance(result["wind_farm"]["turbine_powers"], np.ndarray) @@ -140,15 +178,15 @@ def test_wind_meso_to_power_precom_floris_get_initial_conditions_and_meta_data() def test_wind_meso_to_power_precom_floris_precomputed_wake_deficits(): """Test that wake deficits are precomputed and stored correctly.""" - wind_sim = Wind_MesoToPowerPrecomFloris(h_dict_wind_precom_floris) + wind_sim = WindFarm(h_dict_wind_precom_floris) - # Verify that precomputed wake velocities exist - assert hasattr(wind_sim, "waked_velocities_all") - assert isinstance(wind_sim.waked_velocities_all, np.ndarray) + # Verify that precomputed wake wind speeds exist + assert hasattr(wind_sim, "wind_speeds_withwakes_all") + assert isinstance(wind_sim.wind_speeds_withwakes_all, np.ndarray) # Check shape: should be (n_time_steps, n_turbines) expected_shape = (wind_sim.n_steps, wind_sim.n_turbines) - assert wind_sim.waked_velocities_all.shape == expected_shape + assert wind_sim.wind_speeds_withwakes_all.shape == expected_shape # Verify that initial wake deficits are calculated assert hasattr(wind_sim, "floris_wake_deficits") @@ -160,14 +198,21 @@ def test_wind_meso_to_power_precom_floris_precomputed_wake_deficits(): def test_wind_meso_to_power_precom_floris_velocities_update_correctly(): - """Test that velocities are updated correctly from precomputed arrays during simulation.""" + """Test that wind speeds are updated correctly from precomputed arrays during simulation.""" # Create a temporary wind input file with varying conditions wind_data = { - "time": [0, 1, 2, 3], - "wd_mean": [270.0, 275.0, 280.0, 285.0], # Varying wind direction - "ws_000": [8.0, 9.0, 10.0, 11.0], # Varying wind speed turbine 0 - "ws_001": [8.5, 9.5, 10.5, 11.5], # Varying wind speed turbine 1 - "ws_002": [9.0, 10.0, 11.0, 12.0], # Varying wind speed turbine 2 + "time": [0, 1, 2, 3, 4], + "time_utc": [ + "2018-05-10 12:31:00", + "2018-05-10 12:31:01", + "2018-05-10 12:31:02", + "2018-05-10 12:31:03", + "2018-05-10 12:31:04", + ], + "wd_mean": [270.0, 275.0, 280.0, 285.0, 290.0], # Varying wind direction + "ws_000": [8.0, 9.0, 10.0, 11.0, 12.0], # Varying wind speed turbine 0 + "ws_001": [8.5, 9.5, 10.5, 11.5, 12.5], # Varying wind speed turbine 1 + "ws_002": [9.0, 10.0, 11.0, 12.0, 13.0], # Varying wind speed turbine 2 } # Create temporary file @@ -182,14 +227,16 @@ def test_wind_meso_to_power_precom_floris_velocities_update_correctly(): test_h_dict["wind_farm"]["wind_input_filename"] = temp_wind_file test_h_dict["starttime"] = 0.0 test_h_dict["endtime"] = 4.0 + test_h_dict["starttime_utc"] = "2018-05-10 12:31:00" + test_h_dict["endtime_utc"] = "2018-05-10 12:31:04" test_h_dict["dt"] = 1.0 # Initialize wind simulation - wind_sim = Wind_MesoToPowerPrecomFloris(test_h_dict) + wind_sim = WindFarm(test_h_dict) - # Store initial velocities - initial_unwaked = wind_sim.unwaked_velocities.copy() - initial_waked = wind_sim.waked_velocities.copy() + # Store initial wind speeds + initial_background = wind_sim.wind_speeds_background.copy() + initial_withwakes = wind_sim.wind_speeds_withwakes.copy() # Run a step step_h_dict = {"step": 1} @@ -199,20 +246,20 @@ def test_wind_meso_to_power_precom_floris_velocities_update_correctly(): wind_sim.step(step_h_dict) - # Verify that velocities have been updated - assert not np.array_equal( - wind_sim.unwaked_velocities, initial_unwaked - ), "Unwaked velocities should have been updated" - assert not np.array_equal( - wind_sim.waked_velocities, initial_waked - ), "Waked velocities should have been updated" + # Verify that wind speeds have been updated + assert not np.array_equal(wind_sim.wind_speeds_background, initial_background), ( + "Background wind speeds should have been updated" + ) + assert not np.array_equal(wind_sim.wind_speeds_withwakes, initial_withwakes), ( + "Withwakes wind speeds should have been updated" + ) - # Verify the velocities match the expected values from the input data - expected_unwaked = np.array([9.0, 9.5, 10.0]) # ws values for step 1 - np.testing.assert_array_equal(wind_sim.unwaked_velocities, expected_unwaked) + # Verify the wind speeds match the expected values from the input data + expected_background = np.array([9.0, 9.5, 10.0]) # ws values for step 1 + np.testing.assert_array_equal(wind_sim.wind_speeds_background, expected_background) # Verify that wake deficits are recalculated - expected_wake_deficits = wind_sim.unwaked_velocities - wind_sim.waked_velocities + expected_wake_deficits = wind_sim.wind_speeds_background - wind_sim.wind_speeds_withwakes np.testing.assert_array_equal(wind_sim.floris_wake_deficits, expected_wake_deficits) finally: @@ -222,21 +269,22 @@ def test_wind_meso_to_power_precom_floris_velocities_update_correctly(): def test_wind_meso_to_power_precom_floris_time_utc_reconstruction(): - """Test that time_utc reconstruction works correctly from zero_time_utc metadata + """Test that time_utc reconstruction works correctly from starttime_utc metadata and both time_utc fields are properly set.""" # Create wind input data with time_utc columns wind_data = { - "time": [0, 1, 2, 3], + "time": [0, 1, 2, 3, 4], "time_utc": [ "2023-01-01T00:00:00Z", "2023-01-01T00:00:01Z", "2023-01-01T00:00:02Z", "2023-01-01T00:00:03Z", + "2023-01-01T00:00:04Z", ], - "wd_mean": [270.0, 275.0, 280.0, 285.0], - "ws_000": [8.0, 9.0, 10.0, 11.0], - "ws_001": [8.5, 9.5, 10.5, 11.5], - "ws_002": [9.0, 10.0, 11.0, 12.0], + "wd_mean": [270.0, 275.0, 280.0, 285.0, 290.0], + "ws_000": [8.0, 9.0, 10.0, 11.0, 12.0], + "ws_001": [8.5, 9.5, 10.5, 11.5, 12.5], + "ws_002": [9.0, 10.0, 11.0, 12.0, 13.0], } # Create temporary file @@ -251,52 +299,40 @@ def test_wind_meso_to_power_precom_floris_time_utc_reconstruction(): test_h_dict["wind_farm"]["wind_input_filename"] = temp_wind_file test_h_dict["starttime"] = 0.0 test_h_dict["endtime"] = 4.0 + test_h_dict["starttime_utc"] = "2023-01-01T00:00:00Z" + test_h_dict["endtime_utc"] = "2023-01-01T00:00:04Z" test_h_dict["dt"] = 1.0 # Initialize wind simulation - wind_sim = Wind_MesoToPowerPrecomFloris(test_h_dict) + wind_sim = WindFarm(test_h_dict) - # Verify that both zero_time_utc and start_time_utc are set correctly - assert hasattr(wind_sim, "zero_time_utc"), "zero_time_utc should be set" - assert hasattr(wind_sim, "start_time_utc"), "start_time_utc should be set" + # Verify that starttime_utc is set correctly + assert hasattr(wind_sim, "starttime_utc"), "starttime_utc should be set" - expected_zero_time = pd.to_datetime("2023-01-01T00:00:00Z", utc=True) expected_start_time = pd.to_datetime( "2023-01-01T00:00:00Z", utc=True ) # starttime=0, so same as zero_time # Convert numpy datetime64 to pandas Timestamp for comparison - actual_zero_time = pd.Timestamp(wind_sim.zero_time_utc) - actual_start_time = pd.Timestamp(wind_sim.start_time_utc) + actual_start_time = pd.Timestamp(wind_sim.starttime_utc) # Compare datetime values (ignoring timezone for this test) - assert actual_zero_time.replace(tzinfo=None) == expected_zero_time.replace( - tzinfo=None - ), f"zero_time_utc mismatch: expected {expected_zero_time}, got {actual_zero_time}" - assert actual_start_time.replace(tzinfo=None) == expected_start_time.replace( - tzinfo=None - ), f"start_time_utc mismatch: expected {expected_start_time}, got {actual_start_time}" - - # Test that both time_utc fields are added to h_dict when getting initial conditions + assert actual_start_time.replace(tzinfo=None) == expected_start_time.replace(tzinfo=None), ( + f"starttime_utc mismatch: expected {expected_start_time}, got {actual_start_time}" + ) + + # Test that starttime_utc is added to h_dict when getting initial conditions result = wind_sim.get_initial_conditions_and_meta_data(test_h_dict) - assert ( - "zero_time_utc" in result["wind_farm"] - ), "zero_time_utc should be in wind_farm metadata" - assert ( - "start_time_utc" in result["wind_farm"] - ), "start_time_utc should be in wind_farm metadata" + assert "starttime_utc" in result["wind_farm"], ( + "starttime_utc should be in wind_farm metadata" + ) # Convert numpy datetime64 to pandas Timestamp for comparison - actual_zero_time = pd.Timestamp(result["wind_farm"]["zero_time_utc"]) - actual_start_time = pd.Timestamp(result["wind_farm"]["start_time_utc"]) + actual_start_time = pd.Timestamp(result["wind_farm"]["starttime_utc"]) # Compare datetime values (ignoring timezone for this test) - assert actual_zero_time.replace(tzinfo=None) == expected_zero_time.replace(tzinfo=None), ( - f"zero_time_utc in metadata mismatch: expected {expected_zero_time}, " - f"got {actual_zero_time}" - ) assert actual_start_time.replace(tzinfo=None) == expected_start_time.replace(tzinfo=None), ( - f"start_time_utc in metadata mismatch: expected {expected_start_time}, " + f"starttime_utc in metadata mismatch: expected {expected_start_time}, " f"got {actual_start_time}" ) @@ -313,7 +349,8 @@ def test_wind_meso_to_power_precom_floris_time_utc_reconstruction(): with h5py.File(temp_h5_file, "w") as f: # Create metadata group metadata = f.create_group("metadata") - metadata.attrs["zero_time_utc"] = expected_start_time.timestamp() + # Write starttime_utc in seconds since epoch (UTC) + metadata.attrs["starttime_utc"] = expected_start_time.timestamp() # Create data group with time array data = f.create_group("data") @@ -367,8 +404,7 @@ def test_wind_meso_to_power_precom_floris_time_utc_reconstruction(): def test_wind_meso_to_power_precom_floris_time_utc_different_starttime(): - """Test that zero_time_utc and start_time_utc are correctly distinguished when - starttime != 0.""" + """Test that starttime_utc is correctly set when using a different start time.""" # Create wind input data with time_utc columns wind_data = { "time": [0, 1, 2, 3, 4, 5], @@ -394,54 +430,44 @@ def test_wind_meso_to_power_precom_floris_time_utc_different_starttime(): try: # Create test h_dict with the temporary wind file + # In the new design, time=0 corresponds to starttime_utc + # So if we want to start at 2023-01-01T00:00:02Z, we set that as starttime_utc test_h_dict = copy.deepcopy(h_dict_wind_precom_floris) test_h_dict["wind_farm"]["wind_input_filename"] = temp_wind_file - test_h_dict["starttime"] = 2.0 # Start at time=2, not time=0 - test_h_dict["endtime"] = 6.0 + test_h_dict["starttime"] = 0.0 # Always starts at 0 + test_h_dict["endtime"] = 3.0 # 4 steps (0, 1, 2, 3) + test_h_dict["starttime_utc"] = "2023-01-01T00:00:02Z" # Start at 2 seconds into the data + test_h_dict["endtime_utc"] = "2023-01-01T00:00:05Z" # End at 5 seconds test_h_dict["dt"] = 1.0 # Initialize wind simulation - wind_sim = Wind_MesoToPowerPrecomFloris(test_h_dict) + wind_sim = WindFarm(test_h_dict) - # Verify that both zero_time_utc and start_time_utc are set correctly - assert hasattr(wind_sim, "zero_time_utc"), "zero_time_utc should be set" - assert hasattr(wind_sim, "start_time_utc"), "start_time_utc should be set" + # Verify that starttime_utc is set correctly + assert hasattr(wind_sim, "starttime_utc"), "starttime_utc should be set" - expected_zero_time = pd.to_datetime("2023-01-01T00:00:00Z", utc=True) # time=0 - expected_start_time = pd.to_datetime("2023-01-01T00:00:02Z", utc=True) # time=2 (starttime) + expected_start_time = pd.to_datetime("2023-01-01T00:00:02Z", utc=True) # Convert numpy datetime64 to pandas Timestamp for comparison - actual_zero_time = pd.Timestamp(wind_sim.zero_time_utc) - actual_start_time = pd.Timestamp(wind_sim.start_time_utc) + actual_start_time = pd.Timestamp(wind_sim.starttime_utc) # Compare datetime values (ignoring timezone for this test) - assert actual_zero_time.replace(tzinfo=None) == expected_zero_time.replace( - tzinfo=None - ), f"zero_time_utc mismatch: expected {expected_zero_time}, got {actual_zero_time}" - assert actual_start_time.replace(tzinfo=None) == expected_start_time.replace( - tzinfo=None - ), f"start_time_utc mismatch: expected {expected_start_time}, got {actual_start_time}" - - # Test that both time_utc fields are added to h_dict when getting initial conditions + assert actual_start_time.replace(tzinfo=None) == expected_start_time.replace(tzinfo=None), ( + f"starttime_utc mismatch: expected {expected_start_time}, got {actual_start_time}" + ) + + # Test that starttime_utc is added to h_dict when getting initial conditions result = wind_sim.get_initial_conditions_and_meta_data(test_h_dict) - assert ( - "zero_time_utc" in result["wind_farm"] - ), "zero_time_utc should be in wind_farm metadata" - assert ( - "start_time_utc" in result["wind_farm"] - ), "start_time_utc should be in wind_farm metadata" + assert "starttime_utc" in result["wind_farm"], ( + "starttime_utc should be in wind_farm metadata" + ) # Convert numpy datetime64 to pandas Timestamp for comparison - actual_zero_time = pd.Timestamp(result["wind_farm"]["zero_time_utc"]) - actual_start_time = pd.Timestamp(result["wind_farm"]["start_time_utc"]) + actual_start_time = pd.Timestamp(result["wind_farm"]["starttime_utc"]) # Compare datetime values (ignoring timezone for this test) - assert actual_zero_time.replace(tzinfo=None) == expected_zero_time.replace(tzinfo=None), ( - f"zero_time_utc in metadata mismatch: expected {expected_zero_time}, " - f"got {actual_zero_time}" - ) assert actual_start_time.replace(tzinfo=None) == expected_start_time.replace(tzinfo=None), ( - f"start_time_utc in metadata mismatch: expected {expected_start_time}, " + f"starttime_utc in metadata mismatch: expected {expected_start_time}, " f"got {actual_start_time}" ) diff --git a/tests/wind_meso_to_power_test.py b/tests/wind_meso_to_power_test.py index 28066e06..336aeb05 100644 --- a/tests/wind_meso_to_power_test.py +++ b/tests/wind_meso_to_power_test.py @@ -1,22 +1,24 @@ -"""Tests for the Wind_MesoToPower class.""" +"""Tests for the WindFarm class in dynamic wake mode.""" +import copy import os import tempfile import numpy as np import pandas as pd import pytest -from hercules.plant_components.wind_meso_to_power import TurbineFilterModel, Wind_MesoToPower +from hercules.plant_components.wind_farm import WindFarm +from hercules.utilities import hercules_float_type from tests.test_inputs.h_dict import h_dict_wind def test_wind_meso_to_power_initialization(): - """Test that Wind_MesoToPower initializes correctly with valid inputs.""" - wind_sim = Wind_MesoToPower(h_dict_wind) + """Test that WindFarm initializes correctly with valid inputs (dynamic mode).""" + wind_sim = WindFarm(h_dict_wind) assert wind_sim.component_name == "wind_farm" - assert wind_sim.component_type == "Wind_MesoToPower" + assert wind_sim.component_type == "WindFarm" assert wind_sim.n_turbines == 3 assert wind_sim.dt == 1.0 assert wind_sim.starttime == 0.0 @@ -25,31 +27,70 @@ def test_wind_meso_to_power_initialization(): assert wind_sim.floris_update_time_s == 30.0 +def test_wind_meso_to_power_precom_floris_ws_mean(): + """Test that invalid component_type raises ValueError.""" + + current_dir = os.path.dirname(__file__) + + df_input = pd.read_csv(current_dir + "/test_inputs/wind_input.csv") + df_input["ws_mean"] = 10.0 + df_input.to_csv(current_dir + "/test_inputs/wind_input_temp.csv") + + test_h_dict = copy.deepcopy(h_dict_wind) + test_h_dict["wind_farm"]["wind_input_filename"] = "tests/test_inputs/wind_input_temp.csv" + + # Test that, since individual speed are specified, ws_mean is ignored + # Note that h_dict_wind specifies an end time of 10. + wind_sim = WindFarm(test_h_dict) + assert ( + wind_sim.ws_mat[:, 0] == df_input["ws_000"].to_numpy(dtype=hercules_float_type)[:10] + ).all() + assert np.allclose( + wind_sim.ws_mat_mean, + (df_input[["ws_000", "ws_001", "ws_002"]].mean(axis=1)).to_numpy(dtype=hercules_float_type)[ + :10 + ], + ) + + # Drop individual speeds and test that ws_mean is used instead + df_input = df_input.drop(columns=["ws_000", "ws_001", "ws_002"]) + df_input.to_csv(current_dir + "/test_inputs/wind_input_temp.csv") + + wind_sim = WindFarm(test_h_dict) + assert (wind_sim.ws_mat_mean == 10.0).all() + assert (wind_sim.ws_mat[:, :] == 10.0).all() + + # Delete temp file + os.remove(current_dir + "/test_inputs/wind_input_temp.csv") + + def test_wind_meso_to_power_missing_floris_update_time(): """Test that missing floris_update_time_s raises ValueError.""" - test_h_dict = h_dict_wind.copy() + test_h_dict = copy.deepcopy(h_dict_wind) del test_h_dict["wind_farm"]["floris_update_time_s"] - with pytest.raises(ValueError, match="floris_update_time_s must be in the h_dict"): - Wind_MesoToPower(test_h_dict) + with pytest.raises( + ValueError, match="floris_update_time_s must be specified for wake_method='dynamic'" + ): + WindFarm(test_h_dict) def test_wind_meso_to_power_invalid_update_time(): """Test that invalid update time raises ValueError.""" - test_h_dict = h_dict_wind.copy() + test_h_dict = copy.deepcopy(h_dict_wind) test_h_dict["wind_farm"]["floris_update_time_s"] = 0.5 # Less than 1 second with pytest.raises(ValueError, match="FLORIS update time must be at least 1 second"): - Wind_MesoToPower(test_h_dict) + WindFarm(test_h_dict) def test_wind_meso_to_power_step(): """Test that the step method updates outputs correctly.""" - test_h_dict = h_dict_wind.copy() + test_h_dict = copy.deepcopy(h_dict_wind) # Set a shorter update time for testing test_h_dict["wind_farm"]["floris_update_time_s"] = 1.0 - wind_sim = Wind_MesoToPower(test_h_dict) + wind_sim = WindFarm(test_h_dict) # Add power setpoint values to the step h_dict step_h_dict = {"step": 1} @@ -67,71 +108,14 @@ def test_wind_meso_to_power_step(): assert isinstance(result["wind_farm"]["power"], (int, float)) -def test_turbine_filter_model_initialization(): - """Test that TurbineFilterModel initializes correctly.""" - from floris import FlorisModel - - turbine_dict = {"filter_model": {"time_constant": 12.0}} - - # Use actual FLORIS model - fmodel = FlorisModel("tests/test_inputs/floris_input.yaml") - - turbine = TurbineFilterModel(turbine_dict, dt=1.0, fmodel=fmodel, initial_wind_speed=8.0) - - assert turbine.dt == 1.0 - assert turbine.filter_time_constant == 12.0 - assert turbine.alpha > 0.0 - assert turbine.alpha < 1.0 - assert isinstance(turbine.prev_power, (int, float, np.ndarray)) - - -def test_turbine_filter_model_step(): - """Test that TurbineFilterModel step method works correctly.""" - from floris import FlorisModel - - turbine_dict = {"filter_model": {"time_constant": 12.0}} - - # Use actual FLORIS model - fmodel = FlorisModel("tests/test_inputs/floris_input.yaml") - - turbine = TurbineFilterModel(turbine_dict, dt=1.0, fmodel=fmodel, initial_wind_speed=8.0) - - # Test step with different wind speeds - power1 = turbine.step(wind_speed=10.0, power_setpoint=1000.0) - power2 = turbine.step(wind_speed=12.0, power_setpoint=1500.0) - - assert isinstance(power1, (int, float)) - assert isinstance(power2, (int, float)) - assert power1 >= 0.0 - assert power2 >= 0.0 - - -def test_turbine_filter_model_power_setpoint_limit(): - """Test that TurbineFilterModel respects power setpoint limits.""" - from floris import FlorisModel - - turbine_dict = {"filter_model": {"time_constant": 12.0}} - - # Use actual FLORIS model - fmodel = FlorisModel("tests/test_inputs/floris_input.yaml") - - turbine = TurbineFilterModel(turbine_dict, dt=1.0, fmodel=fmodel, initial_wind_speed=8.0) - - # Test with low power setpoint limit - power = turbine.step(wind_speed=15.0, power_setpoint=500.0) - - assert power <= 500.0 - assert power >= 0.0 - - def test_wind_meso_to_power_time_utc_conversion(): """Test that time_utc column is properly converted to datetime.""" - wind_sim = Wind_MesoToPower(h_dict_wind) + wind_sim = WindFarm(h_dict_wind) # Check that time_utc was converted to datetime type # The wind_sim should have successfully processed the CSV with time_utc column assert wind_sim.component_name == "wind_farm" - assert wind_sim.component_type == "Wind_MesoToPower" + assert wind_sim.component_type == "WindFarm" assert wind_sim.n_turbines == 3 # Verify that the wind data was loaded correctly @@ -142,10 +126,10 @@ def test_wind_meso_to_power_time_utc_conversion(): def test_wind_meso_to_power_power_setpoint_too_high(): """Test that turbine powers are below power setpoint when setpoint is very high.""" - test_h_dict = h_dict_wind.copy() + test_h_dict = copy.deepcopy(h_dict_wind) test_h_dict["wind_farm"]["floris_update_time_s"] = 1.0 - wind_sim = Wind_MesoToPower(test_h_dict) + wind_sim = WindFarm(test_h_dict) # Set very high power setpoint values that should not limit power output step_h_dict = {"step": 1} @@ -165,10 +149,10 @@ def test_wind_meso_to_power_power_setpoint_too_high(): def test_wind_meso_to_power_power_setpoint_applies(): """Test that turbine powers equal power setpoint when setpoint is very low.""" - test_h_dict = h_dict_wind.copy() + test_h_dict = copy.deepcopy(h_dict_wind) test_h_dict["wind_farm"]["floris_update_time_s"] = 1.0 - wind_sim = Wind_MesoToPower(test_h_dict) + wind_sim = WindFarm(test_h_dict) # Set very low power setpoint values that should definitely limit power output step_h_dict = {"step": 1} @@ -183,17 +167,17 @@ def test_wind_meso_to_power_power_setpoint_applies(): power_setpoint_values = [100.0, 200.0, 300.0] for i, (power, setpoint) in enumerate(zip(turbine_powers, power_setpoint_values)): - assert ( - power == setpoint - ), f"Turbine {i} power {power} should equal power setpoint {setpoint}" + assert power == setpoint, ( + f"Turbine {i} power {power} should equal power setpoint {setpoint}" + ) def test_wind_meso_to_power_get_initial_conditions_and_meta_data(): """Test that get_initial_conditions_and_meta_data adds correct metadata to h_dict.""" - wind_sim = Wind_MesoToPower(h_dict_wind) + wind_sim = WindFarm(h_dict_wind) # Create a copy of the input h_dict to avoid modifying the original - test_h_dict = h_dict_wind.copy() + test_h_dict = copy.deepcopy(h_dict_wind) # Call the method result = wind_sim.get_initial_conditions_and_meta_data(test_h_dict) @@ -205,16 +189,16 @@ def test_wind_meso_to_power_get_initial_conditions_and_meta_data(): assert "n_turbines" in result["wind_farm"] assert "capacity" in result["wind_farm"] assert "rated_turbine_power" in result["wind_farm"] - assert "wind_direction" in result["wind_farm"] - assert "wind_speed" in result["wind_farm"] + assert "wind_direction_mean" in result["wind_farm"] + assert "wind_speed_mean_background" in result["wind_farm"] assert "turbine_powers" in result["wind_farm"] # Verify the values match the wind_sim attributes assert result["wind_farm"]["n_turbines"] == wind_sim.n_turbines assert result["wind_farm"]["capacity"] == wind_sim.capacity assert result["wind_farm"]["rated_turbine_power"] == wind_sim.rated_turbine_power - assert result["wind_farm"]["wind_direction"] == wind_sim.wd_mat_mean[0] - assert result["wind_farm"]["wind_speed"] == wind_sim.ws_mat_mean[0] + assert result["wind_farm"]["wind_direction_mean"] == wind_sim.wd_mat_mean[0] + assert result["wind_farm"]["wind_speed_mean_background"] == wind_sim.ws_mat_mean[0] # Verify turbine_powers is a numpy array with correct length assert isinstance(result["wind_farm"]["turbine_powers"], np.ndarray) @@ -237,6 +221,14 @@ def test_wind_meso_to_power_regular_floris_updates(): # Create a temporary wind input file with constant conditions wind_data = { "time": [0, 1, 2, 3, 4, 5], + "time_utc": [ + "2018-05-10 12:31:00", + "2018-05-10 12:31:01", + "2018-05-10 12:31:02", + "2018-05-10 12:31:03", + "2018-05-10 12:31:04", + "2018-05-10 12:31:05", + ], "wd_mean": [270.0, 270.0, 270.0, 270.0, 270.0, 270.0], # Constant wind direction "ws_000": [10.0, 10.0, 10.0, 10.0, 10.0, 10.0], # Constant wind speed "ws_001": [10.0, 10.0, 10.0, 10.0, 10.0, 10.0], # Constant wind speed @@ -251,20 +243,22 @@ def test_wind_meso_to_power_regular_floris_updates(): try: # Create test h_dict with the temporary wind file - test_h_dict = h_dict_wind.copy() + test_h_dict = copy.deepcopy(h_dict_wind) test_h_dict["wind_farm"]["wind_input_filename"] = temp_wind_file test_h_dict["wind_farm"]["floris_update_time_s"] = 2.0 # Update every 2 seconds test_h_dict["starttime"] = 0.0 - test_h_dict["endtime"] = 6.0 # 6 steps (0, 1, 2, 3, 4, 5) + test_h_dict["endtime"] = 5.0 # 5 steps (0, 1, 2, 3, 4) + test_h_dict["starttime_utc"] = "2018-05-10 12:31:00" + test_h_dict["endtime_utc"] = "2018-05-10 12:31:05" test_h_dict["dt"] = 1.0 # Initialize wind simulation - wind_sim = Wind_MesoToPower(test_h_dict) + wind_sim = WindFarm(test_h_dict) - # Run 6 steps with constant power setpoints + # Run 5 steps with constant power setpoints floris_calc_counts = [] - for step in range(6): + for step in range(5): test_h_dict = {"step": step} test_h_dict["wind_farm"] = { "turbine_power_setpoints": np.array([5000.0, 5000.0, 5000.0]), @@ -275,7 +269,7 @@ def test_wind_meso_to_power_regular_floris_updates(): # Verify that FLORIS calculations happen at regular intervals # Should have initial calculation + updates at steps 0, 2, 4 (every 2 seconds) - expected_calcs = [2, 2, 3, 3, 4, 4] # Initial + updates at steps 0, 2, 4 + expected_calcs = [2, 2, 3, 3, 4] # Initial + updates at steps 0, 2, 4 assert floris_calc_counts == expected_calcs finally: @@ -289,6 +283,14 @@ def test_wind_meso_to_power_power_setpoints_buffer(): # Create a temporary wind input file with constant conditions wind_data = { "time": [0, 1, 2, 3, 4, 5], + "time_utc": [ + "2018-05-10 12:31:00", + "2018-05-10 12:31:01", + "2018-05-10 12:31:02", + "2018-05-10 12:31:03", + "2018-05-10 12:31:04", + "2018-05-10 12:31:05", + ], "wd_mean": [270.0, 270.0, 270.0, 270.0, 270.0, 270.0], "ws_000": [10.0, 10.0, 10.0, 10.0, 10.0, 10.0], "ws_001": [10.0, 10.0, 10.0, 10.0, 10.0, 10.0], @@ -303,18 +305,20 @@ def test_wind_meso_to_power_power_setpoints_buffer(): try: # Create test h_dict with the temporary wind file - test_h_dict = h_dict_wind.copy() + test_h_dict = copy.deepcopy(h_dict_wind) test_h_dict["wind_farm"]["wind_input_filename"] = temp_wind_file test_h_dict["wind_farm"]["floris_update_time_s"] = 3.0 # 3-second buffer test_h_dict["starttime"] = 0.0 - test_h_dict["endtime"] = 6.0 + test_h_dict["endtime"] = 5.0 # 5 steps (0, 1, 2, 3, 4) + test_h_dict["starttime_utc"] = "2018-05-10 12:31:00" + test_h_dict["endtime_utc"] = "2018-05-10 12:31:05" test_h_dict["dt"] = 1.0 # Initialize wind simulation - wind_sim = Wind_MesoToPower(test_h_dict) + wind_sim = WindFarm(test_h_dict) # Run steps with varying power setpoints - for step in range(6): + for step in range(5): test_h_dict = {"step": step} # Use different power setpoints for each step power_setpoints = np.array( @@ -327,9 +331,9 @@ def test_wind_meso_to_power_power_setpoints_buffer(): test_h_dict = wind_sim.step(test_h_dict) # Verify that the buffer is working correctly - # The buffer should contain the last 3 power setpoint values + # The buffer should contain the last 3 power setpoint values (steps 2, 3, 4) assert wind_sim.turbine_power_setpoints_buffer.shape == (3, 3) # 3 steps, 3 turbines - assert wind_sim.turbine_power_setpoints_buffer_idx == 0 # Should wrap around + assert wind_sim.turbine_power_setpoints_buffer_idx == 2 # After 5 steps with buffer size 3 finally: # Clean up temporary file diff --git a/timing_tests/00_generate_wind_input.py b/timing_tests/00_generate_wind_input.py index b22eb9c4..8709f93a 100644 --- a/timing_tests/00_generate_wind_input.py +++ b/timing_tests/00_generate_wind_input.py @@ -44,9 +44,6 @@ def generate_wind_input( start_time = datetime(2020, 3, 1, 5, 0, 0) time_utc = [start_time + timedelta(minutes=int(t)) for t in time_minutes] - # Get time in seconds - time_seconds = time_minutes * 60 - # Generate base wind speed and direction using deterministic random walks ws_base = np.full(num_time_steps, base_wind_speed) wd_base = np.full(num_time_steps, base_wind_direction) @@ -83,7 +80,6 @@ def generate_wind_input( # Create the output dictionary wind_data = { - "time": time_seconds, "time_utc": time_utc, "wd_mean": wd_base, } diff --git a/timing_tests/01_generate_solar_input.py b/timing_tests/01_generate_solar_input.py index 634d18da..93612dcf 100644 --- a/timing_tests/01_generate_solar_input.py +++ b/timing_tests/01_generate_solar_input.py @@ -51,9 +51,6 @@ def generate_solar_input( temp_data = np.zeros(num_time_steps) # Temperature wind_speed_data = np.zeros(num_time_steps) # Wind Speed - # Get time in seconds - time_seconds = time_minutes * 60 - for i, (time_min, utc_time) in enumerate(zip(time_minutes, time_utc)): # Calculate solar position (simplified) hour = utc_time.hour + utc_time.minute / 60.0 @@ -125,7 +122,6 @@ def generate_solar_input( "SRRL BMS Dry Bulb Temperature (°C)": temp_data, "SRRL BMS Wind Speed at 19' (m/s)": wind_speed_data, "time_utc": time_utc, - "time": time_seconds, } ) diff --git a/timing_tests/02_plot_wind_solar_data.py b/timing_tests/02_plot_wind_solar_data.py index 855d1664..fa1152d5 100644 --- a/timing_tests/02_plot_wind_solar_data.py +++ b/timing_tests/02_plot_wind_solar_data.py @@ -38,8 +38,12 @@ def plot_wind_solar_data( # Create figure with subplots fig, axarr = plt.subplots(3, 1, figsize=(10, 8), sharex=True) + # Calculate time from time_utc + time = (wind_data["time_utc"] - wind_data["time_utc"].iloc[0]).dt.total_seconds() + wind_data["time"] = time + # Convert time to hours for better x-axis labels - time_hours = wind_data["time"] / 60.0 / 60.0 + time_hours = time / 60.0 / 60.0 # Plot 1: Wind speeds for selected turbines print(f"Plotting wind speeds for {num_wind_turbines_to_plot} turbines...") diff --git a/timing_tests/03_run_wind_timing_test.py b/timing_tests/03_run_wind_timing_test.py index 932e26cb..a555e3e9 100644 --- a/timing_tests/03_run_wind_timing_test.py +++ b/timing_tests/03_run_wind_timing_test.py @@ -5,15 +5,12 @@ power to 20 MW halfway through the simulation (at 500 minutes). """ -import os -import shutil import time import matplotlib.pyplot as plt import numpy as np -from hercules.emulator import Emulator -from hercules.hybrid_plant import HybridPlant -from hercules.utilities import load_hercules_input, setup_logging +from hercules.hercules_model import HerculesModel +from hercules.utilities_examples import prepare_output_directory from utilities import record_timing_result @@ -67,44 +64,31 @@ def main(): """Run the wind timing test with power curtailment.""" print("Starting wind timing test with power curtailment...") - # Clean up output directory - if os.path.exists("outputs"): - shutil.rmtree("outputs") - os.makedirs("outputs") - - # Setup logging - logger = setup_logging() - logger.info("Starting wind timing test with power curtailment") + # Prepare output directory + prepare_output_directory() # Load the input file input_file = "hercules_input_wind.yaml" - logger.info(f"Loading input file: {input_file}") - h_dict = load_hercules_input(input_file) - - # Record start time - start_time = time.time() - # Initialize the hybrid plant - hybrid_plant = HybridPlant(h_dict) + # Initialize the Hercules model + hmodel = HerculesModel(input_file) - # Add meta data - h_dict = hybrid_plant.add_plant_metadata_to_h_dict(h_dict) + # Instantiate the controller and assign to the Hercules model + hmodel.assign_controller(PowerCurtailmentController(hmodel.h_dict)) - # Initialize the controller - controller = PowerCurtailmentController(h_dict) - - # Initialize the emulator - emulator = Emulator(controller, hybrid_plant, h_dict, logger) + # Record start time + start_time = time.time() - # Run the emulator - logger.info("Starting emulator execution...") - emulator.enter_execution(function_targets=[], function_arguments=[[]]) + # Run the simulation + hmodel.logger.info("Starting simulation execution...") + hmodel.run() # Record end time end_time = time.time() execution_time = end_time - start_time - logger.info(f"Emulator execution completed in {execution_time:.2f} seconds") + hmodel.logger.info(f"Simulation execution completed in {execution_time:.2f} seconds") + hmodel.logger.info("Process completed successfully") # Record timing result result_file = "timing_results.csv" @@ -127,7 +111,10 @@ def main(): fig, ax = plt.subplots(figsize=(10, 6)) ax.plot(df_p["time"], df_p["wind_farm.power"]) ax.axvline( - x=controller.curtailment_time, color="red", linestyle="--", label="Curtailment Start" + x=hmodel.controller.curtailment_time, + color="red", + linestyle="--", + label="Curtailment Start", ) ax.legend() ax.set_xlabel("Time (s)") diff --git a/timing_tests/03b_run_wind_precom_timing_test.py b/timing_tests/03b_run_wind_precom_timing_test.py index 3c3323d0..10ffa539 100644 --- a/timing_tests/03b_run_wind_precom_timing_test.py +++ b/timing_tests/03b_run_wind_precom_timing_test.py @@ -7,15 +7,12 @@ Unlike 03_run_wind_timing_test.py, this script uses the precomputed FLORIS model. """ -import os -import shutil import time import matplotlib.pyplot as plt import numpy as np -from hercules.emulator import Emulator -from hercules.hybrid_plant import HybridPlant -from hercules.utilities import load_hercules_input, setup_logging +from hercules.hercules_model import HerculesModel +from hercules.utilities_examples import prepare_output_directory from utilities import record_timing_result @@ -69,44 +66,31 @@ def main(): """Run the wind timing test with power curtailment.""" print("Starting wind timing test with power curtailment...") - # Clean up output directory - if os.path.exists("outputs"): - shutil.rmtree("outputs") - os.makedirs("outputs") - - # Setup logging - logger = setup_logging() - logger.info("Starting wind timing test with power curtailment") + # Prepare output directory + prepare_output_directory() # Load the input file input_file = "hercules_input_wind_precom.yaml" - logger.info(f"Loading input file: {input_file}") - h_dict = load_hercules_input(input_file) - - # Record start time - start_time = time.time() - # Initialize the hybrid plant - hybrid_plant = HybridPlant(h_dict) + # Initialize the Hercules model + hmodel = HerculesModel(input_file) - # Add meta data - h_dict = hybrid_plant.add_plant_metadata_to_h_dict(h_dict) + # Instantiate the controller and assign to the Hercules model + hmodel.assign_controller(PowerCurtailmentController(hmodel.h_dict)) - # Initialize the controller - controller = PowerCurtailmentController(h_dict) - - # Initialize the emulator - emulator = Emulator(controller, hybrid_plant, h_dict, logger) + # Record start time + start_time = time.time() - # Run the emulator - logger.info("Starting emulator execution...") - emulator.enter_execution(function_targets=[], function_arguments=[[]]) + # Run the simulation + hmodel.logger.info("Starting simulation execution...") + hmodel.run() # Record end time end_time = time.time() execution_time = end_time - start_time - logger.info(f"Emulator execution completed in {execution_time:.2f} seconds") + hmodel.logger.info(f"Simulation execution completed in {execution_time:.2f} seconds") + hmodel.logger.info("Process completed successfully") # Record timing result result_file = "timing_results.csv" @@ -129,7 +113,10 @@ def main(): fig, ax = plt.subplots(figsize=(10, 6)) ax.plot(df_p["time"], df_p["wind_farm.power"]) ax.axvline( - x=controller.curtailment_time, color="red", linestyle="--", label="Curtailment Start" + x=hmodel.controller.curtailment_time, + color="red", + linestyle="--", + label="Curtailment Start", ) ax.legend() ax.set_xlabel("Time (s)") diff --git a/timing_tests/04_run_solar_timing_test.py b/timing_tests/04_run_solar_timing_test.py index 80156be6..849b70c7 100644 --- a/timing_tests/04_run_solar_timing_test.py +++ b/timing_tests/04_run_solar_timing_test.py @@ -5,14 +5,11 @@ power to 20 MW halfway through the simulation (at 500 minutes). """ -import os -import shutil import time import matplotlib.pyplot as plt -from hercules.emulator import Emulator -from hercules.hybrid_plant import HybridPlant -from hercules.utilities import load_hercules_input, setup_logging +from hercules.hercules_model import HerculesModel +from hercules.utilities_examples import prepare_output_directory from utilities import record_timing_result @@ -61,44 +58,31 @@ def main(): """Run the solar timing test with power curtailment.""" print("Starting solar timing test with power curtailment...") - # Clean up output directory - if os.path.exists("outputs"): - shutil.rmtree("outputs") - os.makedirs("outputs") - - # Setup logging - logger = setup_logging() - logger.info("Starting solar timing test with power curtailment") + # Prepare output directory + prepare_output_directory() # Load the input file input_file = "hercules_input_solar.yaml" - logger.info(f"Loading input file: {input_file}") - h_dict = load_hercules_input(input_file) - - # Record start time - start_time = time.time() - # Initialize the hybrid plant - hybrid_plant = HybridPlant(h_dict) + # Initialize the Hercules model + hmodel = HerculesModel(input_file) - # Add meta data - h_dict = hybrid_plant.add_plant_metadata_to_h_dict(h_dict) + # Instantiate the controller and assign to the Hercules model + hmodel.assign_controller(PowerCurtailmentController(hmodel.h_dict)) - # Initialize the controller - controller = PowerCurtailmentController(h_dict) - - # Initialize the emulator - emulator = Emulator(controller, hybrid_plant, h_dict, logger) + # Record start time + start_time = time.time() - # Run the emulator - logger.info("Starting emulator execution...") - emulator.enter_execution(function_targets=[], function_arguments=[[]]) + # Run the simulation + hmodel.logger.info("Starting simulation execution...") + hmodel.run() # Record end time end_time = time.time() execution_time = end_time - start_time - logger.info(f"Emulator execution completed in {execution_time:.2f} seconds") + hmodel.logger.info(f"Simulation execution completed in {execution_time:.2f} seconds") + hmodel.logger.info("Process completed successfully") # Record timing result result_file = "timing_results.csv" @@ -121,7 +105,10 @@ def main(): fig, ax = plt.subplots(figsize=(10, 6)) ax.plot(df_p["time"], df_p["solar_farm.power"]) ax.axvline( - x=controller.curtailment_time, color="red", linestyle="--", label="Curtailment Start" + x=hmodel.controller.curtailment_time, + color="red", + linestyle="--", + label="Curtailment Start", ) ax.legend() ax.set_xlabel("Time (s)") diff --git a/timing_tests/hercules_input_solar.yaml b/timing_tests/hercules_input_solar.yaml index 78bd34f8..27658594 100644 --- a/timing_tests/hercules_input_solar.yaml +++ b/timing_tests/hercules_input_solar.yaml @@ -4,12 +4,12 @@ name: example_00 ### -# Describe this emulator setup +# Describe this simulation setup description: Solar Farm Only dt: 1.0 -starttime: 0.0 -endtime: 43200.0 +starttime_utc: "2020-03-01T05:00:00Z" # Mar 1, 2020 05:00 UTC (Zulu time) +endtime_utc: "2020-03-01T17:00:00Z" # 12 hours later (17:00 UTC) verbose: False plant: @@ -24,6 +24,11 @@ solar_farm: system_capacity: 100000 # kW (100 MW) tilt: 0 # degrees losses: 0 + log_channels: + - power + - dni + - poa + - aoi initial_conditions: @@ -31,4 +36,4 @@ solar_farm: dni: 1000 poa: 1000 -controller: \ No newline at end of file +controller: diff --git a/timing_tests/hercules_input_wind.yaml b/timing_tests/hercules_input_wind.yaml index f172832d..018c80ff 100644 --- a/timing_tests/hercules_input_wind.yaml +++ b/timing_tests/hercules_input_wind.yaml @@ -4,12 +4,12 @@ name: example_00 ### -# Describe this emulator setup +# Describe this simulation setup description: Wind Farm Only dt: 1.0 -starttime: 0.0 -endtime: 43200.0 +starttime_utc: "2020-03-01T05:00:00Z" # Mar 1, 2020 05:00 UTC (Zulu time) +endtime_utc: "2020-03-01T17:00:00Z" # 12 hours later (17:00 UTC) verbose: False plant: @@ -17,12 +17,16 @@ plant: wind_farm: - component_type: Wind_MesoToPower + component_type: WindFarm floris_input_file: inputs/floris_input.yaml wind_input_filename: inputs/wind_input.p turbine_file_name: inputs/turbine_filter_model.yaml log_file_name: outputs/log_wind_sim.log - logging_option: base + log_channels: + - power + - wind_speed_mean_background + - wind_speed_mean_withwakes + - wind_direction_mean floris_update_time_s: 300.0 # Update wakes every 5 minutes diff --git a/timing_tests/hercules_input_wind_precom.yaml b/timing_tests/hercules_input_wind_precom.yaml index 41a193b4..525ab446 100644 --- a/timing_tests/hercules_input_wind_precom.yaml +++ b/timing_tests/hercules_input_wind_precom.yaml @@ -4,12 +4,12 @@ name: example_00 ### -# Describe this emulator setup +# Describe this simulation setup description: Wind Farm Only dt: 1.0 -starttime: 0.0 -endtime: 43200.0 +starttime_utc: "2020-03-01T05:00:00Z" # Mar 1, 2020 05:00 UTC (Zulu time) +endtime_utc: "2020-03-01T17:00:00Z" # 12 hours later (17:00 UTC) verbose: False log_every_n: 1 @@ -18,12 +18,16 @@ plant: wind_farm: - component_type: Wind_MesoToPowerPrecomFloris + component_type: WindFarm floris_input_file: inputs/floris_input.yaml wind_input_filename: inputs/wind_input.p turbine_file_name: inputs/turbine_filter_model.yaml log_file_name: outputs/log_wind_sim.log - logging_option: base + log_channels: + - power + - wind_speed_mean_background + - wind_speed_mean_withwakes + - wind_direction_mean floris_update_time_s: 300.0 # FLORIS updates every 5 minutes diff --git a/timing_tests/timing_results.csv b/timing_tests/timing_results.csv index 993afd10..0fddd12c 100644 --- a/timing_tests/timing_results.csv +++ b/timing_tests/timing_results.csv @@ -60,3 +60,13 @@ wind_precom,1.4046452045440674,,d0b65f8e,v2,2025-08-28T14:52:40.968804,arm wind_precom,1.4467580318450928,,d0b65f8e,v2,2025-08-28T15:16:33.303801,arm wind_precom,1.4958651065826416,,d0b65f8e,v2,2025-08-28T15:22:49.645404,arm wind_precom,1.2841532230377197,,229519c4,v2,2025-09-03T09:09:49.964979,arm +wind_precom,1.3886890411376953,,7b471132,feature/time_utc_as_input,2025-10-30T17:58:38.775299,arm +wind_precom,1.3157711029052734,,c13d1e47,feature/reorg_log_channels_v2,2025-10-29T13:18:30.472552,arm +wind_precom,1.4151639938354492,,2dc18b42,feature/reorg_log_channels_v2,2025-10-29T13:44:40.264003,arm +wind_precom,1.3030691146850586,,c829b976,feature/reorg_log_channels_v2,2025-10-29T14:31:48.111826,arm +wind_precom,2.321347951889038,,c829b976,feature/reorg_log_channels_v2,2025-10-29T15:04:32.015310,arm +wind_precom,1.3096461296081543,,c829b976,feature/reorg_log_channels_v2,2025-10-29T15:04:59.714680,arm +wind_precom,1.339259147644043,,01cc59f6,refactor/hercules_model,2025-11-03T10:57:33.285941,arm +wind_precom,1.3076751232147217,,d39d158f,pr/paulf81/169,2025-11-03T14:45:34.572135,arm +wind_precom,0.9631860256195068,,589a2379,pr/paulf81/169,2025-11-03T16:37:30.852842,arm +wind_precom,0.9624876976013184,,589a2379,pr/paulf81/169,2025-11-03T16:37:41.141847,arm