Json configuration (#141)

* Add json config
* Adjust code to new config

---------

Co-authored-by: Chris <git@nootch.de>
This commit is contained in:
Chris 2024-11-11 21:38:13 +01:00 committed by GitHub
parent fc74cde56f
commit ed3226e522
21 changed files with 726 additions and 171 deletions

1
.gitignore vendored
View File

@ -1,5 +1,6 @@
cache/ cache/
output/ output/
EOS.config.json
# Default ignore folders and files for VS Code, Python # Default ignore folders and files for VS Code, Python

View File

@ -9,10 +9,29 @@ See [CONTRIBUTING.md](CONTRIBUTING.md).
## Installation ## Installation
Good installation guide: Good installation guide:
https://meintechblog.de/2024/09/05/andreas-schmitz-joerg-installiert-mein-energieoptimierungssystem/ <https://meintechblog.de/2024/09/05/andreas-schmitz-joerg-installiert-mein-energieoptimierungssystem/>
The project requires Python 3.10 or newer. The project requires Python 3.10 or newer.
## Configuration
This project uses a `config.json` file to manage configuration settings.
### Default Configuration
A default configuration file `default.config.json` is provided. This file contains all the necessary configuration keys with their default values.
### Custom Configuration
Users can specify a custom configuration directory by setting the environment variable `EOS_DIR`.
- If the directory specified by `EOS_DIR` contains an existing `config.json` file, the application will use this configuration file.
- If the `config.json` file does not exist in the specified directory, the `default.config.json` file will be copied to the directory as `config.json`.
### Configuration Updates
If the configuration keys in the `config.json` file are missing or different from those in `default.config.json`, they will be automatically updated to match the default settings, ensuring that all required keys are present.
### Quick Start Guide ### Quick Start Guide
On Linux (Ubuntu/Debian): On Linux (Ubuntu/Debian):
@ -27,8 +46,7 @@ On MacOS (requires [Homebrew](https://brew.sh)):
brew install make brew install make
``` ```
Next, adjust `config.py`. The server can be started with `make run`. A full overview of the main shortcuts is given by `make help`.
The server can then be started with `make run`. A full overview of the main shortcuts is given by `make help`.
### Detailed Instructions ### Detailed Instructions
@ -65,7 +83,6 @@ source .venv/bin/activate
## Usage ## Usage
Adjust `config.py`.
To use the system, run `flask_server.py`, which starts the server: To use the system, run `flask_server.py`, which starts the server:
```bash ```bash

View File

@ -17,6 +17,6 @@ services:
networks: networks:
- "eos" - "eos"
volumes: volumes:
- ./src/akkudoktoreos/config.py:/opt/eos/akkudoktoreos/config.py:ro - ./src/akkudoktoreos/default.config.json:/opt/eos/EOS.config.json:ro
ports: ports:
- "${EOS_PORT}:${EOS_PORT}" - "${EOS_PORT}:${EOS_PORT}"

View File

@ -23,7 +23,6 @@ On MacOS (requires [Homebrew](https://brew.sh)):
brew install make brew install make
``` ```
Next, adjust `config.py`.
The server can then be started with `make run`. A full overview of the main shortcuts is given by `make help`. The server can then be started with `make run`. A full overview of the main shortcuts is given by `make help`.
### Detailed Instructions ### Detailed Instructions
@ -52,6 +51,7 @@ To always use the Python version from the virtual environment, you should activa
```bash ```bash
source .venv/bin/activate source .venv/bin/activate
``` ```
(for Bash users, the default under Linux) or (for Bash users, the default under Linux) or
```zsh ```zsh
@ -60,7 +60,29 @@ source .venv/bin/activate
## Usage ## Usage
Adjust `config.py`. ### Configuration
---
This project uses a `config.json` file to manage configuration settings.
#### Default Configuration
A default configuration file `default.config.json` is provided. This file contains all the necessary configuration keys with their default values.
#### Custom Configuration
Users can specify a custom configuration directory by setting the environment variable `EOS_DIR`.
- If the directory specified by `EOS_DIR` contains an existing `config.json` file, the application will use this configuration file.
- If the `config.json` file does not exist in the specified directory, the `default.config.json` file will be copied to the directory as `config.json`.
#### Configuration Updates
If the configuration keys in the `config.json` file are missing or different from those in `default.config.json`, they will be automatically updated to match the default settings, ensuring that all required keys are present.
### Run server
To use the system, run `flask_server.py`, which starts the server: To use the system, run `flask_server.py`, which starts the server:
```bash ```bash

View File

@ -31,6 +31,7 @@ where = ["src/"]
include = ["akkudoktoreos", "akkudoktoreosserver", ] include = ["akkudoktoreos", "akkudoktoreosserver", ]
[tool.setuptools.package-data] [tool.setuptools.package-data]
akkudoktoreos = ["*.json", ]
akkudoktoreosserver = ["data/*.npz", ] akkudoktoreosserver = ["data/*.npz", ]
[tool.pyright] [tool.pyright]

View File

@ -5,9 +5,8 @@ import time
import numpy as np import numpy as np
from akkudoktoreos.class_numpy_encoder import NumpyEncoder from akkudoktoreos.class_numpy_encoder import NumpyEncoder
# Import necessary modules from the project
from akkudoktoreos.class_optimize import optimization_problem from akkudoktoreos.class_optimize import optimization_problem
from akkudoktoreos.config import get_working_dir, load_config
from akkudoktoreos.visualize import visualisiere_ergebnisse from akkudoktoreos.visualize import visualisiere_ergebnisse
start_hour = 0 start_hour = 0
@ -276,10 +275,10 @@ parameter = {
# Startzeit nehmen # Startzeit nehmen
start_time = time.time() start_time = time.time()
# Initialize the optimization problem # Initialize the optimization problem using the default configuration
opt_class = optimization_problem( working_dir = get_working_dir()
prediction_hours=48, strafe=10, optimization_hours=24, verbose=True, fixed_seed=42 config = load_config(working_dir)
) opt_class = optimization_problem(config, verbose=True, fixed_seed=42)
# Perform the optimisation based on the provided parameters and start hour # Perform the optimisation based on the provided parameters and start hour
ergebnis = opt_class.optimierung_ems(parameter=parameter, start_hour=start_hour) ergebnis = opt_class.optimierung_ems(parameter=parameter, start_hour=start_hour)
@ -299,17 +298,19 @@ ac_charge, dc_charge, discharge = (
) )
visualisiere_ergebnisse( visualisiere_ergebnisse(
gesamtlast, gesamtlast=gesamtlast,
pv_forecast, pv_forecast=pv_forecast,
strompreis_euro_pro_wh, strompreise=strompreis_euro_pro_wh,
ergebnis["result"], ergebnisse=ergebnis["result"],
ac_charge, ac=ac_charge,
dc_charge, dc=dc_charge,
discharge, discharge=discharge,
temperature_forecast, temperature=temperature_forecast,
start_hour, start_hour=start_hour,
48, einspeiseverguetung_euro_pro_wh=np.full(
np.full(48, parameter["einspeiseverguetung_euro_pro_wh"]), config.eos.feed_in_tariff_eur_per_wh, parameter["einspeiseverguetung_euro_pro_wh"]
),
config=config,
filename="visualization_results.pdf", filename="visualization_results.pdf",
extra_data=None, extra_data=None,
) )

View File

@ -3,12 +3,13 @@ from typing import Dict, List, Optional, Union
import numpy as np import numpy as np
from akkudoktoreos.config import prediction_hours from akkudoktoreos.config import EOSConfig
class EnergieManagementSystem: class EnergieManagementSystem:
def __init__( def __init__(
self, self,
config: EOSConfig,
pv_prognose_wh: Optional[np.ndarray] = None, pv_prognose_wh: Optional[np.ndarray] = None,
strompreis_euro_pro_wh: Optional[np.ndarray] = None, strompreis_euro_pro_wh: Optional[np.ndarray] = None,
einspeiseverguetung_euro_pro_wh: Optional[np.ndarray] = None, einspeiseverguetung_euro_pro_wh: Optional[np.ndarray] = None,
@ -25,9 +26,9 @@ class EnergieManagementSystem:
self.eauto = eauto self.eauto = eauto
self.haushaltsgeraet = haushaltsgeraet self.haushaltsgeraet = haushaltsgeraet
self.wechselrichter = wechselrichter self.wechselrichter = wechselrichter
self.ac_charge_hours = np.full(prediction_hours, 0) self.ac_charge_hours = np.full(config.prediction_hours, 0)
self.dc_charge_hours = np.full(prediction_hours, 1) self.dc_charge_hours = np.full(config.prediction_hours, 1)
self.ev_charge_hours = np.full(prediction_hours, 0) self.ev_charge_hours = np.full(config.prediction_hours, 0)
def set_akku_discharge_hours(self, ds: List[int]) -> None: def set_akku_discharge_hours(self, ds: List[int]) -> None:
self.akku.set_discharge_per_hour(ds) self.akku.set_discharge_per_hour(ds)

View File

@ -8,25 +8,24 @@ from akkudoktoreos.class_akku import PVAkku
from akkudoktoreos.class_ems import EnergieManagementSystem from akkudoktoreos.class_ems import EnergieManagementSystem
from akkudoktoreos.class_haushaltsgeraet import Haushaltsgeraet from akkudoktoreos.class_haushaltsgeraet import Haushaltsgeraet
from akkudoktoreos.class_inverter import Wechselrichter from akkudoktoreos.class_inverter import Wechselrichter
from akkudoktoreos.config import possible_ev_charge_currents from akkudoktoreos.config import AppConfig
from akkudoktoreos.visualize import visualisiere_ergebnisse from akkudoktoreos.visualize import visualisiere_ergebnisse
class optimization_problem: class optimization_problem:
def __init__( def __init__(
self, self,
prediction_hours: int = 48, config: AppConfig,
strafe: float = 10,
optimization_hours: int = 24,
verbose: bool = False, verbose: bool = False,
fixed_seed: Optional[int] = None, fixed_seed: Optional[int] = None,
): ):
"""Initialize the optimization problem with the required parameters.""" """Initialize the optimization problem with the required parameters."""
self.prediction_hours = prediction_hours self._config = config
self.strafe = strafe self.prediction_hours = config.eos.prediction_hours
self.strafe = config.eos.penalty
self.opti_param = None self.opti_param = None
self.fixed_eauto_hours = prediction_hours - optimization_hours self.fixed_eauto_hours = config.eos.prediction_hours - config.eos.optimization_hours
self.possible_charge_values = possible_ev_charge_currents self.possible_charge_values = config.eos.available_charging_rates_in_percentage
self.verbose = verbose self.verbose = verbose
self.fix_seed = fixed_seed self.fix_seed = fixed_seed
self.optimize_ev = True self.optimize_ev = True
@ -210,7 +209,10 @@ class optimization_problem:
if self.optimize_ev: if self.optimize_ev:
self.toolbox.register( self.toolbox.register(
"attr_ev_charge_index", random.randint, 0, len(possible_ev_charge_currents) - 1 "attr_ev_charge_index",
random.randint,
0,
len(self._config.eos.available_charging_rates_in_percentage) - 1,
) )
self.toolbox.register("attr_int", random.randint, start_hour, 23) self.toolbox.register("attr_int", random.randint, start_hour, 23)
@ -236,7 +238,7 @@ class optimization_problem:
"mutate_ev_charge_index", "mutate_ev_charge_index",
tools.mutUniformInt, tools.mutUniformInt,
low=0, low=0,
up=len(possible_ev_charge_currents) - 1, up=len(self._config.eos.available_charging_rates_in_percentage) - 1,
indpb=0.2, indpb=0.2,
) )
# - Start hour mutation for household devices # - Start hour mutation for household devices
@ -271,7 +273,8 @@ class optimization_problem:
if self.optimize_ev: if self.optimize_ev:
eautocharge_hours_float = [ eautocharge_hours_float = [
possible_ev_charge_currents[i] for i in eautocharge_hours_index self._config.eos.available_charging_rates_in_percentage[i]
for i in eautocharge_hours_index
] ]
ems.set_ev_charge_hours(eautocharge_hours_float) ems.set_ev_charge_hours(eautocharge_hours_float)
else: else:
@ -420,6 +423,7 @@ class optimization_problem:
# Initialize the inverter and energy management system # Initialize the inverter and energy management system
wr = Wechselrichter(10000, akku) wr = Wechselrichter(10000, akku)
ems = EnergieManagementSystem( ems = EnergieManagementSystem(
config=self._config.eos,
gesamtlast=parameter["gesamtlast"], gesamtlast=parameter["gesamtlast"],
pv_prognose_wh=parameter["pv_forecast"], pv_prognose_wh=parameter["pv_forecast"],
strompreis_euro_pro_wh=parameter["strompreis_euro_pro_wh"], strompreis_euro_pro_wh=parameter["strompreis_euro_pro_wh"],
@ -444,23 +448,24 @@ class optimization_problem:
) )
if self.optimize_ev: if self.optimize_ev:
eautocharge_hours_float = [ eautocharge_hours_float = [
possible_ev_charge_currents[i] for i in eautocharge_hours_float self._config.eos.available_charging_rates_in_percentage[i]
for i in eautocharge_hours_float
] ]
ac_charge, dc_charge, discharge = self.decode_charge_discharge(discharge_hours_bin) ac_charge, dc_charge, discharge = self.decode_charge_discharge(discharge_hours_bin)
# Visualize the results # Visualize the results
visualisiere_ergebnisse( visualisiere_ergebnisse(
parameter["gesamtlast"], gesamtlast=parameter["gesamtlast"],
parameter["pv_forecast"], pv_forecast=parameter["pv_forecast"],
parameter["strompreis_euro_pro_wh"], strompreise=parameter["strompreis_euro_pro_wh"],
o, ergebnisse=o,
ac_charge, ac=ac_charge,
dc_charge, dc=dc_charge,
discharge, discharge=discharge,
parameter["temperature_forecast"], temperature=parameter["temperature_forecast"],
start_hour, start_hour=start_hour,
self.prediction_hours, einspeiseverguetung_euro_pro_wh=einspeiseverguetung_euro_pro_wh,
einspeiseverguetung_euro_pro_wh, config=self._config,
extra_data=extra_data, extra_data=extra_data,
) )

View File

@ -1,12 +1,14 @@
import hashlib import hashlib
import json import json
import os
import zoneinfo import zoneinfo
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from pathlib import Path
import numpy as np import numpy as np
import requests import requests
from akkudoktoreos.config import AppConfig, SetupIncomplete
def repeat_to_shape(array, target_shape): def repeat_to_shape(array, target_shape):
# Check if the array fits the target shape # Check if the array fits the target shape
@ -23,53 +25,57 @@ def repeat_to_shape(array, target_shape):
class HourlyElectricityPriceForecast: class HourlyElectricityPriceForecast:
def __init__( def __init__(
self, source, cache_dir="cache", charges=0.000228, prediction_hours=24, cache=True self, source: str | Path, config: AppConfig, charges=0.000228, use_cache=True
): # 228 ): # 228
self.cache_dir = cache_dir self.cache_dir = config.working_dir / config.directories.cache
self.cache = cache self.use_cache = use_cache
os.makedirs(self.cache_dir, exist_ok=True) if not self.cache_dir.is_dir():
self.cache_time_file = os.path.join(self.cache_dir, "cache_timestamp.txt") raise SetupIncomplete(f"Output path does not exist: {self.cache_dir}.")
self.cache_time_file = self.cache_dir / "cache_timestamp.txt"
self.prices = self.load_data(source) self.prices = self.load_data(source)
self.charges = charges self.charges = charges
self.prediction_hours = prediction_hours self.prediction_hours = config.eos.prediction_hours
def load_data(self, source): def load_data(self, source: str | Path):
cache_filename = self.get_cache_filename(source) cache_file = self.get_cache_file(source)
if source.startswith("http"): if isinstance(source, str):
if os.path.exists(cache_filename) and not self.is_cache_expired() and self.cache: if cache_file.is_file() and not self.is_cache_expired() and self.use_cache:
print("Loading data from cache...") print("Loading data from cache...")
with open(cache_filename, "r") as file: with cache_file.open("r") as file:
json_data = json.load(file) json_data = json.load(file)
else: else:
print("Loading data from the URL...") print("Loading data from the URL...")
response = requests.get(source) response = requests.get(source)
if response.status_code == 200: if response.status_code == 200:
json_data = response.json() json_data = response.json()
with open(cache_filename, "w") as file: with cache_file.open("w") as file:
json.dump(json_data, file) json.dump(json_data, file)
self.update_cache_timestamp() self.update_cache_timestamp()
else: else:
raise Exception(f"Error fetching data: {response.status_code}") raise Exception(f"Error fetching data: {response.status_code}")
else: elif source.is_file():
with open(source, "r") as file: with source.open("r") as file:
json_data = json.load(file) json_data = json.load(file)
else:
raise ValueError(f"Input is not a valid path: {source}")
return json_data["values"] return json_data["values"]
def get_cache_filename(self, url): def get_cache_file(self, url):
hash_object = hashlib.sha256(url.encode()) hash_object = hashlib.sha256(url.encode())
hex_dig = hash_object.hexdigest() hex_dig = hash_object.hexdigest()
return os.path.join(self.cache_dir, f"cache_{hex_dig}.json") return self.cache_dir / f"cache_{hex_dig}.json"
def is_cache_expired(self): def is_cache_expired(self):
if not os.path.exists(self.cache_time_file): if not self.cache_time_file.is_file():
return True return True
with open(self.cache_time_file, "r") as file: with self.cache_time_file.open("r") as file:
timestamp_str = file.read() timestamp_str = file.read()
last_cache_time = datetime.strptime(timestamp_str, "%Y-%m-%d %H:%M:%S") last_cache_time = datetime.strptime(timestamp_str, "%Y-%m-%d %H:%M:%S")
return datetime.now() - last_cache_time > timedelta(hours=1) return datetime.now() - last_cache_time > timedelta(hours=1)
def update_cache_timestamp(self): def update_cache_timestamp(self):
with open(self.cache_time_file, "w") as file: with self.cache_time_file.open("w") as file:
file.write(datetime.now().strftime("%Y-%m-%d %H:%M:%S")) file.write(datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
def get_price_for_date(self, date_str): def get_price_for_date(self, date_str):

View File

@ -1,30 +1,290 @@
"""This module provides functionality to manage and handle configuration for the EOS system.
The module including loading, merging, and validating JSON configuration files.
It also provides utility functions for working directory setup and date handling.
Key features:
- Loading and merging configurations from default or custom JSON files
- Validating configurations using Pydantic models
- Managing directory setups for the application
- Utility to get prediction start and end dates
"""
import json
import os
import shutil
from datetime import datetime, timedelta from datetime import datetime, timedelta
from pathlib import Path
from typing import Any, Optional
output_dir = "output" from pydantic import BaseModel, ValidationError
prediction_hours = 48 EOS_DIR = "EOS_DIR"
optimization_hours = 24 ENCODING = "UTF-8"
strafe = 10 CONFIG_FILE_NAME = "EOS.config.json"
possible_ev_charge_currents = [ DEFAULT_CONFIG_FILE = Path(__file__).parent.joinpath("default.config.json")
0.0,
6.0 / 16.0,
# 7.0 / 16.0,
8.0 / 16.0,
# 9.0 / 16.0,
10.0 / 16.0,
# 11.0 / 16.0,
12.0 / 16.0,
# 13.0 / 16.0,
14.0 / 16.0,
# 15.0 / 16.0,
1.0,
]
def get_start_enddate(prediction_hours=48, startdate=None): class FolderConfig(BaseModel):
############ """Folder configuration for the EOS system.
# Parameter
############ Uses working_dir as root path.
The working directory can be either cwd or
a path or folder defined by the EOS_DIR environment variable.
Attributes:
output (str): Directory name for output files.
cache (str): Directory name for cache files.
"""
output: str
cache: str
class EOSConfig(BaseModel):
"""EOS system-specific configuration.
Attributes:
prediction_hours (int): Number of hours for predictions.
optimization_hours (int): Number of hours for optimizations.
penalty (int): Penalty factor used in optimization.
available_charging_rates_in_percentage (list[float]): List of available charging rates as percentages.
"""
prediction_hours: int
optimization_hours: int
penalty: int
available_charging_rates_in_percentage: list[float]
feed_in_tariff_eur_per_wh: int
class BaseConfig(BaseModel):
"""Base configuration for the EOS system.
Attributes:
directories (FolderConfig): Configuration for directory paths (output, cache).
eos (EOSConfig): Configuration for EOS-specific settings.
"""
directories: FolderConfig
eos: EOSConfig
class AppConfig(BaseConfig):
"""Application-level configuration that extends the base configuration with a working directory.
Attributes:
working_dir (Path): The root directory for the application.
"""
working_dir: Path
def run_setup(self) -> None:
"""Runs setup for the application by ensuring that required directories exist.
If a directory does not exist, it is created.
Raises:
OSError: If directories cannot be created.
"""
print("Checking directory settings and creating missing directories...")
for key, value in self.directories.model_dump().items():
if not isinstance(value, str):
continue
path = self.working_dir / value
print(f"'{key}': {path}")
os.makedirs(path, exist_ok=True)
class SetupIncomplete(Exception):
"""Exception class for errors related to incomplete setup of the EOS system."""
def _load_json(path: Path) -> dict[str, Any]:
"""Load a JSON file from a given path.
Args:
path (Path): Path to the JSON file.
Returns:
dict[str, Any]: Parsed JSON content.
Raises:
FileNotFoundError: If the JSON file does not exist.
json.JSONDecodeError: If the file cannot be parsed as valid JSON.
"""
with path.open("r") as f_in:
return json.load(f_in)
def _merge_json(default_data: dict[str, Any], custom_data: dict[str, Any]) -> dict[str, Any]:
"""Recursively merge two dictionaries, using values from `custom_data` when available.
Args:
default_data (dict[str, Any]): The default configuration values.
custom_data (dict[str, Any]): The custom configuration values.
Returns:
dict[str, Any]: Merged configuration data.
"""
merged_data = {}
for key, default_value in default_data.items():
if key in custom_data:
custom_value = custom_data[key]
if isinstance(default_value, dict) and isinstance(custom_value, dict):
merged_data[key] = _merge_json(default_value, custom_value)
elif type(default_value) is type(custom_value):
merged_data[key] = custom_value
else:
# use default value if types differ
merged_data[key] = default_value
else:
merged_data[key] = default_value
return merged_data
def _config_update_available(merged_data: dict[str, Any], custom_data: dict[str, Any]) -> bool:
"""Check if the configuration needs to be updated by comparing merged data and custom data.
Args:
merged_data (dict[str, Any]): The merged configuration data.
custom_data (dict[str, Any]): The custom configuration data.
Returns:
bool: True if there is a difference indicating that an update is needed, otherwise False.
"""
if merged_data.keys() != custom_data.keys():
return True
for key in merged_data:
value1 = merged_data[key]
value2 = custom_data[key]
if isinstance(value1, dict) and isinstance(value2, dict):
if _config_update_available(value1, value2):
return True
elif value1 != value2:
return True
return False
def get_config_file(path: Path, copy_default: bool) -> Path:
"""Get the valid configuration file path. If the custom config is not found, it uses the default config.
Args:
path (Path): Path to the working directory.
copy_default (bool): If True, copy the default configuration if custom config is not found.
Returns:
Path: Path to the valid configuration file.
"""
config = path.resolve() / CONFIG_FILE_NAME
if config.is_file():
print(f"Using configuration from: {config}")
return config
if not path.is_dir():
print(f"Path does not exist: {path}. Using default configuration...")
return DEFAULT_CONFIG_FILE
if not copy_default:
print("No custom configuration provided. Using default configuration...")
return DEFAULT_CONFIG_FILE
try:
return Path(shutil.copy2(DEFAULT_CONFIG_FILE, config))
except Exception as exc:
print(f"Could not copy default config: {exc}. Using default copy...")
return DEFAULT_CONFIG_FILE
def _merge_and_update(custom_config: Path, update_outdated: bool = False) -> bool:
"""Merge custom and default configurations, and optionally update the custom config if outdated.
Args:
custom_config (Path): Path to the custom configuration file.
update_outdated (bool): If True, update the custom config if it is outdated.
Returns:
bool: True if the custom config was updated, otherwise False.
"""
if custom_config == DEFAULT_CONFIG_FILE:
return False
default_data = _load_json(DEFAULT_CONFIG_FILE)
custom_data = _load_json(custom_config)
merged_data = _merge_json(default_data, custom_data)
if not _config_update_available(merged_data, custom_data):
print(f"Custom config {custom_config} is up-to-date...")
return False
print(f"Custom config {custom_config} is outdated...")
if update_outdated:
with custom_config.open("w") as f_out:
json.dump(merged_data, f_out, indent=2)
return True
return False
def load_config(
working_dir: Path, copy_default: bool = False, update_outdated: bool = True
) -> AppConfig:
"""Load the application configuration from the specified directory, merging with defaults if needed.
Args:
working_dir (Path): Path to the working directory.
copy_default (bool): Whether to copy the default configuration if custom config is missing.
update_outdated (bool): Whether to update outdated custom configuration.
Returns:
AppConfig: Loaded application configuration.
Raises:
ValueError: If the configuration is incomplete or not valid.
"""
# make sure working_dir is always a full path
working_dir = working_dir.resolve()
config = get_config_file(working_dir, copy_default)
_merge_and_update(config, update_outdated)
with config.open("r", encoding=ENCODING) as f_in:
try:
base_config = BaseConfig.model_validate(json.load(f_in))
return AppConfig.model_validate(
{"working_dir": working_dir, **base_config.model_dump()}
)
except ValidationError as exc:
raise ValueError(f"Configuration {config} is incomplete or not valid: {exc}")
def get_working_dir() -> Path:
"""Get the working directory for the application, either from an environment variable or the current working directory.
Returns:
Path: The path to the working directory.
"""
custom_dir = os.getenv(EOS_DIR)
if custom_dir is None:
working_dir = Path.cwd()
print(f"No custom directory provided. Setting working directory to: {working_dir}")
else:
working_dir = Path(custom_dir).resolve()
print(f"Custom directory provided. Setting working directory to: {working_dir}")
return working_dir
def get_start_enddate(
prediction_hours: int, startdate: Optional[datetime] = None
) -> tuple[str, str]:
"""Calculate the start and end dates based on the given prediction hours and optional start date.
Args:
prediction_hours (int): Number of hours for predictions.
startdate (Optional[datetime]): Optional starting datetime.
Returns:
tuple[str, str]: The current date (start date) and end date in the format 'YYYY-MM-DD'.
"""
if startdate is None: if startdate is None:
date = (datetime.now().date() + timedelta(hours=prediction_hours)).strftime("%Y-%m-%d") date = (datetime.now().date() + timedelta(hours=prediction_hours)).strftime("%Y-%m-%d")
date_now = datetime.now().strftime("%Y-%m-%d") date_now = datetime.now().strftime("%Y-%m-%d")

View File

@ -0,0 +1,15 @@
{
"directories": {
"output": "output",
"cache": "cache"
},
"eos": {
"prediction_hours": 48,
"optimization_hours": 24,
"penalty": 10,
"available_charging_rates_in_percentage": [
0.0, 0.375, 0.5, 0.625, 0.75, 0.875, 1.0
],
"feed_in_tariff_eur_per_wh": 48
}
}

View File

@ -1,5 +1,4 @@
import datetime import datetime
import os
# Set the backend for matplotlib to Agg # Set the backend for matplotlib to Agg
import matplotlib import matplotlib
@ -8,7 +7,7 @@ import numpy as np
from matplotlib.backends.backend_pdf import PdfPages from matplotlib.backends.backend_pdf import PdfPages
from akkudoktoreos.class_sommerzeit import ist_dst_wechsel from akkudoktoreos.class_sommerzeit import ist_dst_wechsel
from akkudoktoreos.config import output_dir from akkudoktoreos.config import AppConfig, SetupIncomplete
matplotlib.use("Agg") matplotlib.use("Agg")
@ -23,22 +22,24 @@ def visualisiere_ergebnisse(
discharge, # Discharge allowed discharge, # Discharge allowed
temperature, temperature,
start_hour, start_hour,
prediction_hours,
einspeiseverguetung_euro_pro_wh, einspeiseverguetung_euro_pro_wh,
config: AppConfig,
filename="visualization_results.pdf", filename="visualization_results.pdf",
extra_data=None, extra_data=None,
): ):
##################### #####################
# 24-hour visualization # 24-hour visualization
##################### #####################
if not os.path.exists(output_dir): output_dir = config.working_dir / config.directories.output
os.makedirs(output_dir) if not output_dir.is_dir():
output_file = os.path.join(output_dir, filename) raise SetupIncomplete(f"Output path does not exist: {output_dir}.")
output_file = output_dir.joinpath(filename)
with PdfPages(output_file) as pdf: with PdfPages(output_file) as pdf:
# Load and PV generation # Load and PV generation
plt.figure(figsize=(14, 14)) plt.figure(figsize=(14, 14))
plt.subplot(3, 3, 1) plt.subplot(3, 3, 1)
hours = np.arange(0, prediction_hours) hours = np.arange(0, config.eos.prediction_hours)
gesamtlast_array = np.array(gesamtlast) gesamtlast_array = np.array(gesamtlast)
# Plot individual loads # Plot individual loads
@ -101,9 +102,9 @@ def visualisiere_ergebnisse(
plt.figure(figsize=(14, 10)) plt.figure(figsize=(14, 10))
if ist_dst_wechsel(datetime.datetime.now()): if ist_dst_wechsel(datetime.datetime.now()):
hours = np.arange(start_hour, prediction_hours - 1) hours = np.arange(start_hour, config.eos.prediction_hours - 1)
else: else:
hours = np.arange(start_hour, prediction_hours) hours = np.arange(start_hour, config.eos.prediction_hours)
# Energy flow, grid feed-in, and grid consumption # Energy flow, grid feed-in, and grid consumption
plt.subplot(3, 2, 1) plt.subplot(3, 2, 1)
@ -187,7 +188,7 @@ def visualisiere_ergebnisse(
# Plot for AC, DC charging, and Discharge status using bar charts # Plot for AC, DC charging, and Discharge status using bar charts
ax1 = plt.subplot(3, 2, 5) ax1 = plt.subplot(3, 2, 5)
hours = np.arange(0, prediction_hours) hours = np.arange(0, config.eos.prediction_hours)
# Plot AC charging as bars (relative values between 0 and 1) # Plot AC charging as bars (relative values between 0 and 1)
plt.bar(hours, ac, width=0.4, label="AC Charging (relative)", color="blue", alpha=0.6) plt.bar(hours, ac, width=0.4, label="AC Charging (relative)", color="blue", alpha=0.6)
@ -209,16 +210,16 @@ def visualisiere_ergebnisse(
# Configure the plot # Configure the plot
ax1.legend(loc="upper left") ax1.legend(loc="upper left")
ax1.set_xlim(0, prediction_hours) ax1.set_xlim(0, config.eos.prediction_hours)
ax1.set_xlabel("Hour") ax1.set_xlabel("Hour")
ax1.set_ylabel("Relative Power (0-1) / Discharge (0 or 1)") ax1.set_ylabel("Relative Power (0-1) / Discharge (0 or 1)")
ax1.set_title("AC/DC Charging and Discharge Overview") ax1.set_title("AC/DC Charging and Discharge Overview")
ax1.grid(True) ax1.grid(True)
if ist_dst_wechsel(datetime.datetime.now()): if ist_dst_wechsel(datetime.datetime.now()):
hours = np.arange(start_hour, prediction_hours - 1) hours = np.arange(start_hour, config.eos.prediction_hours - 1)
else: else:
hours = np.arange(start_hour, prediction_hours) hours = np.arange(start_hour, config.eos.prediction_hours)
pdf.savefig() # Save the current figure state to the PDF pdf.savefig() # Save the current figure state to the PDF
plt.close() # Close the current figure to free up memory plt.close() # Close the current figure to free up memory

View File

@ -20,20 +20,18 @@ from akkudoktoreos.class_optimize import optimization_problem
from akkudoktoreos.class_pv_forecast import PVForecast from akkudoktoreos.class_pv_forecast import PVForecast
from akkudoktoreos.class_strompreis import HourlyElectricityPriceForecast from akkudoktoreos.class_strompreis import HourlyElectricityPriceForecast
from akkudoktoreos.config import ( from akkudoktoreos.config import (
SetupIncomplete,
get_start_enddate, get_start_enddate,
optimization_hours, get_working_dir,
output_dir, load_config,
prediction_hours,
) )
app = Flask(__name__) app = Flask(__name__)
opt_class = optimization_problem( working_dir = get_working_dir()
prediction_hours=prediction_hours, # copy config to working directory. Make this a CLI option later
strafe=10, config = load_config(working_dir, True)
optimization_hours=optimization_hours, opt_class = optimization_problem(config)
verbose=True,
)
def isfloat(num: Any) -> TypeGuard[float]: def isfloat(num: Any) -> TypeGuard[float]:
@ -58,14 +56,11 @@ def isfloat(num: Any) -> TypeGuard[float]:
@app.route("/strompreis", methods=["GET"]) @app.route("/strompreis", methods=["GET"])
def flask_strompreis(): def flask_strompreis():
# Get the current date and the end date based on prediction hours # Get the current date and the end date based on prediction hours
date_now, date = get_start_enddate(prediction_hours, startdate=datetime.now().date()) date_now, date = get_start_enddate(config.eos.prediction_hours, startdate=datetime.now().date())
filepath = os.path.join(
r"test_data", r"strompreise_akkudokAPI.json"
) # Adjust the path to the JSON file
price_forecast = HourlyElectricityPriceForecast( price_forecast = HourlyElectricityPriceForecast(
source=f"https://api.akkudoktor.net/prices?start={date_now}&end={date}", source=f"https://api.akkudoktor.net/prices?start={date_now}&end={date}",
prediction_hours=prediction_hours, config=config.eos.prediction_hours,
cache=False, use_cache=False,
) )
specific_date_prices = price_forecast.get_price_for_daterange( specific_date_prices = price_forecast.get_price_for_daterange(
date_now, date date_now, date
@ -141,7 +136,7 @@ def flask_gesamtlast_simple():
request.args.get("year_energy") request.args.get("year_energy")
) # Get annual energy value from query parameters ) # Get annual energy value from query parameters
date_now, date = get_start_enddate( date_now, date = get_start_enddate(
prediction_hours, startdate=datetime.now().date() config.eos.prediction_hours, startdate=datetime.now().date()
) # Get the current date and prediction end date ) # Get the current date and prediction end date
############### ###############
@ -159,7 +154,9 @@ def flask_gesamtlast_simple():
0 0
] # Get expected household load for the date range ] # Get expected household load for the date range
gesamtlast = Gesamtlast(prediction_hours=prediction_hours) # Create Gesamtlast instance gesamtlast = Gesamtlast(
prediction_hours=config.eos.prediction_hours
) # Create Gesamtlast instance
gesamtlast.hinzufuegen( gesamtlast.hinzufuegen(
"Haushalt", leistung_haushalt "Haushalt", leistung_haushalt
) # Add household load to total load calculation ) # Add household load to total load calculation
@ -181,13 +178,15 @@ def flask_pvprognose():
# Retrieve URL and AC power measurement from query parameters # Retrieve URL and AC power measurement from query parameters
url = request.args.get("url") url = request.args.get("url")
ac_power_measurement = request.args.get("ac_power_measurement") ac_power_measurement = request.args.get("ac_power_measurement")
date_now, date = get_start_enddate(prediction_hours, startdate=datetime.now().date()) date_now, date = get_start_enddate(
config.eos.prediction_hours, startdate=datetime.now().date()
)
############### ###############
# PV Forecast # PV Forecast
############### ###############
PVforecast = PVForecast( PVforecast = PVForecast(
prediction_hours=prediction_hours, url=url prediction_hours=config.eos.prediction_hours, url=url
) # Instantiate PVForecast with given parameters ) # Instantiate PVForecast with given parameters
if isfloat(ac_power_measurement): # Check if the AC power measurement is a valid float if isfloat(ac_power_measurement): # Check if the AC power measurement is a valid float
PVforecast.update_ac_power_measurement( PVforecast.update_ac_power_measurement(
@ -255,9 +254,10 @@ def flask_optimize():
@app.route("/visualization_results.pdf") @app.route("/visualization_results.pdf")
def get_pdf(): def get_pdf():
# Endpoint to serve the generated PDF with visualization results # Endpoint to serve the generated PDF with visualization results
return send_from_directory( output_path = config.working_dir / config.directories.output
os.path.abspath(output_dir), "visualization_results.pdf" if not output_path.is_dir():
) # Adjust the directory if needed raise SetupIncomplete(f"Output path does not exist: {output_path}.")
return send_from_directory(output_path, "visualization_results.pdf")
@app.route("/site-map") @app.route("/site-map")
@ -293,6 +293,8 @@ def root():
if __name__ == "__main__": if __name__ == "__main__":
try: try:
config.run_setup()
# Set host and port from environment variables or defaults # Set host and port from environment variables or defaults
host = os.getenv("FLASK_RUN_HOST", "0.0.0.0") host = os.getenv("FLASK_RUN_HOST", "0.0.0.0")
port = os.getenv("FLASK_RUN_PORT", 8503) port = os.getenv("FLASK_RUN_PORT", 8503)

View File

@ -3,10 +3,21 @@ import os
import subprocess import subprocess
import sys import sys
import time import time
from pathlib import Path
import pytest import pytest
from xprocess import ProcessStarter from xprocess import ProcessStarter
from akkudoktoreos.config import EOS_DIR, AppConfig, load_config
@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 = tmp_path
return config
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def disable_debug_logging(): def disable_debug_logging():
@ -29,7 +40,7 @@ def is_full_run(request):
@pytest.fixture @pytest.fixture
def server(xprocess): def server(xprocess, tmp_path: Path):
"""Fixture to start the server. """Fixture to start the server.
Provides URL of the server. Provides URL of the server.
@ -45,8 +56,7 @@ def server(xprocess):
stderr=subprocess.PIPE, stderr=subprocess.PIPE,
) )
except subprocess.CalledProcessError: except subprocess.CalledProcessError:
test_dir = os.path.dirname(os.path.realpath(__file__)) project_dir = Path(__file__).parent.parent
project_dir = os.path.abspath(os.path.join(test_dir, ".."))
subprocess.run( subprocess.run(
[sys.executable, "-m", "pip", "install", "-e", project_dir], [sys.executable, "-m", "pip", "install", "-e", project_dir],
check=True, check=True,
@ -56,14 +66,15 @@ def server(xprocess):
# command to start server process # command to start server process
args = [sys.executable, "-m", "akkudoktoreosserver.flask_server"] args = [sys.executable, "-m", "akkudoktoreosserver.flask_server"]
env = {EOS_DIR: f"{tmp_path}", **os.environ.copy()}
# startup pattern # startup pattern
pattern = "Debugger PIN:" pattern = "Debugger PIN:"
# search the first 12 lines for the startup pattern, if not found # search the first 30 lines for the startup pattern, if not found
# a RuntimeError will be raised informing the user # a RuntimeError will be raised informing the user
max_read_lines = 12 max_read_lines = 30
# will wait for 10 seconds before timing out # will wait for 30 seconds before timing out
timeout = 30 timeout = 30
# xprocess will now attempt to clean up upon interruptions # xprocess will now attempt to clean up upon interruptions

View File

@ -4,7 +4,8 @@ import pytest
from akkudoktoreos.class_akku import PVAkku from akkudoktoreos.class_akku import PVAkku
from akkudoktoreos.class_ems import EnergieManagementSystem from akkudoktoreos.class_ems import EnergieManagementSystem
from akkudoktoreos.class_haushaltsgeraet import Haushaltsgeraet from akkudoktoreos.class_haushaltsgeraet import Haushaltsgeraet
from akkudoktoreos.class_inverter import Wechselrichter # Example import from akkudoktoreos.class_inverter import Wechselrichter
from akkudoktoreos.config import AppConfig
prediction_hours = 48 prediction_hours = 48
optimization_hours = 24 optimization_hours = 24
@ -13,7 +14,7 @@ start_hour = 1
# Example initialization of necessary components # Example initialization of necessary components
@pytest.fixture @pytest.fixture
def create_ems_instance(): def create_ems_instance(tmp_config: AppConfig) -> EnergieManagementSystem:
"""Fixture to create an EnergieManagementSystem instance with given test parameters.""" """Fixture to create an EnergieManagementSystem instance with given test parameters."""
# Initialize the battery and the inverter # Initialize the battery and the inverter
akku = PVAkku(kapazitaet_wh=5000, start_soc_prozent=80, hours=48, min_soc_prozent=10) akku = PVAkku(kapazitaet_wh=5000, start_soc_prozent=80, hours=48, min_soc_prozent=10)
@ -189,6 +190,7 @@ def create_ems_instance():
# Initialize the energy management system with the respective parameters # Initialize the energy management system with the respective parameters
ems = EnergieManagementSystem( ems = EnergieManagementSystem(
config=tmp_config.eos,
pv_prognose_wh=pv_prognose_wh, pv_prognose_wh=pv_prognose_wh,
strompreis_euro_pro_wh=strompreis_euro_pro_wh, strompreis_euro_pro_wh=strompreis_euro_pro_wh,
einspeiseverguetung_euro_pro_wh=einspeiseverguetung_euro_pro_wh, einspeiseverguetung_euro_pro_wh=einspeiseverguetung_euro_pro_wh,

View File

@ -4,7 +4,8 @@ import pytest
from akkudoktoreos.class_akku import PVAkku from akkudoktoreos.class_akku import PVAkku
from akkudoktoreos.class_ems import EnergieManagementSystem from akkudoktoreos.class_ems import EnergieManagementSystem
from akkudoktoreos.class_haushaltsgeraet import Haushaltsgeraet from akkudoktoreos.class_haushaltsgeraet import Haushaltsgeraet
from akkudoktoreos.class_inverter import Wechselrichter # Example import from akkudoktoreos.class_inverter import Wechselrichter
from akkudoktoreos.config import AppConfig
prediction_hours = 48 prediction_hours = 48
optimization_hours = 24 optimization_hours = 24
@ -13,7 +14,7 @@ start_hour = 0
# Example initialization of necessary components # Example initialization of necessary components
@pytest.fixture @pytest.fixture
def create_ems_instance(): def create_ems_instance(tmp_config: AppConfig) -> EnergieManagementSystem:
"""Fixture to create an EnergieManagementSystem instance with given test parameters.""" """Fixture to create an EnergieManagementSystem instance with given test parameters."""
# Initialize the battery and the inverter # Initialize the battery and the inverter
akku = PVAkku(kapazitaet_wh=5000, start_soc_prozent=80, hours=48, min_soc_prozent=10) akku = PVAkku(kapazitaet_wh=5000, start_soc_prozent=80, hours=48, min_soc_prozent=10)
@ -96,6 +97,7 @@ def create_ems_instance():
# Initialize the energy management system with the respective parameters # Initialize the energy management system with the respective parameters
ems = EnergieManagementSystem( ems = EnergieManagementSystem(
config=tmp_config.eos,
pv_prognose_wh=pv_prognose_wh, pv_prognose_wh=pv_prognose_wh,
strompreis_euro_pro_wh=strompreis_euro_pro_wh, strompreis_euro_pro_wh=strompreis_euro_pro_wh,
einspeiseverguetung_euro_pro_wh=einspeiseverguetung_euro_pro_wh, einspeiseverguetung_euro_pro_wh=einspeiseverguetung_euro_pro_wh,

View File

@ -6,6 +6,7 @@ from unittest.mock import patch
import pytest import pytest
from akkudoktoreos.class_optimize import optimization_problem from akkudoktoreos.class_optimize import optimization_problem
from akkudoktoreos.config import AppConfig
DIR_TESTDATA = Path(__file__).parent / "testdata" DIR_TESTDATA = Path(__file__).parent / "testdata"
@ -33,17 +34,25 @@ def compare_dict(actual: dict[str, Any], expected: dict[str, Any]):
], ],
) )
@patch("akkudoktoreos.class_optimize.visualisiere_ergebnisse") @patch("akkudoktoreos.class_optimize.visualisiere_ergebnisse")
def test_optimize(visualisiere_ergebnisse_patch, fn_in: str, fn_out: str, ngen: int, is_full_run): def test_optimize(
visualisiere_ergebnisse_patch,
fn_in: str,
fn_out: str,
ngen: int,
is_full_run: bool,
tmp_config: AppConfig,
):
"""Test optimierung_ems."""
# Load input and output data # Load input and output data
with open(DIR_TESTDATA / fn_in, "r") as f_in: file = DIR_TESTDATA / fn_in
with file.open("r") as f_in:
input_data = json.load(f_in) input_data = json.load(f_in)
with open(DIR_TESTDATA / fn_out, "r") as f_out: file = DIR_TESTDATA / fn_out
with file.open("r") as f_out:
expected_output_data = json.load(f_out) expected_output_data = json.load(f_out)
opt_class = optimization_problem( opt_class = optimization_problem(tmp_config, fixed_seed=42)
prediction_hours=48, strafe=10, optimization_hours=24, fixed_seed=42
)
start_hour = 10 start_hour = 10
if ngen > 10 and not is_full_run: if ngen > 10 and not is_full_run:

71
tests/test_config.py Normal file
View File

@ -0,0 +1,71 @@
import json
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,
)
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}")
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)
load_config(tmp_path, True)
expected_config = tmp_path.joinpath(CONFIG_FILE_NAME)
assert expected_config == get_config_file(tmp_path, False)
assert expected_config.is_file()
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",
}
}
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, tmp_path, 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
def test_setup(tmp_path: Path) -> None:
"""Test setup."""
config = load_config(tmp_path, True)
config.run_setup()
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()

View File

@ -1,10 +1,24 @@
from http import HTTPStatus
from pathlib import Path
import requests import requests
from akkudoktoreos.config import prediction_hours from akkudoktoreos.config import CONFIG_FILE_NAME, load_config
def test_server(server): 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):
"""Test the server.""" """Test the server."""
result = requests.get(f"{server}/gesamtlast_simple?year_energy=2000&") result = requests.get(f"{server}/gesamtlast_simple?year_energy=2000&")
assert result.status_code == 200 assert result.status_code == HTTPStatus.OK
assert len(result.json()) == prediction_hours
config = load_config(tmp_path, False)
assert len(result.json()) == config.eos.prediction_hours

View File

@ -1,10 +1,10 @@
import json import json
from pathlib import Path from pathlib import Path
from unittest.mock import patch
import pytest import pytest
from matplotlib.testing.compare import compare_images from matplotlib.testing.compare import compare_images
from akkudoktoreos.config import AppConfig
from akkudoktoreos.visualize import visualisiere_ergebnisse from akkudoktoreos.visualize import visualisiere_ergebnisse
DIR_TESTDATA = Path(__file__).parent / "testdata" DIR_TESTDATA = Path(__file__).parent / "testdata"
@ -15,14 +15,16 @@ DIR_IMAGEDATA = DIR_TESTDATA / "images"
"fn_in, fn_out, fn_out_base", "fn_in, fn_out, fn_out_base",
[("visualize_input_1.json", "visualize_output_1.pdf", "visualize_base_output_1.pdf")], [("visualize_input_1.json", "visualize_output_1.pdf", "visualize_base_output_1.pdf")],
) )
@patch("akkudoktoreos.visualize.output_dir", DIR_IMAGEDATA) 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: with open(DIR_TESTDATA / fn_in, "r") as f:
input_data = json.load(f) input_data = json.load(f)
visualisiere_ergebnisse(**input_data) visualisiere_ergebnisse(config=tmp_config, **input_data)
output_file: Path = tmp_config.working_dir / tmp_config.directories.output / fn_out
assert output_file.is_file()
assert ( assert (
compare_images( compare_images(
str(DIR_IMAGEDATA / fn_out), str(output_file),
str(DIR_IMAGEDATA / fn_out_base), str(DIR_IMAGEDATA / fn_out_base),
0, 0,
) )

View File

@ -1,62 +1,174 @@
{ {
"gesamtlast": [ "gesamtlast": [
676.71, 876.19, 527.13, 468.88, 531.38, 517.95, 483.15, 472.28, 1011.68, 995.0, 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 676.71, 876.19, 527.13, 468.88, 531.38, 517.95, 483.15, 472.28, 1011.68,
995.0, 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
], ],
"pv_forecast": [ "pv_forecast": [
0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 5000.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 5000.0, 0.0,
0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
0.0, 0.0, 0.0, 0.0
], ],
"strompreise": [ "strompreise": [
0.00001, 0.00001, 0.00001, 0.00001, 0.00001, 0.00001, 0.00001, 0.00001, 0.00001, 0.00001, 0.001, 0.00005, 0.00005, 0.00005, 0.00005, 0.001, 0.001, 0.001, 0.001, 0.001, 0.00001, 0.001, 0.001, 0.001, 0.001, 0.001, 0.001, 0.001, 0.001, 0.001, 0.001, 0.001, 0.001, 0.001, 0.001, 0.001, 0.001, 0.001, 0.001, 0.001, 0.001, 0.001, 0.001, 0.001, 0.001, 0.001, 0.001, 0.001 0.00001, 0.00001, 0.00001, 0.00001, 0.00001, 0.00001, 0.00001, 0.00001,
0.00001, 0.00001, 0.001, 0.00005, 0.00005, 0.00005, 0.00005, 0.001, 0.001,
0.001, 0.001, 0.001, 0.00001, 0.001, 0.001, 0.001, 0.001, 0.001, 0.001,
0.001, 0.001, 0.001, 0.001, 0.001, 0.001, 0.001, 0.001, 0.001, 0.001, 0.001,
0.001, 0.001, 0.001, 0.001, 0.001, 0.001, 0.001, 0.001, 0.001, 0.001
], ],
"ergebnisse": { "ergebnisse": {
"Last_Wh_pro_Stunde": [ "Last_Wh_pro_Stunde": [
12493.71, 10502.19, 12775.13, 15356.88, 11468.38, 4037.95, 6047.15, 3112.2799999999997, 3211.68, 995.0, 1053.07, 1063.91, 1320.56, 1132.03, 1163.67, 1176.82, 1216.22, 1103.78, 1129.12, 1178.71, 1050.98, 988.56, 2035.7436363636361, 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 12493.71, 10502.19, 12775.13, 15356.88, 11468.38, 4037.95, 6047.15,
3112.2799999999997, 3211.68, 995.0, 1053.07, 1063.91, 1320.56, 1132.03,
1163.67, 1176.82, 1216.22, 1103.78, 1129.12, 1178.71, 1050.98, 988.56,
2035.7436363636361, 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
], ],
"Netzeinspeisung_Wh_pro_Stunde": [ "Netzeinspeisung_Wh_pro_Stunde": [
0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 3679.44, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 3679.44, 0.0,
0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
0.0, 0.0, 0.0, 0.0
], ],
"Netzbezug_Wh_pro_Stunde": [ "Netzbezug_Wh_pro_Stunde": [
12493.71, 10502.19, 12775.13, 15356.88, 11468.38, 4037.95, 6047.15, 3112.2799999999997, 3211.68, 995.0, 1053.07, 1063.91, 0.0, 1132.03, 1163.67, 1176.82, 1216.22, 1103.78, 1129.12, 1178.71, 1050.98, 0.0, 2035.7436363636361, 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, 0.0, 1221.04, 0.0, 0.0, 0.0, 592.97 12493.71, 10502.19, 12775.13, 15356.88, 11468.38, 4037.95, 6047.15,
3112.2799999999997, 3211.68, 995.0, 1053.07, 1063.91, 0.0, 1132.03,
1163.67, 1176.82, 1216.22, 1103.78, 1129.12, 1178.71, 1050.98, 0.0,
2035.7436363636361, 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, 0.0, 1221.04, 0.0, 0.0, 0.0,
592.97
], ],
"Kosten_Euro_pro_Stunde": [ "Kosten_Euro_pro_Stunde": [
0.1249371, 0.10502190000000002, 0.1277513, 0.1535688, 0.1146838, 0.0403795, 0.060471500000000004, 0.0311228, 0.0321168, 0.00995, 1.05307, 0.05319550000000001, 0.0, 0.0566015, 0.058183500000000006, 1.17682, 1.21622, 1.10378, 1.12912, 1.1787100000000001, 0.010509800000000001, 0.0, 2.035743636363636, 0.7046100000000001, 0.51637, 0.86805, 0.6943400000000001, 0.6087899999999999, 0.55631, 0.48889, 0.5069100000000001, 0.80489, 1.14198, 1.05697, 0.99246, 1.15599, 0.82701, 1.25798, 1.2326700000000002, 0.87126, 0.86088, 1.15803, 0.0, 1.22104, 0.0, 0.0, 0.0, 0.59297 0.1249371, 0.10502190000000002, 0.1277513, 0.1535688, 0.1146838,
0.0403795, 0.060471500000000004, 0.0311228, 0.0321168, 0.00995, 1.05307,
0.05319550000000001, 0.0, 0.0566015, 0.058183500000000006, 1.17682,
1.21622, 1.10378, 1.12912, 1.1787100000000001, 0.010509800000000001, 0.0,
2.035743636363636, 0.7046100000000001, 0.51637, 0.86805,
0.6943400000000001, 0.6087899999999999, 0.55631, 0.48889,
0.5069100000000001, 0.80489, 1.14198, 1.05697, 0.99246, 1.15599, 0.82701,
1.25798, 1.2326700000000002, 0.87126, 0.86088, 1.15803, 0.0, 1.22104, 0.0,
0.0, 0.0, 0.59297
], ],
"akku_soc_pro_stunde": [ "akku_soc_pro_stunde": [
25.0, 31.666666666666664, 38.333333333333336, 55.00000000000001, 61.66666666666667, 75.0, 81.66666666666667, 91.66666666666666, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 95.7448347107438, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 94.73691460055097, 94.73691460055097, 90.64777031680441, 86.39927685950414, 83.23988464187329, 83.23988464187329 25.0, 31.666666666666664, 38.333333333333336, 55.00000000000001,
61.66666666666667, 75.0, 81.66666666666667, 91.66666666666666, 100.0,
100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0,
100.0, 100.0, 95.7448347107438, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0,
100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0,
100.0, 100.0, 100.0, 100.0, 94.73691460055097, 94.73691460055097,
90.64777031680441, 86.39927685950414, 83.23988464187329, 83.23988464187329
], ],
"Einnahmen_Euro_pro_Stunde": [ "Einnahmen_Euro_pro_Stunde": [
0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.2575608, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.2575608,
0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
0.0, 0.0, 0.0, 0.0, 0.0
], ],
"Gesamtbilanz_Euro": 27.732796636363638, "Gesamtbilanz_Euro": 27.732796636363638,
"E-Auto_SoC_pro_Stunde": [ "E-Auto_SoC_pro_Stunde": [
30.294999999999998, 43.405, 60.885, 78.365, 93.66, 93.66, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0 30.294999999999998, 43.405, 60.885, 78.365, 93.66, 93.66, 100.0, 100.0,
100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0,
100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0,
100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0,
100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0
], ],
"Gesamteinnahmen_Euro": 0.2575608, "Gesamteinnahmen_Euro": 0.2575608,
"Gesamtkosten_Euro": 27.990357436363638, "Gesamtkosten_Euro": 27.990357436363638,
"Verluste_Pro_Stunde": [ "Verluste_Pro_Stunde": [
843.0, 654.0, 792.0, 1152.0, 723.0, 480.0, 440.2105263157896, 360.0, 300.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 134.80363636363631, 153.18595041322305, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 166.73454545454547, 0.0, 129.54409090909098, 134.59227272727276, 100.08954545454549, 0.0 843.0, 654.0, 792.0, 1152.0, 723.0, 480.0, 440.2105263157896, 360.0,
300.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
134.80363636363631, 153.18595041322305, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
166.73454545454547, 0.0, 129.54409090909098, 134.59227272727276,
100.08954545454549, 0.0
], ],
"Gesamt_Verluste": 6563.160567638104, "Gesamt_Verluste": 6563.160567638104,
"Haushaltsgeraet_wh_pro_stunde": [ "Haushaltsgeraet_wh_pro_stunde": [
null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null
] ]
}, },
"ac": [ "ac": [
0.6, 0.4, 0.4, 1.0, 0.4, 0.8, 0.4, 0.6, 1.0, 0.2, 0.2, 0.2, 0.6, 0.0, 0.2, 0.6, 0.0, 0.0, 0.8, 0.8, 0.4, 0.0, 1.0, 0.0, 1.0, 1.0, 0.0, 0.0, 0.2, 0.8, 0.2, 0.4, 0.6, 1.0, 0.0, 1.0, 0.8, 0.4, 0.4, 1.0, 0.2, 0.6, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0 0.6, 0.4, 0.4, 1.0, 0.4, 0.8, 0.4, 0.6, 1.0, 0.2, 0.2, 0.2, 0.6, 0.0, 0.2,
0.6, 0.0, 0.0, 0.8, 0.8, 0.4, 0.0, 1.0, 0.0, 1.0, 1.0, 0.0, 0.0, 0.2, 0.8,
0.2, 0.4, 0.6, 1.0, 0.0, 1.0, 0.8, 0.4, 0.4, 1.0, 0.2, 0.6, 0.0, 0.0, 0.0,
0.0, 0.0, 0.0
], ],
"dc": [ "dc": [
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1
], ],
"discharge": [ "discharge": [
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1, 1, 1, 0 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1, 1, 1, 0
], ],
"temperature": [ "temperature": [
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 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
], ],
"start_hour": 0, "start_hour": 0,
"prediction_hours": 48,
"einspeiseverguetung_euro_pro_wh": [ "einspeiseverguetung_euro_pro_wh": [
0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007,
0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007,
0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007,
0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007,
0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007,
0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007
], ],
"filename": "visualize_output_1.pdf", "filename": "visualize_output_1.pdf",
"extra_data": null "extra_data": null