Improve Configuration and Prediction Usability (#220)

* Update utilities in utils submodule.
* Add base configuration modules.
* Add server base configuration modules.
* Add devices base configuration modules.
* Add optimization base configuration modules.
* Add utils base configuration modules.
* Add prediction abstract and base classes plus tests.
* Add PV forecast to prediction submodule.
   The PV forecast modules are adapted from the class_pvforecast module and
   replace it.
* Add weather forecast to prediction submodule.
   The modules provide classes and methods to retrieve, manage, and process weather forecast data
   from various sources. Includes are structured representations of weather data and utilities
   for fetching forecasts for specific locations and time ranges.
   BrightSky and ClearOutside are currently supported.
* Add electricity price forecast to prediction submodule.
* Adapt fastapi server to base config and add fasthtml server.
* Add ems to core submodule.
* Adapt genetic to config.
* Adapt visualize to config.
* Adapt common test fixtures to config.
* Add load forecast to prediction submodule.
* Add core abstract and base classes.
* Adapt single test optimization to config.
* Adapt devices to config.

Signed-off-by: Bobby Noelte <b0661n0e17e@gmail.com>
This commit is contained in:
Bobby Noelte
2024-12-15 14:40:03 +01:00
committed by GitHub
parent a5e637ab4c
commit aa334d0b61
80 changed files with 29048 additions and 2451 deletions

View File

@@ -1,31 +1,41 @@
import logging
import os
import shutil
import subprocess
import sys
import time
import tempfile
from pathlib import Path
from typing import Optional
import pendulum
import platformdirs
import pytest
from xprocess import ProcessStarter
from akkudoktoreos.config import EOS_DIR, AppConfig, load_config
from akkudoktoreos.config.config import get_config
from akkudoktoreos.utils.logutil import get_logger
logger = get_logger(__name__)
@pytest.fixture(name="tmp_config")
def load_config_tmp(tmp_path: Path) -> AppConfig:
"""Creates an AppConfig from default.config.json with a tmp output directory."""
config = load_config(tmp_path)
config.directories.output = str(tmp_path)
return config
@pytest.fixture()
def disable_debug_logging(scope="session", autouse=True):
"""Automatically disable debug logging for all tests."""
original_levels = {}
root_logger = logging.getLogger()
original_levels[root_logger] = root_logger.level
root_logger.setLevel(logging.INFO)
for logger_name, logger in logging.root.manager.loggerDict.items():
if isinstance(logger, logging.Logger):
original_levels[logger] = logger.level
if logger.level <= logging.DEBUG:
logger.setLevel(logging.INFO)
@pytest.fixture(autouse=True)
def disable_debug_logging():
# Temporarily set logging level higher than DEBUG
logging.disable(logging.DEBUG)
yield
# Re-enable logging back to its original state after the test
logging.disable(logging.NOTSET)
for logger, level in original_levels.items():
logger.setLevel(level)
def pytest_addoption(parser):
@@ -39,6 +49,84 @@ def is_full_run(request):
yield bool(request.config.getoption("--full-run"))
@pytest.fixture
def reset_config(disable_debug_logging):
"""Fixture to reset EOS config to default values."""
config_eos = get_config()
config_eos.reset_settings()
config_eos.reset_to_defaults()
return config_eos
@pytest.fixture
def config_default_dirs():
"""Fixture that provides a list of directories to be used as config dir."""
config_eos = get_config()
# Default config directory from platform user config directory
config_default_dir_user = Path(platformdirs.user_config_dir(config_eos.APP_NAME))
# Default config directory from current working directory
config_default_dir_cwd = Path.cwd()
# Default config directory from default config file
config_default_dir_default = Path(__file__).parent.parent.joinpath("src/akkudoktoreos/data")
return config_default_dir_user, config_default_dir_cwd, config_default_dir_default
@pytest.fixture
def stash_config_file(config_default_dirs):
"""Fixture to temporarily stash away an existing config file during a test.
If the specified config file exists, it moves the file to a temporary directory.
The file is restored to its original location after the test.
Keep right most in fixture parameter list to assure application at last.
Returns:
Path: Path to the stashed config file.
"""
config_eos = get_config()
config_default_dir_user, config_default_dir_cwd, _ = config_default_dirs
config_file_path_user = config_default_dir_user.joinpath(config_eos.CONFIG_FILE_NAME)
config_file_path_cwd = config_default_dir_cwd.joinpath(config_eos.CONFIG_FILE_NAME)
original_config_file_user = None
original_config_file_cwd = None
if config_file_path_user.exists():
original_config_file_user = config_file_path_user
if config_file_path_cwd.exists():
original_config_file_cwd = config_file_path_cwd
temp_dir = tempfile.TemporaryDirectory()
temp_file_user = None
temp_file_cwd = None
# If the file exists, move it to the temporary directory
if original_config_file_user:
temp_file_user = Path(temp_dir.name) / f"user.{original_config_file_user.name}"
shutil.move(original_config_file_user, temp_file_user)
assert not original_config_file_user.exists()
logger.debug(f"Stashed: '{original_config_file_user}'")
if original_config_file_cwd:
temp_file_cwd = Path(temp_dir.name) / f"cwd.{original_config_file_cwd.name}"
shutil.move(original_config_file_cwd, temp_file_cwd)
assert not original_config_file_cwd.exists()
logger.debug(f"Stashed: '{original_config_file_cwd}'")
# Yield the temporary file path to the test
yield temp_file_user, temp_file_cwd
# Cleanup after the test
if temp_file_user:
# Restore the file to its original location
shutil.move(temp_file_user, original_config_file_user)
assert original_config_file_user.exists()
if temp_file_cwd:
# Restore the file to its original location
shutil.move(temp_file_cwd, original_config_file_cwd)
assert original_config_file_cwd.exists()
temp_dir.cleanup()
@pytest.fixture
def server(xprocess, tmp_path: Path):
"""Fixture to start the server.
@@ -50,13 +138,13 @@ def server(xprocess, tmp_path: Path):
# assure server to be installed
try:
subprocess.run(
[sys.executable, "-c", "import akkudoktoreos.server"],
[sys.executable, "-c", "import akkudoktoreos.server.fastapi_server"],
check=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
except subprocess.CalledProcessError:
project_dir = Path(__file__).parent.parent
project_dir = Path(__file__).parent.parent.parent
subprocess.run(
[sys.executable, "-m", "pip", "install", "-e", project_dir],
check=True,
@@ -66,11 +154,15 @@ def server(xprocess, tmp_path: Path):
# command to start server process
args = [sys.executable, "-m", "akkudoktoreos.server.fastapi_server"]
env = {EOS_DIR: f"{tmp_path}", **os.environ.copy()}
config_eos = get_config()
settings = {
"data_folder_path": tmp_path,
}
config_eos.merge_settings_from_dict(settings)
# startup pattern
pattern = "Application startup complete."
# search the first 30 lines for the startup pattern, if not found
# search this number of lines for the startup pattern, if not found
# a RuntimeError will be raised informing the user
max_read_lines = 30
@@ -81,7 +173,7 @@ def server(xprocess, tmp_path: Path):
terminate_on_interrupt = True
# ensure process is running and return its logfile
logfile = xprocess.ensure("eos", Starter)
pid, logfile = xprocess.ensure("eos", Starter)
# create url/port info to the server
url = "http://127.0.0.1:8503"
@@ -92,26 +184,26 @@ def server(xprocess, tmp_path: Path):
@pytest.fixture
def other_timezone():
"""Fixture to temporarily change the timezone.
def set_other_timezone():
"""Temporarily sets a timezone for Pendulum during a test.
Restores the original timezone after the test.
Resets to the original timezone after the test completes.
"""
original_tz = os.environ.get("TZ", None)
original_timezone = pendulum.local_timezone()
other_tz = "Atlantic/Canary"
if original_tz == other_tz:
other_tz = "Asia/Singapore"
default_other_timezone = "Atlantic/Canary"
if default_other_timezone == original_timezone:
default_other_timezone = "Asia/Singapore"
# Change the timezone to another
os.environ["TZ"] = other_tz
time.tzset() # For Unix/Linux to apply the timezone change
def _set_timezone(other_timezone: Optional[str] = None) -> str:
if other_timezone is None:
other_timezone = default_other_timezone
pendulum.set_local_timezone(other_timezone)
assert pendulum.local_timezone() == other_timezone
return other_timezone
yield os.environ["TZ"] # Yield control back to the test case
yield _set_timezone
# Restore the original timezone after the test
if original_tz:
os.environ["TZ"] = original_tz
else:
del os.environ["TZ"]
time.tzset() # Re-apply the original timezone
# Restore the original timezone
pendulum.set_local_timezone(original_timezone)
assert pendulum.local_timezone() == original_timezone

View File

@@ -7,7 +7,7 @@ from time import sleep
import pytest
from akkudoktoreos.utils.cachefilestore import CacheFileStore, cache_in_file
from akkudoktoreos.utils.cacheutil import CacheFileStore, cache_in_file
from akkudoktoreos.utils.datetimeutil import to_datetime
# -----------------------------
@@ -268,7 +268,7 @@ def test_cache_in_file_decorator_uses_cache(cache_store):
assert result == result2
def test_cache_in_file_decorator_forces_update(cache_store):
def test_cache_in_file_decorator_forces_update_data(cache_store):
"""Test that the cache_in_file decorator reuses cached file on subsequent calls."""
# Clear store to assure it is empty
cache_store.clear(clear_all=True)

View File

@@ -1,29 +1,33 @@
import numpy as np
import pytest
from akkudoktoreos.config import AppConfig
from akkudoktoreos.devices.battery import EAutoParameters, PVAkku, PVAkkuParameters
from akkudoktoreos.devices.generic import HomeAppliance, HomeApplianceParameters
from akkudoktoreos.devices.inverter import Wechselrichter, WechselrichterParameters
from akkudoktoreos.prediction.ems import (
from akkudoktoreos.config.config import get_config
from akkudoktoreos.core.ems import (
EnergieManagementSystem,
EnergieManagementSystemParameters,
SimulationResult,
get_ems,
)
from akkudoktoreos.devices.battery import EAutoParameters, PVAkku, PVAkkuParameters
from akkudoktoreos.devices.generic import HomeAppliance, HomeApplianceParameters
from akkudoktoreos.devices.inverter import Wechselrichter, WechselrichterParameters
prediction_hours = 48
optimization_hours = 24
start_hour = 1
# Example initialization of necessary components
@pytest.fixture
def create_ems_instance(tmp_config: AppConfig) -> EnergieManagementSystem:
def create_ems_instance() -> EnergieManagementSystem:
"""Fixture to create an EnergieManagementSystem instance with given test parameters."""
# Assure configuration holds the correct values
config_eos = get_config()
config_eos.merge_settings_from_dict({"prediction_hours": 48, "optimization_hours": 24})
assert config_eos.prediction_hours is not None
# Initialize the battery and the inverter
akku = PVAkku(
PVAkkuParameters(kapazitaet_wh=5000, start_soc_prozent=80, min_soc_prozent=10),
hours=prediction_hours,
hours=config_eos.prediction_hours,
)
akku.reset()
wechselrichter = Wechselrichter(WechselrichterParameters(max_leistung_wh=10000), akku)
@@ -34,16 +38,16 @@ def create_ems_instance(tmp_config: AppConfig) -> EnergieManagementSystem:
consumption_wh=2000,
duration_h=2,
),
hours=prediction_hours,
hours=config_eos.prediction_hours,
)
home_appliance.set_starting_time(2)
# Example initialization of electric car battery
eauto = PVAkku(
EAutoParameters(kapazitaet_wh=26400, start_soc_prozent=10, min_soc_prozent=10),
hours=prediction_hours,
hours=config_eos.prediction_hours,
)
eauto.set_charge_per_hour(np.full(prediction_hours, 1))
eauto.set_charge_per_hour(np.full(config_eos.prediction_hours, 1))
# Parameters based on previous example data
pv_prognose_wh = [
@@ -203,8 +207,8 @@ def create_ems_instance(tmp_config: AppConfig) -> EnergieManagementSystem:
]
# Initialize the energy management system with the respective parameters
ems = EnergieManagementSystem(
tmp_config.eos,
ems = get_ems()
ems.set_parameters(
EnergieManagementSystemParameters(
pv_prognose_wh=pv_prognose_wh,
strompreis_euro_pro_wh=strompreis_euro_pro_wh,

View File

@@ -1,28 +1,32 @@
import numpy as np
import pytest
from akkudoktoreos.config import AppConfig
from akkudoktoreos.config.config import get_config
from akkudoktoreos.core.ems import (
EnergieManagementSystem,
EnergieManagementSystemParameters,
get_ems,
)
from akkudoktoreos.devices.battery import EAutoParameters, PVAkku, PVAkkuParameters
from akkudoktoreos.devices.generic import HomeAppliance, HomeApplianceParameters
from akkudoktoreos.devices.inverter import Wechselrichter, WechselrichterParameters
from akkudoktoreos.prediction.ems import (
EnergieManagementSystem,
EnergieManagementSystemParameters,
)
prediction_hours = 48
optimization_hours = 24
start_hour = 0
# Example initialization of necessary components
@pytest.fixture
def create_ems_instance(tmp_config: AppConfig) -> EnergieManagementSystem:
def create_ems_instance() -> EnergieManagementSystem:
"""Fixture to create an EnergieManagementSystem instance with given test parameters."""
# Assure configuration holds the correct values
config_eos = get_config()
config_eos.merge_settings_from_dict({"prediction_hours": 48, "optimization_hours": 24})
assert config_eos.prediction_hours is not None
# Initialize the battery and the inverter
akku = PVAkku(
PVAkkuParameters(kapazitaet_wh=5000, start_soc_prozent=80, min_soc_prozent=10),
hours=prediction_hours,
hours=config_eos.prediction_hours,
)
akku.reset()
wechselrichter = Wechselrichter(WechselrichterParameters(max_leistung_wh=10000), akku)
@@ -33,27 +37,28 @@ def create_ems_instance(tmp_config: AppConfig) -> EnergieManagementSystem:
consumption_wh=2000,
duration_h=2,
),
hours=prediction_hours,
hours=config_eos.prediction_hours,
)
home_appliance.set_starting_time(2)
# Example initialization of electric car battery
eauto = PVAkku(
EAutoParameters(kapazitaet_wh=26400, start_soc_prozent=100, min_soc_prozent=100),
hours=prediction_hours,
hours=config_eos.prediction_hours,
)
# Parameters based on previous example data
pv_prognose_wh = [0.0] * prediction_hours
pv_prognose_wh = [0.0] * config_eos.prediction_hours
pv_prognose_wh[10] = 5000.0
pv_prognose_wh[11] = 5000.0
strompreis_euro_pro_wh = [0.001] * prediction_hours
strompreis_euro_pro_wh = [0.001] * config_eos.prediction_hours
strompreis_euro_pro_wh[0:10] = [0.00001] * 10
strompreis_euro_pro_wh[11:15] = [0.00005] * 4
strompreis_euro_pro_wh[20] = 0.00001
einspeiseverguetung_euro_pro_wh = [0.00007] * len(strompreis_euro_pro_wh)
preis_euro_pro_wh_akku = 0.0001
gesamtlast = [
676.71,
@@ -107,13 +112,13 @@ def create_ems_instance(tmp_config: AppConfig) -> EnergieManagementSystem:
]
# Initialize the energy management system with the respective parameters
ems = EnergieManagementSystem(
tmp_config.eos,
ems = get_ems()
ems.set_parameters(
EnergieManagementSystemParameters(
pv_prognose_wh=pv_prognose_wh,
strompreis_euro_pro_wh=strompreis_euro_pro_wh,
einspeiseverguetung_euro_pro_wh=einspeiseverguetung_euro_pro_wh,
preis_euro_pro_wh_akku=0,
preis_euro_pro_wh_akku=preis_euro_pro_wh_akku,
gesamtlast=gesamtlast,
),
wechselrichter=wechselrichter,
@@ -121,10 +126,10 @@ def create_ems_instance(tmp_config: AppConfig) -> EnergieManagementSystem:
home_appliance=home_appliance,
)
ac = np.full(prediction_hours, 0)
ac = np.full(config_eos.prediction_hours, 0.0)
ac[20] = 1
ems.set_akku_ac_charge_hours(ac)
dc = np.full(prediction_hours, 0)
dc = np.full(config_eos.prediction_hours, 0.0)
dc[11] = 1
ems.set_akku_dc_charge_hours(dc)

View File

@@ -5,7 +5,7 @@ from unittest.mock import patch
import pytest
from akkudoktoreos.config import AppConfig
from akkudoktoreos.config.config import get_config
from akkudoktoreos.optimization.genetic import (
OptimizationParameters,
OptimizeResponse,
@@ -39,14 +39,13 @@ def compare_dict(actual: dict[str, Any], expected: dict[str, Any]):
)
@patch("akkudoktoreos.optimization.genetic.visualisiere_ergebnisse")
def test_optimize(
visualisiere_ergebnisse_patch,
fn_in: str,
fn_out: str,
ngen: int,
is_full_run: bool,
tmp_config: AppConfig,
visualisiere_ergebnisse_patch, fn_in: str, fn_out: str, ngen: int, is_full_run: bool
):
"""Test optimierung_ems."""
# Assure configuration holds the correct values
config_eos = get_config()
config_eos.merge_settings_from_dict({"prediction_hours": 48, "optimization_hours": 24})
# Load input and output data
file = DIR_TESTDATA / fn_in
with file.open("r") as f_in:
@@ -56,7 +55,7 @@ def test_optimize(
with file.open("r") as f_out:
expected_result = OptimizeResponse(**json.load(f_out))
opt_class = optimization_problem(tmp_config, fixed_seed=42)
opt_class = optimization_problem(fixed_seed=42)
start_hour = 10
if ngen > 10 and not is_full_run:

View File

@@ -1,71 +1,112 @@
import json
import os
import shutil
import tempfile
from pathlib import Path
import pytest
from pydantic import ValidationError
from akkudoktoreos.config import (
CONFIG_FILE_NAME,
DEFAULT_CONFIG_FILE,
get_config_file,
load_config,
)
from akkudoktoreos.config.config import ConfigEOS, get_config
from akkudoktoreos.utils.logutil import get_logger
logger = get_logger(__name__)
config_eos = get_config()
DIR_TESTDATA = Path(__file__).absolute().parent.joinpath("testdata")
FILE_TESTDATA_CONFIGEOS_1_JSON = DIR_TESTDATA.joinpath(config_eos.CONFIG_FILE_NAME)
FILE_TESTDATA_CONFIGEOS_1_DIR = FILE_TESTDATA_CONFIGEOS_1_JSON.parent
def test_config() -> None:
"""Test the default config file."""
try:
load_config(Path.cwd())
except ValidationError as exc:
pytest.fail(f"Default configuration is not valid: {exc}")
@pytest.fixture
def reset_config_singleton():
"""Fixture to reset the ConfigEOS singleton instance before a test."""
ConfigEOS.reset_instance()
yield
ConfigEOS.reset_instance()
def test_config_copy(tmp_path: Path) -> None:
"""Test if the config is copied to the provided path."""
assert DEFAULT_CONFIG_FILE == get_config_file(Path("does", "not", "exist"), False)
def test_fixture_stash_config_file(stash_config_file, config_default_dirs):
"""Assure fixture stash_config_file is working."""
config_default_dir_user, config_default_dir_cwd, _ = config_default_dirs
load_config(tmp_path, True)
expected_config = tmp_path.joinpath(CONFIG_FILE_NAME)
config_file_path_user = config_default_dir_user.joinpath(config_eos.CONFIG_FILE_NAME)
config_file_path_cwd = config_default_dir_cwd.joinpath(config_eos.CONFIG_FILE_NAME)
assert expected_config == get_config_file(tmp_path, False)
assert expected_config.is_file()
assert not config_file_path_user.exists()
assert not config_file_path_cwd.exists()
def test_config_merge(tmp_path: Path) -> None:
"""Test if config is merged and updated correctly."""
config_file = tmp_path.joinpath(CONFIG_FILE_NAME)
custom_config = {
"eos": {
"optimization_hours": 30,
"penalty": 21,
"does_not_exist": "nope",
"available_charging_rates_in_percentage": "False entry",
def test_config_constants():
"""Test config constants are the way expected by the tests."""
assert config_eos.APP_NAME == "net.akkudoktor.eos"
assert config_eos.APP_AUTHOR == "akkudoktor"
assert config_eos.EOS_DIR == "EOS_DIR"
assert config_eos.ENCODING == "UTF-8"
assert config_eos.CONFIG_FILE_NAME == "EOS.config.json"
def test_computed_paths(reset_config):
"""Test computed paths for output and cache."""
config_eos.merge_settings_from_dict(
{
"data_folder_path": "/base/data",
"data_output_subpath": "output",
"data_cache_subpath": "cache",
}
}
with config_file.open("w") as f_out:
json.dump(custom_config, f_out)
assert config_file.exists()
with pytest.raises(ValueError):
# custom configuration is broken but not updated.
load_config(tmp_path, True, False)
with config_file.open("r") as f_in:
# custom configuration is not changed.
assert json.load(f_in) == custom_config
config = load_config(tmp_path)
assert config.eos.optimization_hours == 30
assert config.eos.penalty == 21
)
assert config_eos.data_output_path == Path("/base/data/output")
assert config_eos.data_cache_path == Path("/base/data/cache")
def test_setup(tmp_path: Path) -> None:
"""Test setup."""
config = load_config(tmp_path, True)
config.run_setup()
def test_singleton_behavior(reset_config_singleton):
"""Test that ConfigEOS behaves as a singleton."""
instance1 = ConfigEOS()
instance2 = ConfigEOS()
assert instance1 is instance2
assert tmp_path.joinpath(CONFIG_FILE_NAME).is_file()
assert tmp_path.joinpath(config.directories.cache).is_dir()
assert tmp_path.joinpath(config.directories.output).is_dir()
def test_default_config_path(reset_config, config_default_dirs, stash_config_file):
"""Test that the default config file path is computed correctly."""
_, _, config_default_dir_default = config_default_dirs
expected_path = config_default_dir_default.joinpath("default.config.json")
assert config_eos.config_default_file_path == expected_path
assert config_eos.config_default_file_path.is_file()
def test_config_folder_path(reset_config, config_default_dirs, stash_config_file, monkeypatch):
"""Test that _config_folder_path identifies the correct config directory or None."""
config_default_dir_user, _, _ = config_default_dirs
# All config files are stashed away, no config folder path
assert config_eos._config_folder_path() is None
config_file_user = config_default_dir_user.joinpath(config_eos.CONFIG_FILE_NAME)
shutil.copy2(config_eos.config_default_file_path, config_file_user)
assert config_eos._config_folder_path() == config_default_dir_user
monkeypatch.setenv("EOS_DIR", str(FILE_TESTDATA_CONFIGEOS_1_DIR))
assert config_eos._config_folder_path() == FILE_TESTDATA_CONFIGEOS_1_DIR
# Cleanup after the test
os.remove(config_file_user)
def test_config_copy(reset_config, stash_config_file, monkeypatch):
"""Test if the config is copied to the provided path."""
temp_dir = tempfile.TemporaryDirectory()
temp_folder_path = Path(temp_dir.name)
temp_config_file_path = temp_folder_path.joinpath(config_eos.CONFIG_FILE_NAME).resolve()
monkeypatch.setenv(config_eos.EOS_DIR, str(temp_folder_path))
if temp_config_file_path.exists():
temp_config_file_path.unlink()
assert not temp_config_file_path.exists()
assert config_eos._config_folder_path() is None
assert config_eos._config_file_path() == temp_config_file_path
config_eos.from_config_file()
assert temp_config_file_path.exists()
# Cleanup after the test
temp_dir.cleanup()

94
tests/test_configabc.py Normal file
View File

@@ -0,0 +1,94 @@
from typing import List, Literal, Optional, no_type_check
import pytest
from pydantic import Field, ValidationError
from akkudoktoreos.config.configabc import SettingsBaseModel
class SettingsModel(SettingsBaseModel):
name: str = "Default Name"
age: int = 18
tags: List[str] = Field(default_factory=list)
readonly_field: Literal["ReadOnly"] = "ReadOnly" # Use Literal instead of const
def test_reset_to_defaults():
"""Test resetting to default values."""
instance = SettingsModel(name="Custom Name", age=25, tags=["tag1", "tag2"])
# Modify the instance
instance.name = "Modified Name"
instance.age = 30
instance.tags.append("tag3")
# Ensure the instance is modified
assert instance.name == "Modified Name"
assert instance.age == 30
assert instance.tags == ["tag1", "tag2", "tag3"]
# Reset to defaults
instance.reset_to_defaults()
# Verify default values
assert instance.name == "Default Name"
assert instance.age == 18
assert instance.tags == []
assert instance.readonly_field == "ReadOnly"
@no_type_check
def test_reset_to_defaults_readonly_field():
"""Ensure read-only fields remain unchanged."""
instance = SettingsModel()
# Attempt to modify readonly_field (should raise an error)
with pytest.raises(ValidationError):
instance.readonly_field = "New Value"
# Reset to defaults
instance.reset_to_defaults()
# Ensure readonly_field is still at its default value
assert instance.readonly_field == "ReadOnly"
def test_reset_to_defaults_with_default_factory():
"""Test reset with fields having default_factory."""
class FactoryModel(SettingsBaseModel):
items: List[int] = Field(default_factory=lambda: [1, 2, 3])
value: Optional[int] = None
instance = FactoryModel(items=[4, 5, 6], value=10)
# Ensure instance has custom values
assert instance.items == [4, 5, 6]
assert instance.value == 10
# Reset to defaults
instance.reset_to_defaults()
# Verify reset values
assert instance.items == [1, 2, 3]
assert instance.value is None
@no_type_check
def test_reset_to_defaults_error_handling():
"""Ensure reset_to_defaults skips fields that cannot be set."""
class ReadOnlyModel(SettingsBaseModel):
readonly_field: Literal["ReadOnly"] = "ReadOnly"
instance = ReadOnlyModel()
# Attempt to modify readonly_field (should raise an error)
with pytest.raises(ValidationError):
instance.readonly_field = "New Value"
# Reset to defaults
instance.reset_to_defaults()
# Ensure readonly_field is unaffected
assert instance.readonly_field == "ReadOnly"

667
tests/test_dataabc.py Normal file
View File

@@ -0,0 +1,667 @@
from datetime import datetime, timezone
from typing import Any, ClassVar, List, Optional, Union
import numpy as np
import pandas as pd
import pendulum
import pytest
from pydantic import Field, ValidationError
from akkudoktoreos.config.configabc import SettingsBaseModel
from akkudoktoreos.core.dataabc import (
DataBase,
DataContainer,
DataImportProvider,
DataProvider,
DataRecord,
DataSequence,
)
from akkudoktoreos.core.ems import get_ems
from akkudoktoreos.utils.datetimeutil import compare_datetimes, to_datetime, to_duration
# Derived classes for testing
# ---------------------------
class DerivedConfig(SettingsBaseModel):
env_var: Optional[int] = Field(default=None, description="Test config by environment var")
instance_field: Optional[str] = Field(default=None, description="Test config by instance field")
class_constant: Optional[int] = Field(default=None, description="Test config by class constant")
class DerivedBase(DataBase):
instance_field: Optional[str] = Field(default=None, description="Field Value")
class_constant: ClassVar[int] = 30
class DerivedRecord(DataRecord):
data_value: Optional[float] = Field(default=None, description="Data Value")
class DerivedSequence(DataSequence):
# overload
records: List[DerivedRecord] = Field(
default_factory=list, description="List of DerivedRecord records"
)
@classmethod
def record_class(cls) -> Any:
return DerivedRecord
class DerivedDataProvider(DataProvider):
"""A concrete subclass of DataProvider for testing purposes."""
# overload
records: List[DerivedRecord] = Field(
default_factory=list, description="List of DerivedRecord records"
)
provider_enabled: ClassVar[bool] = False
provider_updated: ClassVar[bool] = False
@classmethod
def record_class(cls) -> Any:
return DerivedRecord
# Implement abstract methods for test purposes
def provider_id(self) -> str:
return "DerivedDataProvider"
def enabled(self) -> bool:
return self.provider_enabled
def _update_data(self, force_update: Optional[bool] = False) -> None:
# Simulate update logic
DerivedDataProvider.provider_updated = True
class DerivedDataImportProvider(DataImportProvider):
"""A concrete subclass of DataImportProvider for testing purposes."""
# overload
records: List[DerivedRecord] = Field(
default_factory=list, description="List of DerivedRecord records"
)
provider_enabled: ClassVar[bool] = False
provider_updated: ClassVar[bool] = False
@classmethod
def record_class(cls) -> Any:
return DerivedRecord
# Implement abstract methods for test purposes
def provider_id(self) -> str:
return "DerivedDataImportProvider"
def enabled(self) -> bool:
return self.provider_enabled
def _update_data(self, force_update: Optional[bool] = False) -> None:
# Simulate update logic
DerivedDataImportProvider.provider_updated = True
class DerivedDataContainer(DataContainer):
providers: List[Union[DerivedDataProvider, DataProvider]] = Field(
default_factory=list, description="List of data providers"
)
# Tests
# ----------
class TestDataBase:
@pytest.fixture
def base(self, reset_config, monkeypatch):
# Provide default values for configuration
derived = DerivedBase()
derived.config.update()
return derived
def test_get_config_value_key_error(self, base):
with pytest.raises(AttributeError):
base.config.non_existent_key
class TestDataRecord:
def create_test_record(self, date, value):
"""Helper function to create a test DataRecord."""
return DerivedRecord(date_time=date, data_value=value)
def test_getitem(self):
record = self.create_test_record(datetime(2024, 1, 3, tzinfo=timezone.utc), 10.0)
assert record["data_value"] == 10.0
def test_setitem(self):
record = self.create_test_record(datetime(2024, 1, 3, tzinfo=timezone.utc), 10.0)
record["data_value"] = 20.0
assert record.data_value == 20.0
def test_delitem(self):
record = self.create_test_record(datetime(2024, 1, 3, tzinfo=timezone.utc), 10.0)
record.data_value = 20.0
del record["data_value"]
assert record.data_value is None
def test_len(self):
record = self.create_test_record(datetime(2024, 1, 3, tzinfo=timezone.utc), 10.0)
record.date_time = None
record.data_value = 20.0
assert len(record) == 2
def test_to_dict(self):
record = self.create_test_record(datetime(2024, 1, 3, tzinfo=timezone.utc), 10.0)
record.data_value = 20.0
record_dict = record.to_dict()
assert "data_value" in record_dict
assert record_dict["data_value"] == 20.0
record2 = DerivedRecord.from_dict(record_dict)
assert record2 == record
def test_to_json(self):
record = self.create_test_record(datetime(2024, 1, 3, tzinfo=timezone.utc), 10.0)
record.data_value = 20.0
json_str = record.to_json()
assert "data_value" in json_str
assert "20.0" in json_str
record2 = DerivedRecord.from_json(json_str)
assert record2 == record
class TestDataSequence:
@pytest.fixture
def sequence(self):
sequence0 = DerivedSequence()
assert len(sequence0) == 0
return sequence0
@pytest.fixture
def sequence2(self):
sequence = DerivedSequence()
record1 = self.create_test_record(datetime(1970, 1, 1), 1970)
record2 = self.create_test_record(datetime(1971, 1, 1), 1971)
sequence.append(record1)
sequence.append(record2)
assert len(sequence) == 2
return sequence
def create_test_record(self, date, value):
"""Helper function to create a test DataRecord."""
return DerivedRecord(date_time=date, data_value=value)
# Test cases
def test_getitem(self, sequence):
assert len(sequence) == 0
record = self.create_test_record("2024-01-01 00:00:00", 0)
sequence.insert_by_datetime(record)
assert isinstance(sequence[0], DerivedRecord)
def test_setitem(self, sequence2):
new_record = self.create_test_record(datetime(2024, 1, 3, tzinfo=timezone.utc), 1)
sequence2[0] = new_record
assert sequence2[0].date_time == datetime(2024, 1, 3, tzinfo=timezone.utc)
def test_set_record_at_index(self, sequence2):
record1 = self.create_test_record(datetime(2024, 1, 3, tzinfo=timezone.utc), 1)
record2 = self.create_test_record(datetime(2023, 11, 5), 0.8)
sequence2[1] = record1
assert sequence2[1].date_time == datetime(2024, 1, 3, tzinfo=timezone.utc)
sequence2[0] = record2
assert len(sequence2) == 2
assert sequence2[0] == record2
def test_insert_duplicate_date_record(self, sequence):
record1 = self.create_test_record(datetime(2023, 11, 5), 0.8)
record2 = self.create_test_record(datetime(2023, 11, 5), 0.9) # Duplicate date
sequence.insert_by_datetime(record1)
sequence.insert_by_datetime(record2)
assert len(sequence) == 1
assert sequence[0].data_value == 0.9 # Record should have merged with new value
def test_sort_by_datetime_ascending(self, sequence):
"""Test sorting records in ascending order by date_time."""
records = [
self.create_test_record(pendulum.datetime(2024, 11, 1), 0.7),
self.create_test_record(pendulum.datetime(2024, 10, 1), 0.8),
self.create_test_record(pendulum.datetime(2024, 12, 1), 0.9),
]
for i, record in enumerate(records):
sequence.insert(i, record)
sequence.sort_by_datetime()
sorted_dates = [record.date_time for record in sequence.records]
for i, expected_date in enumerate(
[
pendulum.datetime(2024, 10, 1),
pendulum.datetime(2024, 11, 1),
pendulum.datetime(2024, 12, 1),
]
):
assert compare_datetimes(sorted_dates[i], expected_date).equal
def test_sort_by_datetime_descending(self, sequence):
"""Test sorting records in descending order by date_time."""
records = [
self.create_test_record(pendulum.datetime(2024, 11, 1), 0.7),
self.create_test_record(pendulum.datetime(2024, 10, 1), 0.8),
self.create_test_record(pendulum.datetime(2024, 12, 1), 0.9),
]
for i, record in enumerate(records):
sequence.insert(i, record)
sequence.sort_by_datetime(reverse=True)
sorted_dates = [record.date_time for record in sequence.records]
for i, expected_date in enumerate(
[
pendulum.datetime(2024, 12, 1),
pendulum.datetime(2024, 11, 1),
pendulum.datetime(2024, 10, 1),
]
):
assert compare_datetimes(sorted_dates[i], expected_date).equal
def test_sort_by_datetime_with_none(self, sequence):
"""Test sorting records when some date_time values are None."""
records = [
self.create_test_record(pendulum.datetime(2024, 11, 1), 0.7),
self.create_test_record(pendulum.datetime(2024, 10, 1), 0.8),
self.create_test_record(pendulum.datetime(2024, 12, 1), 0.9),
]
for i, record in enumerate(records):
sequence.insert(i, record)
sequence.records[2].date_time = None
assert sequence.records[2].date_time is None
sequence.sort_by_datetime()
sorted_dates = [record.date_time for record in sequence.records]
for i, expected_date in enumerate(
[
None, # None values should come first
pendulum.datetime(2024, 10, 1),
pendulum.datetime(2024, 11, 1),
]
):
if expected_date is None:
assert sorted_dates[i] is None
else:
assert compare_datetimes(sorted_dates[i], expected_date).equal
def test_sort_by_datetime_error_on_uncomparable(self, sequence):
"""Test error is raised when date_time contains uncomparable values."""
records = [
self.create_test_record(pendulum.datetime(2024, 11, 1), 0.7),
self.create_test_record(pendulum.datetime(2024, 12, 1), 0.9),
self.create_test_record(pendulum.datetime(2024, 10, 1), 0.8),
]
for i, record in enumerate(records):
sequence.insert(i, record)
with pytest.raises(
ValidationError, match="Date string not_a_datetime does not match any known formats."
):
sequence.records[2].date_time = "not_a_datetime" # Invalid date_time
sequence.sort_by_datetime()
def test_key_to_series(self, sequence):
record = self.create_test_record(datetime(2023, 11, 6), 0.8)
sequence.append(record)
series = sequence.key_to_series("data_value")
assert isinstance(series, pd.Series)
assert series[to_datetime(datetime(2023, 11, 6))] == 0.8
def test_key_from_series(self, sequence):
series = pd.Series(
data=[0.8, 0.9], index=pd.to_datetime([datetime(2023, 11, 5), datetime(2023, 11, 6)])
)
sequence.key_from_series("data_value", series)
assert len(sequence) == 2
assert sequence[0].data_value == 0.8
assert sequence[1].data_value == 0.9
def test_key_to_array(self, sequence):
interval = to_duration("1 day")
start_datetime = to_datetime("2023-11-6")
last_datetime = to_datetime("2023-11-8")
end_datetime = to_datetime("2023-11-9")
record = self.create_test_record(start_datetime, float(start_datetime.day))
sequence.insert_by_datetime(record)
record = self.create_test_record(last_datetime, float(last_datetime.day))
sequence.insert_by_datetime(record)
assert sequence[0].data_value == 6.0
assert sequence[1].data_value == 8.0
series = sequence.key_to_series(
key="data_value", start_datetime=start_datetime, end_datetime=end_datetime
)
assert len(series) == 2
assert series[to_datetime("2023-11-6")] == 6
assert series[to_datetime("2023-11-8")] == 8
array = sequence.key_to_array(
key="data_value",
start_datetime=start_datetime,
end_datetime=end_datetime,
interval=interval,
)
assert isinstance(array, np.ndarray)
assert len(array) == 3
assert array[0] == start_datetime.day
assert array[1] == 7
assert array[2] == last_datetime.day
def test_to_datetimeindex(self, sequence2):
record1 = self.create_test_record(datetime(2023, 11, 5), 0.8)
record2 = self.create_test_record(datetime(2023, 11, 6), 0.9)
sequence2.insert(0, record1)
sequence2.insert(1, record2)
dt_index = sequence2.to_datetimeindex()
assert isinstance(dt_index, pd.DatetimeIndex)
assert dt_index[0] == to_datetime(datetime(2023, 11, 5))
assert dt_index[1] == to_datetime(datetime(2023, 11, 6))
def test_delete_by_datetime_range(self, sequence):
record1 = self.create_test_record(datetime(2023, 11, 5), 0.8)
record2 = self.create_test_record(datetime(2023, 11, 6), 0.9)
record3 = self.create_test_record(datetime(2023, 11, 7), 1.0)
sequence.append(record1)
sequence.append(record2)
sequence.append(record3)
assert len(sequence) == 3
sequence.delete_by_datetime(
start_datetime=datetime(2023, 11, 6), end_datetime=datetime(2023, 11, 7)
)
assert len(sequence) == 2
assert sequence[0].date_time == to_datetime(datetime(2023, 11, 5))
assert sequence[1].date_time == to_datetime(datetime(2023, 11, 7))
def test_delete_by_datetime_start(self, sequence):
record1 = self.create_test_record(datetime(2023, 11, 5), 0.8)
record2 = self.create_test_record(datetime(2023, 11, 6), 0.9)
sequence.append(record1)
sequence.append(record2)
assert len(sequence) == 2
sequence.delete_by_datetime(start_datetime=datetime(2023, 11, 6))
assert len(sequence) == 1
assert sequence[0].date_time == to_datetime(datetime(2023, 11, 5))
def test_delete_by_datetime_end(self, sequence):
record1 = self.create_test_record(datetime(2023, 11, 5), 0.8)
record2 = self.create_test_record(datetime(2023, 11, 6), 0.9)
sequence.append(record1)
sequence.append(record2)
assert len(sequence) == 2
sequence.delete_by_datetime(end_datetime=datetime(2023, 11, 6))
assert len(sequence) == 1
assert sequence[0].date_time == to_datetime(datetime(2023, 11, 6))
def test_filter_by_datetime(self, sequence):
record1 = self.create_test_record(datetime(2023, 11, 5), 0.8)
record2 = self.create_test_record(datetime(2023, 11, 6), 0.9)
sequence.append(record1)
sequence.append(record2)
filtered_sequence = sequence.filter_by_datetime(start_datetime=datetime(2023, 11, 6))
assert len(filtered_sequence) == 1
assert filtered_sequence[0].date_time == to_datetime(datetime(2023, 11, 6))
def test_to_dict(self, sequence):
record = self.create_test_record(datetime(2023, 11, 6), 0.8)
sequence.append(record)
data_dict = sequence.to_dict()
assert isinstance(data_dict, dict)
sequence_other = sequence.from_dict(data_dict)
assert sequence_other == sequence
def test_to_json(self, sequence):
record = self.create_test_record(datetime(2023, 11, 6), 0.8)
sequence.append(record)
json_str = sequence.to_json()
assert isinstance(json_str, str)
assert "2023-11-06" in json_str
assert ":0.8" in json_str
def test_from_json(self, sequence, sequence2):
json_str = sequence2.to_json()
sequence = sequence.from_json(json_str)
assert len(sequence) == len(sequence2)
assert sequence[0].date_time == sequence2[0].date_time
assert sequence[0].data_value == sequence2[0].data_value
def test_key_to_dict(self, sequence):
record1 = self.create_test_record(datetime(2023, 11, 5), 0.8)
record2 = self.create_test_record(datetime(2023, 11, 6), 0.9)
sequence.append(record1)
sequence.append(record2)
data_dict = sequence.key_to_dict("data_value")
assert isinstance(data_dict, dict)
assert data_dict[to_datetime(datetime(2023, 11, 5), as_string=True)] == 0.8
assert data_dict[to_datetime(datetime(2023, 11, 6), as_string=True)] == 0.9
def test_key_to_lists(self, sequence):
record1 = self.create_test_record(datetime(2023, 11, 5), 0.8)
record2 = self.create_test_record(datetime(2023, 11, 6), 0.9)
sequence.append(record1)
sequence.append(record2)
dates, values = sequence.key_to_lists("data_value")
assert dates == [to_datetime(datetime(2023, 11, 5)), to_datetime(datetime(2023, 11, 6))]
assert values == [0.8, 0.9]
class TestDataProvider:
# Fixtures and helper functions
@pytest.fixture
def provider(self):
"""Fixture to provide an instance of TestDataProvider for testing."""
DerivedDataProvider.provider_enabled = True
DerivedDataProvider.provider_updated = False
return DerivedDataProvider()
@pytest.fixture
def sample_start_datetime(self):
"""Fixture for a sample start datetime."""
return to_datetime(datetime(2024, 11, 1, 12, 0))
def create_test_record(self, date, value):
"""Helper function to create a test DataRecord."""
return DerivedRecord(date_time=date, data_value=value)
# Tests
def test_singleton_behavior(self, provider):
"""Test that DataProvider enforces singleton behavior."""
instance1 = provider
instance2 = DerivedDataProvider()
assert (
instance1 is instance2
), "Singleton pattern is not enforced; instances are not the same."
def test_update_method_with_defaults(self, provider, sample_start_datetime, monkeypatch):
"""Test the `update` method with default parameters."""
ems_eos = get_ems()
ems_eos.set_start_datetime(sample_start_datetime)
provider.update_data()
assert provider.start_datetime == sample_start_datetime
def test_update_method_force_enable(self, provider, monkeypatch):
"""Test that `update` executes when `force_enable` is True, even if `enabled` is False."""
# Override enabled to return False for this test
DerivedDataProvider.provider_enabled = False
DerivedDataProvider.provider_updated = False
provider.update_data(force_enable=True)
assert provider.enabled() is False, "Provider should be disabled, but enabled() is True."
assert (
DerivedDataProvider.provider_updated is True
), "Provider should have been executed, but was not."
def test_delete_by_datetime(self, provider, sample_start_datetime):
"""Test `delete_by_datetime` method for removing records by datetime range."""
# Add records to the provider for deletion testing
provider.records = [
self.create_test_record(sample_start_datetime - to_duration("3 hours"), 1),
self.create_test_record(sample_start_datetime - to_duration("1 hour"), 2),
self.create_test_record(sample_start_datetime + to_duration("1 hour"), 3),
]
provider.delete_by_datetime(
start_datetime=sample_start_datetime - to_duration("2 hours"),
end_datetime=sample_start_datetime + to_duration("2 hours"),
)
assert (
len(provider.records) == 1
), "Only one record should remain after deletion by datetime."
assert provider.records[0].date_time == sample_start_datetime - to_duration(
"3 hours"
), "Unexpected record remains."
class TestDataImportProvider:
# Fixtures and helper functions
@pytest.fixture
def provider(self):
"""Fixture to provide an instance of DerivedDataImportProvider for testing."""
DerivedDataImportProvider.provider_enabled = True
DerivedDataImportProvider.provider_updated = False
return DerivedDataImportProvider()
@pytest.mark.parametrize(
"start_datetime, value_count, expected_mapping_count",
[
("2024-11-10 00:00:00", 24, 24), # No DST in Germany
("2024-08-10 00:00:00", 24, 24), # DST in Germany
("2024-03-31 00:00:00", 24, 23), # DST change in Germany (23 hours/ day)
("2024-10-27 00:00:00", 24, 25), # DST change in Germany (25 hours/ day)
],
)
def test_import_datetimes(self, provider, start_datetime, value_count, expected_mapping_count):
ems_eos = get_ems()
ems_eos.set_start_datetime(to_datetime(start_datetime, in_timezone="Europe/Berlin"))
value_datetime_mapping = provider.import_datetimes(value_count)
assert len(value_datetime_mapping) == expected_mapping_count
@pytest.mark.parametrize(
"start_datetime, value_count, expected_mapping_count",
[
("2024-11-10 00:00:00", 24, 24), # No DST in Germany
("2024-08-10 00:00:00", 24, 24), # DST in Germany
("2024-03-31 00:00:00", 24, 23), # DST change in Germany (23 hours/ day)
("2024-10-27 00:00:00", 24, 25), # DST change in Germany (25 hours/ day)
],
)
def test_import_datetimes_utc(
self, set_other_timezone, provider, start_datetime, value_count, expected_mapping_count
):
original_tz = set_other_timezone("Etc/UTC")
ems_eos = get_ems()
ems_eos.set_start_datetime(to_datetime(start_datetime, in_timezone="Europe/Berlin"))
assert ems_eos.start_datetime.timezone.name == "Europe/Berlin"
value_datetime_mapping = provider.import_datetimes(value_count)
assert len(value_datetime_mapping) == expected_mapping_count
class TestDataContainer:
# Fixture and helpers
@pytest.fixture
def container(self):
container = DerivedDataContainer()
return container
@pytest.fixture
def container_with_providers(self):
record1 = self.create_test_record(datetime(2023, 11, 5), 1)
record2 = self.create_test_record(datetime(2023, 11, 6), 2)
record3 = self.create_test_record(datetime(2023, 11, 7), 3)
provider = DerivedDataProvider()
provider.clear()
assert len(provider) == 0
provider.append(record1)
provider.append(record2)
provider.append(record3)
assert len(provider) == 3
container = DerivedDataContainer()
container.providers.clear()
assert len(container.providers) == 0
container.providers.append(provider)
assert len(container.providers) == 1
return container
def create_test_record(self, date, value):
"""Helper function to create a test DataRecord."""
return DerivedRecord(date_time=date, data_value=value)
def test_append_provider(self, container):
assert len(container.providers) == 0
container.providers.append(DerivedDataProvider())
assert len(container.providers) == 1
assert isinstance(container.providers[0], DerivedDataProvider)
@pytest.mark.skip(reason="type check not implemented")
def test_append_provider_invalid_type(self, container):
with pytest.raises(ValueError, match="must be an instance of DataProvider"):
container.providers.append("not_a_provider")
def test_getitem_existing_key(self, container_with_providers):
assert len(container_with_providers.providers) == 1
# check all keys are available (don't care for position)
for key in ["data_value", "date_time"]:
assert key in list(container_with_providers.keys())
series = container_with_providers["data_value"]
assert isinstance(series, pd.Series)
assert series.name == "data_value"
assert series.tolist() == [1.0, 2.0, 3.0]
def test_getitem_non_existing_key(self, container_with_providers):
with pytest.raises(KeyError, match="No data found for key 'non_existent_key'"):
container_with_providers["non_existent_key"]
def test_setitem_existing_key(self, container_with_providers):
new_series = container_with_providers["data_value"]
new_series[:] = [4, 5, 6]
container_with_providers["data_value"] = new_series
series = container_with_providers["data_value"]
assert series.name == "data_value"
assert series.tolist() == [4, 5, 6]
def test_setitem_invalid_value(self, container_with_providers):
with pytest.raises(ValueError, match="Value must be an instance of pd.Series"):
container_with_providers["test_key"] = "not_a_series"
def test_setitem_non_existing_key(self, container_with_providers):
new_series = pd.Series([4, 5, 6], name="non_existent_key")
with pytest.raises(KeyError, match="Key 'non_existent_key' not found"):
container_with_providers["non_existent_key"] = new_series
def test_delitem_existing_key(self, container_with_providers):
del container_with_providers["data_value"]
series = container_with_providers["data_value"]
assert series.name == "data_value"
assert series.tolist() == [None, None, None]
def test_delitem_non_existing_key(self, container_with_providers):
with pytest.raises(KeyError, match="Key 'non_existent_key' not found"):
del container_with_providers["non_existent_key"]
def test_len(self, container_with_providers):
assert len(container_with_providers) == 3
def test_repr(self, container_with_providers):
representation = repr(container_with_providers)
assert representation.startswith("DerivedDataContainer(")
assert "DerivedDataProvider" in representation
def test_to_json(self, container_with_providers):
json_str = container_with_providers.to_json()
container_other = DerivedDataContainer.from_json(json_str)
assert container_other == container_with_providers
def test_from_json(self, container_with_providers):
json_str = container_with_providers.to_json()
container = DerivedDataContainer.from_json(json_str)
assert isinstance(container, DerivedDataContainer)
assert len(container.providers) == 1
assert container.providers[0] == container_with_providers.providers[0]
def test_provider_by_id(self, container_with_providers):
provider = container_with_providers.provider_by_id("DerivedDataProvider")
assert isinstance(provider, DerivedDataProvider)

View File

@@ -1,95 +1,379 @@
"""Test Module for datetimeutil Module."""
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
"""Test Module for pendulum.datetimeutil Module."""
import pendulum
import pytest
from pendulum.tz.timezone import Timezone
from akkudoktoreos.utils.datetimeutil import to_datetime, to_timedelta, to_timezone
from akkudoktoreos.utils.datetimeutil import (
compare_datetimes,
hours_in_day,
to_datetime,
to_duration,
to_timezone,
)
# -----------------------------
# to_datetime
# -----------------------------
# Test cases for valid timedelta inputs
# Test cases for valid pendulum.duration inputs
@pytest.mark.parametrize(
"date_input, as_string, to_timezone, to_naiv, to_maxtime, expected_output",
"test_case, local_timezone, date_input, as_string, in_timezone, to_naiv, to_maxtime, expected_output",
[
# as datetime object
# ---------------------------------------
# from string to pendulum.datetime object
# ---------------------------------------
# - no timezone
(
"2024-10-07T10:20:30.000+02:00",
"TC001",
"Etc/UTC",
"2024-01-01",
None,
None,
None,
False,
pendulum.datetime(2024, 1, 1, 0, 0, 0, tz="Etc/UTC"),
),
(
"TC002",
"Europe/Berlin",
"2024-01-01",
None,
None,
None,
False,
pendulum.datetime(2024, 1, 1, 0, 0, 0, tz="Europe/Berlin"),
),
(
"TC003",
"Europe/Berlin",
"2024-01-01",
None,
None,
None,
False,
pendulum.datetime(2023, 12, 31, 23, 0, 0, tz="Etc/UTC"),
),
(
"TC004",
"Europe/Paris",
"2024-01-01 00:00:00",
None,
None,
None,
False,
pendulum.datetime(2024, 1, 1, 0, 0, 0, tz="Europe/Paris"),
),
(
"TC005",
"Etc/UTC",
"2024-01-01 00:00:00",
None,
None,
None,
False,
pendulum.datetime(2024, 1, 1, 1, 0, 0, tz="Europe/Berlin"),
),
(
"TC006",
"Europe/Berlin",
"2024-01-01 00:00:00",
None,
None,
None,
False,
pendulum.datetime(2023, 12, 31, 23, 0, 0, tz="Etc/UTC"),
),
(
"TC007",
"Atlantic/Canary",
"2024-01-01 12:00:00",
None,
None,
None,
False,
pendulum.datetime(
2024,
1,
1,
12,
0,
0,
tz="Atlantic/Canary",
),
),
(
"TC008",
"Etc/UTC",
"2024-01-01 12:00:00",
None,
None, # force local timezone
None,
False,
pendulum.datetime(2024, 1, 1, 13, 0, 0, tz="Europe/Berlin"),
),
(
"TC009",
"Europe/Berlin",
"2024-01-01 12:00:00",
None,
None,
None,
False,
pendulum.datetime(2024, 1, 1, 11, 0, 0, tz="Etc/UTC"),
),
# - with timezone
(
"TC010",
"Etc/UTC",
"02/02/24",
None,
"Europe/Berlin",
None,
False,
pendulum.datetime(2024, 2, 2, 0, 0, 0, tz="Europe/Berlin"),
),
(
"TC011",
"Etc/UTC",
"2024-03-03T10:20:30.000+01:00", # No dalight saving time at this date
None,
"Europe/Berlin",
None,
None,
datetime(2024, 10, 7, 10, 20, 30, 0, tzinfo=ZoneInfo("Europe/Berlin")),
pendulum.datetime(2024, 3, 3, 10, 20, 30, 0, tz="Europe/Berlin"),
),
(
"2024-10-07T10:20:30.000+02:00",
"TC012",
"Etc/UTC",
"2024-04-04T10:20:30.000+02:00",
None,
"Europe/Berlin",
False,
None,
datetime(2024, 10, 7, 10, 20, 30, 0, tzinfo=ZoneInfo("Europe/Berlin")),
pendulum.datetime(2024, 4, 4, 10, 20, 30, 0, tz="Europe/Berlin"),
),
(
"2024-10-07T10:20:30.000+02:00",
"TC013",
"Etc/UTC",
"2024-05-05T10:20:30.000+02:00",
None,
"Europe/Berlin",
True,
None,
datetime(2024, 10, 7, 10, 20, 30, 0),
pendulum.naive(2024, 5, 5, 10, 20, 30, 0),
),
# - without local timezone as UTC
(
"TC014",
"Atlantic/Canary",
"02/02/24",
None,
"UTC",
None,
False,
pendulum.datetime(2024, 2, 2, 0, 0, 0, tz="UTC"),
),
(
"TC015",
"Atlantic/Canary",
"2024-03-03T10:20:30.000Z", # No dalight saving time at this date
None,
None,
None,
None,
pendulum.datetime(2024, 3, 3, 10, 20, 30, 0, tz="UTC"),
),
# ---------------------------------------
# from pendulum.datetime to pendulum.datetime object
# ---------------------------------------
(
"TC016",
"Atlantic/Canary",
pendulum.datetime(2024, 4, 4, 0, 0, 0),
None,
None,
None,
False,
pendulum.datetime(2024, 4, 4, 0, 0, 0, tz="Etc/UTC"),
),
(
"TC017",
"Atlantic/Canary",
pendulum.datetime(2024, 4, 4, 1, 0, 0),
None,
"Europe/Berlin",
None,
False,
pendulum.datetime(2024, 4, 4, 3, 0, 0, tz="Europe/Berlin"),
),
(
"TC018",
"Atlantic/Canary",
pendulum.datetime(2024, 4, 4, 1, 0, 0, tz="Etc/UTC"),
None,
"Europe/Berlin",
None,
False,
pendulum.datetime(2024, 4, 4, 3, 0, 0, tz="Europe/Berlin"),
),
(
"TC019",
"Atlantic/Canary",
pendulum.datetime(2024, 4, 4, 2, 0, 0, tz="Europe/Berlin"),
None,
"Etc/UTC",
None,
False,
pendulum.datetime(2024, 4, 4, 0, 0, 0, tz="Etc/UTC"),
),
# ---------------------------------------
# from string to UTC string
# ---------------------------------------
# - no timezone
# local timezone UTC
(
"TC020",
"Etc/UTC",
"2023-11-06T00:00:00",
"UTC",
None,
None,
None,
"2023-11-06T00:00:00Z",
),
# local timezone "Europe/Berlin"
(
"TC021",
"Europe/Berlin",
"2023-11-06T00:00:00",
"UTC",
"Europe/Berlin",
None,
None,
"2023-11-05T23:00:00Z",
),
# - no microseconds
(
"TC022",
"Atlantic/Canary",
"2024-10-30T00:00:00+01:00",
"UTC",
None,
None,
None,
"2024-10-29T23:00:00Z",
),
(
"TC023",
"Atlantic/Canary",
"2024-10-30T01:00:00+01:00",
"utc",
None,
None,
None,
"2024-10-30T00:00:00Z",
),
# - with microseconds
(
"TC024",
"Atlantic/Canary",
"2024-10-07T10:20:30.000+02:00",
"UTC",
None,
None,
None,
"2024-10-07T08:20:30Z",
),
# as string
("2024-10-07T10:20:30.000+02:00", "UTC", None, None, None, "2024-10-07T08:20:30+00:00"),
("2024-10-07T10:20:30.000+02:00", "utc", None, None, None, "2024-10-07T08:20:30+00:00"),
],
)
def test_to_datetime(date_input, as_string, to_timezone, to_naiv, to_maxtime, expected_output):
"""Test datetime conversion with valid inputs."""
assert (
to_datetime(
date_input,
as_string=as_string,
to_timezone=to_timezone,
to_naiv=to_naiv,
to_maxtime=to_maxtime,
)
== expected_output
def test_to_datetime(
set_other_timezone,
test_case,
local_timezone,
date_input,
as_string,
in_timezone,
to_naiv,
to_maxtime,
expected_output,
):
"""Test pendulum.datetime conversion with valid inputs."""
set_other_timezone(local_timezone)
result = to_datetime(
date_input,
as_string=as_string,
in_timezone=in_timezone,
to_naiv=to_naiv,
to_maxtime=to_maxtime,
)
# if isinstance(date_input, str):
# print(f"Input: {date_input}")
# else:
# print(f"Input: {date_input} tz={date_input.timezone}")
if isinstance(expected_output, str):
# print(f"Expected: {expected_output}")
# print(f"Result: {result}")
assert result == expected_output
elif expected_output.timezone is None:
# We expect an exception
with pytest.raises(TypeError):
assert compare_datetimes(result, expected_output).equal
else:
compare = compare_datetimes(result, expected_output)
# print(f"---- Testcase: {test_case} ----")
# print(f"Expected: {expected_output} tz={expected_output.timezone}")
# print(f"Result: {result} tz={result.timezone}")
# print(f"Compare: {compare}")
assert compare.equal == True
# -----------------------------
# to_timedelta
# to_duration
# -----------------------------
# Test cases for valid timedelta inputs
# Test cases for valid duration inputs
@pytest.mark.parametrize(
"input_value, expected_output",
[
# timedelta input
(timedelta(days=1), timedelta(days=1)),
# duration input
(pendulum.duration(days=1), pendulum.duration(days=1)),
# String input
("2 days", timedelta(days=2)),
("5 hours", timedelta(hours=5)),
("30 minutes", timedelta(minutes=30)),
("45 seconds", timedelta(seconds=45)),
("1 day 2 hours 30 minutes 15 seconds", timedelta(days=1, hours=2, minutes=30, seconds=15)),
("3 days 4 hours", timedelta(days=3, hours=4)),
("2 days", pendulum.duration(days=2)),
("5 hours", pendulum.duration(hours=5)),
("47 hours", pendulum.duration(hours=47)),
("48 hours", pendulum.duration(seconds=48 * 3600)),
("30 minutes", pendulum.duration(minutes=30)),
("45 seconds", pendulum.duration(seconds=45)),
(
"1 day 2 hours 30 minutes 15 seconds",
pendulum.duration(days=1, hours=2, minutes=30, seconds=15),
),
("3 days 4 hours", pendulum.duration(days=3, hours=4)),
# Integer/Float input
(3600, timedelta(seconds=3600)), # 1 hour
(86400, timedelta(days=1)), # 1 day
(1800.5, timedelta(seconds=1800.5)), # 30 minutes and 0.5 seconds
(3600, pendulum.duration(seconds=3600)), # 1 hour
(86400, pendulum.duration(days=1)), # 1 day
(1800.5, pendulum.duration(seconds=1800.5)), # 30 minutes and 0.5 seconds
# Tuple/List input
((1, 2, 30, 15), timedelta(days=1, hours=2, minutes=30, seconds=15)),
([0, 10, 0, 0], timedelta(hours=10)),
((1, 2, 30, 15), pendulum.duration(days=1, hours=2, minutes=30, seconds=15)),
([0, 10, 0, 0], pendulum.duration(hours=10)),
],
)
def test_to_timedelta_valid(input_value, expected_output):
"""Test to_timedelta with valid inputs."""
assert to_timedelta(input_value) == expected_output
def test_to_duration_valid(input_value, expected_output):
"""Test to_duration with valid inputs."""
assert to_duration(input_value) == expected_output
def test_to_duration_summation():
start_datetime = to_datetime("2028-01-11 00:00:00")
index_datetime = start_datetime
for i in range(48):
expected_datetime = start_datetime + to_duration(f"{i} hours")
assert index_datetime == expected_datetime
index_datetime += to_duration("1 hour")
assert index_datetime == to_datetime("2028-01-13 00:00:00")
# -----------------------------
@@ -99,21 +383,196 @@ def test_to_timedelta_valid(input_value, expected_output):
def test_to_timezone_string():
"""Test to_timezone function returns correct timezone as a string."""
lat, lon = 40.7128, -74.0060 # New York City coordinates
result = to_timezone(lat, lon, as_string=True)
location = (40.7128, -74.0060) # New York City coordinates
result = to_timezone(location=location, as_string=True)
assert result == "America/New_York", "Expected timezone string 'America/New_York'"
def test_to_timezone_zoneinfo():
"""Test to_timezone function returns correct timezone as a ZoneInfo object."""
lat, lon = 40.7128, -74.0060 # New York City coordinates
result = to_timezone(lat, lon)
assert isinstance(result, ZoneInfo), "Expected a ZoneInfo object"
assert result.key == "America/New_York", "Expected ZoneInfo key 'America/New_York'"
def test_to_timezone_timezone():
"""Test to_timezone function returns correct timezone as a Timezone object."""
location = (40.7128, -74.0060) # New York City coordinates
result = to_timezone(location=location)
assert isinstance(result, Timezone), "Expected a Timezone object"
assert result.name == "America/New_York", "Expected Timezone name 'America/New_York'"
def test_to_timezone_invalid_coordinates():
"""Test to_timezone function handles invalid coordinates gracefully."""
lat, lon = 100.0, 200.0 # Invalid coordinates outside Earth range
with pytest.raises(ValueError, match="Invalid location"):
to_timezone(lat, lon, as_string=True)
location = (100.0, 200.0) # Invalid coordinates outside Earth range
with pytest.raises(ValueError, match="Invalid latitude/longitude"):
to_timezone(location=location, as_string=True)
# -----------------------------
# hours_in_day
# -----------------------------
@pytest.mark.parametrize(
"local_timezone, date, in_timezone, expected_hours",
[
("Etc/UTC", "2024-11-10 00:00:00", "Europe/Berlin", 24), # No DST in Germany
("Etc/UTC", "2024-08-10 00:00:00", "Europe/Berlin", 24), # DST in Germany
("Etc/UTC", "2024-03-31 00:00:00", "Europe/Berlin", 23), # DST change (23 hours/ day)
("Etc/UTC", "2024-10-27 00:00:00", "Europe/Berlin", 25), # DST change (25 hours/ day)
("Europe/Berlin", "2024-11-10 00:00:00", "Europe/Berlin", 24), # No DST in Germany
("Europe/Berlin", "2024-08-10 00:00:00", "Europe/Berlin", 24), # DST in Germany
("Europe/Berlin", "2024-03-31 00:00:00", "Europe/Berlin", 23), # DST change (23 hours/ day)
("Europe/Berlin", "2024-10-27 00:00:00", "Europe/Berlin", 25), # DST change (25 hours/ day)
],
)
def test_hours_in_day(set_other_timezone, local_timezone, date, in_timezone, expected_hours):
"""Test the `test_hours_in_day` function."""
set_other_timezone(local_timezone)
date_input = to_datetime(date, in_timezone=in_timezone)
assert date_input.timezone.name == in_timezone
assert hours_in_day(date_input) == expected_hours
# -----------------------------
# compare_datetimes
# -----------------------------
@pytest.mark.parametrize(
"dt1, dt2, equal, ge, gt, le, lt",
[
# Same time in the same timezone
(
pendulum.datetime(2024, 3, 15, 12, 0, 0, tz="UTC"),
pendulum.datetime(2024, 3, 15, 12, 0, 0, tz="UTC"),
True,
True,
False,
True,
False,
),
(
pendulum.datetime(2024, 4, 4, 0, 0, 0, tz="Europe/Berlin"),
pendulum.datetime(2024, 4, 4, 0, 0, 0, tz="Europe/Berlin"),
True,
True,
False,
True,
False,
),
# Same instant in different timezones (converted to UTC)
(
pendulum.datetime(2024, 3, 15, 8, 0, 0, tz="Europe/Berlin"),
pendulum.datetime(2024, 3, 15, 7, 0, 0, tz="UTC"),
True,
True,
False,
True,
False,
),
# Different times across timezones (converted to UTC)
(
pendulum.datetime(2024, 3, 15, 8, 0, 0, tz="America/New_York"),
pendulum.datetime(2024, 3, 15, 12, 0, 0, tz="UTC"),
True,
True,
False,
True,
False,
),
],
)
def test_compare_datetimes_equal(dt1, dt2, equal, ge, gt, le, lt):
# requal = compare_datetimes(dt1, dt2).equal
# rgt = compare_datetimes(dt1, dt2).gt
# rge = compare_datetimes(dt1, dt2).ge
# rlt = compare_datetimes(dt1, dt2).lt
# rle = compare_datetimes(dt1, dt2).le
# print(f"{dt1} vs. {dt2}: expected equal={equal}, ge={ge}, gt={gt}, le={le}, lt={lt}")
# print(f"{dt1} vs. {dt2}: result equal={requal}, ge={rge}, gt={rgt}, le={rle}, lt={rlt}")
assert compare_datetimes(dt1, dt2).equal == equal
assert compare_datetimes(dt1, dt2).ge == ge
assert compare_datetimes(dt1, dt2).gt == gt
assert compare_datetimes(dt1, dt2).le == le
assert compare_datetimes(dt1, dt2).lt == lt
@pytest.mark.parametrize(
"dt1, dt2, equal, ge, gt, le, lt",
[
# Different times in the same timezone
(
pendulum.datetime(2024, 3, 15, 11, 0, 0, tz="UTC"),
pendulum.datetime(2024, 3, 15, 12, 0, 0, tz="UTC"),
False,
False,
False,
True,
True,
),
# Different times across timezones (converted to UTC)
(
pendulum.datetime(2024, 3, 15, 6, 0, 0, tz="America/New_York"),
pendulum.datetime(2024, 3, 15, 12, 0, 0, tz="UTC"),
False,
False,
False,
True,
True,
),
# DST changes: spring forward
(
pendulum.datetime(2024, 3, 10, 1, 59, 0, tz="America/New_York"),
pendulum.datetime(2024, 3, 10, 3, 0, 0, tz="America/New_York"),
False,
False,
False,
True,
True,
),
# DST changes: fall back
(
pendulum.datetime(2024, 11, 3, 1, 0, 0, tz="America/New_York"),
pendulum.datetime(2024, 11, 3, 1, 30, 0, tz="America/New_York"),
False,
False,
False,
True,
True,
),
],
)
def test_compare_datetimes_lt(dt1, dt2, equal, ge, gt, le, lt):
# requal = compare_datetimes(dt1, dt2).equal
# rgt = compare_datetimes(dt1, dt2).gt
# rge = compare_datetimes(dt1, dt2).ge
# rlt = compare_datetimes(dt1, dt2).lt
# rle = compare_datetimes(dt1, dt2).le
# print(f"{dt1} vs. {dt2}: expected equal={equal}, ge={ge}, gt={gt}, le={le}, lt={lt}")
# print(f"{dt1} vs. {dt2}: result equal={requal}, ge={rge}, gt={rgt}, le={rle}, lt={rlt}")
assert compare_datetimes(dt1, dt2).equal == equal
assert compare_datetimes(dt1, dt2).ge == ge
assert compare_datetimes(dt1, dt2).gt == gt
assert compare_datetimes(dt1, dt2).le == le
assert compare_datetimes(dt1, dt2).lt == lt
@pytest.mark.parametrize(
"dt1, dt2",
[
# Different times in the same timezone
(
pendulum.datetime(2024, 3, 15, 13, 0, 0, tz="UTC"),
pendulum.datetime(2024, 3, 15, 12, 0, 0, tz="UTC"),
),
],
)
def test_compare_datetimes_gt(dt1, dt2):
# requal = compare_datetimes(dt1, dt2).equal
# rgt = compare_datetimes(dt1, dt2).gt
# rge = compare_datetimes(dt1, dt2).ge
# rlt = compare_datetimes(dt1, dt2).lt
# rle = compare_datetimes(dt1, dt2).le
# print(f"{dt1} vs. {dt2}: expected equal={equal}, ge={ge}, gt={gt}, le={le}, lt={lt}")
# print(f"{dt1} vs. {dt2}: result equal={requal}, ge={rge}, gt={rgt}, le={rle}, lt={rlt}")
assert compare_datetimes(dt1, dt2).equal == False
assert compare_datetimes(dt1, dt2).ge
assert compare_datetimes(dt1, dt2).gt
assert compare_datetimes(dt1, dt2).le == False
assert compare_datetimes(dt1, dt2).lt == False

View File

@@ -0,0 +1,141 @@
import json
from pathlib import Path
from unittest.mock import Mock, patch
import pytest
from akkudoktoreos.core.ems import get_ems
from akkudoktoreos.prediction.elecpriceakkudoktor import (
AkkudoktorElecPrice,
AkkudoktorElecPriceValue,
ElecPriceAkkudoktor,
)
from akkudoktoreos.utils.cacheutil import CacheFileStore
from akkudoktoreos.utils.datetimeutil import to_datetime
DIR_TESTDATA = Path(__file__).absolute().parent.joinpath("testdata")
FILE_TESTDATA_ELECPRICEAKKUDOKTOR_1_JSON = DIR_TESTDATA.joinpath(
"elecpriceforecast_akkudoktor_1.json"
)
ems_eos = get_ems()
@pytest.fixture
def elecprice_provider(monkeypatch):
"""Fixture to create a ElecPriceProvider instance."""
monkeypatch.setenv("elecprice_provider", "Akkudoktor")
return ElecPriceAkkudoktor()
@pytest.fixture
def sample_akkudoktor_1_json():
"""Fixture that returns sample forecast data report."""
with open(FILE_TESTDATA_ELECPRICEAKKUDOKTOR_1_JSON, "r") as f_res:
input_data = json.load(f_res)
return input_data
@pytest.fixture
def cache_store():
"""A pytest fixture that creates a new CacheFileStore instance for testing."""
return CacheFileStore()
# ------------------------------------------------
# General forecast
# ------------------------------------------------
def test_singleton_instance(elecprice_provider):
"""Test that ElecPriceForecast behaves as a singleton."""
another_instance = ElecPriceAkkudoktor()
assert elecprice_provider is another_instance
def test_invalid_provider(elecprice_provider, monkeypatch):
"""Test requesting an unsupported elecprice_provider."""
monkeypatch.setenv("elecprice_provider", "<invalid>")
elecprice_provider.config.update()
assert elecprice_provider.enabled() == False
# ------------------------------------------------
# Akkudoktor
# ------------------------------------------------
@patch("requests.get")
def test_request_forecast(mock_get, elecprice_provider, sample_akkudoktor_1_json):
"""Test requesting forecast from Akkudoktor."""
# Mock response object
mock_response = Mock()
mock_response.status_code = 200
mock_response.content = json.dumps(sample_akkudoktor_1_json)
mock_get.return_value = mock_response
# Preset, as this is usually done by update()
elecprice_provider.config.update()
# Test function
akkudoktor_data = elecprice_provider._request_forecast()
assert isinstance(akkudoktor_data, AkkudoktorElecPrice)
assert akkudoktor_data.values[0] == AkkudoktorElecPriceValue(
start_timestamp=1733871600000,
end_timestamp=1733875200000,
start="2024-12-10T23:00:00.000Z",
end="2024-12-11T00:00:00.000Z",
marketprice=115.94,
unit="Eur/MWh",
marketpriceEurocentPerKWh=11.59,
)
@patch("requests.get")
def test_update_data(mock_get, elecprice_provider, sample_akkudoktor_1_json, cache_store):
"""Test fetching forecast from Akkudoktor."""
# Mock response object
mock_response = Mock()
mock_response.status_code = 200
mock_response.content = json.dumps(sample_akkudoktor_1_json)
mock_get.return_value = mock_response
cache_store.clear(clear_all=True)
# Call the method
ems_eos.set_start_datetime(to_datetime("2024-12-11 00:00:00", in_timezone="Europe/Berlin"))
elecprice_provider.update_data(force_enable=True, force_update=True)
# Assert: Verify the result is as expected
mock_get.assert_called_once()
assert len(elecprice_provider) == 25
# Assert we get prediction_hours prioce values by resampling
np_price_array = elecprice_provider.key_to_array(
key="elecprice_marketprice",
start_datetime=elecprice_provider.start_datetime,
end_datetime=elecprice_provider.end_datetime,
)
assert len(np_price_array) == elecprice_provider.total_hours
# with open(FILE_TESTDATA_ELECPRICEAKKUDOKTOR_2_JSON, "w") as f_out:
# f_out.write(elecprice_provider.to_json())
# ------------------------------------------------
# Development Akkudoktor
# ------------------------------------------------
@pytest.mark.skip(reason="For development only")
def test_akkudoktor_development_forecast_data(elecprice_provider):
"""Fetch data from real Akkudoktor server."""
# Preset, as this is usually done by update_data()
elecprice_provider.start_datetime = to_datetime("2024-10-26 00:00:00")
akkudoktor_data = elecprice_provider._request_forecast()
with open(FILE_TESTDATA_ELECPRICEAKKUDOKTOR_1_JSON, "w") as f_out:
json.dump(akkudoktor_data, f_out, indent=4)

View File

@@ -0,0 +1,110 @@
import json
from pathlib import Path
import pytest
from akkudoktoreos.config.config import get_config
from akkudoktoreos.core.ems import get_ems
from akkudoktoreos.prediction.elecpriceimport import ElecPriceImport
from akkudoktoreos.utils.datetimeutil import compare_datetimes, to_datetime
DIR_TESTDATA = Path(__file__).absolute().parent.joinpath("testdata")
FILE_TESTDATA_ELECPRICEIMPORT_1_JSON = DIR_TESTDATA.joinpath("import_input_1.json")
config_eos = get_config()
ems_eos = get_ems()
@pytest.fixture
def elecprice_provider(reset_config, sample_import_1_json):
"""Fixture to create a ElecPriceProvider instance."""
settings = {
"elecprice_provider": "ElecPriceImport",
"elecpriceimport_file_path": str(FILE_TESTDATA_ELECPRICEIMPORT_1_JSON),
"elecpriceimport_json": json.dumps(sample_import_1_json),
}
config_eos.merge_settings_from_dict(settings)
provider = ElecPriceImport()
assert provider.enabled() == True
return provider
@pytest.fixture
def sample_import_1_json():
"""Fixture that returns sample forecast data report."""
with open(FILE_TESTDATA_ELECPRICEIMPORT_1_JSON, "r") as f_res:
input_data = json.load(f_res)
return input_data
# ------------------------------------------------
# General forecast
# ------------------------------------------------
def test_singleton_instance(elecprice_provider):
"""Test that ElecPriceForecast behaves as a singleton."""
another_instance = ElecPriceImport()
assert elecprice_provider is another_instance
def test_invalid_provider(elecprice_provider):
"""Test requesting an unsupported elecprice_provider."""
settings = {
"elecprice_provider": "<invalid>",
"elecpriceimport_file_path": str(FILE_TESTDATA_ELECPRICEIMPORT_1_JSON),
}
config_eos.merge_settings_from_dict(settings)
assert elecprice_provider.enabled() == False
# ------------------------------------------------
# Import
# ------------------------------------------------
@pytest.mark.parametrize(
"start_datetime, from_file",
[
("2024-11-10 00:00:00", True), # No DST in Germany
("2024-08-10 00:00:00", True), # DST in Germany
("2024-03-31 00:00:00", True), # DST change in Germany (23 hours/ day)
("2024-10-27 00:00:00", True), # DST change in Germany (25 hours/ day)
("2024-11-10 00:00:00", False), # No DST in Germany
("2024-08-10 00:00:00", False), # DST in Germany
("2024-03-31 00:00:00", False), # DST change in Germany (23 hours/ day)
("2024-10-27 00:00:00", False), # DST change in Germany (25 hours/ day)
],
)
def test_import(elecprice_provider, sample_import_1_json, start_datetime, from_file):
"""Test fetching forecast from Import."""
ems_eos.set_start_datetime(to_datetime(start_datetime, in_timezone="Europe/Berlin"))
if from_file:
config_eos.elecpriceimport_json = None
assert config_eos.elecpriceimport_json is None
else:
config_eos.elecpriceimport_file_path = None
assert config_eos.elecpriceimport_file_path is None
elecprice_provider.clear()
# Call the method
elecprice_provider.update_data()
# Assert: Verify the result is as expected
assert elecprice_provider.start_datetime is not None
assert elecprice_provider.total_hours is not None
assert compare_datetimes(elecprice_provider.start_datetime, ems_eos.start_datetime).equal
values = sample_import_1_json["elecprice_marketprice"]
value_datetime_mapping = elecprice_provider.import_datetimes(len(values))
for i, mapping in enumerate(value_datetime_mapping):
assert i < len(elecprice_provider.records)
expected_datetime, expected_value_index = mapping
expected_value = values[expected_value_index]
result_datetime = elecprice_provider.records[i].date_time
result_value = elecprice_provider.records[i]["elecprice_marketprice"]
# print(f"{i}: Expected: {expected_datetime}:{expected_value}")
# print(f"{i}: Result: {result_datetime}:{result_value}")
assert compare_datetimes(result_datetime, expected_datetime).equal
assert result_value == expected_value

View File

@@ -0,0 +1,99 @@
from unittest.mock import patch
import numpy as np
import pendulum
import pytest
from akkudoktoreos.config.config import get_config
from akkudoktoreos.core.ems import get_ems
from akkudoktoreos.prediction.loadakkudoktor import (
LoadAkkudoktor,
LoadAkkudoktorCommonSettings,
)
config_eos = get_config()
ems_eos = get_ems()
@pytest.fixture
def load_provider(monkeypatch):
"""Fixture to create a LoadAkkudoktor instance."""
settings = {
"load0_provider": "LoadAkkudoktor",
"load0_name": "Akkudoktor Profile",
"loadakkudoktor_year_energy": "1000",
}
config_eos.merge_settings_from_dict(settings)
return LoadAkkudoktor()
@pytest.fixture
def mock_load_profiles_file(tmp_path):
"""Fixture to create a mock load profiles file."""
load_profiles_path = tmp_path / "load_profiles.npz"
np.savez(
load_profiles_path,
yearly_profiles=np.random.rand(365, 24), # Random load profiles
yearly_profiles_std=np.random.rand(365, 24), # Random standard deviation
)
return load_profiles_path
def test_loadakkudoktor_settings_validator():
"""Test the field validator for `loadakkudoktor_year_energy`."""
settings = LoadAkkudoktorCommonSettings(loadakkudoktor_year_energy=1234)
assert isinstance(settings.loadakkudoktor_year_energy, float)
assert settings.loadakkudoktor_year_energy == 1234.0
settings = LoadAkkudoktorCommonSettings(loadakkudoktor_year_energy=1234.56)
assert isinstance(settings.loadakkudoktor_year_energy, float)
assert settings.loadakkudoktor_year_energy == 1234.56
def test_loadakkudoktor_provider_id(load_provider):
"""Test the `provider_id` class method."""
assert load_provider.provider_id() == "LoadAkkudoktor"
@patch("akkudoktoreos.prediction.loadakkudoktor.Path")
@patch("akkudoktoreos.prediction.loadakkudoktor.np.load")
def test_load_data_from_mock(mock_np_load, mock_path, mock_load_profiles_file, load_provider):
"""Test the `load_data` method."""
# Mock path behavior to return the test file
mock_path.return_value.parent.parent.joinpath.return_value = mock_load_profiles_file
# Mock numpy load to return data similar to what would be in the file
mock_np_load.return_value = {
"yearly_profiles": np.ones((365, 24)),
"yearly_profiles_std": np.zeros((365, 24)),
}
# Test data loading
data_year_energy = load_provider.load_data()
assert data_year_energy is not None
assert data_year_energy.shape == (365, 2, 24)
def test_load_data_from_file(load_provider):
"""Test `load_data` loads data from the profiles file."""
data_year_energy = load_provider.load_data()
assert data_year_energy is not None
@patch("akkudoktoreos.prediction.loadakkudoktor.LoadAkkudoktor.load_data")
def test_update_data(mock_load_data, load_provider):
"""Test the `_update` method."""
mock_load_data.return_value = np.random.rand(365, 2, 24)
# Mock methods for updating values
ems_eos.set_start_datetime(pendulum.datetime(2024, 1, 1))
# Assure there are no prediction records
load_provider.clear()
assert len(load_provider) == 0
# Execute the method
load_provider._update_data()
# Validate that update_value is called
assert len(load_provider) > 0

View File

@@ -16,4 +16,9 @@ def test_openapi_spec_current():
new_spec = json.load(f_new)
with open(old_spec_path) as f_old:
old_spec = json.load(f_old)
# Serialize to ensure comparison is consistent
new_spec = json.dumps(new_spec, indent=4, sort_keys=True)
old_spec = json.dumps(old_spec, indent=4, sort_keys=True)
assert new_spec == old_spec

226
tests/test_prediction.py Normal file
View File

@@ -0,0 +1,226 @@
import pytest
from pydantic import ValidationError
from akkudoktoreos.config.config import get_config
from akkudoktoreos.core.ems import get_ems
from akkudoktoreos.prediction.elecpriceakkudoktor import ElecPriceAkkudoktor
from akkudoktoreos.prediction.elecpriceimport import ElecPriceImport
from akkudoktoreos.prediction.loadakkudoktor import LoadAkkudoktor
from akkudoktoreos.prediction.loadimport import LoadImport
from akkudoktoreos.prediction.prediction import (
Prediction,
PredictionCommonSettings,
get_prediction,
)
from akkudoktoreos.prediction.pvforecastakkudoktor import PVForecastAkkudoktor
from akkudoktoreos.prediction.pvforecastimport import PVForecastImport
from akkudoktoreos.prediction.weatherbrightsky import WeatherBrightSky
from akkudoktoreos.prediction.weatherclearoutside import WeatherClearOutside
from akkudoktoreos.prediction.weatherimport import WeatherImport
from akkudoktoreos.utils.datetimeutil import compare_datetimes, to_datetime
@pytest.fixture
def sample_settings(reset_config):
"""Fixture that adds settings data to the global config."""
settings = {
"prediction_hours": 48,
"prediction_historic_hours": 24,
"latitude": 52.52,
"longitude": 13.405,
"pvforecast_provider": "PVForecastAkkudoktor",
"pvforecast0_peakpower": 5.0,
"pvforecast0_surface_azimuth": -10,
"pvforecast0_surface_tilt": 7,
"pvforecast0_userhorizon": [20, 27, 22, 20],
"pvforecast0_inverter_paco": 10000,
"pvforecast1_peakpower": 4.8,
"pvforecast1_surface_azimuth": -90,
"pvforecast1_surface_tilt": 7,
"pvforecast1_userhorizon": [30, 30, 30, 50],
"pvforecast1_inverter_paco": 10000,
"pvforecast2_peakpower": 1.4,
"pvforecast2_surface_azimuth": -40,
"pvforecast2_surface_tilt": 60,
"pvforecast2_userhorizon": [60, 30, 0, 30],
"pvforecast2_inverter_paco": 2000,
"pvforecast3_peakpower": 1.6,
"pvforecast3_surface_azimuth": 5,
"pvforecast3_surface_tilt": 45,
"pvforecast3_userhorizon": [45, 25, 30, 60],
"pvforecast3_inverter_paco": 1400,
"pvforecast4_peakpower": None,
}
# Merge settings to config
config = get_config()
config.merge_settings_from_dict(settings)
return config
@pytest.fixture
def prediction():
"""All EOS predictions."""
return get_prediction()
@pytest.fixture
def forecast_providers():
"""Fixture for singleton forecast provider instances."""
return [
ElecPriceAkkudoktor(),
ElecPriceImport(),
LoadAkkudoktor(),
LoadImport(),
PVForecastAkkudoktor(),
PVForecastImport(),
WeatherBrightSky(),
WeatherClearOutside(),
WeatherImport(),
]
@pytest.mark.parametrize(
"prediction_hours, prediction_historic_hours, latitude, longitude, expected_timezone",
[
(48, 24, 40.7128, -74.0060, "America/New_York"), # Valid latitude/longitude
(0, 0, None, None, None), # No location
(100, 50, 51.5074, -0.1278, "Europe/London"), # Another valid location
],
)
def test_prediction_common_settings_valid(
prediction_hours, prediction_historic_hours, latitude, longitude, expected_timezone
):
"""Test valid settings for PredictionCommonSettings."""
settings = PredictionCommonSettings(
prediction_hours=prediction_hours,
prediction_historic_hours=prediction_historic_hours,
latitude=latitude,
longitude=longitude,
)
assert settings.prediction_hours == prediction_hours
assert settings.prediction_historic_hours == prediction_historic_hours
assert settings.latitude == latitude
assert settings.longitude == longitude
assert settings.timezone == expected_timezone
@pytest.mark.parametrize(
"field_name, invalid_value, expected_error",
[
("prediction_hours", -1, "Input should be greater than or equal to 0"),
("prediction_historic_hours", -5, "Input should be greater than or equal to 0"),
("latitude", -91.0, "Input should be greater than or equal to -90"),
("latitude", 91.0, "Input should be less than or equal to 90"),
("longitude", -181.0, "Input should be greater than or equal to -180"),
("longitude", 181.0, "Input should be less than or equal to 180"),
],
)
def test_prediction_common_settings_invalid(field_name, invalid_value, expected_error):
"""Test invalid settings for PredictionCommonSettings."""
valid_data = {
"prediction_hours": 48,
"prediction_historic_hours": 24,
"latitude": 40.7128,
"longitude": -74.0060,
}
valid_data[field_name] = invalid_value
with pytest.raises(ValidationError, match=expected_error):
PredictionCommonSettings(**valid_data)
def test_prediction_common_settings_no_location():
"""Test that timezone is None when latitude and longitude are not provided."""
settings = PredictionCommonSettings(
prediction_hours=48, prediction_historic_hours=24, latitude=None, longitude=None
)
assert settings.timezone is None
def test_prediction_common_settings_with_location():
"""Test that timezone is correctly computed when latitude and longitude are provided."""
settings = PredictionCommonSettings(
prediction_hours=48, prediction_historic_hours=24, latitude=34.0522, longitude=-118.2437
)
assert settings.timezone == "America/Los_Angeles"
def test_prediction_common_settings_timezone_none_when_coordinates_missing():
"""Test that timezone is None when latitude or longitude is missing."""
config_no_latitude = PredictionCommonSettings(longitude=-74.0060)
config_no_longitude = PredictionCommonSettings(latitude=40.7128)
config_no_coords = PredictionCommonSettings()
assert config_no_latitude.timezone is None
assert config_no_longitude.timezone is None
assert config_no_coords.timezone is None
def test_initialization(prediction, forecast_providers):
"""Test that Prediction is initialized with the correct providers in sequence."""
assert isinstance(prediction, Prediction)
assert prediction.providers == forecast_providers
def test_provider_sequence(prediction):
"""Test the provider sequence is maintained in the Prediction instance."""
assert isinstance(prediction.providers[0], ElecPriceAkkudoktor)
assert isinstance(prediction.providers[1], ElecPriceImport)
assert isinstance(prediction.providers[2], LoadAkkudoktor)
assert isinstance(prediction.providers[3], LoadImport)
assert isinstance(prediction.providers[4], PVForecastAkkudoktor)
assert isinstance(prediction.providers[5], PVForecastImport)
assert isinstance(prediction.providers[6], WeatherBrightSky)
assert isinstance(prediction.providers[7], WeatherClearOutside)
assert isinstance(prediction.providers[8], WeatherImport)
def test_update_calls_providers(sample_settings, prediction):
"""Test that the update method calls the update method for each provider in sequence."""
# Mark the `update_datetime` method for each provider
old_datetime = to_datetime("1970-01-01 00:00:00")
for provider in prediction.providers:
provider.update_datetime = old_datetime
ems_eos = get_ems()
ems_eos.set_start_datetime(to_datetime())
prediction.update_data()
# Verify each provider's `update` method was called
for provider in prediction.providers:
if provider.enabled():
assert compare_datetimes(provider.update_datetime, old_datetime).gt
def test_provider_by_id(prediction, forecast_providers):
"""Test that provider_by_id method returns the correct provider."""
for provider in forecast_providers:
assert prediction.provider_by_id(provider.provider_id()) == provider
def test_prediction_repr(prediction):
"""Test that the Prediction instance's representation is correct."""
result = repr(prediction)
assert "Prediction([" in result
assert "ElecPriceAkkudoktor" in result
assert "ElecPriceImport" in result
assert "LoadAkkudoktor" in result
assert "LoadImport" in result
assert "PVForecastAkkudoktor" in result
assert "PVForecastImport" in result
assert "WeatherBrightSky" in result
assert "WeatherClearOutside" in result
assert "WeatherImport" in result
def test_empty_providers(prediction, forecast_providers):
"""Test behavior when Prediction does not have providers."""
# Clear all prediction providers from prediction
providers_bkup = prediction.providers.copy()
prediction.providers.clear()
assert prediction.providers == []
prediction.update_data() # Should not raise an error even with no providers
# Cleanup after Test
prediction.providers = providers_bkup

437
tests/test_predictionabc.py Normal file
View File

@@ -0,0 +1,437 @@
import os
from datetime import datetime
from typing import Any, ClassVar, List, Optional, Union
import pandas as pd
import pendulum
import pytest
from pydantic import Field
from akkudoktoreos.config.config import get_config
from akkudoktoreos.core.ems import get_ems
from akkudoktoreos.prediction.prediction import PredictionCommonSettings
from akkudoktoreos.prediction.predictionabc import (
PredictionBase,
PredictionContainer,
PredictionProvider,
PredictionRecord,
PredictionSequence,
)
from akkudoktoreos.utils.datetimeutil import compare_datetimes, to_datetime, to_duration
# Derived classes for testing
# ---------------------------
class DerivedConfig(PredictionCommonSettings):
env_var: Optional[int] = Field(default=None, description="Test config by environment var")
instance_field: Optional[str] = Field(default=None, description="Test config by instance field")
class_constant: Optional[int] = Field(default=None, description="Test config by class constant")
class DerivedBase(PredictionBase):
instance_field: Optional[str] = Field(default=None, description="Field Value")
class_constant: ClassVar[int] = 30
class DerivedRecord(PredictionRecord):
prediction_value: Optional[float] = Field(default=None, description="Prediction Value")
class DerivedSequence(PredictionSequence):
# overload
records: List[DerivedRecord] = Field(
default_factory=list, description="List of DerivedRecord records"
)
@classmethod
def record_class(cls) -> Any:
return DerivedRecord
class DerivedPredictionProvider(PredictionProvider):
"""A concrete subclass of PredictionProvider for testing purposes."""
# overload
records: List[DerivedRecord] = Field(
default_factory=list, description="List of DerivedRecord records"
)
provider_enabled: ClassVar[bool] = False
provider_updated: ClassVar[bool] = False
@classmethod
def record_class(cls) -> Any:
return DerivedRecord
# Implement abstract methods for test purposes
def provider_id(self) -> str:
return "DerivedPredictionProvider"
def enabled(self) -> bool:
return self.provider_enabled
def _update_data(self, force_update: Optional[bool] = False) -> None:
# Simulate update logic
DerivedPredictionProvider.provider_updated = True
class DerivedPredictionContainer(PredictionContainer):
providers: List[Union[DerivedPredictionProvider, PredictionProvider]] = Field(
default_factory=list, description="List of prediction providers"
)
# Tests
# ----------
class TestPredictionBase:
@pytest.fixture
def base(self, reset_config, monkeypatch):
# Provide default values for configuration
monkeypatch.setenv("latitude", "50.0")
monkeypatch.setenv("longitude", "10.0")
derived = DerivedBase()
derived.config.update()
return derived
def test_config_value_from_env_variable(self, base, monkeypatch):
# From Prediction Config
monkeypatch.setenv("latitude", "2.5")
base.config.update()
assert base.config.latitude == 2.5
def test_config_value_from_field_default(self, base, monkeypatch):
assert base.config.model_fields["prediction_hours"].default == 48
assert base.config.prediction_hours == 48
monkeypatch.setenv("prediction_hours", "128")
base.config.update()
assert base.config.prediction_hours == 128
monkeypatch.delenv("prediction_hours")
base.config.update()
assert base.config.prediction_hours == 48
def test_get_config_value_key_error(self, base):
with pytest.raises(AttributeError):
base.config.non_existent_key
# TestPredictionRecord fully covered by TestDataRecord
# ----------------------------------------------------
# TestPredictionSequence fully covered by TestDataSequence
# --------------------------------------------------------
# TestPredictionStartEndKeepMixin fully covered by TestPredictionContainer
# --------------------------------------------------------
class TestPredictionProvider:
# Fixtures and helper functions
@pytest.fixture
def provider(self):
"""Fixture to provide an instance of TestPredictionProvider for testing."""
DerivedPredictionProvider.provider_enabled = True
DerivedPredictionProvider.provider_updated = False
return DerivedPredictionProvider()
@pytest.fixture
def sample_start_datetime(self):
"""Fixture for a sample start datetime."""
return to_datetime(datetime(2024, 11, 1, 12, 0))
def create_test_record(self, date, value):
"""Helper function to create a test PredictionRecord."""
return DerivedRecord(date_time=date, prediction_value=value)
# Tests
def test_singleton_behavior(self, provider):
"""Test that PredictionProvider enforces singleton behavior."""
instance1 = provider
instance2 = DerivedPredictionProvider()
assert (
instance1 is instance2
), "Singleton pattern is not enforced; instances are not the same."
def test_update_computed_fields(self, provider, sample_start_datetime):
"""Test that computed fields `end_datetime` and `keep_datetime` are correctly calculated."""
ems_eos = get_ems()
ems_eos.set_start_datetime(sample_start_datetime)
provider.config.prediction_hours = 24 # 24 hours into the future
provider.config.prediction_historic_hours = 48 # 48 hours into the past
expected_end_datetime = sample_start_datetime + to_duration(
provider.config.prediction_hours * 3600
)
expected_keep_datetime = sample_start_datetime - to_duration(
provider.config.prediction_historic_hours * 3600
)
assert (
provider.end_datetime == expected_end_datetime
), "End datetime is not calculated correctly."
assert (
provider.keep_datetime == expected_keep_datetime
), "Keep datetime is not calculated correctly."
def test_update_method_with_defaults(self, provider, sample_start_datetime, monkeypatch):
"""Test the `update` method with default parameters."""
# EOS config supersedes
config_eos = get_config()
ems_eos = get_ems()
# The following values are currently not set in EOS config, we can override
monkeypatch.setenv("prediction_historic_hours", "2")
assert os.getenv("prediction_historic_hours") == "2"
monkeypatch.setenv("latitude", "37.7749")
assert os.getenv("latitude") == "37.7749"
monkeypatch.setenv("longitude", "-122.4194")
assert os.getenv("longitude") == "-122.4194"
ems_eos.set_start_datetime(sample_start_datetime)
provider.update_data()
assert provider.config.prediction_hours == config_eos.prediction_hours
assert provider.config.prediction_historic_hours == 2
assert provider.config.latitude == 37.7749
assert provider.config.longitude == -122.4194
assert provider.start_datetime == sample_start_datetime
assert provider.end_datetime == sample_start_datetime + to_duration(
f"{provider.config.prediction_hours} hours"
)
assert provider.keep_datetime == sample_start_datetime - to_duration("2 hours")
def test_update_method_force_enable(self, provider, monkeypatch):
"""Test that `update` executes when `force_enable` is True, even if `enabled` is False."""
# Preset values that are needed by update
monkeypatch.setenv("latitude", "37.7749")
monkeypatch.setenv("longitude", "-122.4194")
# Override enabled to return False for this test
DerivedPredictionProvider.provider_enabled = False
DerivedPredictionProvider.provider_updated = False
provider.update_data(force_enable=True)
assert provider.enabled() is False, "Provider should be disabled, but enabled() is True."
assert (
DerivedPredictionProvider.provider_updated is True
), "Provider should have been executed, but was not."
def test_delete_by_datetime(self, provider, sample_start_datetime):
"""Test `delete_by_datetime` method for removing records by datetime range."""
# Add records to the provider for deletion testing
provider.records = [
self.create_test_record(sample_start_datetime - to_duration("3 hours"), 1),
self.create_test_record(sample_start_datetime - to_duration("1 hour"), 2),
self.create_test_record(sample_start_datetime + to_duration("1 hour"), 3),
]
provider.delete_by_datetime(
start_datetime=sample_start_datetime - to_duration("2 hours"),
end_datetime=sample_start_datetime + to_duration("2 hours"),
)
assert (
len(provider.records) == 1
), "Only one record should remain after deletion by datetime."
assert provider.records[0].date_time == sample_start_datetime - to_duration(
"3 hours"
), "Unexpected record remains."
class TestPredictionContainer:
# Fixture and helpers
@pytest.fixture
def container(self):
container = DerivedPredictionContainer()
return container
@pytest.fixture
def container_with_providers(self):
record1 = self.create_test_record(datetime(2023, 11, 5), 1)
record2 = self.create_test_record(datetime(2023, 11, 6), 2)
record3 = self.create_test_record(datetime(2023, 11, 7), 3)
provider = DerivedPredictionProvider()
provider.clear()
assert len(provider) == 0
provider.append(record1)
provider.append(record2)
provider.append(record3)
assert len(provider) == 3
container = DerivedPredictionContainer()
container.providers.clear()
assert len(container.providers) == 0
container.providers.append(provider)
assert len(container.providers) == 1
return container
def create_test_record(self, date, value):
"""Helper function to create a test PredictionRecord."""
return DerivedRecord(date_time=date, prediction_value=value)
# Tests
@pytest.mark.parametrize(
"start, hours, end",
[
("2024-11-10 00:00:00", 24, "2024-11-11 00:00:00"), # No DST in Germany
("2024-08-10 00:00:00", 24, "2024-08-11 00:00:00"), # DST in Germany
("2024-03-31 00:00:00", 24, "2024-04-01 00:00:00"), # DST change (23 hours/ day)
("2024-10-27 00:00:00", 24, "2024-10-28 00:00:00"), # DST change (25 hours/ day)
("2024-11-10 00:00:00", 48, "2024-11-12 00:00:00"), # No DST in Germany
("2024-08-10 00:00:00", 48, "2024-08-12 00:00:00"), # DST in Germany
("2024-03-31 00:00:00", 48, "2024-04-02 00:00:00"), # DST change (47 hours/ day)
("2024-10-27 00:00:00", 48, "2024-10-29 00:00:00"), # DST change (49 hours/ day)
],
)
def test_end_datetime(self, container, start, hours, end):
"""Test end datetime calculation from start datetime."""
ems_eos = get_ems()
ems_eos.set_start_datetime(to_datetime(start, in_timezone="Europe/Berlin"))
settings = {
"prediction_hours": hours,
}
container.config.merge_settings_from_dict(settings)
expected = to_datetime(end, in_timezone="Europe/Berlin")
assert compare_datetimes(container.end_datetime, expected).equal
@pytest.mark.parametrize(
"start, historic_hours, expected_keep",
[
# Standard case
(
pendulum.datetime(2024, 8, 10, 0, 0, tz="Europe/Berlin"),
24,
pendulum.datetime(2024, 8, 9, 0, 0, tz="Europe/Berlin"),
),
# With DST, but should not affect historical data
(
pendulum.datetime(2024, 4, 1, 0, 0, tz="Europe/Berlin"),
24,
pendulum.datetime(2024, 3, 30, 23, 0, tz="Europe/Berlin"),
),
],
)
def test_keep_datetime(self, container, start, historic_hours, expected_keep):
"""Test the `keep_datetime` property."""
ems_eos = get_ems()
ems_eos.set_start_datetime(to_datetime(start, in_timezone="Europe/Berlin"))
settings = {
"prediction_historic_hours": historic_hours,
}
container.config.merge_settings_from_dict(settings)
expected = to_datetime(expected_keep, in_timezone="Europe/Berlin")
assert compare_datetimes(container.keep_datetime, expected).equal
@pytest.mark.parametrize(
"start, prediction_hours, expected_hours",
[
("2024-11-10 00:00:00", 24, 24), # No DST in Germany
("2024-08-10 00:00:00", 24, 24), # DST in Germany
("2024-03-31 00:00:00", 24, 23), # DST change in Germany (23 hours/ day)
("2024-10-27 00:00:00", 24, 25), # DST change in Germany (25 hours/ day)
],
)
def test_total_hours(self, container, start, prediction_hours, expected_hours):
"""Test the `total_hours` property."""
ems_eos = get_ems()
ems_eos.set_start_datetime(to_datetime(start, in_timezone="Europe/Berlin"))
settings = {
"prediction_hours": prediction_hours,
}
container.config.merge_settings_from_dict(settings)
assert container.total_hours == expected_hours
@pytest.mark.parametrize(
"start, historic_hours, expected_hours",
[
("2024-11-10 00:00:00", 24, 24), # No DST in Germany
("2024-08-10 00:00:00", 24, 24), # DST in Germany
("2024-04-01 00:00:00", 24, 24), # DST change on 2024-03-31 in Germany (23 hours/ day)
("2024-10-28 00:00:00", 24, 24), # DST change on 2024-10-27 in Germany (25 hours/ day)
],
)
def test_keep_hours(self, container, start, historic_hours, expected_hours):
"""Test the `keep_hours` property."""
ems_eos = get_ems()
ems_eos.set_start_datetime(to_datetime(start, in_timezone="Europe/Berlin"))
settings = {
"prediction_historic_hours": historic_hours,
}
container.config.merge_settings_from_dict(settings)
assert container.keep_hours == expected_hours
def test_append_provider(self, container):
assert len(container.providers) == 0
container.providers.append(DerivedPredictionProvider())
assert len(container.providers) == 1
assert isinstance(container.providers[0], DerivedPredictionProvider)
@pytest.mark.skip(reason="type check not implemented")
def test_append_provider_invalid_type(self, container):
with pytest.raises(ValueError, match="must be an instance of PredictionProvider"):
container.providers.append("not_a_provider")
def test_getitem_existing_key(self, container_with_providers):
assert len(container_with_providers.providers) == 1
# check all keys are available (don't care for position)
for key in ["prediction_value", "date_time"]:
assert key in list(container_with_providers.keys())
series = container_with_providers["prediction_value"]
assert isinstance(series, pd.Series)
assert series.name == "prediction_value"
assert series.tolist() == [1.0, 2.0, 3.0]
def test_getitem_non_existing_key(self, container_with_providers):
with pytest.raises(KeyError, match="No data found for key 'non_existent_key'"):
container_with_providers["non_existent_key"]
def test_setitem_existing_key(self, container_with_providers):
new_series = container_with_providers["prediction_value"]
new_series[:] = [4, 5, 6]
container_with_providers["prediction_value"] = new_series
series = container_with_providers["prediction_value"]
assert series.name == "prediction_value"
assert series.tolist() == [4, 5, 6]
def test_setitem_invalid_value(self, container_with_providers):
with pytest.raises(ValueError, match="Value must be an instance of pd.Series"):
container_with_providers["test_key"] = "not_a_series"
def test_setitem_non_existing_key(self, container_with_providers):
new_series = pd.Series([4, 5, 6], name="non_existent_key")
with pytest.raises(KeyError, match="Key 'non_existent_key' not found"):
container_with_providers["non_existent_key"] = new_series
def test_delitem_existing_key(self, container_with_providers):
del container_with_providers["prediction_value"]
series = container_with_providers["prediction_value"]
assert series.name == "prediction_value"
assert series.tolist() == [None, None, None]
def test_delitem_non_existing_key(self, container_with_providers):
with pytest.raises(KeyError, match="Key 'non_existent_key' not found"):
del container_with_providers["non_existent_key"]
def test_len(self, container_with_providers):
assert len(container_with_providers) == 3
def test_repr(self, container_with_providers):
representation = repr(container_with_providers)
assert representation.startswith("DerivedPredictionContainer(")
assert "DerivedPredictionProvider" in representation
def test_to_json(self, container_with_providers):
json_str = container_with_providers.to_json()
container_other = DerivedPredictionContainer.from_json(json_str)
assert container_other == container_with_providers
def test_from_json(self, container_with_providers):
json_str = container_with_providers.to_json()
container = DerivedPredictionContainer.from_json(json_str)
assert isinstance(container, DerivedPredictionContainer)
assert len(container.providers) == 1
assert container.providers[0] == container_with_providers.providers[0]
def test_provider_by_id(self, container_with_providers):
provider = container_with_providers.provider_by_id("DerivedPredictionProvider")
assert isinstance(provider, DerivedPredictionProvider)

View File

@@ -1,286 +0,0 @@
"""Test Module for PV Power Forecasting Module.
This test module is designed to verify the functionality of the `PVForecast` class
and its methods in the `prediction.pv_forecast` module. The tests include validation for
forecast data processing, updating AC power measurements, retrieving forecast data,
and caching behavior.
Fixtures:
sample_forecast_data: Provides sample forecast data in JSON format for testing.
pv_forecast_instance: Provides an instance of `PVForecast` class with sample data loaded.
Test Cases:
- test_generate_cache_filename: Verifies correct cache filename generation based on URL and date.
- test_update_ac_power_measurement: Tests updating AC power measurement for a matching date.
- test_update_ac_power_measurement_no_match: Ensures no updates occur when there is no matching date.
- test_get_temperature_forecast_for_date: Tests retrieving the temperature forecast for a specific date.
- test_get_pv_forecast_for_date_range: Verifies retrieval of AC power forecast for a specified date range.
- test_get_forecast_dataframe: Ensures forecast data can be correctly converted into a Pandas DataFrame.
- test_cache_loading: Tests loading forecast data from a cached file to ensure caching works as expected.
Usage:
This test module uses `pytest` and requires the `akkudoktoreos.prediction.pv_forecast.py` module to be present.
Run the tests using the command: `pytest test_pv_forecast.py`.
"""
import json
import sys
from datetime import datetime, timedelta
from pathlib import Path
import pytest
from akkudoktoreos.prediction.pv_forecast import PVForecast, validate_pv_forecast_data
from akkudoktoreos.utils.datetimeutil import to_datetime
DIR_TESTDATA = Path(__file__).absolute().parent.joinpath("testdata")
FILE_TESTDATA_PV_FORECAST_INPUT_1 = DIR_TESTDATA.joinpath("pv_forecast_input_1.json")
FILE_TESTDATA_PV_FORECAST_RESULT_1 = DIR_TESTDATA.joinpath("pv_forecast_result_1.txt")
@pytest.fixture
def sample_forecast_data():
"""Fixture that returns sample forecast data."""
with open(FILE_TESTDATA_PV_FORECAST_INPUT_1, "r") as f_in:
input_data = json.load(f_in)
return input_data
@pytest.fixture
def sample_forecast_report():
"""Fixture that returns sample forecast data report."""
with open(FILE_TESTDATA_PV_FORECAST_RESULT_1, "r") as f_res:
input_data = f_res.read()
return input_data
@pytest.fixture
def sample_forecast_start(sample_forecast_data):
"""Fixture that returns the start date of the sample forecast data."""
forecast_start_str = sample_forecast_data["values"][0][0]["datetime"]
assert forecast_start_str == "2024-10-06T00:00:00.000+02:00"
timezone_name = sample_forecast_data["meta"]["timezone"]
assert timezone_name == "Europe/Berlin"
forecast_start = to_datetime(forecast_start_str, to_timezone=timezone_name, to_naiv=True)
assert forecast_start == datetime(2024, 10, 6)
return forecast_start
@pytest.fixture
def pv_forecast_empty_instance():
"""Fixture that returns an empty instance of PVForecast."""
empty_instance = PVForecast()
assert empty_instance.get_forecast_start() is None
return empty_instance
@pytest.fixture
def pv_forecast_instance(sample_forecast_data, sample_forecast_start):
"""Fixture that returns an instance of PVForecast with sample data loaded."""
pv_forecast = PVForecast(
data=sample_forecast_data,
forecast_start=sample_forecast_start,
prediction_hours=48,
)
return pv_forecast
def test_validate_pv_forecast_data(sample_forecast_data):
"""Test validation of PV forecast data on sample data."""
ret = validate_pv_forecast_data({})
assert ret is None
ret = validate_pv_forecast_data(sample_forecast_data)
assert ret == "Akkudoktor"
def test_process_data(sample_forecast_data, sample_forecast_start):
"""Test data processing using sample data."""
pv_forecast_instance = PVForecast(forecast_start=sample_forecast_start)
# Assure the start date is correctly set by init funtion
forecast_start = pv_forecast_instance.get_forecast_start()
expected_start = sample_forecast_start
assert forecast_start == expected_start
# Assure the prediction hours are unset
assert pv_forecast_instance.prediction_hours is None
# Load forecast with sample data - throws exceptions on error
pv_forecast_instance.process_data(data=sample_forecast_data)
def test_update_ac_power_measurement(pv_forecast_instance, sample_forecast_start):
"""Test updating AC power measurement for a specific date."""
forecast_start = pv_forecast_instance.get_forecast_start()
assert forecast_start == sample_forecast_start
updated = pv_forecast_instance.update_ac_power_measurement(1000, forecast_start)
assert updated is True
forecast_data = pv_forecast_instance.get_forecast_data()
assert forecast_data[0].ac_power_measurement == 1000
def test_update_ac_power_measurement_no_match(pv_forecast_instance):
"""Test updating AC power measurement where no date matches."""
date_time = datetime(2023, 10, 2, 1, 0, 0)
updated = pv_forecast_instance.update_ac_power_measurement(1000, date_time)
assert not updated
def test_get_temperature_forecast_for_date(pv_forecast_instance, sample_forecast_start):
"""Test fetching temperature forecast for a specific date."""
forecast_temps = pv_forecast_instance.get_temperature_forecast_for_date(sample_forecast_start)
assert len(forecast_temps) == 24
assert forecast_temps[0] == 7.0
assert forecast_temps[1] == 6.5
assert forecast_temps[2] == 6.0
# Assure function bails out if there is no timezone name available for the system.
tz_name = pv_forecast_instance._tz_name
pv_forecast_instance._tz_name = None
with pytest.raises(Exception) as exc_info:
forecast_temps = pv_forecast_instance.get_temperature_forecast_for_date(
sample_forecast_start
)
pv_forecast_instance._tz_name = tz_name
assert (
exc_info.value.args[0] == "Processing without PV system timezone info ist not implemented!"
)
def test_get_temperature_for_date_range(pv_forecast_instance, sample_forecast_start):
"""Test fetching temperature forecast for a specific date range."""
end_date = sample_forecast_start + timedelta(hours=24)
forecast_temps = pv_forecast_instance.get_temperature_for_date_range(
sample_forecast_start, end_date
)
assert len(forecast_temps) == 48
assert forecast_temps[0] == 7.0
assert forecast_temps[1] == 6.5
assert forecast_temps[2] == 6.0
# Assure function bails out if there is no timezone name available for the system.
tz_name = pv_forecast_instance._tz_name
pv_forecast_instance._tz_name = None
with pytest.raises(Exception) as exc_info:
forecast_temps = pv_forecast_instance.get_temperature_for_date_range(
sample_forecast_start, end_date
)
pv_forecast_instance._tz_name = tz_name
assert (
exc_info.value.args[0] == "Processing without PV system timezone info ist not implemented!"
)
def test_get_forecast_for_date_range(pv_forecast_instance, sample_forecast_start):
"""Test fetching AC power forecast for a specific date range."""
end_date = sample_forecast_start + timedelta(hours=24)
forecast = pv_forecast_instance.get_pv_forecast_for_date_range(sample_forecast_start, end_date)
assert len(forecast) == 48
assert forecast[0] == 0.0
assert forecast[1] == 0.0
assert forecast[2] == 0.0
# Assure function bails out if there is no timezone name available for the system.
tz_name = pv_forecast_instance._tz_name
pv_forecast_instance._tz_name = None
with pytest.raises(Exception) as exc_info:
forecast = pv_forecast_instance.get_pv_forecast_for_date_range(
sample_forecast_start, end_date
)
pv_forecast_instance._tz_name = tz_name
assert (
exc_info.value.args[0] == "Processing without PV system timezone info ist not implemented!"
)
def test_get_forecast_dataframe(pv_forecast_instance):
"""Test converting forecast data to a DataFrame."""
df = pv_forecast_instance.get_forecast_dataframe()
assert len(df) == 288
assert list(df.columns) == ["date_time", "dc_power", "ac_power", "windspeed_10m", "temperature"]
assert df.iloc[0]["dc_power"] == 0.0
assert df.iloc[1]["ac_power"] == 0.0
assert df.iloc[2]["temperature"] == 6.0
def test_load_data_from_file(server, pv_forecast_empty_instance):
"""Test loading data from file."""
# load from valid address file path
filepath = FILE_TESTDATA_PV_FORECAST_INPUT_1
data = pv_forecast_empty_instance.load_data_from_file(filepath)
assert len(data) > 0
def test_load_data_from_url(server, pv_forecast_empty_instance):
"""Test loading data from url."""
# load from valid address of our server
url = f"{server}/gesamtlast_simple?year_energy=2000&"
data = pv_forecast_empty_instance.load_data_from_url(url)
assert len(data) > 0
# load from invalid address of our server
url = f"{server}/invalid?"
data = pv_forecast_empty_instance.load_data_from_url(url)
assert data == f"Failed to load data from `{url}`. Status Code: 404"
def test_load_data_from_url_with_caching(
server, pv_forecast_empty_instance, sample_forecast_data, sample_forecast_start
):
"""Test loading data from url with cache."""
# load from valid address of our server
url = f"{server}/gesamtlast_simple?year_energy=2000&"
data = pv_forecast_empty_instance.load_data_from_url_with_caching(url)
assert len(data) > 0
# load from invalid address of our server
url = f"{server}/invalid?"
data = pv_forecast_empty_instance.load_data_from_url_with_caching(url)
assert data == f"Failed to load data from `{url}`. Status Code: 404"
def test_report_ac_power_and_measurement(pv_forecast_instance, sample_forecast_report):
"""Test reporting."""
report = pv_forecast_instance.report_ac_power_and_measurement()
assert report == sample_forecast_report
@pytest.mark.skipif(
sys.platform.startswith("win"), reason="'other_timezone' fixture not supported on Windows"
)
def test_timezone_behaviour(
pv_forecast_instance, sample_forecast_report, sample_forecast_start, other_timezone
):
"""Test PVForecast in another timezone."""
current_time = datetime.now()
# Test updating AC power measurement for a specific date.
date_time = pv_forecast_instance.get_forecast_start()
assert date_time == sample_forecast_start
updated = pv_forecast_instance.update_ac_power_measurement(1000, date_time)
assert updated is True
forecast_data = pv_forecast_instance.get_forecast_data()
assert forecast_data[0].ac_power_measurement == 1000
# Test fetching temperature forecast for a specific date.
forecast_temps = pv_forecast_instance.get_temperature_forecast_for_date(sample_forecast_start)
assert len(forecast_temps) == 24
assert forecast_temps[0] == 7.0
assert forecast_temps[1] == 6.5
assert forecast_temps[2] == 6.0
# Test fetching AC power forecast
end_date = sample_forecast_start + timedelta(hours=24)
forecast = pv_forecast_instance.get_pv_forecast_for_date_range(sample_forecast_start, end_date)
assert len(forecast) == 48
assert forecast[0] == 1000.0 # changed before
assert forecast[1] == 0.0
assert forecast[2] == 0.0

View File

@@ -0,0 +1,307 @@
import sys
from pathlib import Path
from unittest.mock import Mock, patch
import pytest
from akkudoktoreos.config.config import get_config
from akkudoktoreos.core.ems import get_ems
from akkudoktoreos.prediction.prediction import get_prediction
from akkudoktoreos.prediction.pvforecastakkudoktor import (
AkkudoktorForecastHorizon,
AkkudoktorForecastMeta,
AkkudoktorForecastValue,
PVForecastAkkudoktor,
PVForecastAkkudoktorDataRecord,
)
from akkudoktoreos.utils.datetimeutil import compare_datetimes, to_datetime, to_duration
DIR_TESTDATA = Path(__file__).absolute().parent.joinpath("testdata")
FILE_TESTDATA_PV_FORECAST_INPUT_1 = DIR_TESTDATA.joinpath("pv_forecast_input_1.json")
FILE_TESTDATA_PV_FORECAST_RESULT_1 = DIR_TESTDATA.joinpath("pv_forecast_result_1.txt")
config_eos = get_config()
ems_eos = get_ems()
@pytest.fixture
def sample_settings(reset_config):
"""Fixture that adds settings data to the global config."""
settings = {
"prediction_hours": 48,
"prediction_historic_hours": 24,
"latitude": 52.52,
"longitude": 13.405,
"pvforecast_provider": "PVForecastAkkudoktor",
"pvforecast0_peakpower": 5.0,
"pvforecast0_surface_azimuth": -10,
"pvforecast0_surface_tilt": 7,
"pvforecast0_userhorizon": [20, 27, 22, 20],
"pvforecast0_inverter_paco": 10000,
"pvforecast1_peakpower": 4.8,
"pvforecast1_surface_azimuth": -90,
"pvforecast1_surface_tilt": 7,
"pvforecast1_userhorizon": [30, 30, 30, 50],
"pvforecast1_inverter_paco": 10000,
"pvforecast2_peakpower": 1.4,
"pvforecast2_surface_azimuth": -40,
"pvforecast2_surface_tilt": 60,
"pvforecast2_userhorizon": [60, 30, 0, 30],
"pvforecast2_inverter_paco": 2000,
"pvforecast3_peakpower": 1.6,
"pvforecast3_surface_azimuth": 5,
"pvforecast3_surface_tilt": 45,
"pvforecast3_userhorizon": [45, 25, 30, 60],
"pvforecast3_inverter_paco": 1400,
"pvforecast4_peakpower": None,
}
# Merge settings to config
config_eos.merge_settings_from_dict(settings)
return config_eos
@pytest.fixture
def sample_forecast_data():
"""Fixture that returns sample forecast data converted to pydantic model."""
with open(FILE_TESTDATA_PV_FORECAST_INPUT_1, "r") as f_in:
input_data = f_in.read()
return PVForecastAkkudoktor._validate_data(input_data)
@pytest.fixture
def sample_forecast_data_raw():
"""Fixture that returns raw sample forecast data."""
with open(FILE_TESTDATA_PV_FORECAST_INPUT_1, "r") as f_in:
input_data = f_in.read()
return input_data
@pytest.fixture
def sample_forecast_report():
"""Fixture that returns sample forecast data report."""
with open(FILE_TESTDATA_PV_FORECAST_RESULT_1, "r") as f_res:
input_data = f_res.read()
return input_data
@pytest.fixture
def sample_forecast_start(sample_forecast_data):
"""Fixture that returns the start date of the sample forecast data."""
forecast_start = to_datetime(sample_forecast_data.values[0][0].datetime)
expected_datetime = to_datetime("2024-10-06T00:00:00.000+02:00")
assert compare_datetimes(to_datetime(forecast_start), expected_datetime).equal
timezone_name = sample_forecast_data.meta.timezone
assert timezone_name == "Europe/Berlin"
return forecast_start
@pytest.fixture
def provider():
"""Fixture that returns the PVForecastAkkudoktor instance from the prediction."""
prediction = get_prediction()
provider = prediction.provider_by_id("PVForecastAkkudoktor")
assert isinstance(provider, PVForecastAkkudoktor)
return provider
@pytest.fixture
def provider_empty_instance():
"""Fixture that returns an empty instance of PVForecast."""
empty_instance = PVForecastAkkudoktor()
empty_instance.clear()
assert len(empty_instance) == 0
return empty_instance
# Sample data for testing
sample_horizon = AkkudoktorForecastHorizon(altitude=30, azimuthFrom=90, azimuthTo=180)
sample_meta = AkkudoktorForecastMeta(
lat=52.52,
lon=13.405,
power=[5000],
azimuth=[180],
tilt=[30],
timezone="Europe/Berlin",
albedo=0.25,
past_days=5,
inverterEfficiency=0.8,
powerInverter=[10000],
cellCoEff=-0.36,
range=True,
horizont=[[sample_horizon]],
horizontString=["sample_horizon"],
)
sample_value = AkkudoktorForecastValue(
datetime="2024-11-09T12:00:00",
dcPower=500.0,
power=480.0,
sunTilt=30.0,
sunAzimuth=180.0,
temperature=15.0,
relativehumidity_2m=50.0,
windspeed_10m=10.0,
)
sample_config_data = {
"prediction_hours": 48,
"prediction_historic_hours": 24,
"latitude": 52.52,
"longitude":13.405,
"pvforecast_provider": "PVForecastAkkudoktor",
"pvforecast0_peakpower": 5.0,
"pvforecast0_surface_azimuth": 180,
"pvforecast0_surface_tilt": 30,
"pvforecast0_inverter_paco": 10000,
}
# Tests for AkkudoktorForecastHorizon
def test_akkudoktor_forecast_horizon():
horizon = AkkudoktorForecastHorizon(altitude=30, azimuthFrom=90, azimuthTo=180)
assert horizon.altitude == 30
assert horizon.azimuthFrom == 90
assert horizon.azimuthTo == 180
# Tests for AkkudoktorForecastMeta
def test_akkudoktor_forecast_meta():
meta = sample_meta
assert meta.lat == 52.52
assert meta.lon ==13.405
assert meta.power == [5000]
assert meta.tilt == [30]
assert meta.timezone == "Europe/Berlin"
# Tests for AkkudoktorForecastValue
def test_akkudoktor_forecast_value():
value = sample_value
assert value.dcPower == 500.0
assert value.power == 480.0
assert value.temperature == 15.0
assert value.windspeed_10m == 10.0
# Tests for PVForecastAkkudoktorDataRecord
def test_pvforecast_akkudoktor_data_record():
record = PVForecastAkkudoktorDataRecord(
pvforecastakkudoktor_ac_power_measured=1000.0,
pvforecastakkudoktor_wind_speed_10m=10.0,
pvforecastakkudoktor_temp_air=15.0,
)
assert record.pvforecastakkudoktor_ac_power_measured == 1000.0
assert record.pvforecastakkudoktor_wind_speed_10m == 10.0
assert record.pvforecastakkudoktor_temp_air == 15.0
assert (
record.pvforecastakkudoktor_ac_power_any == 1000.0
) # Assuming AC power measured is preferred
def test_pvforecast_akkudoktor_validate_data(provider_empty_instance, sample_forecast_data_raw):
"""Test validation of PV forecast data on sample data."""
with pytest.raises(
ValueError,
match="Field: meta\nError: Field required\nType: missing\nField: values\nError: Field required\nType: missing\n",
):
ret = provider_empty_instance._validate_data("{}")
data = provider_empty_instance._validate_data(sample_forecast_data_raw)
# everything worked
@patch("requests.get")
def test_pvforecast_akkudoktor_update_with_sample_forecast(
mock_get, sample_settings, sample_forecast_data_raw, sample_forecast_start, provider
):
"""Test data processing using sample forecast data."""
# Mock response object
mock_response = Mock()
mock_response.status_code = 200
mock_response.content = sample_forecast_data_raw
mock_get.return_value = mock_response
# Test that update properly inserts data records
ems_eos.set_start_datetime(sample_forecast_start)
provider.update_data(force_enable=True, force_update=True)
assert compare_datetimes(provider.start_datetime, sample_forecast_start).equal
assert compare_datetimes(provider[0].date_time, to_datetime(sample_forecast_start)).equal
# Report Generation Test
def test_report_ac_power_and_measurement(provider):
# Set the configuration
config = get_config()
config.merge_settings_from_dict(sample_config_data)
record = PVForecastAkkudoktorDataRecord(
pvforecastakkudoktor_ac_power_measured=900.0,
pvforecast_dc_power=450.0,
pvforecast_ac_power=400.0,
)
provider.append(record)
report = provider.report_ac_power_and_measurement()
assert "DC: 450.0" in report
assert "AC: 400.0" in report
assert "AC sampled: 900.0" in report
@pytest.mark.skipif(
sys.platform.startswith("win"), reason="'other_timezone' fixture not supported on Windows"
)
@patch("requests.get")
def test_timezone_behaviour(
mock_get,
sample_settings,
sample_forecast_data_raw,
sample_forecast_start,
provider,
set_other_timezone,
):
"""Test PVForecast in another timezone."""
mock_response = Mock()
mock_response.status_code = 200
mock_response.content = sample_forecast_data_raw
mock_get.return_value = mock_response
# sample forecast start in other timezone
other_timezone = set_other_timezone()
other_start_datetime = to_datetime(sample_forecast_start, in_timezone=other_timezone)
assert compare_datetimes(other_start_datetime, sample_forecast_start).equal
expected_datetime = to_datetime("2024-10-06T00:00:00+0200", in_timezone=other_timezone)
assert compare_datetimes(other_start_datetime, expected_datetime).equal
provider.clear()
assert len(provider) == 0
ems_eos.set_start_datetime(other_start_datetime)
provider.update_data(force_update=True)
assert compare_datetimes(provider.start_datetime, other_start_datetime).equal
# Check wether first record starts at requested sample start time
assert compare_datetimes(provider[0].date_time, sample_forecast_start).equal
# Test updating AC power measurement for a specific date.
provider.update_value(sample_forecast_start, "pvforecastakkudoktor_ac_power_measured", 1000)
# Check wether first record was filled with ac power measurement
assert provider[0].pvforecastakkudoktor_ac_power_measured == 1000
# Test fetching temperature forecast for a specific date.
other_end_datetime = other_start_datetime + to_duration("24 hours")
expected_end_datetime = to_datetime("2024-10-07T00:00:00+0200", in_timezone=other_timezone)
assert compare_datetimes(other_end_datetime, expected_end_datetime).equal
forecast_temps = provider.key_to_series(
"pvforecastakkudoktor_temp_air", other_start_datetime, other_end_datetime
)
assert len(forecast_temps) == 24
assert forecast_temps.iloc[0] == 7.0
assert forecast_temps.iloc[1] == 6.5
assert forecast_temps.iloc[2] == 6.0
# Test fetching AC power forecast
other_end_datetime = other_start_datetime + to_duration("48 hours")
forecast_measured = provider.key_to_series(
"pvforecastakkudoktor_ac_power_measured", other_start_datetime, other_end_datetime
)
assert len(forecast_measured) == 48
assert forecast_measured.iloc[0] == 1000.0 # changed before

View File

@@ -0,0 +1,110 @@
import json
from pathlib import Path
import pytest
from akkudoktoreos.config.config import get_config
from akkudoktoreos.core.ems import get_ems
from akkudoktoreos.prediction.pvforecastimport import PVForecastImport
from akkudoktoreos.utils.datetimeutil import compare_datetimes, to_datetime
DIR_TESTDATA = Path(__file__).absolute().parent.joinpath("testdata")
FILE_TESTDATA_PVFORECASTIMPORT_1_JSON = DIR_TESTDATA.joinpath("import_input_1.json")
config_eos = get_config()
ems_eos = get_ems()
@pytest.fixture
def pvforecast_provider(reset_config, sample_import_1_json):
"""Fixture to create a PVForecastProvider instance."""
settings = {
"pvforecast_provider": "PVForecastImport",
"pvforecastimport_file_path": str(FILE_TESTDATA_PVFORECASTIMPORT_1_JSON),
"pvforecastimport_json": json.dumps(sample_import_1_json),
}
config_eos.merge_settings_from_dict(settings)
provider = PVForecastImport()
assert provider.enabled() == True
return provider
@pytest.fixture
def sample_import_1_json():
"""Fixture that returns sample forecast data report."""
with open(FILE_TESTDATA_PVFORECASTIMPORT_1_JSON, "r") as f_res:
input_data = json.load(f_res)
return input_data
# ------------------------------------------------
# General forecast
# ------------------------------------------------
def test_singleton_instance(pvforecast_provider):
"""Test that PVForecastForecast behaves as a singleton."""
another_instance = PVForecastImport()
assert pvforecast_provider is another_instance
def test_invalid_provider(pvforecast_provider):
"""Test requesting an unsupported pvforecast_provider."""
settings = {
"pvforecast_provider": "<invalid>",
"pvforecastimport_file_path": str(FILE_TESTDATA_PVFORECASTIMPORT_1_JSON),
}
config_eos.merge_settings_from_dict(settings)
assert pvforecast_provider.enabled() == False
# ------------------------------------------------
# Import
# ------------------------------------------------
@pytest.mark.parametrize(
"start_datetime, from_file",
[
("2024-11-10 00:00:00", True), # No DST in Germany
("2024-08-10 00:00:00", True), # DST in Germany
("2024-03-31 00:00:00", True), # DST change in Germany (23 hours/ day)
("2024-10-27 00:00:00", True), # DST change in Germany (25 hours/ day)
("2024-11-10 00:00:00", False), # No DST in Germany
("2024-08-10 00:00:00", False), # DST in Germany
("2024-03-31 00:00:00", False), # DST change in Germany (23 hours/ day)
("2024-10-27 00:00:00", False), # DST change in Germany (25 hours/ day)
],
)
def test_import(pvforecast_provider, sample_import_1_json, start_datetime, from_file):
"""Test fetching forecast from import."""
ems_eos.set_start_datetime(to_datetime(start_datetime, in_timezone="Europe/Berlin"))
if from_file:
config_eos.pvforecastimport_json = None
assert config_eos.pvforecastimport_json is None
else:
config_eos.pvforecastimport_file_path = None
assert config_eos.pvforecastimport_file_path is None
pvforecast_provider.clear()
# Call the method
pvforecast_provider.update_data()
# Assert: Verify the result is as expected
assert pvforecast_provider.start_datetime is not None
assert pvforecast_provider.total_hours is not None
assert compare_datetimes(pvforecast_provider.start_datetime, ems_eos.start_datetime).equal
values = sample_import_1_json["pvforecast_ac_power"]
value_datetime_mapping = pvforecast_provider.import_datetimes(len(values))
for i, mapping in enumerate(value_datetime_mapping):
assert i < len(pvforecast_provider.records)
expected_datetime, expected_value_index = mapping
expected_value = values[expected_value_index]
result_datetime = pvforecast_provider.records[i].date_time
result_value = pvforecast_provider.records[i]["pvforecast_ac_power"]
# print(f"{i}: Expected: {expected_datetime}:{expected_value}")
# print(f"{i}: Result: {result_datetime}:{result_value}")
assert compare_datetimes(result_datetime, expected_datetime).equal
assert result_value == expected_value

View File

@@ -1,24 +1,17 @@
from http import HTTPStatus
from pathlib import Path
import requests
from akkudoktoreos.config import CONFIG_FILE_NAME, load_config
from akkudoktoreos.config.config import get_config
config_eos = get_config()
def test_fixture_setup(server, tmp_path: Path) -> None:
"""Test if the fixture sets up the server with the env var."""
# validate correct path in server
config = load_config(tmp_path, False)
assert tmp_path.joinpath(CONFIG_FILE_NAME).is_file()
cache = tmp_path / config.directories.cache
assert cache.is_dir()
def test_server(server, tmp_path: Path):
def test_server(server):
"""Test the server."""
result = requests.get(f"{server}/gesamtlast_simple?year_energy=2000&")
assert result.status_code == HTTPStatus.OK
# validate correct path in server
assert config_eos.data_folder_path is not None
assert config_eos.data_folder_path.is_dir()
config = load_config(tmp_path, False)
assert len(result.json()) == config.eos.prediction_hours
result = requests.get(f"{server}/config?")
assert result.status_code == HTTPStatus.OK

View File

@@ -4,7 +4,7 @@ from pathlib import Path
import pytest
from matplotlib.testing.compare import compare_images
from akkudoktoreos.config import AppConfig
from akkudoktoreos.config.config import get_config
from akkudoktoreos.visualize import visualisiere_ergebnisse
DIR_TESTDATA = Path(__file__).parent / "testdata"
@@ -15,11 +15,15 @@ DIR_IMAGEDATA = DIR_TESTDATA / "images"
"fn_in, fn_out, fn_out_base",
[("visualize_input_1.json", "visualize_output_1.pdf", "visualize_base_output_1.pdf")],
)
def test_visualisiere_ergebnisse(fn_in, fn_out, fn_out_base, tmp_config: AppConfig):
def test_visualisiere_ergebnisse(fn_in, fn_out, fn_out_base):
with open(DIR_TESTDATA / fn_in, "r") as f:
input_data = json.load(f)
visualisiere_ergebnisse(config=tmp_config, **input_data)
output_file: Path = tmp_config.working_dir / tmp_config.directories.output / fn_out
visualisiere_ergebnisse(**input_data)
config = get_config()
output_dir = config.data_output_path
output_dir.mkdir(parents=True, exist_ok=True)
output_file = output_dir.joinpath(fn_out)
assert output_file.is_file()
assert (

View File

@@ -0,0 +1,193 @@
import json
from pathlib import Path
from unittest.mock import Mock, patch
import pandas as pd
import pytest
from akkudoktoreos.core.ems import get_ems
from akkudoktoreos.prediction.weatherbrightsky import WeatherBrightSky
from akkudoktoreos.utils.cacheutil import CacheFileStore
from akkudoktoreos.utils.datetimeutil import to_datetime
DIR_TESTDATA = Path(__file__).absolute().parent.joinpath("testdata")
FILE_TESTDATA_WEATHERBRIGHTSKY_1_JSON = DIR_TESTDATA.joinpath("weatherforecast_brightsky_1.json")
FILE_TESTDATA_WEATHERBRIGHTSKY_2_JSON = DIR_TESTDATA.joinpath("weatherforecast_brightsky_2.json")
ems_eos = get_ems()
@pytest.fixture
def weather_provider(monkeypatch):
"""Fixture to create a WeatherProvider instance."""
monkeypatch.setenv("weather_provider", "BrightSky")
monkeypatch.setenv("latitude", "50.0")
monkeypatch.setenv("longitude", "10.0")
return WeatherBrightSky()
@pytest.fixture
def sample_brightsky_1_json():
"""Fixture that returns sample forecast data report."""
with open(FILE_TESTDATA_WEATHERBRIGHTSKY_1_JSON, "r") as f_res:
input_data = json.load(f_res)
return input_data
@pytest.fixture
def sample_brightsky_2_json():
"""Fixture that returns sample forecast data report."""
with open(FILE_TESTDATA_WEATHERBRIGHTSKY_2_JSON, "r") as f_res:
input_data = json.load(f_res)
return input_data
@pytest.fixture
def cache_store():
"""A pytest fixture that creates a new CacheFileStore instance for testing."""
return CacheFileStore()
# ------------------------------------------------
# General forecast
# ------------------------------------------------
def test_singleton_instance(weather_provider):
"""Test that WeatherForecast behaves as a singleton."""
another_instance = WeatherBrightSky()
assert weather_provider is another_instance
def test_invalid_provider(weather_provider, monkeypatch):
"""Test requesting an unsupported weather_provider."""
monkeypatch.setenv("weather_provider", "<invalid>")
weather_provider.config.update()
assert weather_provider.enabled() == False
def test_invalid_coordinates(weather_provider, monkeypatch):
"""Test invalid coordinates raise ValueError."""
monkeypatch.setenv("latitude", "1000")
monkeypatch.setenv("longitude", "1000")
with pytest.raises(
ValueError, # match="Latitude '1000' and/ or longitude `1000` out of valid range."
):
weather_provider.config.update()
# ------------------------------------------------
# Irradiance caclulation
# ------------------------------------------------
def test_irridiance_estimate_from_cloud_cover(weather_provider):
"""Test cloud cover to irradiance estimation."""
cloud_cover_data = pd.Series(
data=[20, 50, 80], index=pd.date_range("2023-10-22", periods=3, freq="h")
)
ghi, dni, dhi = weather_provider.estimate_irradiance_from_cloud_cover(
50.0, 10.0, cloud_cover_data
)
assert ghi == [0, 0, 0]
assert dhi == [0, 0, 0]
assert dni == [0, 0, 0]
# ------------------------------------------------
# BrightSky
# ------------------------------------------------
@patch("requests.get")
def test_request_forecast(mock_get, weather_provider, sample_brightsky_1_json):
"""Test requesting forecast from BrightSky."""
# Mock response object
mock_response = Mock()
mock_response.status_code = 200
mock_response.content = json.dumps(sample_brightsky_1_json)
mock_get.return_value = mock_response
# Preset, as this is usually done by update()
weather_provider.config.update()
# Test function
brightsky_data = weather_provider._request_forecast()
assert isinstance(brightsky_data, dict)
assert brightsky_data["weather"][0] == {
"timestamp": "2024-10-26T00:00:00+02:00",
"source_id": 46567,
"precipitation": 0.0,
"pressure_msl": 1022.9,
"sunshine": 0.0,
"temperature": 6.2,
"wind_direction": 40,
"wind_speed": 4.7,
"cloud_cover": 100,
"dew_point": 5.8,
"relative_humidity": 97,
"visibility": 140,
"wind_gust_direction": 70,
"wind_gust_speed": 11.9,
"condition": "dry",
"precipitation_probability": None,
"precipitation_probability_6h": None,
"solar": None,
"fallback_source_ids": {
"wind_gust_speed": 219419,
"pressure_msl": 219419,
"cloud_cover": 219419,
"wind_gust_direction": 219419,
"wind_direction": 219419,
"wind_speed": 219419,
"sunshine": 219419,
"visibility": 219419,
},
"icon": "cloudy",
}
@patch("requests.get")
def test_update_data(mock_get, weather_provider, sample_brightsky_1_json, cache_store):
"""Test fetching forecast from BrightSky."""
# Mock response object
mock_response = Mock()
mock_response.status_code = 200
mock_response.content = json.dumps(sample_brightsky_1_json)
mock_get.return_value = mock_response
cache_store.clear(clear_all=True)
# Call the method
ems_eos.set_start_datetime(to_datetime("2024-10-26 00:00:00", in_timezone="Europe/Berlin"))
weather_provider.update_data(force_enable=True, force_update=True)
# Assert: Verify the result is as expected
mock_get.assert_called_once()
assert len(weather_provider) == 338
# with open(FILE_TESTDATA_WEATHERBRIGHTSKY_2_JSON, "w") as f_out:
# f_out.write(weather_provider.to_json())
# ------------------------------------------------
# Development BrightSky
# ------------------------------------------------
@pytest.mark.skip(reason="For development only")
def test_brightsky_development_forecast_data(weather_provider):
"""Fetch data from real BrightSky server."""
# Preset, as this is usually done by update_data()
weather_provider.start_datetime = to_datetime("2024-10-26 00:00:00")
weather_provider.latitude = 50.0
weather_provider.longitude = 10.0
brightsky_data = weather_provider._request_forecast()
with open(FILE_TESTDATA_WEATHERBRIGHTSKY_1_JSON, "w") as f_out:
json.dump(brightsky_data, f_out, indent=4)

View File

@@ -0,0 +1,569 @@
import re
from datetime import datetime, timedelta, timezone
from pathlib import Path
from unittest.mock import Mock, patch
import numpy as np
import pandas as pd
import pvlib
import pytest
from bs4 import BeautifulSoup
from akkudoktoreos.config.config import get_config
from akkudoktoreos.core.ems import get_ems
from akkudoktoreos.prediction.weatherclearoutside import WeatherClearOutside
from akkudoktoreos.utils.cacheutil import CacheFileStore
from akkudoktoreos.utils.datetimeutil import compare_datetimes, to_datetime
DIR_TESTDATA = Path(__file__).absolute().parent.joinpath("testdata")
FILE_TESTDATA_WEATHERCLEAROUTSIDE_1_HTML = DIR_TESTDATA.joinpath("weatherforecast_clearout_1.html")
FILE_TESTDATA_WEATHERCLEAROUTSIDE_1_DATA = DIR_TESTDATA.joinpath("weatherforecast_clearout_1.json")
config_eos = get_config()
ems_eos = get_ems()
@pytest.fixture
def weather_provider():
"""Fixture to create a WeatherProvider instance."""
settings = {
"weather_provider": "ClearOutside",
"latitude": 50.0,
"longitude": 10.0,
}
config_eos.merge_settings_from_dict(settings)
return WeatherClearOutside()
@pytest.fixture
def sample_clearout_1_html():
"""Fixture that returns sample forecast data report."""
with open(FILE_TESTDATA_WEATHERCLEAROUTSIDE_1_HTML, "r") as f_res:
input_data = f_res.read()
return input_data
@pytest.fixture
def sample_clearout_1_data():
"""Fixture that returns sample forecast data."""
with open(FILE_TESTDATA_WEATHERCLEAROUTSIDE_1_DATA, "r") as f_in:
json_str = f_in.read()
data = WeatherClearOutside.from_json(json_str)
return data
@pytest.fixture
def cache_store():
"""A pytest fixture that creates a new CacheFileStore instance for testing."""
return CacheFileStore()
# ------------------------------------------------
# General WeatherProvider
# ------------------------------------------------
def test_singleton_instance(weather_provider):
"""Test that WeatherForecast behaves as a singleton."""
another_instance = WeatherClearOutside()
assert weather_provider is another_instance
def test_invalid_provider(weather_provider):
"""Test requesting an unsupported weather_provider."""
settings = {
"weather_provider": "<invalid>",
}
config_eos.merge_settings_from_dict(settings)
assert weather_provider.enabled() == False
def test_invalid_coordinates(weather_provider):
"""Test invalid coordinates raise ValueError."""
settings = {
"weather_provider": "ClearOutside",
"latitude": 1000.0,
"longitude": 1000.0,
}
with pytest.raises(
ValueError, # match="Latitude '1000' and/ or longitude `1000` out of valid range."
):
config_eos.merge_settings_from_dict(settings)
# ------------------------------------------------
# Irradiance caclulation
# ------------------------------------------------
def test_irridiance_estimate_from_cloud_cover(weather_provider):
"""Test cloud cover to irradiance estimation."""
cloud_cover_data = pd.Series(
data=[20, 50, 80], index=pd.date_range("2023-10-22", periods=3, freq="h")
)
ghi, dni, dhi = weather_provider.estimate_irradiance_from_cloud_cover(
50.0, 10.0, cloud_cover_data
)
assert ghi == [0, 0, 0]
assert dhi == [0, 0, 0]
assert dni == [0, 0, 0]
# ------------------------------------------------
# ClearOutside
# ------------------------------------------------
@patch("requests.get")
def test_request_forecast(mock_get, weather_provider, sample_clearout_1_html):
"""Test fetching forecast from ClearOutside."""
# Mock response object
mock_response = Mock()
mock_response.status_code = 200
mock_response.content = sample_clearout_1_html
mock_get.return_value = mock_response
# Preset, as this is usually done by update()
config_eos.update()
# Test function
response = weather_provider._request_forecast()
assert response.status_code == 200
assert response.content == sample_clearout_1_html
@patch("requests.get")
def test_update_data(mock_get, weather_provider, sample_clearout_1_html, sample_clearout_1_data):
# Mock response object
mock_response = Mock()
mock_response.status_code = 200
mock_response.content = sample_clearout_1_html
mock_get.return_value = mock_response
expected_start = to_datetime("2024-10-26 00:00:00", in_timezone="Europe/Berlin")
expected_end = to_datetime("2024-10-28 00:00:00", in_timezone="Europe/Berlin")
expected_keep = to_datetime("2024-10-24 00:00:00", in_timezone="Europe/Berlin")
# Call the method
ems_eos.set_start_datetime(expected_start)
weather_provider.update_data()
# Check for correct prediction time window
assert weather_provider.config.prediction_hours == 48
assert weather_provider.config.prediction_historic_hours == 48
assert compare_datetimes(weather_provider.start_datetime, expected_start).equal
assert compare_datetimes(weather_provider.end_datetime, expected_end).equal
assert compare_datetimes(weather_provider.keep_datetime, expected_keep).equal
# Verify the data
assert len(weather_provider) == 165 # 6 days, 24 hours per day - 7th day 21 hours
# Check that specific values match the expected output
# for i, record in enumerate(weather_data.records):
# # Compare datetime and specific values
# assert record.datetime == sample_clearout_1_data.records[i].datetime
# assert record.data['total_clouds'] == sample_clearout_1_data.records[i].data['total_clouds']
# # Check additional weather attributes as necessary
@pytest.mark.skip(reason="Test fixture to be improved")
@patch("requests.get")
def test_cache_forecast(mock_get, weather_provider, sample_clearout_1_html, cache_store):
"""Test that ClearOutside forecast data is cached with TTL.
This can not be tested with mock_get. Mock objects are not pickable and therefor can not be
cached to a file. Keep it for documentation.
"""
# Mock response object
mock_response = Mock()
mock_response.status_code = 200
mock_response.content = sample_clearout_1_html
mock_get.return_value = mock_response
cache_store.clear(clear_all=True)
weather_provider.update_data()
mock_get.assert_called_once()
forecast_data_first = weather_provider.to_json()
weather_provider.update_data()
forecast_data_second = weather_provider.to_json()
# Verify that cache returns the same object without calling the method again
assert forecast_data_first == forecast_data_second
# A mock object is not pickable and therefor can not be chached to file
assert mock_get.call_count == 2
# ------------------------------------------------
# Development ClearOutside
# ------------------------------------------------
@pytest.mark.skip(reason="For development only")
@patch("requests.get")
def test_development_forecast_data(mock_get, weather_provider, sample_clearout_1_html):
# Mock response object
mock_response = Mock()
mock_response.status_code = 200
mock_response.content = sample_clearout_1_html
mock_get.return_value = mock_response
# Fill the instance
weather_provider.update_data(force_enable=True)
with open(FILE_TESTDATA_WEATHERCLEAROUTSIDE_1_DATA, "w") as f_out:
f_out.write(weather_provider.to_json())
@pytest.mark.skip(reason="For development only")
def test_clearoutsides_development_scraper(weather_provider, sample_clearout_1_html):
"""Test scraping from ClearOutside."""
soup = BeautifulSoup(sample_clearout_1_html, "html.parser")
# Sample was created for the loacation
lat = 50.0
lon = 10.0
# Find generation data
p_generated = soup.find("h2", string=lambda text: text and text.startswith("Generated:"))
assert p_generated is not None
# Extract forecast start and end dates
forecast_pattern = r"Forecast: (\d{2}/\d{2}/\d{2}) to (\d{2}/\d{2}/\d{2})"
forecast_match = re.search(forecast_pattern, p_generated.get_text())
if forecast_match:
forecast_start_date = forecast_match.group(1)
forecast_end_date = forecast_match.group(2)
else:
assert False
assert forecast_start_date == "26/10/24"
assert forecast_end_date == "01/11/24"
# Extract timezone offset
timezone_pattern = r"Timezone: UTC([+-]\d+)\.(\d+)"
timezone_match = re.search(timezone_pattern, p_generated.get_text())
if timezone_match:
hours = int(timezone_match.group(1))
assert hours == 2
# Convert the decimal part to minutes (e.g., .50 -> 30 minutes)
minutes = int(timezone_match.group(2)) * 6 # Multiply by 6 to convert to minutes
assert minutes == 0
# Create the timezone object using timedelta for the offset
forecast_timezone = timezone(timedelta(hours=hours, minutes=minutes))
else:
assert False
forecast_start_datetime = to_datetime(
forecast_start_date, in_timezone=forecast_timezone, to_naiv=False, to_maxtime=False
)
assert forecast_start_datetime == datetime(2024, 10, 26, 0, 0)
# Find all paragraphs with id 'day_<x>'. There should be seven.
p_days = soup.find_all(id=re.compile(r"day_[0-9]"))
assert len(p_days) == 7
p_day = p_days[0]
# Within day_x paragraph find the details labels
p_detail_labels = p_day.find_all(class_="fc_detail_label")
detail_names = [p.get_text() for p in p_detail_labels]
assert detail_names == [
"Total Clouds (% Sky Obscured)",
"Low Clouds (% Sky Obscured)",
"Medium Clouds (% Sky Obscured)",
"High Clouds (% Sky Obscured)",
"ISS Passover",
"Visibility (miles)",
"Fog (%)",
"Precipitation Type",
"Precipitation Probability (%)",
"Precipitation Amount (mm)",
"Wind Speed/Direction (mph)",
"Chance of Frost",
"Temperature (°C)",
"Feels Like (°C)",
"Dew Point (°C)",
"Relative Humidity (%)",
"Pressure (mb)",
"Ozone (du)",
]
# Find all the paragraphs that are associated to the details.
# Beware there is one ul paragraph before that is not associated to a detail
p_detail_tables = p_day.find_all("ul")
assert len(p_detail_tables) == len(detail_names) + 1
p_detail_tables.pop(0)
# Create clearout data
clearout_data = {}
# Add data values
for i, detail_name in enumerate(detail_names):
p_detail_values = p_detail_tables[i].find_all("li")
detail_data = []
for p_detail_value in p_detail_values:
if (
detail_name in ("Precipitation Type", "Chance of Frost")
and hasattr(p_detail_value, "title")
and p_detail_value.title
):
value_str = p_detail_value.title.string
else:
value_str = p_detail_value.get_text()
try:
value = float(value_str)
except ValueError:
value = value_str
detail_data.append(value)
assert len(detail_data) == 24
clearout_data[detail_name] = detail_data
assert clearout_data["Temperature (°C)"] == [
14.0,
14.0,
13.0,
12.0,
11.0,
11.0,
10.0,
10.0,
9.0,
9.0,
9.0,
9.0,
9.0,
10.0,
9.0,
9.0,
10.0,
11.0,
13.0,
14.0,
15.0,
16.0,
16.0,
16.0,
]
assert clearout_data["Relative Humidity (%)"] == [
59.0,
68.0,
75.0,
81.0,
84.0,
85.0,
85.0,
91.0,
91.0,
93.0,
93.0,
93.0,
93.0,
93.0,
95.0,
95.0,
93.0,
87.0,
81.0,
76.0,
70.0,
66.0,
66.0,
69.0,
]
assert clearout_data["Wind Speed/Direction (mph)"] == [
7.0,
6.0,
4.0,
4.0,
4.0,
4.0,
4.0,
4.0,
3.0,
3.0,
3.0,
2.0,
1.0,
1.0,
1.0,
2.0,
2.0,
2.0,
4.0,
5.0,
6.0,
6.0,
5.0,
5.0,
]
# Add datetimes of the scrapped data
clearout_data["DateTime"] = [forecast_start_datetime + timedelta(hours=i) for i in range(24)]
detail_names.append("DateTime")
assert len(clearout_data["DateTime"]) == 24
assert clearout_data["DateTime"][0] == to_datetime(
"2024-10-26 00:00:00", in_timezone=forecast_timezone
)
assert clearout_data["DateTime"][23] == to_datetime(
"2024-10-26 23:00:00", in_timezone=forecast_timezone
)
# Converting the cloud cover into Global Horizontal Irradiance (GHI) with a PVLib method
offset = 35 # The default
offset_fraction = offset / 100.0 # Adjust percentage to scaling factor
cloud_cover = pd.Series(clearout_data["Total Clouds (% Sky Obscured)"])
# Convert datetime list to a pandas DatetimeIndex
cloud_cover_times = pd.DatetimeIndex(clearout_data["DateTime"])
# Create a location object
location = pvlib.location.Location(latitude=lat, longitude=lon)
# Get solar position and clear-sky GHI using the Ineichen model
solpos = location.get_solarposition(cloud_cover_times)
clear_sky = location.get_clearsky(cloud_cover_times, model="ineichen")
# Convert cloud cover percentage to a scaling factor
cloud_cover_fraction = np.array(cloud_cover) / 100.0
# Calculate adjusted GHI with proportional offset adjustment
adjusted_ghi = clear_sky["ghi"] * (
offset_fraction + (1 - offset_fraction) * (1 - cloud_cover_fraction)
)
adjusted_ghi.fillna(0.0, inplace=True)
# Apply DISC model to estimate Direct Normal Irradiance (DNI) from adjusted GHI
disc_output = pvlib.irradiance.disc(adjusted_ghi, solpos["zenith"], cloud_cover_times)
adjusted_dni = disc_output["dni"]
adjusted_dni.fillna(0.0, inplace=True)
# Calculate Diffuse Horizontal Irradiance (DHI) as DHI = GHI - DNI * cos(zenith)
zenith_rad = np.radians(solpos["zenith"])
adjusted_dhi = adjusted_ghi - adjusted_dni * np.cos(zenith_rad)
adjusted_dhi.fillna(0.0, inplace=True)
# Add GHI, DNI, DHI to clearout data
clearout_data["Global Horizontal Irradiance (W/m2)"] = adjusted_ghi.to_list()
detail_names.append("Global Horizontal Irradiance (W/m2)")
clearout_data["Direct Normal Irradiance (W/m2)"] = adjusted_dni.to_list()
detail_names.append("Direct Normal Irradiance (W/m2)")
clearout_data["Diffuse Horizontal Irradiance (W/m2)"] = adjusted_dhi.to_list()
detail_names.append("Diffuse Horizontal Irradiance (W/m2)")
assert clearout_data["Global Horizontal Irradiance (W/m2)"] == [
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
24.291000436601216,
85.88494154645998,
136.09269403109946,
139.26925350542064,
146.7174434892616,
149.0167479382964,
138.97458866666065,
103.47132353697396,
46.81279774519421,
0.12972168074047014,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
]
assert clearout_data["Direct Normal Irradiance (W/m2)"] == [
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
10.19687368654253,
0.0,
0.0,
2.9434862632289804,
9.621272744657047,
9.384995789935898,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
]
assert clearout_data["Diffuse Horizontal Irradiance (W/m2)"] == [
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
24.291000436601216,
85.88494154645998,
132.32210426501337,
139.26925350542064,
146.7174434892616,
147.721968406295,
135.32240392326145,
100.82522311704261,
46.81279774519421,
0.12972168074047014,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
]
# Preciptable Water (PWAT) with a PVLib method
clearout_data["Preciptable Water (cm)"] = pvlib.atmosphere.gueymard94_pw(
pd.Series(data=clearout_data["Temperature (°C)"]),
pd.Series(data=clearout_data["Relative Humidity (%)"]),
).to_list()
detail_names.append("Preciptable Water (cm)")
assert clearout_data["Preciptable Water (cm)"] == [
1.5345406562673334,
1.7686231292572652,
1.8354895631381385,
1.8651290310892348,
1.8197998755611786,
1.8414641597940502,
1.7325709431177607,
1.8548700685143087,
1.7453005409540279,
1.783658794601369,
1.783658794601369,
1.783658794601369,
1.783658794601369,
1.8956364436464912,
1.8220170482487101,
1.8220170482487101,
1.8956364436464912,
1.8847927282597918,
1.9823287281891897,
1.9766964385816497,
1.9346943880237457,
1.9381315133101413,
1.9381315133101413,
2.026228400278784,
]

110
tests/test_weatherimport.py Normal file
View File

@@ -0,0 +1,110 @@
import json
from pathlib import Path
import pytest
from akkudoktoreos.config.config import get_config
from akkudoktoreos.core.ems import get_ems
from akkudoktoreos.prediction.weatherimport import WeatherImport
from akkudoktoreos.utils.datetimeutil import compare_datetimes, to_datetime
DIR_TESTDATA = Path(__file__).absolute().parent.joinpath("testdata")
FILE_TESTDATA_WEATHERIMPORT_1_JSON = DIR_TESTDATA.joinpath("import_input_1.json")
config_eos = get_config()
ems_eos = get_ems()
@pytest.fixture
def weather_provider(reset_config, sample_import_1_json):
"""Fixture to create a WeatherProvider instance."""
settings = {
"weather_provider": "WeatherImport",
"weatherimport_file_path": str(FILE_TESTDATA_WEATHERIMPORT_1_JSON),
"weatherimport_json": json.dumps(sample_import_1_json),
}
config_eos.merge_settings_from_dict(settings)
provider = WeatherImport()
assert provider.enabled() == True
return provider
@pytest.fixture
def sample_import_1_json():
"""Fixture that returns sample forecast data report."""
with open(FILE_TESTDATA_WEATHERIMPORT_1_JSON, "r") as f_res:
input_data = json.load(f_res)
return input_data
# ------------------------------------------------
# General forecast
# ------------------------------------------------
def test_singleton_instance(weather_provider):
"""Test that WeatherForecast behaves as a singleton."""
another_instance = WeatherImport()
assert weather_provider is another_instance
def test_invalid_provider(weather_provider, monkeypatch):
"""Test requesting an unsupported weather_provider."""
settings = {
"weather_provider": "<invalid>",
"weatherimport_file_path": str(FILE_TESTDATA_WEATHERIMPORT_1_JSON),
}
config_eos.merge_settings_from_dict(settings)
assert weather_provider.enabled() == False
# ------------------------------------------------
# Import
# ------------------------------------------------
@pytest.mark.parametrize(
"start_datetime, from_file",
[
("2024-11-10 00:00:00", True), # No DST in Germany
("2024-08-10 00:00:00", True), # DST in Germany
("2024-03-31 00:00:00", True), # DST change in Germany (23 hours/ day)
("2024-10-27 00:00:00", True), # DST change in Germany (25 hours/ day)
("2024-11-10 00:00:00", False), # No DST in Germany
("2024-08-10 00:00:00", False), # DST in Germany
("2024-03-31 00:00:00", False), # DST change in Germany (23 hours/ day)
("2024-10-27 00:00:00", False), # DST change in Germany (25 hours/ day)
],
)
def test_import(weather_provider, sample_import_1_json, start_datetime, from_file):
"""Test fetching forecast from Import."""
ems_eos.set_start_datetime(to_datetime(start_datetime, in_timezone="Europe/Berlin"))
if from_file:
config_eos.weatherimport_json = None
assert config_eos.weatherimport_json is None
else:
config_eos.weatherimport_file_path = None
assert config_eos.weatherimport_file_path is None
weather_provider.clear()
# Call the method
weather_provider.update_data()
# Assert: Verify the result is as expected
assert weather_provider.start_datetime is not None
assert weather_provider.total_hours is not None
assert compare_datetimes(weather_provider.start_datetime, ems_eos.start_datetime).equal
values = sample_import_1_json["weather_temp_air"]
value_datetime_mapping = weather_provider.import_datetimes(len(values))
for i, mapping in enumerate(value_datetime_mapping):
assert i < len(weather_provider.records)
expected_datetime, expected_value_index = mapping
expected_value = values[expected_value_index]
result_datetime = weather_provider.records[i].date_time
result_value = weather_provider.records[i]["weather_temp_air"]
# print(f"{i}: Expected: {expected_datetime}:{expected_value}")
# print(f"{i}: Result: {result_datetime}:{result_value}")
assert compare_datetimes(result_datetime, expected_datetime).equal
assert result_value == expected_value

122
tests/testdata/EOS.config.json vendored Normal file
View File

@@ -0,0 +1,122 @@
{
"config_file_path": null,
"config_folder_path": null,
"data_cache_path": null,
"data_cache_subpath": null,
"data_folder_path": null,
"data_output_path": null,
"data_output_subpath": null,
"elecprice_provider": null,
"elecpriceimport_file_path": null,
"latitude": null,
"load0_import_file_path": null,
"load0_name": null,
"load0_provider": null,
"load1_import_file_path": null,
"load1_name": null,
"load1_provider": null,
"load2_import_file_path": null,
"load2_name": null,
"load2_provider": null,
"load3_import_file_path": null,
"load3_name": null,
"load3_provider": null,
"load4_import_file_path": null,
"load4_name": null,
"load4_provider": null,
"loadakkudoktor_year_energy": null,
"longitude": null,
"optimization_ev_available_charge_rates_percent": [],
"optimization_hours": 24,
"optimization_penalty": null,
"prediction_historic_hours": 48,
"prediction_hours": 48,
"pvforecast0_albedo": null,
"pvforecast0_inverter_model": null,
"pvforecast0_inverter_paco": null,
"pvforecast0_loss": null,
"pvforecast0_module_model": null,
"pvforecast0_modules_per_string": null,
"pvforecast0_mountingplace": "free",
"pvforecast0_optimal_surface_tilt": false,
"pvforecast0_optimalangles": false,
"pvforecast0_peakpower": null,
"pvforecast0_pvtechchoice": "crystSi",
"pvforecast0_strings_per_inverter": null,
"pvforecast0_surface_azimuth": 180,
"pvforecast0_surface_tilt": 0,
"pvforecast0_trackingtype": 0,
"pvforecast0_userhorizon": null,
"pvforecast1_albedo": null,
"pvforecast1_inverter_model": null,
"pvforecast1_inverter_paco": null,
"pvforecast1_loss": 0,
"pvforecast1_module_model": null,
"pvforecast1_modules_per_string": null,
"pvforecast1_mountingplace": "free",
"pvforecast1_optimal_surface_tilt": false,
"pvforecast1_optimalangles": false,
"pvforecast1_peakpower": null,
"pvforecast1_pvtechchoice": "crystSi",
"pvforecast1_strings_per_inverter": null,
"pvforecast1_surface_azimuth": 180,
"pvforecast1_surface_tilt": 0,
"pvforecast1_trackingtype": 0,
"pvforecast1_userhorizon": null,
"pvforecast2_albedo": null,
"pvforecast2_inverter_model": null,
"pvforecast2_inverter_paco": null,
"pvforecast2_loss": 0,
"pvforecast2_module_model": null,
"pvforecast2_modules_per_string": null,
"pvforecast2_mountingplace": "free",
"pvforecast2_optimal_surface_tilt": false,
"pvforecast2_optimalangles": false,
"pvforecast2_peakpower": null,
"pvforecast2_pvtechchoice": "crystSi",
"pvforecast2_strings_per_inverter": null,
"pvforecast2_surface_azimuth": 180,
"pvforecast2_surface_tilt": 0,
"pvforecast2_trackingtype": 0,
"pvforecast2_userhorizon": null,
"pvforecast3_albedo": null,
"pvforecast3_inverter_model": null,
"pvforecast3_inverter_paco": null,
"pvforecast3_loss": 0,
"pvforecast3_module_model": null,
"pvforecast3_modules_per_string": null,
"pvforecast3_mountingplace": "free",
"pvforecast3_optimal_surface_tilt": false,
"pvforecast3_optimalangles": false,
"pvforecast3_peakpower": null,
"pvforecast3_pvtechchoice": "crystSi",
"pvforecast3_strings_per_inverter": null,
"pvforecast3_surface_azimuth": 180,
"pvforecast3_surface_tilt": 0,
"pvforecast3_trackingtype": 0,
"pvforecast3_userhorizon": null,
"pvforecast4_albedo": null,
"pvforecast4_inverter_model": null,
"pvforecast4_inverter_paco": null,
"pvforecast4_loss": 0,
"pvforecast4_module_model": null,
"pvforecast4_modules_per_string": null,
"pvforecast4_mountingplace": "free",
"pvforecast4_optimal_surface_tilt": false,
"pvforecast4_optimalangles": false,
"pvforecast4_peakpower": null,
"pvforecast4_pvtechchoice": "crystSi",
"pvforecast4_strings_per_inverter": null,
"pvforecast4_surface_azimuth": 180,
"pvforecast4_surface_tilt": 0,
"pvforecast4_trackingtype": 0,
"pvforecast4_userhorizon": null,
"pvforecast_provider": null,
"pvforecastimport_file_path": null,
"server_fastapi_host": "0.0.0.0",
"server_fastapi_port": 8503,
"server_fasthtml_host": "0.0.0.0",
"server_fasthtml_port": 8504,
"weather_provider": null,
"weatherimport_file_path": null
}

File diff suppressed because one or more lines are too long

30
tests/testdata/import_input_1.json vendored Normal file
View File

@@ -0,0 +1,30 @@
{
"load0_mean": [
676.71, 876.19, 527.13, 468.88, 531.38, 517.95, 483.15, 472.28, 1011.68, 995.00,
1053.07, 1063.91, 1320.56, 1132.03, 1163.67, 1176.82, 1216.22, 1103.78, 1129.12,
1178.71, 1050.98, 988.56, 912.38, 704.61, 516.37, 868.05, 694.34, 608.79, 556.31,
488.89, 506.91, 804.89, 1141.98, 1056.97, 992.46, 1155.99, 827.01, 1257.98, 1232.67,
871.26, 860.88, 1158.03, 1222.72, 1221.04, 949.99, 987.01, 733.99, 592.97
],
"elecprice_marketprice": [
0.3384, 0.3318, 0.3284, 0.3283, 0.3289, 0.3334, 0.3290,
0.3302, 0.3042, 0.2430, 0.2280, 0.2212, 0.2093, 0.1879,
0.1838, 0.2004, 0.2198, 0.2270, 0.2997, 0.3195, 0.3081,
0.2969, 0.2921, 0.2780, 0.3384, 0.3318, 0.3284, 0.3283,
0.3289, 0.3334, 0.3290, 0.3302, 0.3042, 0.2430, 0.2280,
0.2212, 0.2093, 0.1879, 0.1838, 0.2004, 0.2198, 0.2270,
0.2997, 0.3195, 0.3081, 0.2969, 0.2921, 0.2780
],
"pvforecast_ac_power": [
0, 0, 0, 0, 0, 0, 0, 8.05, 352.91, 728.51, 930.28, 1043.25, 1106.74, 1161.69,
6018.82, 5519.07, 3969.88, 3017.96, 1943.07, 1007.17, 319.67, 7.88, 0, 0, 0, 0,
0, 0, 0, 0, 0, 5.04, 335.59, 705.32, 1121.12, 1604.79, 2157.38, 1433.25, 5718.49,
4553.96, 3027.55, 2574.46, 1720.4, 963.4, 383.3, 0, 0, 0
],
"weather_temp_air": [
18.3, 17.8, 16.9, 16.2, 15.6, 15.1, 14.6, 14.2, 14.3, 14.8, 15.7, 16.7,
17.4, 18.0, 18.6, 19.2, 19.1, 18.7, 18.5, 17.7, 16.2, 14.6, 13.6, 13.0,
12.6, 12.2, 11.7, 11.6, 11.3, 11.0, 10.7, 10.2, 11.4, 14.4, 16.4, 18.3,
19.5, 20.7, 21.9, 22.7, 23.1, 23.1, 22.8, 21.8, 20.2, 19.1, 18.0, 17.4
]
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,340 @@
[
"{\"date_time\":\"2024-10-25T23:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":140.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.5700713856497344,\"wind_speed\":4.7,\"wind_direction\":40.0,\"frost_chance\":null,\"temp_air\":6.2,\"feels_like\":null,\"dew_point\":5.8,\"relative_humidity\":97.0,\"pressure\":1022.9,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-10-26T00:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":90.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.6249985327562004,\"wind_speed\":6.1,\"wind_direction\":10.0,\"frost_chance\":null,\"temp_air\":6.6,\"feels_like\":null,\"dew_point\":6.3,\"relative_humidity\":98.0,\"pressure\":1023.1,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-10-26T01:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":740.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.7334253361156824,\"wind_speed\":3.6,\"wind_direction\":20.0,\"frost_chance\":null,\"temp_air\":7.5,\"feels_like\":null,\"dew_point\":7.3,\"relative_humidity\":99.0,\"pressure\":1023.0,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-10-26T02:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":580.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.8455721653466037,\"wind_speed\":2.9,\"wind_direction\":50.0,\"frost_chance\":null,\"temp_air\":8.7,\"feels_like\":null,\"dew_point\":8.5,\"relative_humidity\":98.0,\"pressure\":1022.9,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-10-26T03:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":140.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.9376328848315296,\"wind_speed\":4.7,\"wind_direction\":10.0,\"frost_chance\":null,\"temp_air\":9.5,\"feels_like\":null,\"dew_point\":9.3,\"relative_humidity\":98.0,\"pressure\":1022.6,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-10-26T04:00:00+01:00\",\"total_clouds\":87.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":250.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.9532243896939583,\"wind_speed\":4.7,\"wind_direction\":80.0,\"frost_chance\":null,\"temp_air\":9.8,\"feels_like\":null,\"dew_point\":9.4,\"relative_humidity\":97.0,\"pressure\":1022.6,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-10-26T05:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":2280.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.9807797336643354,\"wind_speed\":6.1,\"wind_direction\":70.0,\"frost_chance\":null,\"temp_air\":10.2,\"feels_like\":null,\"dew_point\":9.5,\"relative_humidity\":96.0,\"pressure\":1022.6,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-10-26T06:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":2650.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.9601466114386656,\"wind_speed\":7.6,\"wind_direction\":40.0,\"frost_chance\":null,\"temp_air\":10.2,\"feels_like\":null,\"dew_point\":9.4,\"relative_humidity\":95.0,\"pressure\":1022.3,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-10-26T07:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":2290.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.9395134892129953,\"wind_speed\":8.3,\"wind_direction\":40.0,\"frost_chance\":null,\"temp_air\":10.2,\"feels_like\":null,\"dew_point\":9.3,\"relative_humidity\":94.0,\"pressure\":1022.3,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-10-26T08:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":2530.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.9721291734030872,\"wind_speed\":7.6,\"wind_direction\":60.0,\"frost_chance\":null,\"temp_air\":10.3,\"feels_like\":null,\"dew_point\":9.5,\"relative_humidity\":95.0,\"pressure\":1022.4,\"ozone\":null,\"ghi\":22.22705922303379,\"dni\":0.0,\"dhi\":22.22705922303379}",
"{\"date_time\":\"2024-10-26T09:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":1660.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.9601466114386656,\"wind_speed\":6.8,\"wind_direction\":80.0,\"frost_chance\":null,\"temp_air\":10.2,\"feels_like\":null,\"dew_point\":9.4,\"relative_humidity\":95.0,\"pressure\":1022.4,\"ozone\":null,\"ghi\":68.16265202099999,\"dni\":0.0,\"dhi\":68.16265202099999}",
"{\"date_time\":\"2024-10-26T10:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":4420.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.9395134892129953,\"wind_speed\":7.2,\"wind_direction\":70.0,\"frost_chance\":null,\"temp_air\":10.2,\"feels_like\":null,\"dew_point\":9.3,\"relative_humidity\":94.0,\"pressure\":1022.1,\"ozone\":null,\"ghi\":108.0100746278567,\"dni\":0.0,\"dhi\":108.0100746278567}",
"{\"date_time\":\"2024-10-26T11:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":2010.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.9513699189462126,\"wind_speed\":7.6,\"wind_direction\":50.0,\"frost_chance\":null,\"temp_air\":10.3,\"feels_like\":null,\"dew_point\":9.3,\"relative_humidity\":94.0,\"pressure\":1021.8,\"ozone\":null,\"ghi\":134.2816493853918,\"dni\":0.0,\"dhi\":134.2816493853918}",
"{\"date_time\":\"2024-10-26T12:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":4340.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.8683329011187144,\"wind_speed\":7.9,\"wind_direction\":40.0,\"frost_chance\":null,\"temp_air\":10.3,\"feels_like\":null,\"dew_point\":8.8,\"relative_humidity\":90.0,\"pressure\":1021.2,\"ozone\":null,\"ghi\":144.04237088707308,\"dni\":0.0,\"dhi\":144.04237088707308}",
"{\"date_time\":\"2024-10-26T13:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":9060.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.9164012929980223,\"wind_speed\":7.6,\"wind_direction\":40.0,\"frost_chance\":null,\"temp_air\":10.9,\"feels_like\":null,\"dew_point\":9.2,\"relative_humidity\":89.0,\"pressure\":1020.8,\"ozone\":null,\"ghi\":136.35519419190516,\"dni\":0.0,\"dhi\":136.35519419190516}",
"{\"date_time\":\"2024-10-26T14:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":13650.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.979008294319185,\"wind_speed\":6.8,\"wind_direction\":40.0,\"frost_chance\":null,\"temp_air\":11.8,\"feels_like\":null,\"dew_point\":9.7,\"relative_humidity\":87.0,\"pressure\":1020.0,\"ozone\":null,\"ghi\":111.94730962791996,\"dni\":0.0,\"dhi\":111.94730962791996}",
"{\"date_time\":\"2024-10-26T15:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":17560.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.9946143405085994,\"wind_speed\":7.2,\"wind_direction\":20.0,\"frost_chance\":null,\"temp_air\":12.9,\"feels_like\":null,\"dew_point\":9.8,\"relative_humidity\":82.0,\"pressure\":1019.8,\"ozone\":null,\"ghi\":73.45834328182735,\"dni\":0.0,\"dhi\":73.45834328182735}",
"{\"date_time\":\"2024-10-26T16:00:00+01:00\",\"total_clouds\":87.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":17960.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.9099689648264042,\"wind_speed\":8.3,\"wind_direction\":30.0,\"frost_chance\":null,\"temp_air\":12.8,\"feels_like\":null,\"dew_point\":9.3,\"relative_humidity\":79.0,\"pressure\":1019.2,\"ozone\":null,\"ghi\":34.07062080450064,\"dni\":0.0,\"dhi\":34.07062080450064}",
"{\"date_time\":\"2024-10-26T17:00:00+01:00\",\"total_clouds\":62.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":14910.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.843603353612786,\"wind_speed\":6.8,\"wind_direction\":70.0,\"frost_chance\":null,\"temp_air\":9.9,\"feels_like\":null,\"dew_point\":8.5,\"relative_humidity\":91.0,\"pressure\":1018.8,\"ozone\":null,\"ghi\":0.11256372587508819,\"dni\":0.0,\"dhi\":0.11256372587508819}",
"{\"date_time\":\"2024-10-26T18:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":6400.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.7277309224807302,\"wind_speed\":5.4,\"wind_direction\":40.0,\"frost_chance\":null,\"temp_air\":8.3,\"feels_like\":null,\"dew_point\":7.5,\"relative_humidity\":94.0,\"pressure\":1018.7,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-10-26T19:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":720.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.6911147392871873,\"wind_speed\":5.4,\"wind_direction\":20.0,\"frost_chance\":null,\"temp_air\":7.6,\"feels_like\":null,\"dew_point\":6.9,\"relative_humidity\":96.0,\"pressure\":1018.7,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-10-26T20:00:00+01:00\",\"total_clouds\":87.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":5730.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.6378354583834347,\"wind_speed\":4.7,\"wind_direction\":20.0,\"frost_chance\":null,\"temp_air\":6.9,\"feels_like\":null,\"dew_point\":6.4,\"relative_humidity\":97.0,\"pressure\":1018.7,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-10-26T21:00:00+01:00\",\"total_clouds\":87.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":90.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.618159751885058,\"wind_speed\":4.7,\"wind_direction\":340.0,\"frost_chance\":null,\"temp_air\":6.7,\"feels_like\":null,\"dew_point\":6.3,\"relative_humidity\":97.0,\"pressure\":1018.9,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-10-26T22:00:00+01:00\",\"total_clouds\":87.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":90.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.7087305178214287,\"wind_speed\":7.2,\"wind_direction\":360.0,\"frost_chance\":null,\"temp_air\":7.6,\"feels_like\":null,\"dew_point\":7.1,\"relative_humidity\":97.0,\"pressure\":1019.3,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-10-26T23:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":200.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.764491154873937,\"wind_speed\":10.4,\"wind_direction\":360.0,\"frost_chance\":null,\"temp_air\":8.3,\"feels_like\":null,\"dew_point\":7.6,\"relative_humidity\":96.0,\"pressure\":1019.0,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-10-27T00:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":3610.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.830030033835469,\"wind_speed\":8.3,\"wind_direction\":10.0,\"frost_chance\":null,\"temp_air\":8.9,\"feels_like\":null,\"dew_point\":8.3,\"relative_humidity\":96.0,\"pressure\":1019.0,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-10-27T01:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":5770.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.8928153879508465,\"wind_speed\":9.0,\"wind_direction\":10.0,\"frost_chance\":null,\"temp_air\":9.8,\"feels_like\":null,\"dew_point\":8.9,\"relative_humidity\":94.0,\"pressure\":1018.7,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-10-27T02:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":5980.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.945096824920762,\"wind_speed\":7.2,\"wind_direction\":360.0,\"frost_chance\":null,\"temp_air\":10.6,\"feels_like\":null,\"dew_point\":9.3,\"relative_humidity\":92.0,\"pressure\":1018.8,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-10-27T03:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":7950.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.947556370182495,\"wind_speed\":6.5,\"wind_direction\":360.0,\"frost_chance\":null,\"temp_air\":10.8,\"feels_like\":null,\"dew_point\":9.4,\"relative_humidity\":91.0,\"pressure\":1018.2,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-10-27T04:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":6120.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.9903598068898019,\"wind_speed\":4.7,\"wind_direction\":360.0,\"frost_chance\":null,\"temp_air\":10.8,\"feels_like\":null,\"dew_point\":9.7,\"relative_humidity\":93.0,\"pressure\":1018.2,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-10-27T05:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":4830.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.987381538505997,\"wind_speed\":4.3,\"wind_direction\":10.0,\"frost_chance\":null,\"temp_air\":10.6,\"feels_like\":null,\"dew_point\":9.6,\"relative_humidity\":94.0,\"pressure\":1018.4,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-10-27T06:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":5360.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.9963174492677347,\"wind_speed\":3.6,\"wind_direction\":350.0,\"frost_chance\":null,\"temp_air\":10.5,\"feels_like\":null,\"dew_point\":9.8,\"relative_humidity\":95.0,\"pressure\":1018.9,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-10-27T07:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":4660.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":2.0085238952986137,\"wind_speed\":2.2,\"wind_direction\":190.0,\"frost_chance\":null,\"temp_air\":10.6,\"feels_like\":null,\"dew_point\":9.8,\"relative_humidity\":95.0,\"pressure\":1019.8,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-10-27T08:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":5570.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":2.024064286986675,\"wind_speed\":3.2,\"wind_direction\":180.0,\"frost_chance\":null,\"temp_air\":10.9,\"feels_like\":null,\"dew_point\":10.0,\"relative_humidity\":94.0,\"pressure\":1020.5,\"ozone\":null,\"ghi\":20.901591088639343,\"dni\":0.0,\"dhi\":20.901591088639343}",
"{\"date_time\":\"2024-10-27T09:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":7670.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":2.020123074823889,\"wind_speed\":7.2,\"wind_direction\":190.0,\"frost_chance\":null,\"temp_air\":11.4,\"feels_like\":null,\"dew_point\":10.1,\"relative_humidity\":91.0,\"pressure\":1021.1,\"ozone\":null,\"ghi\":66.41841804602629,\"dni\":0.0,\"dhi\":66.41841804602629}",
"{\"date_time\":\"2024-10-27T10:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":8790.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":2.0744781114397735,\"wind_speed\":11.2,\"wind_direction\":190.0,\"frost_chance\":null,\"temp_air\":12.2,\"feels_like\":null,\"dew_point\":10.4,\"relative_humidity\":89.0,\"pressure\":1021.4,\"ozone\":null,\"ghi\":106.12345605852113,\"dni\":0.0,\"dhi\":106.12345605852113}",
"{\"date_time\":\"2024-10-27T11:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":8040.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":2.067588035893061,\"wind_speed\":10.8,\"wind_direction\":200.0,\"frost_chance\":null,\"temp_air\":12.9,\"feels_like\":null,\"dew_point\":10.4,\"relative_humidity\":85.0,\"pressure\":1022.0,\"ozone\":null,\"ghi\":132.31929512932624,\"dni\":0.0,\"dhi\":132.31929512932624}",
"{\"date_time\":\"2024-10-27T12:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":9300.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":2.0813625709358203,\"wind_speed\":11.5,\"wind_direction\":220.0,\"frost_chance\":null,\"temp_air\":13.4,\"feels_like\":null,\"dew_point\":10.6,\"relative_humidity\":83.0,\"pressure\":1022.6,\"ozone\":null,\"ghi\":142.03807516868267,\"dni\":0.0,\"dhi\":142.03807516868267}",
"{\"date_time\":\"2024-10-27T13:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":9560.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":2.119817892495729,\"wind_speed\":10.8,\"wind_direction\":230.0,\"frost_chance\":null,\"temp_air\":13.9,\"feels_like\":null,\"dew_point\":10.9,\"relative_humidity\":82.0,\"pressure\":1022.7,\"ozone\":null,\"ghi\":134.33853283469773,\"dni\":0.0,\"dhi\":134.33853283469773}",
"{\"date_time\":\"2024-10-27T14:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":12400.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":2.119593668836106,\"wind_speed\":10.4,\"wind_direction\":220.0,\"frost_chance\":null,\"temp_air\":14.1,\"feels_like\":null,\"dew_point\":11.0,\"relative_humidity\":81.0,\"pressure\":1022.9,\"ozone\":null,\"ghi\":109.95561941571053,\"dni\":0.0,\"dhi\":109.95561941571053}",
"{\"date_time\":\"2024-10-27T15:00:00+01:00\",\"total_clouds\":87.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":12100.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":2.1843956516771077,\"wind_speed\":11.9,\"wind_direction\":200.0,\"frost_chance\":null,\"temp_air\":14.8,\"feels_like\":null,\"dew_point\":11.4,\"relative_humidity\":80.0,\"pressure\":1023.0,\"ozone\":null,\"ghi\":88.84019629738314,\"dni\":0.0,\"dhi\":88.84019629738314}",
"{\"date_time\":\"2024-10-27T16:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":13590.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":2.183440809341084,\"wind_speed\":13.3,\"wind_direction\":200.0,\"frost_chance\":null,\"temp_air\":15.0,\"feels_like\":null,\"dew_point\":11.4,\"relative_humidity\":79.0,\"pressure\":1023.8,\"ozone\":null,\"ghi\":25.90303659201319,\"dni\":0.0,\"dhi\":25.90303659201319}",
"{\"date_time\":\"2024-10-27T17:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":14720.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":2.1291678932402403,\"wind_speed\":8.6,\"wind_direction\":220.0,\"frost_chance\":null,\"temp_air\":13.0,\"feels_like\":null,\"dew_point\":11.0,\"relative_humidity\":87.0,\"pressure\":1024.4,\"ozone\":null,\"ghi\":0.027781191847857583,\"dni\":0.0,\"dhi\":0.027781191847857583}",
"{\"date_time\":\"2024-10-27T18:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":14300.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":2.0574147959693145,\"wind_speed\":9.0,\"wind_direction\":190.0,\"frost_chance\":null,\"temp_air\":11.7,\"feels_like\":null,\"dew_point\":10.3,\"relative_humidity\":91.0,\"pressure\":1025.0,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-10-27T19:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":12740.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":2.0324781084809054,\"wind_speed\":7.2,\"wind_direction\":180.0,\"frost_chance\":null,\"temp_air\":11.5,\"feels_like\":null,\"dew_point\":10.0,\"relative_humidity\":91.0,\"pressure\":1025.59,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-10-27T20:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":12320.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.9995340646535906,\"wind_speed\":7.6,\"wind_direction\":180.0,\"frost_chance\":null,\"temp_air\":10.7,\"feels_like\":null,\"dew_point\":9.8,\"relative_humidity\":94.0,\"pressure\":1026.0,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-10-27T21:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":10820.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":2.1311188481658605,\"wind_speed\":9.0,\"wind_direction\":180.0,\"frost_chance\":null,\"temp_air\":11.4,\"feels_like\":null,\"dew_point\":10.8,\"relative_humidity\":96.0,\"pressure\":1026.5,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-10-27T22:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":8040.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":2.1609860685094553,\"wind_speed\":9.4,\"wind_direction\":180.0,\"frost_chance\":null,\"temp_air\":11.8,\"feels_like\":null,\"dew_point\":11.0,\"relative_humidity\":95.0,\"pressure\":1027.0,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-10-27T23:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":4860.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":2.174201406952952,\"wind_speed\":8.6,\"wind_direction\":170.0,\"frost_chance\":null,\"temp_air\":11.9,\"feels_like\":null,\"dew_point\":11.2,\"relative_humidity\":95.0,\"pressure\":1027.0,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-10-28T00:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":3910.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":2.174201406952952,\"wind_speed\":11.9,\"wind_direction\":180.0,\"frost_chance\":null,\"temp_air\":11.9,\"feels_like\":null,\"dew_point\":11.1,\"relative_humidity\":95.0,\"pressure\":1027.0,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-10-28T01:00:00+01:00\",\"total_clouds\":87.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":7410.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":2.1284287457539426,\"wind_speed\":12.6,\"wind_direction\":180.0,\"frost_chance\":null,\"temp_air\":11.9,\"feels_like\":null,\"dew_point\":10.9,\"relative_humidity\":93.0,\"pressure\":1027.0,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-10-28T02:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":4020.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":2.174201406952952,\"wind_speed\":5.8,\"wind_direction\":190.0,\"frost_chance\":null,\"temp_air\":11.9,\"feels_like\":null,\"dew_point\":11.1,\"relative_humidity\":95.0,\"pressure\":1027.09,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-10-28T03:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":3500.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":2.125241657374896,\"wind_speed\":6.8,\"wind_direction\":190.0,\"frost_chance\":null,\"temp_air\":11.7,\"feels_like\":null,\"dew_point\":10.7,\"relative_humidity\":94.0,\"pressure\":1027.0,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-10-28T04:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":2770.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":2.1089196934974663,\"wind_speed\":5.0,\"wind_direction\":210.0,\"frost_chance\":null,\"temp_air\":11.4,\"feels_like\":null,\"dew_point\":10.6,\"relative_humidity\":95.0,\"pressure\":1026.59,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-10-28T05:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":2300.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":2.0519715153105804,\"wind_speed\":2.9,\"wind_direction\":230.0,\"frost_chance\":null,\"temp_air\":11.3,\"feels_like\":null,\"dew_point\":10.3,\"relative_humidity\":93.0,\"pressure\":1026.4,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-10-28T06:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":3330.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":2.045596885784406,\"wind_speed\":5.8,\"wind_direction\":180.0,\"frost_chance\":null,\"temp_air\":10.9,\"feels_like\":null,\"dew_point\":10.2,\"relative_humidity\":95.0,\"pressure\":1026.3,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-10-28T07:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":3100.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":2.0545649619507635,\"wind_speed\":8.3,\"wind_direction\":180.0,\"frost_chance\":null,\"temp_air\":10.8,\"feels_like\":null,\"dew_point\":10.1,\"relative_humidity\":96.0,\"pressure\":1026.59,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-10-28T08:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":2900.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":2.020805703639267,\"wind_speed\":6.5,\"wind_direction\":210.0,\"frost_chance\":null,\"temp_air\":10.7,\"feels_like\":null,\"dew_point\":9.9,\"relative_humidity\":95.0,\"pressure\":1027.09,\"ozone\":null,\"ghi\":19.602868340686165,\"dni\":0.0,\"dhi\":19.602868340686165}",
"{\"date_time\":\"2024-10-28T09:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":1960.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":2.0581070021227617,\"wind_speed\":8.3,\"wind_direction\":200.0,\"frost_chance\":null,\"temp_air\":11.0,\"feels_like\":null,\"dew_point\":10.3,\"relative_humidity\":95.0,\"pressure\":1027.2,\"ozone\":null,\"ghi\":64.68374862563667,\"dni\":0.0,\"dhi\":64.68374862563667}",
"{\"date_time\":\"2024-10-28T10:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":4930.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":2.017567894096528,\"wind_speed\":9.4,\"wind_direction\":200.0,\"frost_chance\":null,\"temp_air\":11.2,\"feels_like\":null,\"dew_point\":10.0,\"relative_humidity\":92.0,\"pressure\":1027.3,\"ozone\":null,\"ghi\":104.24512318045389,\"dni\":0.0,\"dhi\":104.24512318045389}",
"{\"date_time\":\"2024-10-28T11:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":5410.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":2.0574147959693145,\"wind_speed\":10.8,\"wind_direction\":200.0,\"frost_chance\":null,\"temp_air\":11.7,\"feels_like\":null,\"dew_point\":10.3,\"relative_humidity\":91.0,\"pressure\":1027.4,\"ozone\":null,\"ghi\":130.36720205010565,\"dni\":0.0,\"dhi\":130.36720205010565}",
"{\"date_time\":\"2024-10-28T12:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":5520.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":2.0744781114397735,\"wind_speed\":10.1,\"wind_direction\":200.0,\"frost_chance\":null,\"temp_air\":12.2,\"feels_like\":null,\"dew_point\":10.4,\"relative_humidity\":89.0,\"pressure\":1027.2,\"ozone\":null,\"ghi\":140.04739059314485,\"dni\":0.0,\"dhi\":140.04739059314485}",
"{\"date_time\":\"2024-10-28T13:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":6840.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":2.1175533778696294,\"wind_speed\":9.4,\"wind_direction\":180.0,\"frost_chance\":null,\"temp_air\":13.1,\"feels_like\":null,\"dew_point\":10.8,\"relative_humidity\":86.0,\"pressure\":1026.59,\"ozone\":null,\"ghi\":132.3396471104131,\"dni\":0.0,\"dhi\":132.3396471104131}",
"{\"date_time\":\"2024-10-28T14:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":11920.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":2.158848465791691,\"wind_speed\":11.9,\"wind_direction\":190.0,\"frost_chance\":null,\"temp_air\":14.2,\"feels_like\":null,\"dew_point\":11.2,\"relative_humidity\":82.0,\"pressure\":1026.0,\"ozone\":null,\"ghi\":107.98652507155819,\"dni\":0.0,\"dhi\":107.98652507155819}",
"{\"date_time\":\"2024-10-28T15:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":13760.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":2.1586051188053688,\"wind_speed\":7.6,\"wind_direction\":210.0,\"frost_chance\":null,\"temp_air\":14.4,\"feels_like\":null,\"dew_point\":11.2,\"relative_humidity\":81.0,\"pressure\":1025.8,\"ozone\":null,\"ghi\":69.69629401139778,\"dni\":0.0,\"dhi\":69.69629401139778}",
"{\"date_time\":\"2024-10-28T16:00:00+01:00\",\"total_clouds\":87.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":21080.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":2.172012702785343,\"wind_speed\":7.2,\"wind_direction\":200.0,\"frost_chance\":null,\"temp_air\":14.3,\"feels_like\":null,\"dew_point\":11.4,\"relative_humidity\":82.0,\"pressure\":1025.4,\"ozone\":null,\"ghi\":30.299076442472742,\"dni\":0.0,\"dhi\":30.299076442472742}",
"{\"date_time\":\"2024-10-28T17:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":16720.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":2.0723655900991496,\"wind_speed\":8.6,\"wind_direction\":190.0,\"frost_chance\":null,\"temp_air\":12.0,\"feels_like\":null,\"dew_point\":10.5,\"relative_humidity\":90.0,\"pressure\":1025.2,\"ozone\":null,\"ghi\":0.007313988512718714,\"dni\":0.0,\"dhi\":0.007313988512718714}",
"{\"date_time\":\"2024-10-28T18:00:00+01:00\",\"total_clouds\":75.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":8950.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.9542897134936768,\"wind_speed\":3.6,\"wind_direction\":280.0,\"frost_chance\":null,\"temp_air\":10.5,\"feels_like\":null,\"dew_point\":9.5,\"relative_humidity\":93.0,\"pressure\":1025.59,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-10-28T19:00:00+01:00\",\"total_clouds\":87.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":7810.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.8443259335596536,\"wind_speed\":3.2,\"wind_direction\":330.0,\"frost_chance\":null,\"temp_air\":9.2,\"feels_like\":null,\"dew_point\":8.5,\"relative_humidity\":95.0,\"pressure\":1026.0,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-10-28T20:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":90.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.8378811294487585,\"wind_speed\":2.5,\"wind_direction\":170.0,\"frost_chance\":null,\"temp_air\":8.8,\"feels_like\":null,\"dew_point\":8.3,\"relative_humidity\":97.0,\"pressure\":1026.4,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-10-28T21:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":100.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.925871372365559,\"wind_speed\":2.5,\"wind_direction\":190.0,\"frost_chance\":null,\"temp_air\":9.4,\"feels_like\":null,\"dew_point\":9.1,\"relative_humidity\":98.0,\"pressure\":1026.5,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-10-28T22:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":60.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.801251387267144,\"wind_speed\":2.9,\"wind_direction\":200.0,\"frost_chance\":null,\"temp_air\":8.3,\"feels_like\":null,\"dew_point\":8.0,\"relative_humidity\":98.0,\"pressure\":1026.8,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-10-28T23:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":90.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.7720765939942027,\"wind_speed\":3.2,\"wind_direction\":170.0,\"frost_chance\":null,\"temp_air\":8.2,\"feels_like\":null,\"dew_point\":7.8,\"relative_humidity\":97.0,\"pressure\":1026.8,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-10-29T00:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":80.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.8455721653466037,\"wind_speed\":3.6,\"wind_direction\":200.0,\"frost_chance\":null,\"temp_air\":8.7,\"feels_like\":null,\"dew_point\":8.4,\"relative_humidity\":98.0,\"pressure\":1026.8,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-10-29T01:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":160.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.779508453411574,\"wind_speed\":2.2,\"wind_direction\":330.0,\"frost_chance\":null,\"temp_air\":8.1,\"feels_like\":null,\"dew_point\":7.8,\"relative_humidity\":98.0,\"pressure\":1026.8,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-10-29T02:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":100.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.7580400895133157,\"wind_speed\":2.5,\"wind_direction\":10.0,\"frost_chance\":null,\"temp_air\":7.9,\"feels_like\":null,\"dew_point\":7.5,\"relative_humidity\":98.0,\"pressure\":1026.7,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-10-29T03:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":120.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.8196315034637476,\"wind_speed\":1.4,\"wind_direction\":0.0,\"frost_chance\":null,\"temp_air\":8.3,\"feels_like\":null,\"dew_point\":8.1,\"relative_humidity\":99.0,\"pressure\":1026.59,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-10-29T04:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":100.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.7976667029361821,\"wind_speed\":1.8,\"wind_direction\":320.0,\"frost_chance\":null,\"temp_air\":8.1,\"feels_like\":null,\"dew_point\":8.0,\"relative_humidity\":99.0,\"pressure\":1026.59,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-10-29T05:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":110.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.786788492758286,\"wind_speed\":1.4,\"wind_direction\":20.0,\"frost_chance\":null,\"temp_air\":8.0,\"feels_like\":null,\"dew_point\":7.9,\"relative_humidity\":99.0,\"pressure\":1026.9,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-10-29T06:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":110.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.7022172735352934,\"wind_speed\":2.2,\"wind_direction\":240.0,\"frost_chance\":null,\"temp_air\":7.2,\"feels_like\":null,\"dew_point\":7.1,\"relative_humidity\":99.0,\"pressure\":1027.0,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-10-29T07:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":110.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.722955851908333,\"wind_speed\":2.9,\"wind_direction\":10.0,\"frost_chance\":null,\"temp_air\":7.4,\"feels_like\":null,\"dew_point\":7.3,\"relative_humidity\":99.0,\"pressure\":1027.3,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-10-29T08:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":130.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.7652386971092027,\"wind_speed\":3.2,\"wind_direction\":350.0,\"frost_chance\":null,\"temp_air\":7.8,\"feels_like\":null,\"dew_point\":7.6,\"relative_humidity\":99.0,\"pressure\":1027.5,\"ozone\":null,\"ghi\":18.332702353547074,\"dni\":0.0,\"dhi\":18.332702353547074}",
"{\"date_time\":\"2024-10-29T09:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":170.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.8644045343807525,\"wind_speed\":4.3,\"wind_direction\":350.0,\"frost_chance\":null,\"temp_air\":8.7,\"feels_like\":null,\"dew_point\":8.5,\"relative_humidity\":99.0,\"pressure\":1027.59,\"ozone\":null,\"ghi\":62.95962823548937,\"dni\":0.0,\"dhi\":62.95962823548937}",
"{\"date_time\":\"2024-10-29T10:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":340.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.9141831337652606,\"wind_speed\":2.5,\"wind_direction\":340.0,\"frost_chance\":null,\"temp_air\":9.3,\"feels_like\":null,\"dew_point\":9.0,\"relative_humidity\":98.0,\"pressure\":1027.59,\"ozone\":null,\"ghi\":102.37603626073972,\"dni\":0.0,\"dhi\":102.37603626073972}",
"{\"date_time\":\"2024-10-29T11:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":270.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":2.013647682316836,\"wind_speed\":3.6,\"wind_direction\":10.0,\"frost_chance\":null,\"temp_air\":10.3,\"feels_like\":null,\"dew_point\":9.9,\"relative_humidity\":97.0,\"pressure\":1027.5,\"ozone\":null,\"ghi\":128.42638331065623,\"dni\":0.0,\"dhi\":128.42638331065623}",
"{\"date_time\":\"2024-10-29T12:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":320.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":2.1313720084431926,\"wind_speed\":5.0,\"wind_direction\":170.0,\"frost_chance\":null,\"temp_air\":12.1,\"feels_like\":null,\"dew_point\":10.9,\"relative_humidity\":92.0,\"pressure\":1027.2,\"ozone\":null,\"ghi\":138.07137517499854,\"dni\":0.0,\"dhi\":138.07137517499854}",
"{\"date_time\":\"2024-10-29T13:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":11100.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":2.291852348361256,\"wind_speed\":6.1,\"wind_direction\":160.0,\"frost_chance\":null,\"temp_air\":14.4,\"feels_like\":null,\"dew_point\":12.1,\"relative_humidity\":86.0,\"pressure\":1026.59,\"ozone\":null,\"ghi\":130.35961341605747,\"dni\":0.0,\"dhi\":130.35961341605747}",
"{\"date_time\":\"2024-10-29T14:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":16900.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":2.3255616039808618,\"wind_speed\":5.0,\"wind_direction\":170.0,\"frost_chance\":null,\"temp_air\":16.9,\"feels_like\":null,\"dew_point\":12.4,\"relative_humidity\":75.0,\"pressure\":1025.9,\"ozone\":null,\"ghi\":106.04108723155429,\"dni\":0.0,\"dhi\":106.04108723155429}",
"{\"date_time\":\"2024-10-29T15:00:00+01:00\",\"total_clouds\":87.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":34440.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":2.1836631339806405,\"wind_speed\":6.5,\"wind_direction\":180.0,\"frost_chance\":null,\"temp_air\":17.0,\"feels_like\":null,\"dew_point\":11.6,\"relative_humidity\":70.0,\"pressure\":1025.7,\"ozone\":null,\"ghi\":84.24287206656912,\"dni\":0.0,\"dhi\":84.24287206656912}",
"{\"date_time\":\"2024-10-29T16:00:00+01:00\",\"total_clouds\":37.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":58950.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":2.2475055388051737,\"wind_speed\":4.3,\"wind_direction\":210.0,\"frost_chance\":null,\"temp_air\":15.9,\"feels_like\":null,\"dew_point\":11.9,\"relative_humidity\":77.0,\"pressure\":1025.4,\"ozone\":null,\"ghi\":49.81579897492729,\"dni\":14.538665837772964,\"dhi\":47.79892049551232}",
"{\"date_time\":\"2024-10-29T17:00:00+01:00\",\"total_clouds\":50.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":47830.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":2.0652816544204247,\"wind_speed\":10.4,\"wind_direction\":250.0,\"frost_chance\":null,\"temp_air\":12.5,\"feels_like\":null,\"dew_point\":10.4,\"relative_humidity\":87.0,\"pressure\":1025.59,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-10-29T18:00:00+01:00\",\"total_clouds\":0.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":41010.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":2.020123074823889,\"wind_speed\":6.1,\"wind_direction\":260.0,\"frost_chance\":null,\"temp_air\":11.4,\"feels_like\":null,\"dew_point\":10.0,\"relative_humidity\":91.0,\"pressure\":1025.9,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-10-29T19:00:00+01:00\",\"total_clouds\":0.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":18250.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.9662391817133793,\"wind_speed\":5.4,\"wind_direction\":200.0,\"frost_chance\":null,\"temp_air\":10.6,\"feels_like\":null,\"dew_point\":9.5,\"relative_humidity\":93.0,\"pressure\":1026.2,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-10-29T20:00:00+01:00\",\"total_clouds\":12.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":7840.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":2.0394979799019253,\"wind_speed\":6.5,\"wind_direction\":230.0,\"frost_chance\":null,\"temp_air\":11.2,\"feels_like\":null,\"dew_point\":10.2,\"relative_humidity\":93.0,\"pressure\":1026.59,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-10-29T21:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":7370.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.7702426892100076,\"wind_speed\":5.0,\"wind_direction\":210.0,\"frost_chance\":null,\"temp_air\":8.7,\"feels_like\":null,\"dew_point\":7.8,\"relative_humidity\":94.0,\"pressure\":1026.5,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-10-29T22:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":3850.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.8220170482487101,\"wind_speed\":4.3,\"wind_direction\":360.0,\"frost_chance\":null,\"temp_air\":9.0,\"feels_like\":null,\"dew_point\":8.3,\"relative_humidity\":95.0,\"pressure\":1026.59,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-10-29T23:00:00+01:00\",\"total_clouds\":87.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":7260.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.8443259335596536,\"wind_speed\":4.3,\"wind_direction\":350.0,\"frost_chance\":null,\"temp_air\":9.2,\"feels_like\":null,\"dew_point\":8.5,\"relative_humidity\":95.0,\"pressure\":1026.59,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-10-30T00:00:00+01:00\",\"total_clouds\":50.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":180.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.729577309288815,\"wind_speed\":3.6,\"wind_direction\":30.0,\"frost_chance\":null,\"temp_air\":7.8,\"feels_like\":null,\"dew_point\":7.3,\"relative_humidity\":97.0,\"pressure\":1026.8,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-10-30T01:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":70.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.6378354583834347,\"wind_speed\":2.2,\"wind_direction\":240.0,\"frost_chance\":null,\"temp_air\":6.9,\"feels_like\":null,\"dew_point\":6.5,\"relative_humidity\":97.0,\"pressure\":1027.0,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-10-30T02:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":100.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.7263462963556704,\"wind_speed\":2.9,\"wind_direction\":360.0,\"frost_chance\":null,\"temp_air\":7.6,\"feels_like\":null,\"dew_point\":7.3,\"relative_humidity\":98.0,\"pressure\":1027.2,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-10-30T03:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":100.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.6952547605843942,\"wind_speed\":1.8,\"wind_direction\":20.0,\"frost_chance\":null,\"temp_air\":7.3,\"feels_like\":null,\"dew_point\":7.1,\"relative_humidity\":98.0,\"pressure\":1027.09,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-10-30T04:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":90.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.8268830865919616,\"wind_speed\":2.2,\"wind_direction\":350.0,\"frost_chance\":null,\"temp_air\":8.2,\"feels_like\":null,\"dew_point\":8.2,\"relative_humidity\":100.0,\"pressure\":1027.4,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-10-30T05:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":150.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.8492109088214057,\"wind_speed\":1.4,\"wind_direction\":290.0,\"frost_chance\":null,\"temp_air\":8.4,\"feels_like\":null,\"dew_point\":8.3,\"relative_humidity\":100.0,\"pressure\":1027.9,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-10-30T06:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":7840.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.8872184723928271,\"wind_speed\":2.5,\"wind_direction\":150.0,\"frost_chance\":null,\"temp_air\":8.9,\"feels_like\":null,\"dew_point\":8.8,\"relative_humidity\":99.0,\"pressure\":1027.8,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-10-30T07:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":2810.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.8872184723928271,\"wind_speed\":2.5,\"wind_direction\":290.0,\"frost_chance\":null,\"temp_air\":8.9,\"feels_like\":null,\"dew_point\":8.7,\"relative_humidity\":99.0,\"pressure\":1028.0,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-10-30T08:00:00+01:00\",\"total_clouds\":87.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":460.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.921981762341113,\"wind_speed\":2.2,\"wind_direction\":200.0,\"frost_chance\":null,\"temp_air\":9.2,\"feels_like\":null,\"dew_point\":9.0,\"relative_humidity\":99.0,\"pressure\":1028.3,\"ozone\":null,\"ghi\":21.219671542362477,\"dni\":0.0,\"dhi\":21.219671542362477}",
"{\"date_time\":\"2024-10-30T09:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":1620.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.9813912463341716,\"wind_speed\":2.5,\"wind_direction\":170.0,\"frost_chance\":null,\"temp_air\":9.7,\"feels_like\":null,\"dew_point\":9.6,\"relative_humidity\":99.0,\"pressure\":1028.4,\"ozone\":null,\"ghi\":61.24705643100921,\"dni\":0.0,\"dhi\":61.24705643100921}",
"{\"date_time\":\"2024-10-30T10:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":13580.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":2.0886168395153506,\"wind_speed\":1.8,\"wind_direction\":180.0,\"frost_chance\":null,\"temp_air\":10.4,\"feels_like\":null,\"dew_point\":10.4,\"relative_humidity\":100.0,\"pressure\":1028.7,\"ozone\":null,\"ghi\":100.51716584295312,\"dni\":0.0,\"dhi\":100.51716584295312}",
"{\"date_time\":\"2024-10-30T11:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":22490.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":2.0364427178898907,\"wind_speed\":5.4,\"wind_direction\":180.0,\"frost_chance\":null,\"temp_air\":11.0,\"feels_like\":null,\"dew_point\":10.2,\"relative_humidity\":94.0,\"pressure\":1028.59,\"ozone\":null,\"ghi\":126.49785855652105,\"dni\":0.0,\"dhi\":126.49785855652105}",
"{\"date_time\":\"2024-10-30T12:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":17130.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":2.108204921394897,\"wind_speed\":5.4,\"wind_direction\":170.0,\"frost_chance\":null,\"temp_air\":12.1,\"feels_like\":null,\"dew_point\":10.7,\"relative_humidity\":91.0,\"pressure\":1028.59,\"ozone\":null,\"ghi\":136.11108832250144,\"dni\":0.0,\"dhi\":136.11108832250144}",
"{\"date_time\":\"2024-10-30T13:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":27620.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":2.0744781114397735,\"wind_speed\":5.8,\"wind_direction\":180.0,\"frost_chance\":null,\"temp_air\":12.2,\"feels_like\":null,\"dew_point\":10.4,\"relative_humidity\":89.0,\"pressure\":1028.0,\"ozone\":null,\"ghi\":128.39950312276366,\"dni\":0.0,\"dhi\":128.39950312276366}",
"{\"date_time\":\"2024-10-30T14:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":23300.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":2.0792067212034273,\"wind_speed\":10.8,\"wind_direction\":170.0,\"frost_chance\":null,\"temp_air\":12.8,\"feels_like\":null,\"dew_point\":10.6,\"relative_humidity\":86.0,\"pressure\":1028.0,\"ozone\":null,\"ghi\":104.12035323578755,\"dni\":0.0,\"dhi\":104.12035323578755}",
"{\"date_time\":\"2024-10-30T15:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":17420.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":2.067588035893061,\"wind_speed\":10.4,\"wind_direction\":160.0,\"frost_chance\":null,\"temp_air\":12.9,\"feels_like\":null,\"dew_point\":10.5,\"relative_humidity\":85.0,\"pressure\":1027.7,\"ozone\":null,\"ghi\":66.05384442265539,\"dni\":0.0,\"dhi\":66.05384442265539}",
"{\"date_time\":\"2024-10-30T16:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":12780.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":2.116237166149368,\"wind_speed\":9.0,\"wind_direction\":170.0,\"frost_chance\":null,\"temp_air\":12.9,\"feels_like\":null,\"dew_point\":10.7,\"relative_humidity\":87.0,\"pressure\":1028.0,\"ozone\":null,\"ghi\":21.554012703673507,\"dni\":0.0,\"dhi\":21.554012703673507}",
"{\"date_time\":\"2024-10-30T17:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":9610.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":2.077906151217825,\"wind_speed\":5.0,\"wind_direction\":150.0,\"frost_chance\":null,\"temp_air\":12.6,\"feels_like\":null,\"dew_point\":10.5,\"relative_humidity\":87.0,\"pressure\":1027.9,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-10-30T18:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":10940.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":2.0723655900991496,\"wind_speed\":3.2,\"wind_direction\":160.0,\"frost_chance\":null,\"temp_air\":12.0,\"feels_like\":null,\"dew_point\":10.4,\"relative_humidity\":90.0,\"pressure\":1028.4,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-10-30T19:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":7710.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":2.04490856047528,\"wind_speed\":4.0,\"wind_direction\":180.0,\"frost_chance\":null,\"temp_air\":11.6,\"feels_like\":null,\"dew_point\":10.1,\"relative_humidity\":91.0,\"pressure\":1028.59,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-10-30T20:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":3170.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":2.0324781084809054,\"wind_speed\":3.2,\"wind_direction\":180.0,\"frost_chance\":null,\"temp_air\":11.5,\"feels_like\":null,\"dew_point\":10.1,\"relative_humidity\":91.0,\"pressure\":1028.9,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-10-30T21:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":3950.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":2.020123074823889,\"wind_speed\":6.8,\"wind_direction\":180.0,\"frost_chance\":null,\"temp_air\":11.4,\"feels_like\":null,\"dew_point\":10.0,\"relative_humidity\":91.0,\"pressure\":1028.8,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-10-30T22:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":2590.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":2.0423222294922834,\"wind_speed\":4.3,\"wind_direction\":190.0,\"frost_chance\":null,\"temp_air\":11.4,\"feels_like\":null,\"dew_point\":10.2,\"relative_humidity\":92.0,\"pressure\":1028.59,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-10-30T23:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":2940.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":2.0394979799019253,\"wind_speed\":2.5,\"wind_direction\":190.0,\"frost_chance\":null,\"temp_air\":11.2,\"feels_like\":null,\"dew_point\":10.2,\"relative_humidity\":93.0,\"pressure\":1028.9,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-10-31T00:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":1970.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.9424136607492761,\"wind_speed\":3.2,\"wind_direction\":200.0,\"frost_chance\":null,\"temp_air\":10.4,\"feels_like\":null,\"dew_point\":9.4,\"relative_humidity\":93.0,\"pressure\":1028.9,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-10-31T01:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":2510.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.9395134892129953,\"wind_speed\":4.7,\"wind_direction\":200.0,\"frost_chance\":null,\"temp_air\":10.2,\"feels_like\":null,\"dew_point\":9.3,\"relative_humidity\":94.0,\"pressure\":1028.9,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-10-31T02:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":2060.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":2.0085238952986137,\"wind_speed\":6.1,\"wind_direction\":190.0,\"frost_chance\":null,\"temp_air\":10.6,\"feels_like\":null,\"dew_point\":9.9,\"relative_humidity\":95.0,\"pressure\":1028.9,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-10-31T03:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":240.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":2.0050721659347364,\"wind_speed\":5.8,\"wind_direction\":190.0,\"frost_chance\":null,\"temp_air\":10.4,\"feels_like\":null,\"dew_point\":9.8,\"relative_humidity\":96.0,\"pressure\":1028.7,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-10-31T04:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":2970.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":2.0173313171547624,\"wind_speed\":6.8,\"wind_direction\":190.0,\"frost_chance\":null,\"temp_air\":10.5,\"feels_like\":null,\"dew_point\":9.9,\"relative_humidity\":96.0,\"pressure\":1028.5,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-10-31T05:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":3290.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.9963174492677347,\"wind_speed\":3.6,\"wind_direction\":210.0,\"frost_chance\":null,\"temp_air\":10.5,\"feels_like\":null,\"dew_point\":9.8,\"relative_humidity\":95.0,\"pressure\":1029.0,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-10-31T06:00:00+01:00\",\"total_clouds\":87.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":5990.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":2.0173313171547624,\"wind_speed\":4.3,\"wind_direction\":200.0,\"frost_chance\":null,\"temp_air\":10.5,\"feels_like\":null,\"dew_point\":10.0,\"relative_humidity\":96.0,\"pressure\":1028.7,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-10-31T07:00:00+01:00\",\"total_clouds\":87.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":170.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.9753035813807054,\"wind_speed\":5.4,\"wind_direction\":190.0,\"frost_chance\":null,\"temp_air\":10.5,\"feels_like\":null,\"dew_point\":9.7,\"relative_humidity\":94.0,\"pressure\":1029.0,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-10-31T08:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":5700.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.9513699189462126,\"wind_speed\":7.6,\"wind_direction\":190.0,\"frost_chance\":null,\"temp_air\":10.3,\"feels_like\":null,\"dew_point\":9.4,\"relative_humidity\":94.0,\"pressure\":1029.2,\"ozone\":null,\"ghi\":15.885486574503958,\"dni\":0.0,\"dhi\":15.885486574503958}",
"{\"date_time\":\"2024-10-31T09:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":8690.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.9835068514961423,\"wind_speed\":5.0,\"wind_direction\":200.0,\"frost_chance\":null,\"temp_air\":11.1,\"feels_like\":null,\"dew_point\":9.6,\"relative_humidity\":91.0,\"pressure\":1029.5,\"ozone\":null,\"ghi\":59.547045720693454,\"dni\":0.0,\"dhi\":59.547045720693454}",
"{\"date_time\":\"2024-10-31T10:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":15600.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.9682244315574093,\"wind_speed\":2.2,\"wind_direction\":200.0,\"frost_chance\":null,\"temp_air\":11.9,\"feels_like\":null,\"dew_point\":9.7,\"relative_humidity\":86.0,\"pressure\":1029.59,\"ozone\":null,\"ghi\":98.66949090664382,\"dni\":0.0,\"dhi\":98.66949090664382}",
"{\"date_time\":\"2024-10-31T11:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":25190.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.9682244315574093,\"wind_speed\":2.2,\"wind_direction\":210.0,\"frost_chance\":null,\"temp_air\":11.9,\"feels_like\":null,\"dew_point\":9.6,\"relative_humidity\":86.0,\"pressure\":1029.3,\"ozone\":null,\"ghi\":124.5826522461644,\"dni\":0.0,\"dhi\":124.5826522461644}",
"{\"date_time\":\"2024-10-31T12:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":33140.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.9459652102522924,\"wind_speed\":7.6,\"wind_direction\":200.0,\"frost_chance\":null,\"temp_air\":12.9,\"feels_like\":null,\"dew_point\":9.6,\"relative_humidity\":80.0,\"pressure\":1028.9,\"ozone\":null,\"ghi\":134.16758927009357,\"dni\":0.0,\"dhi\":134.16758927009357}",
"{\"date_time\":\"2024-10-31T13:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":36550.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.827634823721784,\"wind_speed\":9.0,\"wind_direction\":190.0,\"frost_chance\":null,\"temp_air\":13.6,\"feels_like\":null,\"dew_point\":8.7,\"relative_humidity\":72.0,\"pressure\":1028.4,\"ozone\":null,\"ghi\":126.46038107187944,\"dni\":0.0,\"dhi\":126.46038107187944}",
"{\"date_time\":\"2024-10-31T14:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":57100.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.8500141411416122,\"wind_speed\":9.0,\"wind_direction\":210.0,\"frost_chance\":null,\"temp_air\":13.8,\"feels_like\":null,\"dew_point\":8.8,\"relative_humidity\":72.0,\"pressure\":1027.9,\"ozone\":null,\"ghi\":102.22535560233246,\"dni\":0.0,\"dhi\":102.22535560233246}",
"{\"date_time\":\"2024-10-31T15:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":52570.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.8317476150435485,\"wind_speed\":5.4,\"wind_direction\":190.0,\"frost_chance\":null,\"temp_air\":14.1,\"feels_like\":null,\"dew_point\":8.7,\"relative_humidity\":70.0,\"pressure\":1027.7,\"ozone\":null,\"ghi\":64.2799263126679,\"dni\":0.0,\"dhi\":64.2799263126679}",
"{\"date_time\":\"2024-10-31T16:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":45660.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.861303515362104,\"wind_speed\":5.8,\"wind_direction\":180.0,\"frost_chance\":null,\"temp_air\":13.9,\"feels_like\":null,\"dew_point\":8.9,\"relative_humidity\":72.0,\"pressure\":1027.4,\"ozone\":null,\"ghi\":20.199874599546952,\"dni\":0.0,\"dhi\":20.199874599546952}",
"{\"date_time\":\"2024-10-31T17:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":51810.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.848666949739678,\"wind_speed\":3.6,\"wind_direction\":230.0,\"frost_chance\":null,\"temp_air\":12.9,\"feels_like\":null,\"dew_point\":8.7,\"relative_humidity\":76.0,\"pressure\":1027.3,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-10-31T18:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":44110.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.8876079019771812,\"wind_speed\":2.9,\"wind_direction\":230.0,\"frost_chance\":null,\"temp_air\":11.6,\"feels_like\":null,\"dew_point\":9.0,\"relative_humidity\":84.0,\"pressure\":1027.3,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-10-31T19:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":48080.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.9313264561503112,\"wind_speed\":2.2,\"wind_direction\":110.0,\"frost_chance\":null,\"temp_air\":11.4,\"feels_like\":null,\"dew_point\":9.2,\"relative_humidity\":87.0,\"pressure\":1027.2,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-10-31T20:00:00+01:00\",\"total_clouds\":87.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":30700.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.9443700269600115,\"wind_speed\":3.6,\"wind_direction\":180.0,\"frost_chance\":null,\"temp_air\":11.7,\"feels_like\":null,\"dew_point\":9.4,\"relative_humidity\":86.0,\"pressure\":1027.59,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-10-31T21:00:00+01:00\",\"total_clouds\":87.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":25400.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.9313264561503112,\"wind_speed\":2.9,\"wind_direction\":170.0,\"frost_chance\":null,\"temp_air\":11.4,\"feels_like\":null,\"dew_point\":9.4,\"relative_humidity\":87.0,\"pressure\":1027.4,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-10-31T22:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":25670.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.9298475508749398,\"wind_speed\":4.3,\"wind_direction\":180.0,\"frost_chance\":null,\"temp_air\":11.2,\"feels_like\":null,\"dew_point\":9.3,\"relative_humidity\":88.0,\"pressure\":1027.4,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-10-31T23:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":24450.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.9281212967255343,\"wind_speed\":2.9,\"wind_direction\":230.0,\"frost_chance\":null,\"temp_air\":11.0,\"feels_like\":null,\"dew_point\":9.3,\"relative_humidity\":89.0,\"pressure\":1027.0,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-01T00:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":24100.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.947556370182495,\"wind_speed\":2.9,\"wind_direction\":210.0,\"frost_chance\":null,\"temp_air\":10.8,\"feels_like\":null,\"dew_point\":9.4,\"relative_humidity\":91.0,\"pressure\":1026.8,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-01T01:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":20630.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.9753035813807054,\"wind_speed\":5.0,\"wind_direction\":190.0,\"frost_chance\":null,\"temp_air\":10.5,\"feels_like\":null,\"dew_point\":9.5,\"relative_humidity\":94.0,\"pressure\":1026.4,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-01T02:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":24020.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.9807797336643354,\"wind_speed\":6.5,\"wind_direction\":210.0,\"frost_chance\":null,\"temp_air\":10.2,\"feels_like\":null,\"dew_point\":9.6,\"relative_humidity\":96.0,\"pressure\":1026.2,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-01T03:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":8730.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.9807797336643354,\"wind_speed\":9.4,\"wind_direction\":210.0,\"frost_chance\":null,\"temp_air\":10.2,\"feels_like\":null,\"dew_point\":9.6,\"relative_humidity\":96.0,\"pressure\":1026.09,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-01T04:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":5560.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.9687457153823724,\"wind_speed\":9.0,\"wind_direction\":250.0,\"frost_chance\":null,\"temp_air\":10.1,\"feels_like\":null,\"dew_point\":9.6,\"relative_humidity\":96.0,\"pressure\":1025.9,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-01T05:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":5030.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.1,\"preciptable_water\":1.9807797336643354,\"wind_speed\":8.3,\"wind_direction\":250.0,\"frost_chance\":null,\"temp_air\":10.2,\"feels_like\":null,\"dew_point\":9.6,\"relative_humidity\":96.0,\"pressure\":1026.0,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-01T06:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":7340.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.1,\"preciptable_water\":1.9449002411739278,\"wind_speed\":7.9,\"wind_direction\":270.0,\"frost_chance\":null,\"temp_air\":9.9,\"feels_like\":null,\"dew_point\":9.2,\"relative_humidity\":96.0,\"pressure\":1025.8,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-01T07:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":6340.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.8669161262727356,\"wind_speed\":3.2,\"wind_direction\":260.0,\"frost_chance\":null,\"temp_air\":9.4,\"feels_like\":null,\"dew_point\":8.7,\"relative_humidity\":95.0,\"pressure\":1025.7,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-01T08:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":12030.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.8360532099381073,\"wind_speed\":5.8,\"wind_direction\":220.0,\"frost_chance\":null,\"temp_air\":9.3,\"feels_like\":null,\"dew_point\":8.5,\"relative_humidity\":94.0,\"pressure\":1025.7,\"ozone\":null,\"ghi\":14.712237798164347,\"dni\":0.0,\"dhi\":14.712237798164347}",
"{\"date_time\":\"2024-11-01T09:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":29210.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.8301128504372512,\"wind_speed\":11.9,\"wind_direction\":220.0,\"frost_chance\":null,\"temp_air\":9.6,\"feels_like\":null,\"dew_point\":8.4,\"relative_humidity\":92.0,\"pressure\":1026.0,\"ozone\":null,\"ghi\":57.8606202914984,\"dni\":0.0,\"dhi\":57.8606202914984}",
"{\"date_time\":\"2024-11-01T10:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":24570.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.8548700685143087,\"wind_speed\":12.2,\"wind_direction\":210.0,\"frost_chance\":null,\"temp_air\":10.0,\"feels_like\":null,\"dew_point\":8.6,\"relative_humidity\":91.0,\"pressure\":1025.9,\"ozone\":null,\"ghi\":96.83399790992624,\"dni\":0.0,\"dhi\":96.83399790992624}",
"{\"date_time\":\"2024-11-01T11:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":19290.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.8475736466618398,\"wind_speed\":11.9,\"wind_direction\":200.0,\"frost_chance\":null,\"temp_air\":10.3,\"feels_like\":null,\"dew_point\":8.6,\"relative_humidity\":89.0,\"pressure\":1026.3,\"ozone\":null,\"ghi\":122.68179272930055,\"dni\":0.0,\"dhi\":122.68179272930055}",
"{\"date_time\":\"2024-11-01T12:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":38750.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.8492203740585325,\"wind_speed\":12.6,\"wind_direction\":220.0,\"frost_chance\":null,\"temp_air\":10.5,\"feels_like\":null,\"dew_point\":8.6,\"relative_humidity\":88.0,\"pressure\":1025.8,\"ozone\":null,\"ghi\":132.24193611040087,\"dni\":0.0,\"dhi\":132.24193611040087}",
"{\"date_time\":\"2024-11-01T13:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":47950.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.850632591753855,\"wind_speed\":13.3,\"wind_direction\":220.0,\"frost_chance\":null,\"temp_air\":10.7,\"feels_like\":null,\"dew_point\":8.6,\"relative_humidity\":87.0,\"pressure\":1025.2,\"ozone\":null,\"ghi\":124.54330452319319,\"dni\":0.0,\"dhi\":124.54330452319319}",
"{\"date_time\":\"2024-11-01T14:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":39370.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.8087382990093692,\"wind_speed\":10.4,\"wind_direction\":240.0,\"frost_chance\":null,\"temp_air\":10.9,\"feels_like\":null,\"dew_point\":8.3,\"relative_humidity\":84.0,\"pressure\":1024.7,\"ozone\":null,\"ghi\":100.35711080592202,\"dni\":0.0,\"dhi\":100.35711080592202}",
"{\"date_time\":\"2024-11-01T15:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":45050.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.8201971218479545,\"wind_speed\":10.1,\"wind_direction\":240.0,\"frost_chance\":null,\"temp_air\":11.2,\"feels_like\":null,\"dew_point\":8.5,\"relative_humidity\":83.0,\"pressure\":1024.3,\"ozone\":null,\"ghi\":62.53879690593976,\"dni\":0.0,\"dhi\":62.53879690593976}",
"{\"date_time\":\"2024-11-01T16:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":50820.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.8313294168900875,\"wind_speed\":9.0,\"wind_direction\":220.0,\"frost_chance\":null,\"temp_air\":11.3,\"feels_like\":null,\"dew_point\":8.5,\"relative_humidity\":83.0,\"pressure\":1024.0,\"ozone\":null,\"ghi\":18.895062599949565,\"dni\":0.0,\"dhi\":18.895062599949565}",
"{\"date_time\":\"2024-11-01T17:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":44670.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.7977443417069183,\"wind_speed\":9.0,\"wind_direction\":200.0,\"frost_chance\":null,\"temp_air\":10.8,\"feels_like\":null,\"dew_point\":8.2,\"relative_humidity\":84.0,\"pressure\":1024.0,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-01T18:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":49650.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.7548156137872095,\"wind_speed\":5.4,\"wind_direction\":240.0,\"frost_chance\":null,\"temp_air\":10.6,\"feels_like\":null,\"dew_point\":7.9,\"relative_humidity\":83.0,\"pressure\":1024.4,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-01T19:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":34320.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.785295883291216,\"wind_speed\":9.0,\"wind_direction\":270.0,\"frost_chance\":null,\"temp_air\":10.3,\"feels_like\":null,\"dew_point\":8.0,\"relative_humidity\":86.0,\"pressure\":1025.0,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-01T20:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":28820.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.7021447330910096,\"wind_speed\":13.3,\"wind_direction\":300.0,\"frost_chance\":null,\"temp_air\":10.1,\"feels_like\":null,\"dew_point\":7.3,\"relative_humidity\":83.0,\"pressure\":1025.59,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-01T21:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":27910.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.6511793809783977,\"wind_speed\":12.2,\"wind_direction\":290.0,\"frost_chance\":null,\"temp_air\":9.8,\"feels_like\":null,\"dew_point\":6.9,\"relative_humidity\":82.0,\"pressure\":1025.9,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-01T22:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":27860.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.621195919413435,\"wind_speed\":10.4,\"wind_direction\":280.0,\"frost_chance\":null,\"temp_air\":9.3,\"feels_like\":null,\"dew_point\":6.6,\"relative_humidity\":83.0,\"pressure\":1026.5,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-01T23:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":26550.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.620878591918624,\"wind_speed\":7.9,\"wind_direction\":300.0,\"frost_chance\":null,\"temp_air\":9.1,\"feels_like\":null,\"dew_point\":6.5,\"relative_humidity\":84.0,\"pressure\":1026.8,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-02T00:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":33360.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.601582418205307,\"wind_speed\":6.1,\"wind_direction\":330.0,\"frost_chance\":null,\"temp_air\":9.1,\"feels_like\":null,\"dew_point\":6.4,\"relative_humidity\":83.0,\"pressure\":1027.09,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-02T01:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":30030.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.639401905310941,\"wind_speed\":4.0,\"wind_direction\":230.0,\"frost_chance\":null,\"temp_air\":8.9,\"feels_like\":null,\"dew_point\":6.7,\"relative_humidity\":86.0,\"pressure\":1027.2,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-02T02:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":34110.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.6572484750051133,\"wind_speed\":4.7,\"wind_direction\":210.0,\"frost_chance\":null,\"temp_air\":8.7,\"feels_like\":null,\"dew_point\":6.8,\"relative_humidity\":88.0,\"pressure\":1027.4,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-02T03:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":27390.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.6572484750051133,\"wind_speed\":5.8,\"wind_direction\":200.0,\"frost_chance\":null,\"temp_air\":8.7,\"feels_like\":null,\"dew_point\":6.8,\"relative_humidity\":88.0,\"pressure\":1027.3,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-02T04:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":25060.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.6484088480622887,\"wind_speed\":2.9,\"wind_direction\":190.0,\"frost_chance\":null,\"temp_air\":8.8,\"feels_like\":null,\"dew_point\":6.8,\"relative_humidity\":87.0,\"pressure\":1027.8,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-02T05:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":23760.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.70525053247823,\"wind_speed\":4.3,\"wind_direction\":210.0,\"frost_chance\":null,\"temp_air\":8.8,\"feels_like\":null,\"dew_point\":7.2,\"relative_humidity\":90.0,\"pressure\":1028.0,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-02T06:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":21300.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.6863033043395828,\"wind_speed\":5.8,\"wind_direction\":190.0,\"frost_chance\":null,\"temp_air\":8.8,\"feels_like\":null,\"dew_point\":7.1,\"relative_humidity\":89.0,\"pressure\":1028.09,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-02T07:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":18210.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.70525053247823,\"wind_speed\":4.0,\"wind_direction\":170.0,\"frost_chance\":null,\"temp_air\":8.8,\"feels_like\":null,\"dew_point\":7.3,\"relative_humidity\":90.0,\"pressure\":1028.8,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-02T08:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":18570.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.7173594604852087,\"wind_speed\":1.8,\"wind_direction\":260.0,\"frost_chance\":null,\"temp_air\":9.1,\"feels_like\":null,\"dew_point\":7.4,\"relative_humidity\":89.0,\"pressure\":1029.4,\"ozone\":null,\"ghi\":13.575130473514132,\"dni\":0.0,\"dhi\":13.575130473514132}",
"{\"date_time\":\"2024-11-02T09:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":19450.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.7306501955221831,\"wind_speed\":2.5,\"wind_direction\":180.0,\"frost_chance\":null,\"temp_air\":9.6,\"feels_like\":null,\"dew_point\":7.6,\"relative_humidity\":87.0,\"pressure\":1030.0,\"ozone\":null,\"ghi\":56.188815268002756,\"dni\":0.0,\"dhi\":56.188815268002756}",
"{\"date_time\":\"2024-11-02T10:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":20370.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.7861787703974465,\"wind_speed\":1.8,\"wind_direction\":170.0,\"frost_chance\":null,\"temp_air\":10.5,\"feels_like\":null,\"dew_point\":8.2,\"relative_humidity\":85.0,\"pressure\":1030.5,\"ozone\":null,\"ghi\":95.01168041969699,\"dni\":0.0,\"dhi\":95.01168041969699}",
"{\"date_time\":\"2024-11-02T11:00:00+01:00\",\"total_clouds\":87.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":32720.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.7981315281399455,\"wind_speed\":1.8,\"wind_direction\":150.0,\"frost_chance\":null,\"temp_air\":11.4,\"feels_like\":null,\"dew_point\":8.3,\"relative_humidity\":81.0,\"pressure\":1030.4,\"ozone\":null,\"ghi\":149.95999291205823,\"dni\":12.813701117903975,\"dhi\":144.84157493139483}",
"{\"date_time\":\"2024-11-02T12:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":63890.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.7119416707379567,\"wind_speed\":4.0,\"wind_direction\":110.0,\"frost_chance\":null,\"temp_air\":12.3,\"feels_like\":null,\"dew_point\":7.6,\"relative_humidity\":73.0,\"pressure\":1030.4,\"ozone\":null,\"ghi\":130.33518534986166,\"dni\":0.0,\"dhi\":130.33518534986166}",
"{\"date_time\":\"2024-11-02T13:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":75000.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.7341189545032323,\"wind_speed\":5.0,\"wind_direction\":90.0,\"frost_chance\":null,\"temp_air\":13.2,\"feels_like\":null,\"dew_point\":7.8,\"relative_humidity\":70.0,\"pressure\":1030.09,\"ozone\":null,\"ghi\":122.64932256772778,\"dni\":0.0,\"dhi\":122.64932256772778}",
"{\"date_time\":\"2024-11-02T14:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":70510.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.7674144274726324,\"wind_speed\":4.0,\"wind_direction\":110.0,\"frost_chance\":null,\"temp_air\":12.6,\"feels_like\":null,\"dew_point\":8.2,\"relative_humidity\":74.0,\"pressure\":1029.8,\"ozone\":null,\"ghi\":98.5166184540087,\"dni\":0.0,\"dhi\":98.5166184540087}",
"{\"date_time\":\"2024-11-02T15:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":73670.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.815182384971893,\"wind_speed\":5.0,\"wind_direction\":50.0,\"frost_chance\":null,\"temp_air\":12.6,\"feels_like\":null,\"dew_point\":8.4,\"relative_humidity\":76.0,\"pressure\":1029.7,\"ozone\":null,\"ghi\":60.83135423410029,\"dni\":0.0,\"dhi\":60.83135423410029}",
"{\"date_time\":\"2024-11-02T16:00:00+01:00\",\"total_clouds\":87.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":75000.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.850307043871555,\"wind_speed\":7.6,\"wind_direction\":60.0,\"frost_chance\":null,\"temp_air\":12.7,\"feels_like\":null,\"dew_point\":8.7,\"relative_humidity\":77.0,\"pressure\":1030.0,\"ozone\":null,\"ghi\":21.89924464521845,\"dni\":0.0,\"dhi\":21.89924464521845}",
"{\"date_time\":\"2024-11-02T17:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":75000.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.8087382990093692,\"wind_speed\":9.7,\"wind_direction\":60.0,\"frost_chance\":null,\"temp_air\":10.9,\"feels_like\":null,\"dew_point\":8.3,\"relative_humidity\":84.0,\"pressure\":1030.2,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-02T18:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":31130.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.8833512151215333,\"wind_speed\":10.4,\"wind_direction\":50.0,\"frost_chance\":null,\"temp_air\":10.8,\"feels_like\":null,\"dew_point\":9.0,\"relative_humidity\":88.0,\"pressure\":1030.8,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-02T19:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":75000.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.8745229585567942,\"wind_speed\":11.5,\"wind_direction\":50.0,\"frost_chance\":null,\"temp_air\":11.1,\"feels_like\":null,\"dew_point\":8.9,\"relative_humidity\":86.0,\"pressure\":1031.4,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-02T20:00:00+01:00\",\"total_clouds\":37.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":75000.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.6919160225049534,\"wind_speed\":11.5,\"wind_direction\":50.0,\"frost_chance\":null,\"temp_air\":10.2,\"feels_like\":null,\"dew_point\":7.3,\"relative_humidity\":82.0,\"pressure\":1031.9,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-02T21:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":73550.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.5255496443180916,\"wind_speed\":8.6,\"wind_direction\":30.0,\"frost_chance\":null,\"temp_air\":8.3,\"feels_like\":null,\"dew_point\":5.6,\"relative_humidity\":83.0,\"pressure\":1032.7,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-02T22:00:00+01:00\",\"total_clouds\":87.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":64570.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.4958879070461668,\"wind_speed\":9.0,\"wind_direction\":50.0,\"frost_chance\":null,\"temp_air\":7.2,\"feels_like\":null,\"dew_point\":5.2,\"relative_humidity\":87.0,\"pressure\":1032.8,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-02T23:00:00+01:00\",\"total_clouds\":87.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":66050.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.390501273322162,\"wind_speed\":5.0,\"wind_direction\":40.0,\"frost_chance\":null,\"temp_air\":5.8,\"feels_like\":null,\"dew_point\":4.0,\"relative_humidity\":88.0,\"pressure\":1033.09,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-03T00:00:00+01:00\",\"total_clouds\":87.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":68320.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.4252928707378512,\"wind_speed\":5.8,\"wind_direction\":30.0,\"frost_chance\":null,\"temp_air\":6.4,\"feels_like\":null,\"dew_point\":4.4,\"relative_humidity\":87.0,\"pressure\":1033.2,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-03T01:00:00+01:00\",\"total_clouds\":37.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":62300.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.3403838896380875,\"wind_speed\":7.2,\"wind_direction\":40.0,\"frost_chance\":null,\"temp_air\":5.0,\"feels_like\":null,\"dew_point\":3.4,\"relative_humidity\":89.0,\"pressure\":1033.4,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-03T02:00:00+01:00\",\"total_clouds\":87.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":52300.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.3623270721453318,\"wind_speed\":6.8,\"wind_direction\":20.0,\"frost_chance\":null,\"temp_air\":4.9,\"feels_like\":null,\"dew_point\":3.6,\"relative_humidity\":91.0,\"pressure\":1033.3,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-03T03:00:00+01:00\",\"total_clouds\":75.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":46680.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.2794609296319466,\"wind_speed\":9.0,\"wind_direction\":10.0,\"frost_chance\":null,\"temp_air\":3.3,\"feels_like\":null,\"dew_point\":2.5,\"relative_humidity\":94.0,\"pressure\":1033.3,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-03T04:00:00+01:00\",\"total_clouds\":87.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":25080.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.199274182505457,\"wind_speed\":7.6,\"wind_direction\":10.0,\"frost_chance\":null,\"temp_air\":2.2,\"feels_like\":null,\"dew_point\":1.3,\"relative_humidity\":94.0,\"pressure\":1033.0,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-03T05:00:00+01:00\",\"total_clouds\":37.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":31420.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.2392275984186982,\"wind_speed\":7.6,\"wind_direction\":10.0,\"frost_chance\":null,\"temp_air\":2.4,\"feels_like\":null,\"dew_point\":1.8,\"relative_humidity\":96.0,\"pressure\":1032.9,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-03T06:00:00+01:00\",\"total_clouds\":12.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":7340.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.2105500551096946,\"wind_speed\":5.8,\"wind_direction\":10.0,\"frost_chance\":null,\"temp_air\":2.0,\"feels_like\":null,\"dew_point\":1.4,\"relative_humidity\":96.0,\"pressure\":1032.8,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-03T07:00:00+01:00\",\"total_clouds\":37.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":14380.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.1434707306695828,\"wind_speed\":6.1,\"wind_direction\":360.0,\"frost_chance\":null,\"temp_air\":1.2,\"feels_like\":null,\"dew_point\":0.5,\"relative_humidity\":95.0,\"pressure\":1032.9,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-03T08:00:00+01:00\",\"total_clouds\":25.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":190.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.1488384034632042,\"wind_speed\":6.1,\"wind_direction\":360.0,\"frost_chance\":null,\"temp_air\":1.1,\"feels_like\":null,\"dew_point\":0.5,\"relative_humidity\":96.0,\"pressure\":1033.09,\"ozone\":null,\"ghi\":29.853526900099773,\"dni\":0.0,\"dhi\":29.853526900099773}",
"{\"date_time\":\"2024-11-03T09:00:00+01:00\",\"total_clouds\":0.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":14830.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.1982804081056098,\"wind_speed\":6.5,\"wind_direction\":360.0,\"frost_chance\":null,\"temp_air\":3.5,\"feels_like\":null,\"dew_point\":1.6,\"relative_humidity\":87.0,\"pressure\":1033.5,\"ozone\":null,\"ghi\":155.80764565813612,\"dni\":359.7647285960036,\"dhi\":73.0052545725479}",
"{\"date_time\":\"2024-11-03T10:00:00+01:00\",\"total_clouds\":0.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":29490.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.3676770322583323,\"wind_speed\":3.6,\"wind_direction\":10.0,\"frost_chance\":null,\"temp_air\":6.9,\"feels_like\":null,\"dew_point\":3.9,\"relative_humidity\":81.0,\"pressure\":1033.09,\"ozone\":null,\"ghi\":266.29582531703034,\"dni\":542.6987046512027,\"dhi\":86.93886764577462}",
"{\"date_time\":\"2024-11-03T11:00:00+01:00\",\"total_clouds\":37.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":35910.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.413965797496583,\"wind_speed\":3.2,\"wind_direction\":350.0,\"frost_chance\":null,\"temp_air\":8.5,\"feels_like\":null,\"dew_point\":4.5,\"relative_humidity\":76.0,\"pressure\":1032.59,\"ozone\":null,\"ghi\":258.0721216984891,\"dni\":250.78797925254707,\"dhi\":159.12427101947398}",
"{\"date_time\":\"2024-11-03T12:00:00+01:00\",\"total_clouds\":62.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":40750.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.441145619265592,\"wind_speed\":5.0,\"wind_direction\":30.0,\"frost_chance\":null,\"temp_air\":10.4,\"feels_like\":null,\"dew_point\":5.0,\"relative_humidity\":69.0,\"pressure\":1032.0,\"ozone\":null,\"ghi\":219.09625671981206,\"dni\":99.04311158893928,\"dhi\":177.70064914620303}",
"{\"date_time\":\"2024-11-03T13:00:00+01:00\",\"total_clouds\":37.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":45690.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.510497819567314,\"wind_speed\":4.3,\"wind_direction\":60.0,\"frost_chance\":null,\"temp_air\":11.9,\"feels_like\":null,\"dew_point\":5.8,\"relative_humidity\":66.0,\"pressure\":1031.3,\"ozone\":null,\"ghi\":262.09146259237264,\"dni\":252.71731147093138,\"dhi\":161.2289221818615}",
"{\"date_time\":\"2024-11-03T14:00:00+01:00\",\"total_clouds\":25.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":20640.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.4989629850536337,\"wind_speed\":4.7,\"wind_direction\":90.0,\"frost_chance\":null,\"temp_air\":12.8,\"feels_like\":null,\"dew_point\":5.8,\"relative_humidity\":62.0,\"pressure\":1030.7,\"ozone\":null,\"ghi\":231.40091702563853,\"dni\":315.5806670947059,\"dhi\":124.32565938039977}",
"{\"date_time\":\"2024-11-03T15:00:00+01:00\",\"total_clouds\":37.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":39980.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.5138875813494541,\"wind_speed\":2.9,\"wind_direction\":70.0,\"frost_chance\":null,\"temp_air\":12.7,\"feels_like\":null,\"dew_point\":5.8,\"relative_humidity\":63.0,\"pressure\":1030.3,\"ozone\":null,\"ghi\":128.3738640423681,\"dni\":143.98607922934983,\"dhi\":93.44607846301753}",
"{\"date_time\":\"2024-11-03T16:00:00+01:00\",\"total_clouds\":25.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":52750.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.510497819567314,\"wind_speed\":3.2,\"wind_direction\":90.0,\"frost_chance\":null,\"temp_air\":11.9,\"feels_like\":null,\"dew_point\":5.8,\"relative_humidity\":66.0,\"pressure\":1029.8,\"ozone\":null,\"ghi\":39.33002179401603,\"dni\":3.9206199292686486,\"dhi\":38.87702761430884}",
"{\"date_time\":\"2024-11-03T17:00:00+01:00\",\"total_clouds\":87.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":49460.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.4883850499964026,\"wind_speed\":4.0,\"wind_direction\":90.0,\"frost_chance\":null,\"temp_air\":8.5,\"feels_like\":null,\"dew_point\":5.3,\"relative_humidity\":80.0,\"pressure\":1029.8,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-03T18:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":26380.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.398891798445423,\"wind_speed\":3.2,\"wind_direction\":70.0,\"frost_chance\":null,\"temp_air\":5.9,\"feels_like\":null,\"dew_point\":4.1,\"relative_humidity\":88.0,\"pressure\":1030.2,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-03T19:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":32120.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.3609257229104745,\"wind_speed\":5.0,\"wind_direction\":50.0,\"frost_chance\":null,\"temp_air\":4.7,\"feels_like\":null,\"dew_point\":3.5,\"relative_humidity\":92.0,\"pressure\":1030.59,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-03T20:00:00+01:00\",\"total_clouds\":87.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":24750.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.2733596216558292,\"wind_speed\":5.8,\"wind_direction\":20.0,\"frost_chance\":null,\"temp_air\":3.4,\"feels_like\":null,\"dew_point\":2.4,\"relative_humidity\":93.0,\"pressure\":1030.9,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-03T21:00:00+01:00\",\"total_clouds\":25.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":16220.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.226318977601837,\"wind_speed\":5.8,\"wind_direction\":40.0,\"frost_chance\":null,\"temp_air\":2.4,\"feels_like\":null,\"dew_point\":1.6,\"relative_humidity\":95.0,\"pressure\":1031.0,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-03T22:00:00+01:00\",\"total_clouds\":0.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":8920.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.1965035965509712,\"wind_speed\":6.1,\"wind_direction\":10.0,\"frost_chance\":null,\"temp_air\":1.8,\"feels_like\":null,\"dew_point\":1.2,\"relative_humidity\":96.0,\"pressure\":1031.4,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-03T23:00:00+01:00\",\"total_clouds\":0.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":6530.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.1368713367604626,\"wind_speed\":7.2,\"wind_direction\":360.0,\"frost_chance\":null,\"temp_air\":1.1,\"feels_like\":null,\"dew_point\":0.4,\"relative_humidity\":95.0,\"pressure\":1031.7,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-04T00:00:00+01:00\",\"total_clouds\":0.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":690.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.1356387287723233,\"wind_speed\":7.2,\"wind_direction\":10.0,\"frost_chance\":null,\"temp_air\":0.9,\"feels_like\":null,\"dew_point\":0.4,\"relative_humidity\":96.0,\"pressure\":1031.8,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-04T01:00:00+01:00\",\"total_clouds\":0.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":290.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.090861721609437,\"wind_speed\":7.2,\"wind_direction\":350.0,\"frost_chance\":null,\"temp_air\":0.2,\"feels_like\":null,\"dew_point\":-0.3,\"relative_humidity\":96.0,\"pressure\":1031.59,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-04T02:00:00+01:00\",\"total_clouds\":37.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":480.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.0835031721133945,\"wind_speed\":7.2,\"wind_direction\":10.0,\"frost_chance\":null,\"temp_air\":-0.1,\"feels_like\":null,\"dew_point\":-0.5,\"relative_humidity\":97.0,\"pressure\":1031.8,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-04T03:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":110.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.1009339806416942,\"wind_speed\":6.8,\"wind_direction\":20.0,\"frost_chance\":null,\"temp_air\":0.0,\"feels_like\":null,\"dew_point\":-0.3,\"relative_humidity\":98.0,\"pressure\":1031.7,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-04T04:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":110.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.1135880074763005,\"wind_speed\":6.8,\"wind_direction\":10.0,\"frost_chance\":null,\"temp_air\":0.2,\"feels_like\":null,\"dew_point\":-0.1,\"relative_humidity\":98.0,\"pressure\":1031.7,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-04T05:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":160.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.1264206880151613,\"wind_speed\":4.7,\"wind_direction\":360.0,\"frost_chance\":null,\"temp_air\":0.4,\"feels_like\":null,\"dew_point\":0.2,\"relative_humidity\":98.0,\"pressure\":1031.59,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-04T06:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":120.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.1460092992885946,\"wind_speed\":4.3,\"wind_direction\":10.0,\"frost_chance\":null,\"temp_air\":0.7,\"feels_like\":null,\"dew_point\":0.5,\"relative_humidity\":98.0,\"pressure\":1031.59,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-04T07:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":120.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.157703271730315,\"wind_speed\":5.4,\"wind_direction\":360.0,\"frost_chance\":null,\"temp_air\":0.7,\"feels_like\":null,\"dew_point\":0.6,\"relative_humidity\":99.0,\"pressure\":1031.7,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-04T08:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":90.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.1541137262196406,\"wind_speed\":5.0,\"wind_direction\":360.0,\"frost_chance\":null,\"temp_air\":1.0,\"feels_like\":null,\"dew_point\":0.6,\"relative_humidity\":97.0,\"pressure\":1032.0,\"ozone\":null,\"ghi\":11.417075555130713,\"dni\":0.0,\"dhi\":11.417075555130713}",
"{\"date_time\":\"2024-11-04T09:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":120.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.22143075481245,\"wind_speed\":5.0,\"wind_direction\":20.0,\"frost_chance\":null,\"temp_air\":1.8,\"feels_like\":null,\"dew_point\":1.6,\"relative_humidity\":98.0,\"pressure\":1032.2,\"ozone\":null,\"ghi\":52.89325642044978,\"dni\":0.0,\"dhi\":52.89325642044978}",
"{\"date_time\":\"2024-11-04T10:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":150.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.3316895356787095,\"wind_speed\":5.0,\"wind_direction\":360.0,\"frost_chance\":null,\"temp_air\":3.1,\"feels_like\":null,\"dew_point\":2.9,\"relative_humidity\":99.0,\"pressure\":1032.0,\"ozone\":null,\"ghi\":91.41057965841121,\"dni\":0.0,\"dhi\":91.41057965841121}",
"{\"date_time\":\"2024-11-04T11:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":120.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.3555118553110441,\"wind_speed\":4.0,\"wind_direction\":350.0,\"frost_chance\":null,\"temp_air\":3.4,\"feels_like\":null,\"dew_point\":3.2,\"relative_humidity\":99.0,\"pressure\":1031.7,\"ozone\":null,\"ghi\":117.07563013335894,\"dni\":0.0,\"dhi\":117.07563013335894}",
"{\"date_time\":\"2024-11-04T12:00:00+01:00\",\"total_clouds\":87.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":350.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.411639131909973,\"wind_speed\":3.6,\"wind_direction\":350.0,\"frost_chance\":null,\"temp_air\":4.6,\"feels_like\":null,\"dew_point\":4.0,\"relative_humidity\":96.0,\"pressure\":1030.9,\"ozone\":null,\"ghi\":157.14326578481194,\"dni\":14.639827403702798,\"dhi\":151.09589042551917}",
"{\"date_time\":\"2024-11-04T13:00:00+01:00\",\"total_clouds\":0.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":21410.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.4587699149332078,\"wind_speed\":4.3,\"wind_direction\":30.0,\"frost_chance\":null,\"temp_air\":5.5,\"feels_like\":null,\"dew_point\":4.6,\"relative_humidity\":94.0,\"pressure\":1029.9,\"ozone\":null,\"ghi\":339.81370340762766,\"dni\":626.3444200231016,\"dhi\":92.86155820300033}",
"{\"date_time\":\"2024-11-04T14:00:00+01:00\",\"total_clouds\":0.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":39060.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.5252929600670635,\"wind_speed\":2.9,\"wind_direction\":10.0,\"frost_chance\":null,\"temp_air\":8.1,\"feels_like\":null,\"dew_point\":5.5,\"relative_humidity\":84.0,\"pressure\":1029.0,\"ozone\":null,\"ghi\":271.20800751093515,\"dni\":549.6301264862547,\"dhi\":87.32285840607426}",
"{\"date_time\":\"2024-11-04T15:00:00+01:00\",\"total_clouds\":0.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":36810.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.5619693663437841,\"wind_speed\":5.4,\"wind_direction\":50.0,\"frost_chance\":null,\"temp_air\":9.5,\"feels_like\":null,\"dew_point\":6.0,\"relative_humidity\":79.0,\"pressure\":1028.5,\"ozone\":null,\"ghi\":164.34557743320204,\"dni\":378.3659028909279,\"dhi\":74.29470308711969}",
"{\"date_time\":\"2024-11-04T16:00:00+01:00\",\"total_clouds\":0.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":61290.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.5325727324790135,\"wind_speed\":5.4,\"wind_direction\":40.0,\"frost_chance\":null,\"temp_air\":7.6,\"feels_like\":null,\"dew_point\":5.6,\"relative_humidity\":87.0,\"pressure\":1028.09,\"ozone\":null,\"ghi\":43.66802704534112,\"dni\":44.64467562847794,\"dhi\":38.705191642431814}",
"{\"date_time\":\"2024-11-04T17:00:00+01:00\",\"total_clouds\":0.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":45940.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.3447696273196648,\"wind_speed\":4.3,\"wind_direction\":40.0,\"frost_chance\":null,\"temp_air\":4.5,\"feels_like\":null,\"dew_point\":3.3,\"relative_humidity\":92.0,\"pressure\":1028.2,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-04T18:00:00+01:00\",\"total_clouds\":0.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":44410.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.2349798532829575,\"wind_speed\":4.3,\"wind_direction\":40.0,\"frost_chance\":null,\"temp_air\":2.7,\"feels_like\":null,\"dew_point\":1.8,\"relative_humidity\":94.0,\"pressure\":1028.2,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-04T19:00:00+01:00\",\"total_clouds\":12.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":44940.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.2247906544736582,\"wind_speed\":6.1,\"wind_direction\":10.0,\"frost_chance\":null,\"temp_air\":2.2,\"feels_like\":null,\"dew_point\":1.7,\"relative_humidity\":96.0,\"pressure\":1028.59,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-04T20:00:00+01:00\",\"total_clouds\":0.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":47840.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.2160391817564757,\"wind_speed\":7.6,\"wind_direction\":360.0,\"frost_chance\":null,\"temp_air\":1.9,\"feels_like\":null,\"dew_point\":1.5,\"relative_humidity\":97.0,\"pressure\":1029.0,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-04T21:00:00+01:00\",\"total_clouds\":0.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":37930.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.2430135737816375,\"wind_speed\":7.2,\"wind_direction\":360.0,\"frost_chance\":null,\"temp_air\":2.1,\"feels_like\":null,\"dew_point\":1.9,\"relative_humidity\":98.0,\"pressure\":1029.09,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-04T22:00:00+01:00\",\"total_clouds\":0.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":18950.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.2503071264418593,\"wind_speed\":4.0,\"wind_direction\":10.0,\"frost_chance\":null,\"temp_air\":2.2,\"feels_like\":null,\"dew_point\":1.9,\"relative_humidity\":98.0,\"pressure\":1029.2,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-04T23:00:00+01:00\",\"total_clouds\":0.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":12300.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.255697385759001,\"wind_speed\":7.6,\"wind_direction\":10.0,\"frost_chance\":null,\"temp_air\":2.1,\"feels_like\":null,\"dew_point\":1.9,\"relative_humidity\":99.0,\"pressure\":1029.0,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-05T00:00:00+01:00\",\"total_clouds\":12.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":6660.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.2430135737816375,\"wind_speed\":7.6,\"wind_direction\":10.0,\"frost_chance\":null,\"temp_air\":2.1,\"feels_like\":null,\"dew_point\":1.8,\"relative_humidity\":98.0,\"pressure\":1028.8,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-05T01:00:00+01:00\",\"total_clouds\":12.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":1020.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.1847396035714295,\"wind_speed\":8.6,\"wind_direction\":360.0,\"frost_chance\":null,\"temp_air\":1.1,\"feels_like\":null,\"dew_point\":0.9,\"relative_humidity\":99.0,\"pressure\":1028.7,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-05T02:00:00+01:00\",\"total_clouds\":75.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":4820.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.0896999604310647,\"wind_speed\":7.9,\"wind_direction\":10.0,\"frost_chance\":null,\"temp_air\":0.0,\"feels_like\":null,\"dew_point\":-0.4,\"relative_humidity\":97.0,\"pressure\":1028.59,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-05T03:00:00+01:00\",\"total_clouds\":87.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":230.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.0472255738551546,\"wind_speed\":7.9,\"wind_direction\":360.0,\"frost_chance\":null,\"temp_air\":-0.7,\"feels_like\":null,\"dew_point\":-1.1,\"relative_humidity\":97.0,\"pressure\":1028.5,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-05T04:00:00+01:00\",\"total_clouds\":87.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":1170.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.0402709520121391,\"wind_speed\":6.8,\"wind_direction\":10.0,\"frost_chance\":null,\"temp_air\":-1.0,\"feels_like\":null,\"dew_point\":-1.3,\"relative_humidity\":98.0,\"pressure\":1028.3,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-05T05:00:00+01:00\",\"total_clouds\":0.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":430.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":0.9964253886852408,\"wind_speed\":7.2,\"wind_direction\":10.0,\"frost_chance\":null,\"temp_air\":-1.4,\"feels_like\":null,\"dew_point\":-1.9,\"relative_humidity\":96.0,\"pressure\":1028.5,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-05T06:00:00+01:00\",\"total_clouds\":25.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":200.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":0.9908715007922109,\"wind_speed\":7.9,\"wind_direction\":10.0,\"frost_chance\":null,\"temp_air\":-1.5,\"feels_like\":null,\"dew_point\":-2.0,\"relative_humidity\":96.0,\"pressure\":1028.7,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-05T07:00:00+01:00\",\"total_clouds\":37.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":180.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":0.9845964358395034,\"wind_speed\":7.2,\"wind_direction\":360.0,\"frost_chance\":null,\"temp_air\":-1.8,\"feels_like\":null,\"dew_point\":-2.2,\"relative_humidity\":97.0,\"pressure\":1028.8,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-05T08:00:00+01:00\",\"total_clouds\":87.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":250.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.01904093258332,\"wind_speed\":7.2,\"wind_direction\":360.0,\"frost_chance\":null,\"temp_air\":-1.0,\"feels_like\":null,\"dew_point\":-1.5,\"relative_humidity\":96.0,\"pressure\":1029.2,\"ozone\":null,\"ghi\":12.910799386398546,\"dni\":0.0,\"dhi\":12.910799386398546}",
"{\"date_time\":\"2024-11-05T09:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":150.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.1949683522039627,\"wind_speed\":4.7,\"wind_direction\":10.0,\"frost_chance\":null,\"temp_air\":1.6,\"feels_like\":null,\"dew_point\":1.1,\"relative_humidity\":97.0,\"pressure\":1029.09,\"ozone\":null,\"ghi\":51.27161617785122,\"dni\":0.0,\"dhi\":51.27161617785122}",
"{\"date_time\":\"2024-11-05T10:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":2630.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.2612560203740841,\"wind_speed\":3.2,\"wind_direction\":10.0,\"frost_chance\":null,\"temp_air\":2.7,\"feels_like\":null,\"dew_point\":2.2,\"relative_humidity\":96.0,\"pressure\":1028.7,\"ozone\":null,\"ghi\":89.63381300017787,\"dni\":0.0,\"dhi\":89.63381300017787}",
"{\"date_time\":\"2024-11-05T11:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":11070.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.2885323670339885,\"wind_speed\":5.8,\"wind_direction\":60.0,\"frost_chance\":null,\"temp_air\":3.6,\"feels_like\":null,\"dew_point\":2.6,\"relative_humidity\":93.0,\"pressure\":1028.2,\"ozone\":null,\"ghi\":115.24250625572647,\"dni\":0.0,\"dhi\":115.24250625572647}",
"{\"date_time\":\"2024-11-05T12:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":12260.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.3288271029735363,\"wind_speed\":7.9,\"wind_direction\":100.0,\"frost_chance\":null,\"temp_air\":4.3,\"feels_like\":null,\"dew_point\":3.2,\"relative_humidity\":92.0,\"pressure\":1028.0,\"ozone\":null,\"ghi\":124.73888203411694,\"dni\":0.0,\"dhi\":124.73888203411694}",
"{\"date_time\":\"2024-11-05T13:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":13640.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.3461330520092738,\"wind_speed\":12.6,\"wind_direction\":160.0,\"frost_chance\":null,\"temp_air\":4.7,\"feels_like\":null,\"dew_point\":3.4,\"relative_humidity\":91.0,\"pressure\":1027.7,\"ozone\":null,\"ghi\":117.11630570621142,\"dni\":0.0,\"dhi\":117.11630570621142}",
"{\"date_time\":\"2024-11-05T14:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":14990.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.380031254206577,\"wind_speed\":10.8,\"wind_direction\":170.0,\"frost_chance\":null,\"temp_air\":5.3,\"feels_like\":null,\"dew_point\":3.8,\"relative_humidity\":90.0,\"pressure\":1027.5,\"ozone\":null,\"ghi\":93.17139021360312,\"dni\":0.0,\"dhi\":93.17139021360312}",
"{\"date_time\":\"2024-11-05T15:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":14400.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.3738841320739368,\"wind_speed\":8.6,\"wind_direction\":160.0,\"frost_chance\":null,\"temp_air\":5.6,\"feels_like\":null,\"dew_point\":3.8,\"relative_humidity\":88.0,\"pressure\":1027.3,\"ozone\":null,\"ghi\":55.91961682093465,\"dni\":0.0,\"dhi\":55.91961682093465}",
"{\"date_time\":\"2024-11-05T16:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":15490.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.381175770521867,\"wind_speed\":6.1,\"wind_direction\":150.0,\"frost_chance\":null,\"temp_air\":5.5,\"feels_like\":null,\"dew_point\":3.8,\"relative_humidity\":89.0,\"pressure\":1027.3,\"ozone\":null,\"ghi\":14.182910983589771,\"dni\":0.0,\"dhi\":14.182910983589771}",
"{\"date_time\":\"2024-11-05T17:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":12550.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.4260322960134626,\"wind_speed\":5.4,\"wind_direction\":140.0,\"frost_chance\":null,\"temp_air\":5.3,\"feels_like\":null,\"dew_point\":4.2,\"relative_humidity\":93.0,\"pressure\":1027.2,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-05T18:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":3050.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.4479915832345371,\"wind_speed\":4.0,\"wind_direction\":100.0,\"frost_chance\":null,\"temp_air\":5.2,\"feels_like\":null,\"dew_point\":4.4,\"relative_humidity\":95.0,\"pressure\":1027.5,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-05T19:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":470.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.4346133748222865,\"wind_speed\":3.2,\"wind_direction\":360.0,\"frost_chance\":null,\"temp_air\":5.4,\"feels_like\":null,\"dew_point\":4.3,\"relative_humidity\":93.0,\"pressure\":1027.4,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-05T20:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":320.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.4346133748222865,\"wind_speed\":5.0,\"wind_direction\":350.0,\"frost_chance\":null,\"temp_air\":5.4,\"feels_like\":null,\"dew_point\":4.4,\"relative_humidity\":93.0,\"pressure\":1027.5,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-05T21:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":350.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.4587699149332078,\"wind_speed\":4.0,\"wind_direction\":350.0,\"frost_chance\":null,\"temp_air\":5.5,\"feels_like\":null,\"dew_point\":4.7,\"relative_humidity\":94.0,\"pressure\":1027.59,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-05T22:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":200.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.4808912256230051,\"wind_speed\":4.0,\"wind_direction\":10.0,\"frost_chance\":null,\"temp_air\":5.4,\"feels_like\":null,\"dew_point\":4.8,\"relative_humidity\":96.0,\"pressure\":1027.5,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-05T23:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":170.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.4873670184226437,\"wind_speed\":3.6,\"wind_direction\":10.0,\"frost_chance\":null,\"temp_air\":5.3,\"feels_like\":null,\"dew_point\":4.9,\"relative_humidity\":97.0,\"pressure\":1027.7,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-06T00:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":210.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.4784756165657906,\"wind_speed\":2.5,\"wind_direction\":10.0,\"frost_chance\":null,\"temp_air\":5.2,\"feels_like\":null,\"dew_point\":4.8,\"relative_humidity\":97.0,\"pressure\":1027.9,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-06T01:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":170.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.4696426588157527,\"wind_speed\":2.5,\"wind_direction\":10.0,\"frost_chance\":null,\"temp_air\":5.1,\"feels_like\":null,\"dew_point\":4.7,\"relative_humidity\":97.0,\"pressure\":1028.0,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-06T02:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":690.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.4696426588157527,\"wind_speed\":3.2,\"wind_direction\":200.0,\"frost_chance\":null,\"temp_air\":5.1,\"feels_like\":null,\"dew_point\":4.7,\"relative_humidity\":97.0,\"pressure\":1028.0,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-06T03:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":900.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.452150835143925,\"wind_speed\":5.8,\"wind_direction\":200.0,\"frost_chance\":null,\"temp_air\":4.9,\"feels_like\":null,\"dew_point\":4.5,\"relative_humidity\":97.0,\"pressure\":1028.09,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-06T04:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":2150.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.1,\"preciptable_water\":1.4458073416320942,\"wind_speed\":5.0,\"wind_direction\":190.0,\"frost_chance\":null,\"temp_air\":5.0,\"feels_like\":null,\"dew_point\":4.4,\"relative_humidity\":96.0,\"pressure\":1028.09,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-06T05:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":2410.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.4608678347740953,\"wind_speed\":2.5,\"wind_direction\":210.0,\"frost_chance\":null,\"temp_air\":5.0,\"feels_like\":null,\"dew_point\":4.5,\"relative_humidity\":97.0,\"pressure\":1028.5,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-06T06:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":3520.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.484793614061276,\"wind_speed\":1.8,\"wind_direction\":180.0,\"frost_chance\":null,\"temp_air\":5.1,\"feels_like\":null,\"dew_point\":4.8,\"relative_humidity\":98.0,\"pressure\":1028.7,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-06T07:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":900.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.4632335999001638,\"wind_speed\":2.2,\"wind_direction\":160.0,\"frost_chance\":null,\"temp_air\":5.2,\"feels_like\":null,\"dew_point\":4.6,\"relative_humidity\":96.0,\"pressure\":1029.2,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-06T08:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":12750.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.4808912256230051,\"wind_speed\":1.8,\"wind_direction\":120.0,\"frost_chance\":null,\"temp_air\":5.4,\"feels_like\":null,\"dew_point\":4.9,\"relative_humidity\":96.0,\"pressure\":1029.7,\"ozone\":null,\"ghi\":9.426582342676907,\"dni\":0.0,\"dhi\":9.426582342676907}",
"{\"date_time\":\"2024-11-06T09:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":6980.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.4831703698525456,\"wind_speed\":2.2,\"wind_direction\":340.0,\"frost_chance\":null,\"temp_air\":5.6,\"feels_like\":null,\"dew_point\":4.8,\"relative_humidity\":95.0,\"pressure\":1030.5,\"ozone\":null,\"ghi\":49.66881557471828,\"dni\":0.0,\"dhi\":49.66881557471828}",
"{\"date_time\":\"2024-11-06T10:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":15760.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.4233298143003077,\"wind_speed\":3.2,\"wind_direction\":360.0,\"frost_chance\":null,\"temp_air\":6.0,\"feels_like\":null,\"dew_point\":4.3,\"relative_humidity\":89.0,\"pressure\":1030.9,\"ozone\":null,\"ghi\":87.87424898748947,\"dni\":0.0,\"dhi\":87.87424898748947}",
"{\"date_time\":\"2024-11-06T11:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":29630.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.4252928707378512,\"wind_speed\":5.0,\"wind_direction\":30.0,\"frost_chance\":null,\"temp_air\":6.4,\"feels_like\":null,\"dew_point\":4.4,\"relative_humidity\":87.0,\"pressure\":1031.5,\"ozone\":null,\"ghi\":113.42891032649719,\"dni\":0.0,\"dhi\":113.42891032649719}",
"{\"date_time\":\"2024-11-06T12:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":19110.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.409437502900786,\"wind_speed\":3.6,\"wind_direction\":20.0,\"frost_chance\":null,\"temp_air\":6.6,\"feels_like\":null,\"dew_point\":4.2,\"relative_humidity\":85.0,\"pressure\":1031.59,\"ozone\":null,\"ghi\":122.91825781571202,\"dni\":0.0,\"dhi\":122.91825781571202}",
"{\"date_time\":\"2024-11-06T13:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":25120.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.3846109217160805,\"wind_speed\":5.0,\"wind_direction\":360.0,\"frost_chance\":null,\"temp_air\":6.7,\"feels_like\":null,\"dew_point\":4.0,\"relative_humidity\":83.0,\"pressure\":1031.7,\"ozone\":null,\"ghi\":115.32501526045236,\"dni\":0.0,\"dhi\":115.32501526045236}",
"{\"date_time\":\"2024-11-06T14:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":28400.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.4012929810138648,\"wind_speed\":4.0,\"wind_direction\":340.0,\"frost_chance\":null,\"temp_air\":6.7,\"feels_like\":null,\"dew_point\":4.2,\"relative_humidity\":84.0,\"pressure\":1031.5,\"ozone\":null,\"ghi\":91.45155097121383,\"dni\":0.0,\"dhi\":91.45155097121383}",
"{\"date_time\":\"2024-11-06T15:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":25950.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.3844732659494836,\"wind_speed\":4.3,\"wind_direction\":90.0,\"frost_chance\":null,\"temp_air\":6.5,\"feels_like\":null,\"dew_point\":4.1,\"relative_humidity\":84.0,\"pressure\":1031.59,\"ozone\":null,\"ghi\":54.35521490956087,\"dni\":0.0,\"dhi\":54.35521490956087}",
"{\"date_time\":\"2024-11-06T16:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":22070.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.3678703280167073,\"wind_speed\":4.7,\"wind_direction\":90.0,\"frost_chance\":null,\"temp_air\":6.3,\"feels_like\":null,\"dew_point\":3.8,\"relative_humidity\":84.0,\"pressure\":1031.8,\"ozone\":null,\"ghi\":13.133993037937158,\"dni\":0.0,\"dhi\":13.133993037937158}",
"{\"date_time\":\"2024-11-06T17:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":22360.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.36757098972718,\"wind_speed\":6.1,\"wind_direction\":100.0,\"frost_chance\":null,\"temp_air\":6.1,\"feels_like\":null,\"dew_point\":3.8,\"relative_humidity\":85.0,\"pressure\":1032.09,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-06T18:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":17470.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.3829953007358158,\"wind_speed\":7.6,\"wind_direction\":110.0,\"frost_chance\":null,\"temp_air\":5.9,\"feels_like\":null,\"dew_point\":3.9,\"relative_humidity\":87.0,\"pressure\":1032.3,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-06T19:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":18050.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.3114348533433033,\"wind_speed\":2.2,\"wind_direction\":50.0,\"frost_chance\":null,\"temp_air\":5.6,\"feels_like\":null,\"dew_point\":3.1,\"relative_humidity\":84.0,\"pressure\":1032.59,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-06T20:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":16940.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.3112057726870359,\"wind_speed\":4.7,\"wind_direction\":50.0,\"frost_chance\":null,\"temp_air\":5.4,\"feels_like\":null,\"dew_point\":3.1,\"relative_humidity\":85.0,\"pressure\":1032.8,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-06T21:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":16260.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.3266317229539424,\"wind_speed\":5.8,\"wind_direction\":60.0,\"frost_chance\":null,\"temp_air\":5.4,\"feels_like\":null,\"dew_point\":3.3,\"relative_humidity\":86.0,\"pressure\":1033.2,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-06T22:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":14540.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.3112057726870359,\"wind_speed\":4.3,\"wind_direction\":60.0,\"frost_chance\":null,\"temp_air\":5.4,\"feels_like\":null,\"dew_point\":3.0,\"relative_humidity\":85.0,\"pressure\":1033.59,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-06T23:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":14150.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.2803293999126435,\"wind_speed\":2.9,\"wind_direction\":50.0,\"frost_chance\":null,\"temp_air\":5.2,\"feels_like\":null,\"dew_point\":2.8,\"relative_humidity\":84.0,\"pressure\":1033.7,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-07T00:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":17300.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.2878311958694741,\"wind_speed\":6.5,\"wind_direction\":70.0,\"frost_chance\":null,\"temp_air\":5.1,\"feels_like\":null,\"dew_point\":2.8,\"relative_humidity\":85.0,\"pressure\":1033.8,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-07T01:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":20750.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.2952024102120847,\"wind_speed\":6.5,\"wind_direction\":50.0,\"frost_chance\":null,\"temp_air\":5.0,\"feels_like\":null,\"dew_point\":2.9,\"relative_humidity\":86.0,\"pressure\":1033.7,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-07T02:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":19330.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.2725033091467384,\"wind_speed\":5.4,\"wind_direction\":40.0,\"frost_chance\":null,\"temp_air\":4.9,\"feels_like\":null,\"dew_point\":2.7,\"relative_humidity\":85.0,\"pressure\":1033.7,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-07T03:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":17310.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.2500209307860815,\"wind_speed\":5.0,\"wind_direction\":30.0,\"frost_chance\":null,\"temp_air\":5.0,\"feels_like\":null,\"dew_point\":2.4,\"relative_humidity\":83.0,\"pressure\":1033.59,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-07T04:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":28980.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.2878311958694741,\"wind_speed\":7.2,\"wind_direction\":80.0,\"frost_chance\":null,\"temp_air\":5.1,\"feels_like\":null,\"dew_point\":2.8,\"relative_humidity\":85.0,\"pressure\":1033.5,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-07T05:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":22600.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.2726802406239508,\"wind_speed\":7.6,\"wind_direction\":90.0,\"frost_chance\":null,\"temp_air\":5.1,\"feels_like\":null,\"dew_point\":2.7,\"relative_humidity\":84.0,\"pressure\":1033.4,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-07T06:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":21600.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.2952024102120847,\"wind_speed\":5.4,\"wind_direction\":40.0,\"frost_chance\":null,\"temp_air\":5.0,\"feels_like\":null,\"dew_point\":2.8,\"relative_humidity\":86.0,\"pressure\":1033.2,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-07T07:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":19120.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.2803293999126435,\"wind_speed\":5.8,\"wind_direction\":40.0,\"frost_chance\":null,\"temp_air\":5.2,\"feels_like\":null,\"dew_point\":2.7,\"relative_humidity\":84.0,\"pressure\":1033.3,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-07T08:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":16970.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.3112057726870359,\"wind_speed\":5.4,\"wind_direction\":30.0,\"frost_chance\":null,\"temp_air\":5.4,\"feels_like\":null,\"dew_point\":3.1,\"relative_humidity\":85.0,\"pressure\":1033.8,\"ozone\":null,\"ghi\":8.498732233274223,\"dni\":0.0,\"dhi\":8.498732233274223}",
"{\"date_time\":\"2024-11-07T09:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":20190.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.3036333508504807,\"wind_speed\":6.8,\"wind_direction\":60.0,\"frost_chance\":null,\"temp_air\":5.7,\"feels_like\":null,\"dew_point\":3.1,\"relative_humidity\":83.0,\"pressure\":1034.2,\"ozone\":null,\"ghi\":48.08590935974426,\"dni\":0.0,\"dhi\":48.08590935974426}",
"{\"date_time\":\"2024-11-07T10:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":20410.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.311382525535115,\"wind_speed\":6.1,\"wind_direction\":70.0,\"frost_chance\":null,\"temp_air\":6.0,\"feels_like\":null,\"dew_point\":3.1,\"relative_humidity\":82.0,\"pressure\":1034.4,\"ozone\":null,\"ghi\":86.13289253240517,\"dni\":0.0,\"dhi\":86.13289253240517}",
"{\"date_time\":\"2024-11-07T11:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":23940.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.3032147078576655,\"wind_speed\":6.8,\"wind_direction\":60.0,\"frost_chance\":null,\"temp_air\":6.1,\"feels_like\":null,\"dew_point\":3.1,\"relative_humidity\":81.0,\"pressure\":1034.5,\"ozone\":null,\"ghi\":111.63587311647375,\"dni\":0.0,\"dhi\":111.63587311647375}",
"{\"date_time\":\"2024-11-07T12:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":22710.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.3106141340118171,\"wind_speed\":7.9,\"wind_direction\":90.0,\"frost_chance\":null,\"temp_air\":6.4,\"feels_like\":null,\"dew_point\":3.3,\"relative_humidity\":80.0,\"pressure\":1034.0,\"ozone\":null,\"ghi\":121.12176907062565,\"dni\":0.0,\"dhi\":121.12176907062565}",
"{\"date_time\":\"2024-11-07T13:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":22430.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.2701653045869423,\"wind_speed\":9.4,\"wind_direction\":80.0,\"frost_chance\":null,\"temp_air\":6.3,\"feels_like\":null,\"dew_point\":2.7,\"relative_humidity\":78.0,\"pressure\":1033.7,\"ozone\":null,\"ghi\":113.56192265137761,\"dni\":0.0,\"dhi\":113.56192265137761}",
"{\"date_time\":\"2024-11-07T14:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":23210.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.3020641429763,\"wind_speed\":7.9,\"wind_direction\":50.0,\"frost_chance\":null,\"temp_air\":6.5,\"feels_like\":null,\"dew_point\":3.2,\"relative_humidity\":79.0,\"pressure\":1033.3,\"ozone\":null,\"ghi\":89.7641924158202,\"dni\":0.0,\"dhi\":89.7641924158202}",
"{\"date_time\":\"2024-11-07T15:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":23220.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.2845185659293759,\"wind_speed\":7.2,\"wind_direction\":50.0,\"frost_chance\":null,\"temp_air\":6.7,\"feels_like\":null,\"dew_point\":3.0,\"relative_humidity\":77.0,\"pressure\":1032.9,\"ozone\":null,\"ghi\":52.82846758743223,\"dni\":0.0,\"dhi\":52.82846758743223}",
"{\"date_time\":\"2024-11-07T16:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":24990.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.293366179132486,\"wind_speed\":8.3,\"wind_direction\":50.0,\"frost_chance\":null,\"temp_air\":6.6,\"feels_like\":null,\"dew_point\":3.1,\"relative_humidity\":78.0,\"pressure\":1032.8,\"ozone\":null,\"ghi\":12.137164591797857,\"dni\":0.0,\"dhi\":12.137164591797857}",
"{\"date_time\":\"2024-11-07T17:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":23620.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.2855823183816633,\"wind_speed\":5.8,\"wind_direction\":60.0,\"frost_chance\":null,\"temp_air\":6.5,\"feels_like\":null,\"dew_point\":2.9,\"relative_humidity\":78.0,\"pressure\":1032.7,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-07T18:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":20330.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.2949042355874099,\"wind_speed\":5.0,\"wind_direction\":60.0,\"frost_chance\":null,\"temp_air\":6.2,\"feels_like\":null,\"dew_point\":3.1,\"relative_humidity\":80.0,\"pressure\":1032.8,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-07T19:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":23590.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.2625316296977247,\"wind_speed\":4.0,\"wind_direction\":70.0,\"frost_chance\":null,\"temp_air\":6.2,\"feels_like\":null,\"dew_point\":2.7,\"relative_humidity\":78.0,\"pressure\":1032.9,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-07T20:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":18610.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.3032147078576655,\"wind_speed\":6.1,\"wind_direction\":70.0,\"frost_chance\":null,\"temp_air\":6.1,\"feels_like\":null,\"dew_point\":3.1,\"relative_humidity\":81.0,\"pressure\":1033.0,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-07T21:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":15020.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.311382525535115,\"wind_speed\":9.0,\"wind_direction\":80.0,\"frost_chance\":null,\"temp_air\":6.0,\"feels_like\":null,\"dew_point\":3.1,\"relative_humidity\":82.0,\"pressure\":1033.2,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-07T22:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":14560.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.311382525535115,\"wind_speed\":9.4,\"wind_direction\":80.0,\"frost_chance\":null,\"temp_air\":6.0,\"feels_like\":null,\"dew_point\":3.1,\"relative_humidity\":82.0,\"pressure\":1033.4,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-07T23:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":14290.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.2956943683229234,\"wind_speed\":9.4,\"wind_direction\":60.0,\"frost_chance\":null,\"temp_air\":5.8,\"feels_like\":null,\"dew_point\":2.9,\"relative_humidity\":82.0,\"pressure\":1033.09,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-08T00:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":13230.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.2879269249366194,\"wind_speed\":9.0,\"wind_direction\":60.0,\"frost_chance\":null,\"temp_air\":5.7,\"feels_like\":null,\"dew_point\":2.8,\"relative_humidity\":82.0,\"pressure\":1033.09,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-08T01:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":10600.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.295822533660645,\"wind_speed\":6.8,\"wind_direction\":80.0,\"frost_chance\":null,\"temp_air\":5.6,\"feels_like\":null,\"dew_point\":2.9,\"relative_humidity\":83.0,\"pressure\":1033.0,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-08T02:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":10300.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.2726954899905094,\"wind_speed\":9.0,\"wind_direction\":50.0,\"frost_chance\":null,\"temp_air\":5.3,\"feels_like\":null,\"dew_point\":2.7,\"relative_humidity\":83.0,\"pressure\":1032.59,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-08T03:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":10800.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.2726802406239508,\"wind_speed\":9.0,\"wind_direction\":40.0,\"frost_chance\":null,\"temp_air\":5.1,\"feels_like\":null,\"dew_point\":2.7,\"relative_humidity\":84.0,\"pressure\":1032.0,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-08T04:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":10600.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.25003374788666,\"wind_speed\":9.0,\"wind_direction\":50.0,\"frost_chance\":null,\"temp_air\":4.8,\"feels_like\":null,\"dew_point\":2.3,\"relative_humidity\":84.0,\"pressure\":1031.5,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-08T05:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":11200.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.257532681980306,\"wind_speed\":11.9,\"wind_direction\":40.0,\"frost_chance\":null,\"temp_air\":4.9,\"feels_like\":null,\"dew_point\":2.4,\"relative_humidity\":84.0,\"pressure\":1031.4,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-08T06:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":11200.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.2351842404212265,\"wind_speed\":9.0,\"wind_direction\":30.0,\"frost_chance\":null,\"temp_air\":4.6,\"feels_like\":null,\"dew_point\":2.2,\"relative_humidity\":84.0,\"pressure\":1031.09,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-08T07:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":13800.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.22783313798752,\"wind_speed\":10.1,\"wind_direction\":30.0,\"frost_chance\":null,\"temp_air\":4.5,\"feels_like\":null,\"dew_point\":2.1,\"relative_humidity\":84.0,\"pressure\":1030.7,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-08T08:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":14300.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.2351842404212265,\"wind_speed\":9.0,\"wind_direction\":20.0,\"frost_chance\":null,\"temp_air\":4.6,\"feels_like\":null,\"dew_point\":2.1,\"relative_humidity\":84.0,\"pressure\":1030.59,\"ozone\":null,\"ghi\":7.618065280685061,\"dni\":0.0,\"dhi\":7.618065280685061}",
"{\"date_time\":\"2024-11-08T09:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":13200.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.257532681980306,\"wind_speed\":2.9,\"wind_direction\":350.0,\"frost_chance\":null,\"temp_air\":4.9,\"feels_like\":null,\"dew_point\":2.5,\"relative_humidity\":84.0,\"pressure\":1030.5,\"ozone\":null,\"ghi\":46.52393962571433,\"dni\":0.0,\"dhi\":46.52393962571433}",
"{\"date_time\":\"2024-11-08T10:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":12300.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.2952024102120847,\"wind_speed\":5.0,\"wind_direction\":40.0,\"frost_chance\":null,\"temp_air\":5.0,\"feels_like\":null,\"dew_point\":2.8,\"relative_humidity\":86.0,\"pressure\":1030.3,\"ozone\":null,\"ghi\":84.41073757319909,\"dni\":0.0,\"dhi\":84.41073757319909}",
"{\"date_time\":\"2024-11-08T11:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":11900.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.3635859720970902,\"wind_speed\":6.8,\"wind_direction\":60.0,\"frost_chance\":null,\"temp_air\":5.1,\"feels_like\":null,\"dew_point\":3.6,\"relative_humidity\":90.0,\"pressure\":1030.2,\"ozone\":null,\"ghi\":109.86441465473348,\"dni\":0.0,\"dhi\":109.86441465473348}",
"{\"date_time\":\"2024-11-08T12:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":9500.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.4695070274881938,\"wind_speed\":7.9,\"wind_direction\":70.0,\"frost_chance\":null,\"temp_air\":5.8,\"feels_like\":null,\"dew_point\":4.7,\"relative_humidity\":93.0,\"pressure\":1029.5,\"ozone\":null,\"ghi\":119.35043772145661,\"dni\":0.0,\"dhi\":119.35043772145661}",
"{\"date_time\":\"2024-11-08T13:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":14500.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.498143692589727,\"wind_speed\":6.1,\"wind_direction\":70.0,\"frost_chance\":null,\"temp_air\":6.3,\"feels_like\":null,\"dew_point\":5.1,\"relative_humidity\":92.0,\"pressure\":1028.8,\"ozone\":null,\"ghi\":111.82801067882036,\"dni\":0.0,\"dhi\":111.82801067882036}",
"{\"date_time\":\"2024-11-08T14:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":17100.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.1,\"preciptable_water\":1.4923455913067147,\"wind_speed\":11.2,\"wind_direction\":90.0,\"frost_chance\":null,\"temp_air\":6.6,\"feels_like\":null,\"dew_point\":5.1,\"relative_humidity\":90.0,\"pressure\":1028.4,\"ozone\":null,\"ghi\":88.1102014938565,\"dni\":0.0,\"dhi\":88.1102014938565}",
"{\"date_time\":\"2024-11-08T15:00:00+01:00\",\"total_clouds\":88.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":10200.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.4801944829988296,\"wind_speed\":14.0,\"wind_direction\":130.0,\"frost_chance\":null,\"temp_air\":6.1,\"feels_like\":null,\"dew_point\":4.9,\"relative_humidity\":92.0,\"pressure\":1028.4,\"ozone\":null,\"ghi\":62.78155778969431,\"dni\":0.0,\"dhi\":62.78155778969431}",
"{\"date_time\":\"2024-11-08T16:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":12000.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.456767265035836,\"wind_speed\":13.0,\"wind_direction\":120.0,\"frost_chance\":null,\"temp_air\":6.2,\"feels_like\":null,\"dew_point\":4.7,\"relative_humidity\":90.0,\"pressure\":1028.3,\"ozone\":null,\"ghi\":11.192374342113588,\"dni\":0.0,\"dhi\":11.192374342113588}",
"{\"date_time\":\"2024-11-08T17:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":7900.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.4243946591461507,\"wind_speed\":11.2,\"wind_direction\":110.0,\"frost_chance\":null,\"temp_air\":6.2,\"feels_like\":null,\"dew_point\":4.4,\"relative_humidity\":88.0,\"pressure\":1028.2,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-08T18:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":9200.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.3913448746531096,\"wind_speed\":15.1,\"wind_direction\":130.0,\"frost_chance\":null,\"temp_air\":6.0,\"feels_like\":null,\"dew_point\":4.0,\"relative_humidity\":87.0,\"pressure\":1028.7,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-08T19:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":9500.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.3829953007358158,\"wind_speed\":9.0,\"wind_direction\":140.0,\"frost_chance\":null,\"temp_air\":5.9,\"feels_like\":null,\"dew_point\":3.9,\"relative_humidity\":87.0,\"pressure\":1028.59,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-08T20:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":10100.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.3588989716557491,\"wind_speed\":9.0,\"wind_direction\":140.0,\"frost_chance\":null,\"temp_air\":5.8,\"feels_like\":null,\"dew_point\":3.7,\"relative_humidity\":86.0,\"pressure\":1028.59,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-08T21:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":9900.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.3430978208225428,\"wind_speed\":9.0,\"wind_direction\":140.0,\"frost_chance\":null,\"temp_air\":5.8,\"feels_like\":null,\"dew_point\":3.5,\"relative_humidity\":85.0,\"pressure\":1028.59,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-08T22:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":17700.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.295822533660645,\"wind_speed\":10.1,\"wind_direction\":150.0,\"frost_chance\":null,\"temp_air\":5.6,\"feels_like\":null,\"dew_point\":3.0,\"relative_humidity\":83.0,\"pressure\":1028.7,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-08T23:00:00+01:00\",\"total_clouds\":100.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":15500.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":null,\"precip_amt\":0.0,\"preciptable_water\":1.2957798224201298,\"wind_speed\":10.1,\"wind_direction\":130.0,\"frost_chance\":null,\"temp_air\":5.4,\"feels_like\":null,\"dew_point\":2.9,\"relative_humidity\":84.0,\"pressure\":1028.7,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}",
"{\"date_time\":\"2024-11-09T00:00:00+01:00\",\"total_clouds\":86.0,\"low_clouds\":null,\"medium_clouds\":null,\"high_clouds\":null,\"visibility\":8700.0,\"fog\":null,\"precip_type\":null,\"precip_prob\":1.0,\"precip_amt\":0.0,\"preciptable_water\":null,\"wind_speed\":5.5,\"wind_direction\":87.0,\"frost_chance\":null,\"temp_air\":5.3,\"feels_like\":null,\"dew_point\":2.8,\"relative_humidity\":null,\"pressure\":1027.4,\"ozone\":null,\"ghi\":0.0,\"dni\":0.0,\"dhi\":0.0}"
]

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long