From b43bf105aabb3b3dc915192a8534477e477047ee Mon Sep 17 00:00:00 2001 From: Normann Date: Fri, 10 Jan 2025 23:20:55 +0100 Subject: [PATCH 1/4] test_elecpriceakkudoktor bugfix (#360) * test_elecpriceakkudoktor bugfix * remove print * warning --- src/akkudoktoreos/prediction/elecpriceakkudoktor.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/akkudoktoreos/prediction/elecpriceakkudoktor.py b/src/akkudoktoreos/prediction/elecpriceakkudoktor.py index 486ed562..d9d7f2b6 100644 --- a/src/akkudoktoreos/prediction/elecpriceakkudoktor.py +++ b/src/akkudoktoreos/prediction/elecpriceakkudoktor.py @@ -187,6 +187,13 @@ def _update_data( self.config.prediction_hours - ((highest_orig_datetime - self.start_datetime).total_seconds() // 3600) ) + + if needed_prediction_hours <= 0: + logger.warning( + f"No prediction needed. needed_prediction_hours={needed_prediction_hours}, prediction_hours={self.config.prediction_hours},highest_orig_datetime {highest_orig_datetime}, start_datetime {self.start_datetime}" + ) # this might keep data longer than self.start_datetime + self.config.prediction_hours in the records + return + if amount_datasets > 800: # we do the full ets with seasons of 1 week prediction = self._predict_ets( history, seasonal_periods=168, prediction_hours=needed_prediction_hours From 1bf49c8c524ee2db966a9a771339c8141287d867 Mon Sep 17 00:00:00 2001 From: Normann Date: Sun, 12 Jan 2025 00:00:14 +0100 Subject: [PATCH 2/4] feature: Pv forecast refactoring (#354) * refactoring --- .../prediction/pvforecastakkudoktor.py | 123 ++++++++++-------- 1 file changed, 70 insertions(+), 53 deletions(-) diff --git a/src/akkudoktoreos/prediction/pvforecastakkudoktor.py b/src/akkudoktoreos/prediction/pvforecastakkudoktor.py index ef63c0b9..7f8184e4 100644 --- a/src/akkudoktoreos/prediction/pvforecastakkudoktor.py +++ b/src/akkudoktoreos/prediction/pvforecastakkudoktor.py @@ -201,25 +201,38 @@ def _validate_data(cls, json_str: Union[bytes, Any]) -> AkkudoktorForecast: def _url(self) -> str: """Build akkudoktor.net API request URL.""" - url = f"https://api.akkudoktor.net/forecast?lat={self.config.latitude}&lon={self.config.longitude}&" - planes_peakpower = self.config.pvforecast_planes_peakpower - planes_azimuth = self.config.pvforecast_planes_azimuth - planes_tilt = self.config.pvforecast_planes_tilt - planes_inverter_paco = self.config.pvforecast_planes_inverter_paco - planes_userhorizon = self.config.pvforecast_planes_userhorizon - for i, plane in enumerate(self.config.pvforecast_planes): - url += f"power={int(planes_peakpower[i]*1000)}&" - url += f"azimuth={int(planes_azimuth[i])}&" - url += f"tilt={int(planes_tilt[i])}&" - url += f"powerInverter={int(planes_inverter_paco[i])}&" - url += "horizont=" - for horizon in planes_userhorizon[i]: - url += f"{int(horizon)}," - url = url[:-1] # remove trailing comma - url += "&" - url += "past_days=5&cellCoEff=-0.36&inverterEfficiency=0.8&albedo=0.25&" - url += f"timezone={self.config.timezone}&" - url += "hourly=relativehumidity_2m%2Cwindspeed_10m" + base_url = "https://api.akkudoktor.net/forecast" + query_params = [ + f"lat={self.config.latitude}", + f"lon={self.config.longitude}", + ] + + for i in range(len(self.config.pvforecast_planes)): + query_params.append(f"power={int(self.config.pvforecast_planes_peakpower[i] * 1000)}") + query_params.append(f"azimuth={int(self.config.pvforecast_planes_azimuth[i])}") + query_params.append(f"tilt={int(self.config.pvforecast_planes_tilt[i])}") + query_params.append( + f"powerInverter={int(self.config.pvforecast_planes_inverter_paco[i])}" + ) + horizon_values = ",".join( + str(int(h)) for h in self.config.pvforecast_planes_userhorizon[i] + ) + query_params.append(f"horizont={horizon_values}") + + # Append fixed query parameters + query_params.extend( + [ + "past_days=5", + "cellCoEff=-0.36", + "inverterEfficiency=0.8", + "albedo=0.25", + f"timezone={self.config.timezone}", + "hourly=relativehumidity_2m%2Cwindspeed_10m", + ] + ) + + # Join all query parameters with `&` + url = f"{base_url}?{'&'.join(query_params)}" logger.debug(f"Akkudoktor URL: {url}") return url @@ -252,7 +265,7 @@ def _update_data(self, force_update: Optional[bool] = False) -> None: `PVForecastAkkudoktorDataRecord`. """ # Assure we have something to request PV power for. - if len(self.config.pvforecast_planes) == 0: + if not self.config.pvforecast_planes: # No planes for PV error_msg = "Requested PV forecast, but no planes configured." logger.error(f"Configuration error: {error_msg}") @@ -269,34 +282,36 @@ def _update_data(self, force_update: Optional[bool] = False) -> None: # Assumption that all lists are the same length and are ordered chronologically # in ascending order and have the same timestamps. - values_len = len(akkudoktor_data.values[0]) - if values_len < self.config.prediction_hours: + if len(akkudoktor_data.values[0]) < self.config.prediction_hours: # Expect one value set per prediction hour error_msg = ( f"The forecast must cover at least {self.config.prediction_hours} hours, " - f"but only {values_len} data sets are given in forecast data." + f"but only {len(akkudoktor_data.values[0])} data sets are given in forecast data." ) logger.error(f"Akkudoktor schema change: {error_msg}") raise ValueError(error_msg) - for i in range(values_len): - original_datetime = akkudoktor_data.values[0][i].datetime + assert self.start_datetime # mypy fix + + # Iterate over forecast data points + for forecast_values in zip(*akkudoktor_data.values): + original_datetime = forecast_values[0].datetime dt = to_datetime(original_datetime, in_timezone=self.config.timezone) - # We provide prediction starting at start of day, to be compatible to old system. + # Skip outdated forecast data if compare_datetimes(dt, self.start_datetime.start_of("day")).lt: - # forecast data is too old continue - sum_dc_power = sum(values[i].dcPower for values in akkudoktor_data.values) - sum_ac_power = sum(values[i].power for values in akkudoktor_data.values) + sum_dc_power = sum(values.dcPower for values in forecast_values) + sum_ac_power = sum(values.power for values in forecast_values) data = { "pvforecast_dc_power": sum_dc_power, "pvforecast_ac_power": sum_ac_power, - "pvforecastakkudoktor_wind_speed_10m": akkudoktor_data.values[0][i].windspeed_10m, - "pvforecastakkudoktor_temp_air": akkudoktor_data.values[0][i].temperature, + "pvforecastakkudoktor_wind_speed_10m": forecast_values[0].windspeed_10m, + "pvforecastakkudoktor_temp_air": forecast_values[0].temperature, } + self.update_value(dt, data) if len(self) < self.config.prediction_hours: @@ -307,35 +322,37 @@ def _update_data(self, force_update: Optional[bool] = False) -> None: ) def report_ac_power_and_measurement(self) -> str: - """Report DC/ AC power, and AC power measurement for each forecast hour. + """Generate a report of DC power, forecasted AC power, measured AC power, and other AC power values. - For each forecast entry, the time, DC power, forecasted AC power, measured AC power - (if available), and the value returned by the `get_ac_power` method is provided. + For each forecast entry, the following details are included: + - Time of the forecast + - DC power + - Forecasted AC power + - Measured AC power (if available) + - Value returned by `get_ac_power` (if available) Returns: - str: The report. + str: A formatted report containing details for each forecast entry. """ - rep = "" + + def format_value(value: float | None) -> str: + """Helper to format values as rounded strings or 'N/A' if None.""" + return f"{round(value, 2)}" if value is not None else "N/A" + + report_lines = [] for record in self.records: date_time = record.date_time - dc_pow = round(record.pvforecast_dc_power, 2) if record.pvforecast_dc_power else None - ac_pow = round(record.pvforecast_ac_power, 2) if record.pvforecast_ac_power else None - ac_pow_measurement = ( - round(record.pvforecastakkudoktor_ac_power_measured, 2) - if record.pvforecastakkudoktor_ac_power_measured - else None - ) - ac_pow_any = ( - round(record.pvforecastakkudoktor_ac_power_any, 2) - if record.pvforecastakkudoktor_ac_power_any - else None + dc_power = format_value(record.pvforecast_dc_power) + ac_power = format_value(record.pvforecast_ac_power) + ac_power_measured = format_value(record.pvforecastakkudoktor_ac_power_measured) + ac_power_any = format_value(record.pvforecastakkudoktor_ac_power_any) + + report_lines.append( + f"Date&Time: {date_time}, DC: {dc_power}, AC: {ac_power}, " + f"AC sampled: {ac_power_measured}, AC any: {ac_power_any}" ) - rep += ( - f"Date&Time: {date_time}, DC: {dc_pow}, AC: {ac_pow}, " - f"AC sampled: {ac_pow_measurement}, AC any: {ac_pow_any}" - "\n" - ) - return rep + + return "\n".join(report_lines) # Example of how to use the PVForecastAkkudoktor class From b6111517ca3775fffc6f63ace56f4f219806be8c Mon Sep 17 00:00:00 2001 From: Dominique Lasserre Date: Sun, 12 Jan 2025 00:52:42 +0100 Subject: [PATCH 3/4] workflow: docker-build upload to DockerHub (#318) (#363) * workflow: docker-build upload to DockerHub - Upload on release, tag, push to main. - Build on pr to main (amd64 only). * docker: - Update documentation. - Temporarily set akkudoktor/eos:main in compose.yml (with releases/tags it should be replaced by latest again) --- .env | 2 +- .github/workflows/docker-build.yml | 161 ++++++++++++++++++++++------- Dockerfile | 2 +- README.md | 4 +- 4 files changed, 126 insertions(+), 43 deletions(-) diff --git a/.env b/.env index 55fd2f0e..5b21faf4 100644 --- a/.env +++ b/.env @@ -1,4 +1,4 @@ -EOS_VERSION=latest +EOS_VERSION=main EOS_PORT=8503 PYTHON_VERSION=3.12.6 diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index fac6f436..ff588bc3 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -1,27 +1,53 @@ name: docker-build on: - release: - types: [published] + # pipeline runs per trigger condition, so release trigger not required as tag is sufficient + #release: + # types: [published] + push: + branches: + - 'main' + - 'feature/config-overhaul' + tags: + - 'v*' + pull_request: + branches: + - 'main' + - 'feature/config-overhaul' env: - REGISTRY: ghcr.io - + DOCKERHUB_REPO: akkudoktor/eos + GHCR_REPO: ghcr.io/akkudoktor-eos/eos + EOS_LICENSE: Apache-2.0 + +# From https://docs.docker.com/build/ci/github-actions/multi-platform/ +# Changes: +# - adjusted rw permissions +# - manually set undetected license (label+annotation) +# - set description for index manifest +# - add attestation +# - conditionally don't push on pr +# - on pr just use amd64 platform jobs: - variables: + # Build platform matrix excludes. if-conditional with matrix on job level is not + # supported, see https://github.com/actions/runner/issues/1985 + platform-excludes: + runs-on: ubuntu-latest outputs: - repository: ${{ steps.var.outputs.repository}} - runs-on: "ubuntu-latest" + excludes: ${{ steps.excludes.outputs.matrix }} steps: - - name: Setting global variables - uses: actions/github-script@v6 - id: var - with: - script: | - core.setOutput('repository', '${{ github.repository }}'.toLowerCase()); + - id: excludes + run: | + if ${{ github.event_name == 'pull_request' }}; then + echo 'matrix=[ + {"platform": "linux/arm64"} + ]' | tr -d '[:space:]' >> $GITHUB_OUTPUT + else + echo 'matrix=[]' >> $GITHUB_OUTPUT + fi + build: - needs: - - variables + needs: platform-excludes runs-on: ubuntu-latest permissions: contents: read @@ -34,6 +60,7 @@ jobs: platform: - linux/amd64 - linux/arm64 + exclude: ${{ fromJSON(needs.platform-excludes.outputs.excludes) }} steps: - name: Prepare run: | @@ -44,35 +71,64 @@ jobs: id: meta uses: docker/metadata-action@v5 with: - images: "${{ env.REGISTRY }}/${{ needs.variables.outputs.repository }}" - - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 + images: | + ${{ env.DOCKERHUB_REPO }} + ${{ env.GHCR_REPO }} + labels: | + org.opencontainers.image.licenses=${{ env.EOS_LICENSE }} + annotations: | + org.opencontainers.image.licenses=${{ env.EOS_LICENSE }} + env: + DOCKER_METADATA_ANNOTATIONS_LEVELS: manifest,index + + # Prepare to extract description so it can be manually set for index manifest (group of platform manifests) + - name: Prepare description + id: get_description + run: | + echo "EOS_REPO_DESCRIPTION=$(jq -cr '.labels."org.opencontainers.image.description"' <<< "$DOCKER_METADATA_OUTPUT_JSON")" >> $GITHUB_ENV - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_PASSWORD }} - - name: Login to GitHub + - name: Login to GHCR uses: docker/login-action@v3 with: - registry: ${{ env.REGISTRY }} + registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Build and push by digest id: build uses: docker/build-push-action@v6 with: platforms: ${{ matrix.platform }} labels: ${{ steps.meta.outputs.labels }} - outputs: type=image,name=${{ env.REGISTRY }}/${{ needs.variables.outputs.repository }},push-by-digest=true,name-canonical=true,push=true + annotations: ${{ steps.meta.outputs.annotations }} + outputs: type=image,"name=${{ env.DOCKERHUB_REPO }},${{ env.GHCR_REPO }}",push-by-digest=true,name-canonical=true,"push=${{ github.event_name != 'pull_request' }}","annotation-index.org.opencontainers.image.description=${{ env.EOS_REPO_DESCRIPTION }}" + #push: ${{ github.event_name != 'pull_request' }} - - name: Generate artifact attestation - uses: actions/attest-build-provenance@v1 + - name: Generate artifact attestation DockerHub + uses: actions/attest-build-provenance@v2 with: - subject-name: "${{ env.REGISTRY }}/${{ needs.variables.outputs.repository }}" + subject-name: docker.io/${{ env.DOCKERHUB_REPO }} subject-digest: ${{ steps.build.outputs.digest }} - push-to-registry: true + push-to-registry: ${{ github.event_name != 'pull_request' }} + + - name: Generate artifact attestation GitHub + uses: actions/attest-build-provenance@v2 + with: + subject-name: ${{ env.GHCR_REPO }} + subject-digest: ${{ steps.build.outputs.digest }} + push-to-registry: ${{ github.event_name != 'pull_request' }} - name: Export digest run: | @@ -90,9 +146,14 @@ jobs: merge: runs-on: ubuntu-latest + permissions: + contents: read + packages: write + id-token: write needs: - build - - variables + # skip for pull requests + if: ${{ github.event_name != 'pull_request' }} steps: - name: Download digests uses: actions/download-artifact@v4 @@ -101,6 +162,19 @@ jobs: pattern: digests-* merge-multiple: true + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_PASSWORD }} + + - name: Login to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 @@ -108,21 +182,30 @@ jobs: id: meta uses: docker/metadata-action@v5 with: - images: "${{ env.REGISTRY }}/${{ needs.variables.outputs.repository }}" - - - name: Login to GitHub - uses: docker/login-action@v3 - with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} + images: | + ${{ env.DOCKERHUB_REPO }} + ${{ env.GHCR_REPO }} + tags: | + type=ref,event=branch + type=ref,event=pr + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + labels: | + org.opencontainers.image.licenses=${{ env.EOS_LICENSE }} + annotations: | + org.opencontainers.image.licenses=${{ env.EOS_LICENSE }} + env: + DOCKER_METADATA_ANNOTATIONS_LEVELS: manifest,index - name: Create manifest list and push working-directory: /tmp/digests run: | docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ - $(printf '${{ env.REGISTRY }}/${{ needs.variables.outputs.repository }}@sha256:%s ' *) + $(printf '${{ env.DOCKERHUB_REPO }}@sha256:%s ' *) + docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ + $(printf '${{ env.GHCR_REPO }}@sha256:%s ' *) - name: Inspect image run: | - docker buildx imagetools inspect ${{ env.REGISTRY }}/${{ needs.variables.outputs.repository }}:${{ steps.meta.outputs.version }} + docker buildx imagetools inspect ${{ env.DOCKERHUB_REPO }}:${{ steps.meta.outputs.version }} + docker buildx imagetools inspect ${{ env.GHCR_REPO }}:${{ steps.meta.outputs.version }} diff --git a/Dockerfile b/Dockerfile index 28e55dba..f9e46f2b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -35,6 +35,6 @@ ENTRYPOINT [] EXPOSE 8503 -CMD ["python", "-m", "akkudoktoreos.server.fastapi_server"] +CMD ["python", "-m", "akkudoktoreos.server.eos"] VOLUME ["${MPLCONFIGDIR}", "${EOS_CACHE_DIR}", "${EOS_OUTPUT_DIR}", "${EOS_CONFIG_DIR}"] diff --git a/README.md b/README.md index 58f4c31b..cf77a83f 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ See [CONTRIBUTING.md](CONTRIBUTING.md). ## Installation -The project requires Python 3.10 or newer. Currently there are no official packages or images published. +The project requires Python 3.10 or newer. Official docker images can be found at [akkudoktor/eos](https://hub.docker.com/r/akkudoktor/eos). Following sections describe how to locally start the EOS server on `http://localhost:8503`. @@ -49,7 +49,7 @@ Windows: ### Docker ```bash -docker compose up --build +docker compose up ``` ## Configuration From d317aa99371a386afc0f9a2240ba683473c5e5dd Mon Sep 17 00:00:00 2001 From: Normann Date: Sun, 12 Jan 2025 14:33:02 +0100 Subject: [PATCH 4/4] Feature Branch: Generation Fitness plot (#362) * Generation Fitness plot * comment change * - * print on debug, add_json_page, add_text_page * set debug for example * known seed if debug * removed unused code * - * bugfix empty page --- single_test_optimization.py | 3 + src/akkudoktoreos/optimization/genetic.py | 28 +++- src/akkudoktoreos/utils/visualize.py | 167 ++++++++++++++++++---- 3 files changed, 169 insertions(+), 29 deletions(-) diff --git a/single_test_optimization.py b/single_test_optimization.py index 83a99d7c..ac3fd099 100644 --- a/single_test_optimization.py +++ b/single_test_optimization.py @@ -10,12 +10,15 @@ from akkudoktoreos.config.config import get_config from akkudoktoreos.core.ems import get_ems +from akkudoktoreos.core.logging import get_logger from akkudoktoreos.optimization.genetic import ( OptimizationParameters, optimization_problem, ) from akkudoktoreos.prediction.prediction import get_prediction +get_logger(__name__, logging_level="DEBUG") + def prepare_optimization_real_parameters() -> OptimizationParameters: """Prepare and return optimization parameters with real world data. diff --git a/src/akkudoktoreos/optimization/genetic.py b/src/akkudoktoreos/optimization/genetic.py index a05f8497..8847f0ff 100644 --- a/src/akkudoktoreos/optimization/genetic.py +++ b/src/akkudoktoreos/optimization/genetic.py @@ -1,3 +1,4 @@ +import logging import random import time from pathlib import Path @@ -14,6 +15,7 @@ EnergyManagementSystemMixin, ) from akkudoktoreos.core.ems import EnergieManagementSystemParameters, SimulationResult +from akkudoktoreos.core.logging import get_logger from akkudoktoreos.devices.battery import ( Battery, ElectricVehicleParameters, @@ -25,6 +27,8 @@ from akkudoktoreos.prediction.interpolator import SelfConsumptionPropabilityInterpolator from akkudoktoreos.utils.utils import NumpyEncoder +logger = get_logger(__name__) + class OptimizationParameters(BaseModel): ems: EnergieManagementSystemParameters @@ -113,10 +117,14 @@ def __init__( self.fix_seed = fixed_seed self.optimize_ev = True self.optimize_dc_charge = False + self.fitness_history: dict[str, Any] = {} - # Set a fixed seed for random operations if provided - if fixed_seed is not None: - random.seed(fixed_seed) + # Set a fixed seed for random operations if provided or in debug mode + if self.fix_seed is not None: + random.seed(self.fix_seed) + elif logger.level == logging.DEBUG: + self.fix_seed = random.randint(1, 100000000000) + random.seed(self.fix_seed) def decode_charge_discharge( self, discharge_hours_bin: np.ndarray @@ -493,6 +501,8 @@ def optimize( hof = tools.HallOfFame(1) stats = tools.Statistics(lambda ind: ind.fitness.values) stats.register("min", np.min) + stats.register("avg", np.mean) + stats.register("max", np.max) if self.verbose: print("Start optimize:", start_solution) @@ -503,7 +513,7 @@ def optimize( population.insert(0, creator.Individual(start_solution)) # Run the evolutionary algorithm - algorithms.eaMuPlusLambda( + pop, log = algorithms.eaMuPlusLambda( population, self.toolbox, mu=100, @@ -516,6 +526,14 @@ def optimize( verbose=self.verbose, ) + # Store fitness history + self.fitness_history = { + "gen": log.select("gen"), # Generation numbers (X-axis) + "avg": log.select("avg"), # Average fitness for each generation (Y-axis) + "max": log.select("max"), # Maximum fitness for each generation (Y-axis) + "min": log.select("min"), # Minimum fitness for each generation (Y-axis) + } + member: dict[str, list[float]] = {"bilanz": [], "verluste": [], "nebenbedingung": []} for ind in population: if hasattr(ind, "extra_data"): @@ -627,6 +645,8 @@ def optimierung_ems( "start_solution": start_solution, "spuelstart": washingstart_int, "extra_data": extra_data, + "fitness_history": self.fitness_history, + "fixed_seed": self.fix_seed, } from akkudoktoreos.utils.visualize import prepare_visualize diff --git a/src/akkudoktoreos/utils/visualize.py b/src/akkudoktoreos/utils/visualize.py index 2ab17a64..d4b47685 100644 --- a/src/akkudoktoreos/utils/visualize.py +++ b/src/akkudoktoreos/utils/visualize.py @@ -1,4 +1,7 @@ +import json +import logging import os +import textwrap from collections.abc import Sequence from typing import Callable, Optional, Union @@ -7,8 +10,11 @@ from matplotlib.backends.backend_pdf import PdfPages from akkudoktoreos.core.coreabc import ConfigMixin +from akkudoktoreos.core.logging import get_logger from akkudoktoreos.optimization.genetic import OptimizationParameters +logger = get_logger(__name__) + class VisualizationReport(ConfigMixin): def __init__(self, filename: str = "visualization_results.pdf") -> None: @@ -50,40 +56,42 @@ def _initialize_pdf(self) -> None: def _save_group_to_pdf(self, group: list[Callable[[], None]]) -> None: """Save a group of charts to the PDF.""" fig_count = len(group) # Number of charts in the group + if fig_count == 0: - print("Attempted to save an empty group to PDF!") # Warn if group is empty - return # Prevent saving an empty group + print("Attempted to save an empty group to PDF!") + return - # Create a figure layout based on the number of charts - if fig_count == 3: - # Layout for three charts: 1 full-width on top, 2 below - fig = plt.figure(figsize=(14, 10)) # Set a larger figure size - ax1 = fig.add_subplot(2, 1, 1) # Full-width subplot - ax2 = fig.add_subplot(2, 2, 3) # Bottom left subplot - ax3 = fig.add_subplot(2, 2, 4) # Bottom right subplot + # Check for special charts before creating layout + special_keywords = {"add_text_page", "add_json_page"} + for chart_func in group: + if any(keyword in chart_func.__qualname__ for keyword in special_keywords): + chart_func() # Special chart functions handle their own rendering + return - # Store axes in a list for easy access + # Create layout only if no special charts are detected + if fig_count == 3: + fig = plt.figure(figsize=(14, 10)) + ax1 = fig.add_subplot(2, 1, 1) + ax2 = fig.add_subplot(2, 2, 3) + ax3 = fig.add_subplot(2, 2, 4) axs = [ax1, ax2, ax3] else: - # Dynamic layout for any other number of charts - cols = 2 if fig_count > 1 else 1 # Determine number of columns - rows = (fig_count // 2) + (fig_count % 2) # Calculate required rows - fig, axs = plt.subplots(rows, cols, figsize=(14, 7 * rows)) # Create subplots - # If axs is a 2D array of axes, flatten it into a 1D list - # if isinstance(axs, np.ndarray): + cols = 2 if fig_count > 1 else 1 + rows = (fig_count + 1) // 2 + fig, axs = plt.subplots(rows, cols, figsize=(14, 7 * rows)) axs = list(np.array(axs).reshape(-1)) - # Draw each chart in the corresponding axes + # Render each chart in its corresponding axis for idx, chart_func in enumerate(group): - plt.sca(axs[idx]) # Set current axes - chart_func() # Call the chart function to draw + plt.sca(axs[idx]) # Set current axis + chart_func() # Render the chart - # Hide any unused axes + # Save the figure to the PDF and clean up for idx in range(fig_count, len(axs)): - axs[idx].set_visible(False) # Hide unused axes - self.pdf_pages.savefig(fig) # Save the figure to the PDF + axs[idx].set_visible(False) - plt.close(fig) # Close the figure to free up memory + self.pdf_pages.savefig(fig) # Save the figure to the PDF + plt.close(fig) def create_line_chart( self, @@ -232,6 +240,63 @@ def chart() -> None: self.add_chart_to_group(chart) # Add chart function to current group + def add_text_page(self, text: str, title: Optional[str] = None, fontsize: int = 12) -> None: + """Add a page with text content to the PDF.""" + + def chart() -> None: + fig = plt.figure(figsize=(8.5, 11)) # Create a standard page size + plt.axis("off") # Turn off axes for a clean page + wrapped_text = textwrap.fill(text, width=80) # Wrap text to fit the page width + y = 0.95 # Start at the top of the page + + if title: + plt.text(0.5, y, title, ha="center", va="top", fontsize=fontsize + 4, weight="bold") + y -= 0.05 # Add space after the title + + plt.text(0.5, y, wrapped_text, ha="center", va="top", fontsize=fontsize, wrap=True) + self.pdf_pages.savefig(fig) # Save the figure as a page in the PDF + plt.close(fig) # Close the figure to free up memory + + self.add_chart_to_group(chart) # Treat the text page as a "chart" in the group + + def add_json_page( + self, json_obj: dict, title: Optional[str] = None, fontsize: int = 12 + ) -> None: + """Add a page with a formatted JSON object to the PDF. + + Args: + json_obj (dict): The JSON object to display. + title (Optional[str]): An optional title for the page. + fontsize (int): The font size for the JSON text. + """ + + def chart() -> None: + # Convert JSON object to a formatted string + json_str = json.dumps(json_obj, indent=4) + + fig = plt.figure(figsize=(8.5, 11)) # Standard page size + plt.axis("off") # Turn off axes for a clean page + + y = 0.95 # Start at the top of the page + if title: + plt.text(0.5, y, title, ha="center", va="top", fontsize=fontsize + 4, weight="bold") + y -= 0.05 # Add space after the title + + # Split the JSON string into lines and render them + lines = json_str.splitlines() + for line in lines: + plt.text(0.05, y, line, ha="left", va="top", fontsize=fontsize, family="monospace") + y -= 0.02 # Move down for the next line + + # Stop if the text exceeds the page + if y < 0.05: + break + + self.pdf_pages.savefig(fig) # Save the figure as a page in the PDF + plt.close(fig) # Close the figure to free up memory + + self.add_chart_to_group(chart) # Treat the JSON page as a "chart" in the group + def generate_pdf(self) -> None: """Generate the PDF report with all the added chart groups.""" self._initialize_pdf() # Initialize the PDF @@ -366,7 +431,6 @@ def prepare_visualize( c=extra_data["nebenbedingung"], ) - # Example usage values_list = [ [ results["result"]["Gesamtkosten_Euro"], @@ -422,7 +486,25 @@ def prepare_visualize( if filtered_balance.size > 0 or filtered_losses.size > 0: report.finalize_group() - + if logger.level == logging.DEBUG or results["fixed_seed"]: + report.create_line_chart( + 0, + [ + results["fitness_history"]["avg"], + results["fitness_history"]["max"], + results["fitness_history"]["min"], + ], + title=f"DEBUG: Generation Fitness for seed {results['fixed_seed']}", + xlabel="Generation", + ylabel="Fitness", + labels=[ + "avg", + "max", + "min", + ], + markers=[".", ".", "."], + ) + report.finalize_group() # Generate the PDF report report.generate_pdf() @@ -500,6 +582,41 @@ def generate_example_report(filename: str = "example_report.pdf") -> None: report.finalize_group() # Finalize the third group of charts + logger.setLevel(logging.DEBUG) # set level for example report + + if logger.level == logging.DEBUG: + report.create_line_chart( + x_hours, + [np.array([0.2, 0.25, 0.3, 0.35])], + title="DEBUG", + xlabel="DEBUG", + ylabel="DEBUG", + ) + report.finalize_group() # Finalize the third group of charts + + report.add_text_page( + text=" Bisher passierte folgendes:" + "Am Anfang wurde das Universum erschaffen." + "Das machte viele Leute sehr wütend und wurde allent-" + "halben als Schritt in die falsche Richtung angesehen...", + title="Don't Panic!", + fontsize=14, + ) + report.finalize_group() + + sample_json = { + "name": "Visualization Report", + "version": 1.0, + "charts": [ + {"type": "line", "data_points": 50}, + {"type": "bar", "categories": 10}, + ], + "metadata": {"author": "AI Assistant", "date": "2025-01-11"}, + } + + report.add_json_page(json_obj=sample_json, title="Formatted JSON Data", fontsize=10) + report.finalize_group() + # Generate the PDF report report.generate_pdf()